Skip to content

Commit 25bd1e1

Browse files
committed
Merge origin/main (d282087) into claude/issue-20282-cube-descriptions-meta
Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
2 parents 6502285 + d282087 commit 25bd1e1

40 files changed

Lines changed: 986 additions & 157 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
Provenance comments in `service-analytics` were re-anchored
6+
7+
Comment and docblock lines under `src/` that cited tracker numbers which no
8+
longer resolve on GitHub now cite the commit in this repository's history that
9+
decided the matter, and say in their own words what was decided. Comments
10+
only: no type, schema, export, log or refusal text, or runtime behaviour changes.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(objectql)!: a `time` field is a zone-less wall clock — a time of day written with a `Z` or an offset (`"10:00Z"`, `"10:00+08:00"`), and an instant whose UTC year has no four-digit spelling (`"+010000-01-01T10:00:00Z"`), are refused with `VALIDATION_FAILED` / 400 (`invalid_time`) instead of being stored verbatim on memory and SQLite and read back differently, or failing with a 500, on PostgreSQL (#20671)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a written VALUE at the record validator's time arm: no authorable key, spelling or stored shape of metadata moves, and no schema, type or export changes. `packages/spec` gains one sentence in the built-in validation-message catalog (`invalid_time_zoned`, a rendering variant of the existing `invalid_time` wire code, which is unchanged). A stored row keeps whatever it holds, and which wall clock a zone-suffixed time of day meant is not something a ledger entry can decide. The other categories are closed on facts: the packages publish (not `unpublished`); no ADR-0087 id covers a write-door value check (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->
11+
12+
**BREAKING**: this narrows what a `time` field accepts as a written value. It ships as `minor` under the launch-window convention for accept-set narrowings (`check-changeset-no-major` refuses `major` until GA; the breaking-ness is carried by this banner and the ADR-0087 disposition above).
13+
14+
The record validator's `time` arm now asks `@objectstack/core`'s one temporal rule, the same one the `time` filter comparand door asks, so a value is refused as a written `time` exactly when it is refused as a `time` comparand. It reads two things: a bare wall clock `HH:MM[:SS[.fraction]]` in range, and an instant in one of the ISO 8601 spellings a `datetime` is written in, on a calendar day that exists, whose UTC year has four digits (its UTC time of day is stored). Everything else is refused with `VALIDATION_FAILED` / 400 and the field code `invalid_time`, naming the field, on insert, update, a multi-row update and `engine.validate`, before anything is written.
15+
16+
Refused now, where they were accepted:
17+
18+
- **A time of day with a zone suffix**: `"10:00Z"`, `"10:00+08:00"`, `"10:00:00+0800"`, `"10:00:00.250Z"`. A `time` field carries no zone. The refusal has its own sentence, which `@objectstack/spec`'s validation-message catalog now carries in all four locales (`invalid_time_zoned`; English: "… is a time of day with no time zone: drop the Z or offset (HH:MM or HH:MM:SS), or use a datetime field for an instant"). The wire code stays `invalid_time`.
19+
- **An instant the rule does not read as a time of day**: an extended year (`"+010000-01-01T10:00:00Z"`, or a `Date` of it), an instant whose UTC year is 10000 (`"9999-12-31T23:00:00-02:00"`), a day that does not exist (`"2026-02-30T10:00:00Z"`), and a spelling the `datetime` arm already refuses (`"2026-07-15 10:00Z"`, a space and a zone; `"2026-07-15t10:00:00z"`, lower case).
20+
21+
What a caller sees, before and after, through `POST /api/v1/data/:object` and a read-back, the process in America/New_York, PostgreSQL 16 at `Asia/Shanghai`:
22+
23+
| written to a `time` | memory | SQLite | PostgreSQL | now, on all three |
24+
|:--|:--|:--|:--|:--|
25+
| `"+010000-01-01T10:00:00Z"`, `"9999-12-31T23:00:00-02:00"` | 201, read back as written | the same | 500 `DATABASE_ERROR` | 400 `invalid_time` |
26+
| `"10:00Z"`, `"10:00+08:00"`, `"10:00:00+0800"` | 201, read back as written | the same | 201, read back `"10:00:00"` | 400 `invalid_time`, the zone sentence |
27+
| `"2026-07-15 10:00Z"` | 201, `"10:00:00"` | the same | the same | 400 `invalid_time` |
28+
29+
**Who is affected.** A caller that writes a `time` field as a string with a `Z` or an offset, or as an out-of-range instant: a REST or SDK client, a flow, an MCP `create_record` / `update_record` call written by a model. A row already stored with such a value keeps it; nothing rewrites it. PostgreSQL stored a zone-suffixed time of day as its bare wall clock, so only a memory or SQLite deployment can hold one. An update that omits the field is not affected; one that sends the old value back is refused, naming the field. A `time` field whose literal `defaultValue` carries a `Z` or an offset has each insert that falls back to that default refused the same way. The server import (`POST /api/v1/data/:object/import`) turns a `time` cell into `HH:MM:SS` itself before the write, and already refused a zone-suffixed time-of-day cell, so its cells are unchanged.
30+
31+
**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through REST:
32+
33+
- a bare wall clock: `"10:00"` and `"10:00:00"` read back `"10:00:00"`, `"10:00:00.250"` reads back `"10:00:00.250"`;
34+
- a full ISO instant with a four-digit year, stored as its UTC time of day: `"2026-07-15T10:00:00Z"` and `"2026-07-15T18:00:00+08:00"` read back `"10:00:00"`;
35+
- a `Date` with a four-digit year, still accepted; an epoch-millisecond number, a `{placeholder}` and an out-of-range clock (`"25:00"`), still refused with `invalid_time`;
36+
- every `date` and `datetime` value, and every filter comparand.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
fix(service-automation)!: disabling a packaged subflow completes once its packaged callers are switched off and hold no parked run, and the refusal names the parked runs and the cancel door (#20678)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata changes shape and nothing an author wrote is renamed or removed, so `objectstack migrate meta` has nothing to rewrite. What moves is which calls the activation switch accepts, in both directions. -->
10+
11+
**BREAKING**: shipped as `minor` under the launch-window convention. `toggleFlow(name, enabled)` on the automation service, and so `POST /api/v1/automation/:name/toggle`, now accepts some disables it used to refuse (the widening) and refuses one corner of enables it used to accept (the narrowing).
12+
13+
**The disable direction (the widening).** Disabling a packaged flow that a packaged flow calls as a subflow (the `flowName` of a `subflow` or `map` node) was refused while any such caller existed, even one already switched off. So the refusal's own remedy, "disable the calling flow first", could never complete. A caller now guards the disable only while it can still reach the subflow node:
14+
15+
- **An enabled caller** guards, as before. The refusal names it, and the step is to disable it first.
16+
- **A disabled caller** (switched off in the activation ledger, or disabled by its definition's `status`) guards only while it holds a **parked run**: a run paused at a wait, an approval, a screen, or at a `map` node between items. Switching a flow off stops its new runs only, and a parked run still resumes into its subflow node. The refusal names each parked run id and the operator cancel door, `POST /api/v1/automation/:name/runs/:runId/cancel` (ADR-0044). Cancel those runs, or let them finish, and the disable completes.
17+
- **A disabled caller with no parked run** no longer guards, so "disable the caller, then the callee" completes.
18+
19+
Parked runs are read from both the in-process runs and the durable suspended-run store, including runs a previous process parked. If the durable store cannot be listed at that moment, the disable fails with the store's own error and nothing is written; it is never read as "no parked run". The refusal keeps `DELETE_RESTRICTED` / `409` and its `subflowCallers` list, which now names exactly the callers that guard.
20+
21+
**The enable direction (the narrowing).** A subflow in a cycle of ledger-switched-off flows with the flow being enabled was skipped whole, even when its definition's `status` also disabled it. So the enable was accepted onto a subflow that stays disabled, and the publish remedy was never named. Such a subflow is now named, with both reasons and both steps (publish it with status `active`, then enable it). The cycle exemption covers the activation switch only, because no enable order changes a status.

‎.changeset/20678-subflow-disable-sequence.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@ Clause-②: no (narrowing)
1919

2020
- Enabling a flow that is already enabled. Nothing is re-armed.
2121
- A flow the customer authored, or a subflow the customer authored.
22-
- A subflow in a cycle of switched-off flows with the flow being enabled, including a flow that calls itself. Each flow in such a cycle would refuse the others, so no order could complete.
22+
- A subflow in a cycle of switched-off flows with the flow being enabled, including a flow that calls itself. Each flow in such a cycle would refuse the others, so no order could complete. A subflow in such a cycle whose definition's `status` also disables it is still named, with its publish remedy: no enable order changes a status.
2323

24-
Disabling a subflow is unchanged.
24+
The disable direction of the same guard is described in its own entry, "disabling a packaged subflow completes once its packaged callers are switched off and hold no parked run".

‎content/docs/protocol/objectql/types.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -465,9 +465,11 @@ business_hours_start:
465465

466466
**Storage format:** `HH:MM:SS`, gaining a `.fff` millisecond suffix **only** when
467467
the milliseconds are non-zero (`14:30:00`, but `14:30:00.100`). Input accepts
468-
`HH:MM` or `HH:MM:SS` (with an optional fractional part and `Z`/offset); `14:30`
469-
is completed to `14:30:00`, so one wall clock can never split into several stored
470-
values. A `Date`, an epoch, or a full timestamp folds to its **UTC** time-of-day.
468+
`HH:MM` or `HH:MM:SS`, with an optional fractional part and no zone: `14:30Z` or
469+
`14:30+08:00` is refused with `invalid_time` (drop the suffix, or use a `datetime`
470+
field for an instant). `14:30` is completed to `14:30:00`, so one wall clock can
471+
never split into several stored values. A `Date` or a full ISO 8601 timestamp with
472+
a four-digit year folds to its **UTC** time-of-day; an epoch number is refused.
471473
A `time` is a wall-clock value, not an instant: it is validated as a time-of-day,
472474
not parsed as a date.
473475

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20671] What the `time` write door admits, this driver stores as the wall
5+
* clock it names, and reads back identically.
6+
*
7+
* The engine's record validator now refuses a time of day with a `Z` or an
8+
* offset (`"10:00Z"`, `"10:00+08:00"`) and an instant the `time` rule does not
9+
* read (`"+010000-01-01T10:00:00Z"`), with `VALIDATION_FAILED` / `invalid_time`
10+
* before any driver write (`packages/objectql/src/engine-time-write-zone-less.test.ts`).
11+
* Measured on the base through REST over this driver, the process in
12+
* America/New_York, both were stored verbatim here: `"10:00Z"` read back
13+
* `"10:00Z"`, while PostgreSQL read the same write back as `"10:00:00"`. The
14+
* refusal is therefore not this driver's. What it owes is the other half: a
15+
* plain `"10:00"` / `"10:00:00"`, and an ISO instant with a four-digit UTC
16+
* year, are stored as `HH:MM:SS` and found by it, with the process in a zone
17+
* that is not UTC so a host-zone reading would show. SQLite and PostgreSQL
18+
* read the same values back the same way in
19+
* `packages/rest/src/data-temporal-write-real-day-iso.test.ts`.
20+
*/
21+
22+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
23+
import { InMemoryDriver } from './memory-driver.js';
24+
25+
const OBJECT = 'schedule_time_20671';
26+
const FIELDS = { slot: { type: 'time' } };
27+
const HOST_ZONE = 'America/New_York';
28+
29+
/** Each spelling the `time` write door admits → the wall clock it is stored and read back as. */
30+
const ADMITTED: ReadonlyArray<readonly [string, unknown, string]> = [
31+
['hh-mm', '10:00', '10:00:00'],
32+
['hh-mm-ss', '10:00:00', '10:00:00'],
33+
['fraction', '10:00:00.250', '10:00:00.250'],
34+
['utc-instant', '2026-07-15T10:00:00Z', '10:00:00'],
35+
['offset-instant', '2026-07-15T18:00:00+08:00', '10:00:00'],
36+
['naive-instant', '2026-07-15 10:00', '10:00:00'],
37+
['a-date', new Date(Date.UTC(2026, 6, 15, 10)), '10:00:00'],
38+
];
39+
40+
const originalTz = process.env.TZ;
41+
42+
describe('[#20671] every spelling the time write door admits is stored as its wall clock and read back identically', () => {
43+
let driver: InMemoryDriver;
44+
45+
beforeAll(async () => {
46+
process.env.TZ = HOST_ZONE;
47+
expect(Intl.DateTimeFormat().resolvedOptions().timeZone, 'the host zone really changed').toBe(HOST_ZONE);
48+
driver = new InMemoryDriver({});
49+
await driver.connect();
50+
await driver.syncSchema(OBJECT, { name: OBJECT, fields: FIELDS });
51+
for (const [id, slot] of ADMITTED) await driver.create(OBJECT, { id, slot });
52+
await driver.create(OBJECT, { id: 'before', slot: '09:59:59' });
53+
});
54+
55+
afterAll(() => {
56+
if (originalTz === undefined) delete process.env.TZ;
57+
else process.env.TZ = originalTz;
58+
});
59+
60+
it('reads each one back as its wall clock', async () => {
61+
for (const [id, , stored] of ADMITTED) {
62+
expect((await driver.findOne(OBJECT, { where: { id } }))?.slot, id).toBe(stored);
63+
}
64+
});
65+
66+
it('finds each one by the wall clock it names, and orders it after the second before', async () => {
67+
const ids = async (where: Record<string, unknown>) =>
68+
(await driver.find(OBJECT, { where })).map((r) => r.id as string).sort();
69+
const ten = ADMITTED.filter(([, , stored]) => stored === '10:00:00').map(([id]) => id).sort();
70+
expect(await ids({ slot: { $eq: '10:00:00' } })).toEqual(ten);
71+
expect(await ids({ slot: { $eq: '10:00' } }), 'the HH:MM spelling').toEqual(ten);
72+
expect(await ids({ slot: { $gt: '09:59:59', $lt: '10:00:00.100' } })).toEqual(ten);
73+
});
74+
});

0 commit comments

Comments
 (0)