diff --git a/.changeset/spec-prose-carriers-state-the-shipped-contract.md b/.changeset/spec-prose-carriers-state-the-shipped-contract.md new file mode 100644 index 0000000000..9145bc2e3a --- /dev/null +++ b/.changeset/spec-prose-carriers-state-the-shipped-contract.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": patch +--- + +Three published prose carriers in `packages/spec` now state what the tree does, instead of a premise the same package's own code contradicts. No predicate, schema arm, key or export moves; every accept set is byte-identical. + +All three reach consumers: `@objectstack/spec` ships `src/**/*.zod.ts` in its published `files[]`, so a TSDoc comment in one of these files is a published byte, and one of the three is a `.describe()` that additionally lands in the JSON Schema and the generated reference page. + +- **`FILE_REFERENCE_TYPES` said the stored schema "deliberately admits both until D3 lands".** ADR-0104 D3 wave 2 landed: `valueSchemaFor` returns `FileReferenceIdValueSchema` ALONE for `form === 'stored'` and the id-or-object union only for `'expanded'`, and `field-value.test.ts` pins both directions. The docblock now states the shipped contract — the stored value is an opaque `sys_file` id, the inline metadata object is the expanded READ form — and names where legacy stored values are actually admitted: the warn-first ADR-0104 value-shape path in `@objectstack/objectql`'s record validator, never this schema. The sentence promised a future that had already arrived, which is exactly how it kept reading as current. +- **`CreateManyDataResponseSchema.droppedFields` justified its aggregated shape with "schema-uniform, so every row drops the same set".** Ruling C moved the static-`readonly` strip INSIDE `engine.insert`, after the `beforeInsert` hooks, and exempts keys a hook itself wrote — tracked per row (`rowHookWrittenKeys`). A hook that stamps a protected key on some rows and not others therefore makes those rows drop different sets, so the premise is false. The shape is unchanged and stays right for the reason the producer already gives: this response is `{ object, records, count }` and has no per-row slot to hang a drop set on, so a union is the only view it can represent. The description now says that, and tells a reader how to read a name in the array — "at least one row dropped this field", not "every row did". Callers needing row precision still use the per-row `insertMany`/`batch` results. +- **`FieldReferenceSchema`'s `@example` spelled the #14104 shape as the relation path `duty.grace_days`.** Nineteen lines below it, in the same block, the Execution support prose states that SQL push-down compiles same-table columns only and refuses a dotted path with `INVALID_FILTER`. An author copying the example gets a filter that passes in memory — `matchesFilter` walks dot paths — and 400s on SQL. The example is now the same-table spelling both execution paths compile, matching the wording the hand-written query-syntax guide already publishes; the block's own prose remains the statement of what a dotted path does. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 38f24ad2ad..a1cc755e3a 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -569,7 +569,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **object** | `string` | ✅ | Object name | | **records** | `Record[]` | ✅ | Created records | | **count** | `number` | ✅ | Number of records created | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied `readonly` fields the in-engine create-side strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the union of dropped field names) rather than per-row, because the insert-time strip is static-`readonly` only — schema-uniform, so every row drops the same set. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` on each result instead — see BatchOperationResultSchema.) | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied `readonly` fields the in-engine create-side strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the UNION of dropped field names) rather than per-row, because this response is `{ object, records, count }` and has no per-row slot to hang a drop set on — a union is the only view it can represent. So read a name here as "at least one row dropped this field", NOT "every row dropped the same set": the strip runs INSIDE `engine.insert` after the `beforeInsert` hooks and exempts keys a hook itself wrote, tracked per row: rows where a hook stamped a protected key drop a different set from rows where it did not. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` on each result instead — see BatchOperationResultSchema.) | ### Nested Shape: `CreateManyDataResponse.droppedFields[number]` diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 1b3d1e969a..34019bf779 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2354,12 +2354,20 @@ export const CreateManyDataResponseSchema = lazySchema(() => z.object({ object: z.string().describe('Object name'), records: z.array(z.record(z.string(), z.unknown())).describe('Created records'), count: z.number().describe('Number of records created'), + // The per-row read below is maintainer ruling C (#14147): the static-`readonly` strip + // moved into `engine.insert`, after `beforeInsert`, exempting keys a hook wrote + // (`rowHookWrittenKeys`). The id stays in this comment, never in the `.describe()` — + // that string is printed AT the customer, who has no tracker to resolve it. droppedFields: z.array(DroppedFieldsEventSchema).optional().describe( 'Write-observability: caller-supplied `readonly` fields the in-engine create-side ' + 'strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch ' + - '(one event per object/reason with the union of dropped field names) rather than per-row, ' + - 'because the insert-time strip is static-`readonly` only — schema-uniform, so every row ' + - 'drops the same set. Present ONLY when ≥1 field was dropped; the creates still succeeded ' + + '(one event per object/reason with the UNION of dropped field names) rather than per-row, ' + + 'because this response is `{ object, records, count }` and has no per-row slot to hang a ' + + 'drop set on — a union is the only view it can represent. So read a name here as "at least ' + + 'one row dropped this field", NOT "every row dropped the same set": the strip runs INSIDE ' + + '`engine.insert` after the `beforeInsert` hooks and exempts keys a hook itself wrote, ' + + 'tracked per row: rows where a hook stamped a protected key drop a different set from ' + + 'rows where it did not. Present ONLY when ≥1 field was dropped; the creates still succeeded ' + 'without them (count/success unchanged). Optional — omit-when-empty keeps the shape ' + 'backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` ' + 'on each result instead — see BatchOperationResultSchema.)' diff --git a/packages/spec/src/data/field-value.zod.ts b/packages/spec/src/data/field-value.zod.ts index 3de81a3089..249fab9b8b 100644 --- a/packages/spec/src/data/field-value.zod.ts +++ b/packages/spec/src/data/field-value.zod.ts @@ -174,10 +174,19 @@ export function referenceTargetOf(def: unknown): string | undefined { } /** - * Media/attachment types. Stored form TODAY is the legacy inline metadata - * object (`{url, name?, size?, ...}`) or an opaque file-id/url string; - * ADR-0104 D3 (file-as-reference) narrows this to a `sys_file` id. The stored - * schema below deliberately admits both until D3 lands. + * Media/attachment types. The STORED value of every member is an opaque + * `sys_file` id ({@link FileReferenceIdValueSchema}); the inline metadata + * object (`{url, name?, size?, ...}`) is the `expanded` READ form + * ({@link FileValueSchema}), derived rather than stored. ADR-0104 D3 wave 2 + * (file-as-reference) narrowed it, and the classifier below is where that + * landed: `valueSchemaFor` returns the id ALONE for `form === 'stored'` and + * the id-or-object union only for `'expanded'`. Both directions are pinned in + * `field-value.test.ts`. + * + * Legacy values already in storage — an inline blob, an external URL — are + * admitted by the RUNTIME, never by this schema: they surface as warn-first + * ADR-0104 value-shape warnings from `@objectstack/objectql`'s record + * validator until a deployment opts into strict enforcement. */ export const FILE_REFERENCE_TYPES: ReadonlySet = new Set([ 'image', 'file', 'avatar', 'video', 'audio', diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 75ed958278..866ae925ab 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -37,8 +37,10 @@ import { bareDateRangePresetComparandMessage, isDateRangePresetName } from './da * { "$eq": { "$field": "order.owner_id" } } * * @example - * // completed_at <= due_date + duty.grace_days (#14104) - * { "$lte": { "$field": "due_date", "addDays": { "$field": "duty.grace_days" } } } + * // completed_at <= due_date + grace_days (#14104 — a SAME-TABLE offset + * // column, the spelling both execution paths compile; "Execution support" + * // below states what a dotted path does instead) + * { "$lte": { "$field": "due_date", "addDays": { "$field": "grace_days" } } } * * ## Execution support (#5041 → #5222 → #14104) *