diff --git a/.changeset/27945-lookup-pick-autofill.md b/.changeset/27945-lookup-pick-autofill.md new file mode 100644 index 00000000000..23f028a8a3d --- /dev/null +++ b/.changeset/27945-lookup-pick-autofill.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +'@objectstack/driver-sql': minor +'@objectstack/metadata-core': minor +--- + +feat(spec): a lookup field declares which fields a pick fills (`lookupAutofill`) + +Clause-②: yes (widening) +Metadata-protocol: added + +A single-value `lookup` / `master_detail` field can now declare `lookupAutofill`: a list of `{ target, source, overwrite? }` pairs. A pick in the lookup copies the picked record's `source` field (a field of the referenced object) into the record's `target` field. A quote line's product lookup declares `[{ target: 'unit_price', source: 'list_price' }]`, so the sales price can fill from the product as soon as the product is picked, instead of only after save. + +- **The overwrite rule.** By default (`overwrite` absent or false), a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in, so a typed value, or one the record already stored, stays. A pair that declares `overwrite: true` replaces the target on every pick. +- **One home for the form and the grid.** The declaration lives on the field, so the record form and a line-item grid column over the field both read it. The grid column key `autofill` is unchanged: it still governs only the grid's same-named copy, on by default. A declared pair takes its target out of that copy. +- **Client-side only.** It fills the form. Nothing on the write path reads it, so an import or API write that sets the lookup fills nothing. +- **Refused at parse:** the key on anything but a single-value relationship; a target that is the lookup itself, not a field of the object, `readonly`, computed (`formula`, `summary`, `autonumber`, or a field with an `expression`), or named twice; an unknown key in a pair. +- **Refused by `os validate` / `os build`** (`STACK_CROSS_REFERENCE_INVALID`): a source the referenced object does not declare, an `internal` source, and a source whose value the target cannot hold (the same type and multiplicity, a string into a plain text field, `number` and `currency` into each other, or a relationship over the same object). +- **Every-key tables classify the new key.** `@objectstack/driver-sql` classifies it `presentation` in `FIELD_KEY_STORAGE_CLASS`: no column DDL reads it. `@objectstack/metadata-core`'s field-level-security scrub (`FIELD_REFERENCE_POSITIONS`) drops a pair whose `target` is a field the caller cannot read, and deletes a list left empty. Each `source` names a field of the referenced object, which that object's own projection governs, so it is kept. + +The form and grid that write the declared targets on pick ship in a follow-up. Until then the declaration is checked and stored, and fills nothing. Nothing to do for existing metadata. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index b2d44f90ab6..542713f20b7 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -86,6 +86,7 @@ const result = CurrencyConfigSchema.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | @@ -224,6 +225,14 @@ const result = CurrencyConfigSchema.parse(data); | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`). | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing. | +### Nested Shape: `Field.lookupAutofill[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **target** | `string` | ✅ | Field of THIS object the pick fills (snake_case machine name). | +| **source** | `string` | ✅ | Field of the picked record — a field of this lookup's `reference` object — whose value is copied (snake_case machine name). | +| **overwrite** | `boolean` | optional | TRUE: the pick replaces whatever the target holds, a typed value included. Absent or FALSE: the pick fills the target only while it is empty or still holds the value an earlier pick filled in. | + ### Nested Shape: `Field.summaryOperations` | Property | Type | Required | Description | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index d1cd1ad59a5..48d5c1e3d18 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -264,6 +264,7 @@ const result = ApiMethod.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | @@ -628,6 +629,7 @@ const result = ApiMethod.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 6dfd8af2882..3628ded946c 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -98,6 +98,7 @@ Add a new field to an existing object | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | @@ -530,6 +531,7 @@ Add a new field to an existing object | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index 6d67334be3f..4af374d9628 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 154 | 88 | 2 | 0 | 64 | +| `data/` | 155 | 89 | 2 | 0 | 64 | ## `data/` — sites @@ -45,7 +45,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `driver/turso.zod.ts` | 2 | | `external-catalog.zod.ts` | 4 | | `field-value.zod.ts` | 4 | -| `field.zod.ts` | 13 | +| `field.zod.ts` | 14 | | `filter.zod.ts` | 13 | | `hook-body.zod.ts` | 2 | | `hook.zod.ts` | 8 | @@ -57,7 +57,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 2 | | `validation.zod.ts` | 6 | -| **total** | **154** | +| **total** | **155** | ## `data/` — open @@ -65,7 +65,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**64 strip of 154**, in 10 file(s). +**64 strip of 155**, in 10 file(s). | File | Strip | Sites | |---|---|---| @@ -74,12 +74,12 @@ over it is here. | `driver-sql.zod.ts` | 2 | 2 | | `driver.zod.ts` | 9 | 9 | | `external-catalog.zod.ts` | 4 | 4 | -| `field.zod.ts` | 1 | 13 | +| `field.zod.ts` | 1 | 14 | | `filter.zod.ts` | 11 | 13 | | `hook.zod.ts` | 5 | 8 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **64** | **154** | +| **total** | **64** | **155** | | Bucket | Sites | |---|---| diff --git a/packages/drivers/driver-sql/src/builtin-column-collision.ts b/packages/drivers/driver-sql/src/builtin-column-collision.ts index aacd9839276..662acacea84 100644 --- a/packages/drivers/driver-sql/src/builtin-column-collision.ts +++ b/packages/drivers/driver-sql/src/builtin-column-collision.ts @@ -148,6 +148,7 @@ export const FIELD_KEY_STORAGE_CLASS: Readonly> = lookupFilters: 'presentation', dependsOn: 'presentation', allowCreate: 'presentation', + lookupAutofill: 'presentation', // a lookup's pick autofill pairs (#27945), read by the form and grid on pick — never by `createColumn` // Read by the WRITE path, never by the DDL: `initObjects` registers an // autonumber generator off `type: 'auto_number'` without skipping builtin // names, so the format an author declares is honoured rather than discarded. diff --git a/packages/metadata-core/src/object-schema-fls-references.test.ts b/packages/metadata-core/src/object-schema-fls-references.test.ts index 922e89cf2e1..14e9ee4c6d4 100644 --- a/packages/metadata-core/src/object-schema-fls-references.test.ts +++ b/packages/metadata-core/src/object-schema-fls-references.test.ts @@ -23,6 +23,7 @@ import { FIELD_REFERENCE_POSITIONS, INLINE_COLUMN_POSITIONS, LIST_COLUMN_POSITIONS, + LOOKUP_AUTOFILL_PAIR_POSITIONS, OBJECT_REFERENCE_POSITIONS, mentionsDenied, } from './object-schema-fls-references.js'; @@ -404,6 +405,29 @@ describe('[ADR-0106 D1] references to a denied field — inside a READABLE field expect(served.fields.title).not.toHaveProperty('inlineAmountField'); }); + it('drops a `lookupAutofill` pair whose target is denied, and keeps a source named on the referenced object', () => { + // #27945 — `target` is a field of THIS object; `source` names a field of + // the lookup's `reference` object, which this projection cannot judge. + const served = mask({ + name: 'thing', + fields: fieldsWith({ + type: 'lookup', + reference: 'other', + lookupAutofill: [ + { target: 'id', source: 'secret_score' }, + { target: 'secret_score', source: 'price', overwrite: true }, + ], + }), + }); + expect(served.fields.title.lookupAutofill).toEqual([{ target: 'id', source: 'secret_score' }]); + + const allDenied = mask({ + name: 'thing', + fields: fieldsWith({ type: 'lookup', reference: 'other', lookupAutofill: [{ target: 'secret_score', source: 'price' }] }), + }); + expect(allDenied.fields.title).not.toHaveProperty('lookupAutofill'); + }); + it('keeps a readable inline amount field, and deletes a grid left with no column', () => { const served = mask({ name: 'thing', @@ -731,6 +755,14 @@ describe('[ADR-0106 D1] every position is classified — closed against the live expect(unclassified, 'classify each new InlineGridColumnSchema key in INLINE_COLUMN_POSITIONS').toEqual([]); }); + // A lookup autofill pair's `target` names a field of this object (#27945). + it('classifies every lookupAutofill pair key, and nothing it no longer declares', () => { + const list = (FieldSchema.shape as unknown as Record).lookupAutofill!; + const keys = declaredKeys(list.unwrap().element); + expect(keys.filter((key) => !(key in LOOKUP_AUTOFILL_PAIR_POSITIONS)), 'classify each new lookupAutofill pair key').toEqual([]); + expect(Object.keys(LOOKUP_AUTOFILL_PAIR_POSITIONS).filter((key) => !keys.includes(key))).toEqual([]); + }); + // A list column's nested pointers (`prefix.field`, `summary.field`) name // fields of this object: a new facet must be classified before it ships. for (const [label, schema, table] of [ diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index 9dba1178c71..0ed47ef4aba 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -589,6 +589,27 @@ const deletedWhen: Scrub = (value, denied) => { return typeof field === 'string' && namesDeniedField(field, denied) ? REMOVE : value; }; +/** + * [ADR-0106 D1] One `lookupAutofill` pair `{ target, source, overwrite? }` + * (#27945). `target` is a field of THIS object the pick fills; `source` names a + * field of the lookup's `reference` object — that object's projection governs + * it, and this scrub cannot verify it, so it is kept. A pair whose `target` is + * denied is dropped whole: a bare `source` would fill a column the caller is not + * served. An emptied list is deleted by {@link arrayOf}. + */ +export const LOOKUP_AUTOFILL_PAIR_POSITIONS: Readonly> = { + target: pointer, + source: keep, + overwrite: keep, +}; + +const lookupAutofillPair: Scrub = (value, denied) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return unclassified(value, denied); + const rec = value as Record; + if (typeof rec.target === 'string' && namesDeniedField(rec.target, denied)) return REMOVE; + return scrubRecord(rec, LOOKUP_AUTOFILL_PAIR_POSITIONS, denied); +}; + /** * [ADR-0106 D1] Every `FieldSchema` key, classified. Pinned against the live * schema shape by `object-schema-fls-references.test.ts`. @@ -629,6 +650,9 @@ export const FIELD_REFERENCE_POSITIONS: Readonly> = { referenceDeletedWhen: deletedWhen, inlineAmountField: pointer, inlineColumns: arrayOf(inlineColumn), + // A lookup's pick autofill pairs (#27945): each `target` is a field of this + // object, each `source` one of the referenced object's. + lookupAutofill: arrayOf(lookupAutofillPair), relatedListColumns: names, dependsOn: nameList(DEPENDS_ON_ENTRY_POSITIONS), // Evaluated against this object's record. diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index eedbbfca509..cdb362681fa 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -350,8 +350,10 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no // record page's related lists. // [#27973] `field` 79 → 80: `percentDisplay`, a percent field's read-only // face (the progress bar or the plain percentage). + // [#27945] `field` 80 → 81: `lookupAutofill`, the fields a pick in a + // lookup fills. const CARD_PROPERTY_COUNTS: Record = { - agent: 24, app: 22, dashboard: 20, dataset: 16, field: 80, flow: 22, + agent: 24, app: 22, dashboard: 20, dataset: 16, field: 81, flow: 22, hook: 23, object: 49, page: 26, position: 13, report: 23, skill: 16, tool: 14, }; diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 5b02a374062..be518ea52e0 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -314,6 +314,7 @@ "data/Field:internal", "data/Field:label", "data/Field:language", + "data/Field:lookupAutofill", "data/Field:lookupColumns", "data/Field:lookupFilters", "data/Field:lookupPageSize", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index edea8b8dda4..deaee0208df 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -378,6 +378,30 @@ "verifiedAt": "2026-10-16", "note": "objectui LookupField — opt-in inline quick-create (dataSource.create from typed text). Read camelCase-only at objectui @daeb77fc84: packages/fields/src/widgets/LookupField.tsx#explicitAllowCreate (objectui#12157 retired the `allow_create` read)." }, + "lookupAutofill": { + "status": "planned", + "verifiedAt": "2026-10-30", + "evidenceScope": "in-repo", + "evidence": "packages/spec/src/data/field.zod.ts#lookupAutofill (the parse-time door, field half: the FieldSchema superRefine refuses the key off a single-value `lookup` / `master_detail`); packages/spec/src/data/object.zod.ts#refuseDishonestLookupAutofill (the parse-time door, target half: each target a declared, author-written field of the same object, not the lookup itself, named once); packages/spec/src/stack.zod.ts#collectLookupAutofillErrors (the `os validate` / `os build` door, source half: a declared, non-`internal` field of the referenced object whose value the target holds); packages/spec/src/data/field-lookup-autofill.test.ts (the accept and refuse pins)", + "note": "PLANNED — declared ahead of its one reader by the card's own sequencing (#27945 is the spec half; the ui half, the record form's lookup widget and the line-item grid writing the declared targets on pick, is a separate domain:ui card). PRODUCER: HotCRM's quote line item (product lookup, `unit_price` from the product's `list_price`), today filled only after save by a server hook. CONSUMER: ui/packages/fields LookupField (the form) and ui/packages/fields/src/widgets/GridField.tsx#lookupAutofillPatch (the grid), which today copies only same-named fields under the column key `autofill`. Measured in this repo at 3a2f4c00: no reader of `lookupAutofill` anywhere outside packages/spec, ui/ included (the key is new). No `authorWarn`, on the `referenceDeletedWhen` precedent: the authoring half (parse, target and source checks) is enforced now, an author who sets it today re-authors nothing when the reader lands, and a warned field row would reopen the lint's field walk for one key. Flip to `live` when the ui card lands, citing both read points.", + "children": { + "target": { + "status": "planned", + "verifiedAt": "2026-10-30", + "note": "The field of the declaring object a pick fills. Judged at parse by the container's evidence (snake_case; declared on the same object, not the lookup itself, not readonly, not computed, named once); written by nothing until the ui card's form and grid read the pairs. Shares the container's verdict and its flip." + }, + "source": { + "status": "planned", + "verifiedAt": "2026-10-30", + "note": "The field of the picked record whose value is copied. Judged at `os validate` / `os build` by the container's evidence (declared on the referenced object, not internal, a value the target holds); read by nothing until the ui card lands. Shares the container's verdict and its flip." + }, + "overwrite": { + "status": "planned", + "verifiedAt": "2026-10-30", + "note": "Opt-in to replace a value the target already holds on every pick; absent, a pick fills only an empty target or one an earlier pick filled. Read by nothing until the ui card lands. Shares the container's verdict and its flip." + } + } + }, "searchable": { "status": "live", "evidence": "ui/packages/plugin-dashboard/src/WidgetConfigPanel.tsx (:458 in objectui, commit not pinned)", diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index 48598f1aa23..e61689f1e07 100644 --- a/packages/spec/liveness/state-counts/field.md +++ b/packages/spec/liveness/state-counts/field.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `field` | 100 | 0 | 0 | 1 | 6 | 107 | +| `field` | 100 | 0 | 0 | 1 | 9 | 110 | diff --git a/packages/spec/src/data/field-lookup-autofill.test.ts b/packages/spec/src/data/field-lookup-autofill.test.ts new file mode 100644 index 00000000000..dfe308c0192 --- /dev/null +++ b/packages/spec/src/data/field-lookup-autofill.test.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { FieldSchema, InlineGridColumnSchema } from './field.zod'; +import { ObjectSchema } from './object.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { defineStack } from '../stack.zod'; + +// --------------------------------------------------------------------------- +// [#27945] `lookupAutofill: [{ target, source, overwrite? }]` on a single-value +// relationship field: a pick in the lookup fills `target` (a field of THIS +// object) from `source` (a field of the referenced object). Three doors judge +// it, and each refusal is pinned here by its code and its path: +// +// - field half (`FieldSchema`): only on a single-value `lookup` / +// `master_detail`; a pair is a closed shape of snake_case names; +// - target half (`ObjectSchema`): a declared field of this object, not the +// lookup itself, not `readonly`, not computed, named once; +// - source half (`defineStack`'s cross-reference pass): a declared field of +// the referenced object, not `internal`, whose value the target can hold. +// +// The producer's own shape (HotCRM's quote line: `unit_price` from the picked +// product's `list_price`) parses clean, and the grid column key `autofill` +// keeps its contract (the control at the end). +// --------------------------------------------------------------------------- + +const manifest = { + id: 'com.example.lookupautofill', + name: 'lookup-autofill-test', + version: '1.0.0', + type: 'app' as const, +}; + +const product = (extra: Record = {}) => ({ + name: 'crm_product', + label: 'Product', + fields: { + name: { type: 'text', label: 'Name' }, + description: { type: 'textarea', label: 'Description' }, + support_email: { type: 'email', label: 'Support Email' }, + list_price: { type: 'currency', label: 'List Price' }, + weight: { type: 'number', label: 'Weight' }, + is_active: { type: 'boolean', label: 'Active' }, + category: { type: 'lookup', label: 'Category', reference: 'crm_category' }, + tags: { type: 'multiselect', label: 'Tags', options: [{ label: 'Alpha', value: 'alpha' }] }, + cost_token: { type: 'text', label: 'Cost Token', internal: true }, + margin: { type: 'formula', label: 'Margin', returnType: 'currency', expression: 'record.list_price * 0.2' }, + ...extra, + }, +}); + +const category = { name: 'crm_category', label: 'Category', fields: { name: { type: 'text', label: 'Name' } } }; +const region = { name: 'crm_region', label: 'Region', fields: { name: { type: 'text', label: 'Name' } } }; + +const line = (pairs: unknown, productField: Record = {}) => ({ + name: 'crm_quote_line_item', + label: 'Quote Line Item', + fields: { + crm_product: { type: 'lookup', label: 'Product', reference: 'crm_product', lookupAutofill: pairs, ...productField }, + unit_price: { type: 'currency', label: 'Sales Price' }, + list_price: { type: 'currency', label: 'List Price', readonly: true }, + quantity: { type: 'number', label: 'Quantity' }, + notes: { type: 'text', label: 'Notes' }, + description: { type: 'textarea', label: 'Description' }, + is_taxable: { type: 'boolean', label: 'Taxable' }, + category: { type: 'lookup', label: 'Category', reference: 'crm_category' }, + region: { type: 'lookup', label: 'Region', reference: 'crm_region' }, + tags: { type: 'multiselect', label: 'Tags', options: [{ label: 'Alpha', value: 'alpha' }] }, + subtotal: { type: 'formula', label: 'Subtotal', returnType: 'currency', expression: 'record.quantity * record.unit_price' }, + line_no: { type: 'autonumber', label: 'Line', autonumberFormat: 'L-{0000}' }, + net_price: { type: 'currency', label: 'Net', expression: 'record.unit_price * 0.9' }, + }, +}); + +/** HotCRM's quote line, on the overwrite rule's default: the negotiated price is filled while empty. */ +const HOTCRM_PAIRS = [{ target: 'unit_price', source: 'list_price' }]; + +type Issue = { code: string; path: PropertyKey[]; message: string }; + +/** The one issue a refused value must carry. */ +function refusedOnce(result: { success: boolean; error?: { issues: Issue[] } }): Issue { + expect(result.success, 'the declaration must NOT parse clean').toBe(false); + expect(result.error!.issues).toHaveLength(1); + return result.error!.issues[0]!; +} + +/** The object's one located target refusal. */ +function targetRefusedAt(pairs: unknown, index: number): Issue { + const issue = refusedOnce(ObjectSchema.safeParse(line(pairs)) as never); + expect([issue.code, issue.path]).toEqual(['custom', ['fields', 'crm_product', 'lookupAutofill', index, 'target']]); + return issue; +} + +const build = (stack: unknown) => defineStack(stack as Parameters[0]); + +type Refusal = { code?: string; status?: number; issues?: unknown[] }; + +/** The stack's one cross-reference finding, at the pair's source. */ +function sourceRefusedAt(objects: unknown[], index: number): string { + let refusal: Refusal | undefined; + try { + build({ manifest, objects }); + } catch (error) { + refusal = error as Refusal; + } + expect(refusal, 'expected defineStack to REFUSE this stack, and it accepted it').toBeDefined(); + expect(refusal!.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refusal!.status).toBe(422); + expect(refusal!.issues).toHaveLength(1); + const finding = String(refusal!.issues![0]); + expect(finding.startsWith(`Object 'crm_quote_line_item' field 'crm_product' lookupAutofill[${index}].source: `)).toBe(true); + return finding; +} + +const stackOf = (pairs: unknown) => ({ manifest, objects: [category, region, product(), line(pairs)] }); + +describe('`lookupAutofill` — field half (FieldSchema)', () => { + it('accepts the pairs on a `lookup` and on a `master_detail` (control)', () => { + for (const type of ['lookup', 'master_detail']) { + const parsed = FieldSchema.safeParse({ type, reference: 'crm_product', lookupAutofill: [{ target: 'unit_price', source: 'list_price', overwrite: true }] }); + expect(parsed.success).toBe(true); + } + }); + + it('refuses the key on a field that is not a relationship', () => { + const issue = refusedOnce(FieldSchema.safeParse({ type: 'text', lookupAutofill: HOTCRM_PAIRS }) as never); + expect([issue.code, issue.path]).toEqual(['custom', ['lookupAutofill']]); + expect(issue.message.startsWith("`lookupAutofill` is only valid on `type: 'lookup'` or `type: 'master_detail'`")).toBe(true); + }); + + it('refuses the key on a multi-value relationship', () => { + const issue = refusedOnce(FieldSchema.safeParse({ type: 'lookup', reference: 'crm_product', multiple: true, lookupAutofill: HOTCRM_PAIRS }) as never); + expect([issue.code, issue.path]).toEqual(['custom', ['lookupAutofill']]); + expect(issue.message.startsWith('`lookupAutofill` is not valid on a multi-value relationship (`multiple: true`)')).toBe(true); + }); + + it('refuses an empty list, a non-snake_case name and an unknown key in a pair', () => { + const empty = refusedOnce(FieldSchema.safeParse({ type: 'lookup', reference: 'crm_product', lookupAutofill: [] }) as never); + expect([empty.code, empty.path]).toEqual(['too_small', ['lookupAutofill']]); + + const casing = refusedOnce(FieldSchema.safeParse({ type: 'lookup', reference: 'crm_product', lookupAutofill: [{ target: 'UnitPrice', source: 'list_price' }] }) as never); + expect([casing.code, casing.path]).toEqual(['invalid_format', ['lookupAutofill', 0, 'target']]); + + const alias = refusedOnce(FieldSchema.safeParse({ type: 'lookup', reference: 'crm_product', lookupAutofill: [{ target: 'unit_price', from: 'list_price', source: 'list_price' }] }) as never); + expect([alias.code, alias.path]).toEqual(['unrecognized_keys', ['lookupAutofill', 0]]); + expect(alias.message).toContain('`source`'); + }); +}); + +describe('`lookupAutofill` — target half (ObjectSchema)', () => { + it('accepts the producer shape, and each target kind a pick may fill (control)', () => { + expect(ObjectSchema.safeParse(line(HOTCRM_PAIRS)).success).toBe(true); + expect(ObjectSchema.safeParse(line([ + { target: 'unit_price', source: 'list_price' }, + { target: 'description', source: 'description', overwrite: true }, + { target: 'category', source: 'category' }, + ])).success).toBe(true); + }); + + it('refuses the lookup itself as a target', () => { + expect(targetRefusedAt([{ target: 'crm_product', source: 'name' }], 0).message).toContain('which is the lookup itself'); + }); + + it('refuses a target the object does not declare', () => { + expect(targetRefusedAt([{ target: 'sales_price', source: 'list_price' }], 0).message).toContain('declares no field `sales_price`'); + }); + + it('refuses a `readonly` target', () => { + expect(targetRefusedAt([{ target: 'list_price', source: 'list_price' }], 0).message).toContain('a `readonly` field'); + }); + + it('refuses a computed target — formula, autonumber, and a stored field with an `expression`', () => { + expect(targetRefusedAt([{ target: 'subtotal', source: 'list_price' }], 0).message).toContain('a `formula` field'); + expect(targetRefusedAt([{ target: 'line_no', source: 'name' }], 0).message).toContain('a `autonumber` field'); + expect(targetRefusedAt([{ target: 'net_price', source: 'list_price' }], 0).message).toContain('computed from its `expression`'); + }); + + it('refuses a target named by an earlier pair, at the later pair', () => { + const issue = targetRefusedAt([ + { target: 'unit_price', source: 'list_price' }, + { target: 'unit_price', source: 'margin', overwrite: true }, + ], 1); + expect(issue.message).toContain('an earlier pair of this field already fills'); + }); + + it('refuses at the save door too (the registered `object` type schema)', () => { + const objectType = getMetadataTypeSchema('object')!; + const issue = refusedOnce(objectType.safeParse(line([{ target: 'list_price', source: 'list_price' }])) as never); + expect([issue.code, issue.path]).toEqual(['custom', ['fields', 'crm_product', 'lookupAutofill', 0, 'target']]); + }); +}); + +describe('`lookupAutofill` — source half (defineStack cross-reference)', () => { + it('accepts the producer shape and every compatible pairing (control)', () => { + expect(() => build(stackOf(HOTCRM_PAIRS))).not.toThrow(); + expect(() => build(stackOf([ + { target: 'unit_price', source: 'margin' }, // a currency formula into currency + { target: 'quantity', source: 'weight' }, // number into number + { target: 'notes', source: 'support_email' }, // a string into plain text + { target: 'description', source: 'description', overwrite: true }, + { target: 'is_taxable', source: 'is_active' }, + { target: 'category', source: 'category' }, // a relationship over the same object + { target: 'tags', source: 'tags' }, + ]))).not.toThrow(); + // `number` and `currency` hold each other's value. + expect(() => build(stackOf([{ target: 'quantity', source: 'list_price' }]))).not.toThrow(); + }); + + it('does not judge a referenced object the stack does not declare, nor an injected audit column', () => { + expect(() => build({ manifest, objects: [category, region, line([{ target: 'notes', source: 'anything' }])] })).not.toThrow(); + expect(() => build(stackOf([{ target: 'notes', source: 'created_at' }]))).not.toThrow(); + }); + + it('refuses a source the referenced object does not declare', () => { + const finding = sourceRefusedAt([category, region, product(), line([{ target: 'unit_price', source: 'price' }])], 0); + expect(finding).toContain("'price' is not a field of 'crm_product'"); + }); + + it('refuses an `internal` source', () => { + const finding = sourceRefusedAt([category, region, product(), line([{ target: 'notes', source: 'cost_token' }])], 0); + expect(finding).toContain("'crm_product.cost_token' is `internal`"); + }); + + it('refuses a source whose value the target cannot hold', () => { + const objects = (pairs: unknown) => [category, region, product(), line(pairs)]; + expect(sourceRefusedAt(objects([{ target: 'unit_price', source: 'name' }]), 0)) + .toContain("cannot fill 'crm_quote_line_item.unit_price' — the target cannot hold the source's value (`text` → `currency`)"); + expect(sourceRefusedAt(objects([{ target: 'unit_price', source: 'list_price' }, { target: 'is_taxable', source: 'weight' }]), 1)) + .toContain("the target cannot hold the source's value (`number` → `boolean`)"); + expect(sourceRefusedAt(objects([{ target: 'region', source: 'category' }]), 0)) + .toContain("a record id fills only a relationship over the same object (`lookup` over 'crm_category' → `lookup` over 'crm_region')"); + expect(sourceRefusedAt(objects([{ target: 'notes', source: 'tags' }]), 0)) + .toContain('one holds a list and the other a single value (`multiselect` → `text`)'); + expect(sourceRefusedAt(objects([{ target: 'is_taxable', source: 'margin' }]), 0)) + .toContain("the target cannot hold the source's value (`formula` returning `currency` → `boolean`)"); + }); +}); + +describe('control: the grid column key `autofill` keeps its contract', () => { + it('a lookup column still takes `autofill` on and off, and its describe is unchanged', () => { + for (const autofill of [true, false]) { + expect(InlineGridColumnSchema.safeParse({ name: 'crm_product', type: 'lookup', reference: 'crm_product', autofill }).success).toBe(true); + } + expect(InlineGridColumnSchema.safeParse({ name: 'crm_product' }).success).toBe(true); + const shape = (InlineGridColumnSchema as unknown as { shape: Record }).shape; + expect(shape.autofill!.description).toBe( + "For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable.", + ); + }); + + it('the declared pairs live on the field: a grid column refuses `lookupAutofill`', () => { + const issue = refusedOnce(InlineGridColumnSchema.safeParse({ name: 'crm_product', lookupAutofill: HOTCRM_PAIRS }) as never); + expect(issue.code).toBe('unrecognized_keys'); + }); +}); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 345c7ddce6d..570bb6f57de 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1986,6 +1986,55 @@ export const FieldSchema = lazySchema(() => { })])).optional().describe("Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; {field,param} when the remote filter key differs — the {field,param} form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent."), allowCreate: z.boolean().optional().describe('Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field.'), + /** + * LOOKUP-PICK AUTOFILL — which fields of the record being edited a pick in + * this lookup fills, each from a named field of the picked record + * (`target ← lookup.source`): a quote line's `unit_price` from the picked + * product's `list_price`. + * + * ⭐ The FIELD is its one home, not a form view. A record form draws this + * field from its definition whichever form is showing it (a declared form + * view, a `formViews` entry, or the derived form an object without one + * gets), and the inline line-item grid hydrates its columns from the same + * child field definitions — so both read one declaration. A form-view key + * would be invisible to the grid and to every other form of the object. + * + * ⭐ The overwrite rule is part of the contract, stated in the describe: a + * pick never replaces a value somebody wrote unless the pair says + * `overwrite: true`. + * + * ⭐ Relation to the grid column key `autofill` (`InlineGridColumnSchema`), + * which is unchanged: that key governs only the grid's implicit SAME-NAMED + * copy (on by default, overwriting). A declared pair applies in the grid too, + * under this key's own overwrite rule, and takes its target out of the + * same-named copy, so one target never has two writers on one pick. + * + * Three doors judge it: the parse (this field's superRefine — a single-value + * relationship only), the object's parse (`refuseDishonestLookupAutofill` in + * `object.zod.ts` — each target a writable field of the same object, named + * once), and the stack's cross-reference pass (`collectLookupAutofillErrors` + * in `stack.zod.ts` — each source a readable field of the referenced object + * whose value the target can hold). + */ + lookupAutofill: z.array(strictObject({ + surface: 'this lookupAutofill entry', + history: 'A new shape, closed from its first release: a misspelt key in a pair would otherwise fill nothing and say nothing.', + aliases: { + field: 'target', to: 'target', targetField: 'target', + from: 'source', sourceField: 'source', lookupField: 'source', + replace: 'overwrite', force: 'overwrite', + }, + }, { + target: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Field of THIS object the pick fills (snake_case machine name).'), + source: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe("Field of the picked record — a field of this lookup's `reference` object — whose value is copied (snake_case machine name)."), + overwrite: z.boolean().optional().describe('TRUE: the pick replaces whatever the target holds, a typed value included. Absent or FALSE: the pick fills the target only while it is empty or still holds the value an earlier pick filled in.'), + })).min(1).optional().describe( + "Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. " + + "OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. " + + 'Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. ' + + "Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy.", + ), + /** * Calculation — CEL formula. Plain string accepted for back-compat; build emits canonical envelope. * On a `formula` field it is computed on read and has no column. On any other (stored) field the @@ -2516,6 +2565,32 @@ export const FieldSchema = lazySchema(() => { "`type: 'email'` if it holds an email address, or drop the key.", }); } + // [#27945] `lookupAutofill` copies fields of the ONE record a pick chooses. + // Off a relationship there is no pick, and on a multi-value relationship a + // pick chooses several records with no single source to copy from — either + // way the declaration would parse and fill nothing. The target and source + // halves need the object's field map and the stack's objects, and are judged + // in `ObjectSchema`'s superRefine and the stack's cross-reference pass. + if (field.lookupAutofill !== undefined && field.type !== 'lookup' && field.type !== 'master_detail') { + ctx.addIssue({ + code: 'custom', + path: ['lookupAutofill'], + message: + "`lookupAutofill` is only valid on `type: 'lookup'` or `type: 'master_detail'` (this field is " + + `\`${field.type}\`): it fills fields of the record being edited from the record a pick in this ` + + 'field chooses, and only a relationship field has a pick. Declare it on the relationship field ' + + 'whose pick should fill the form, or drop the key.', + }); + } else if (field.lookupAutofill !== undefined && field.multiple === true) { + ctx.addIssue({ + code: 'custom', + path: ['lookupAutofill'], + message: + '`lookupAutofill` is not valid on a multi-value relationship (`multiple: true`): a pick there ' + + 'chooses several records, so there is no single record to copy each `source` from. Drop the key, ' + + 'or make the field single-value if one record is picked.', + }); + } // [#13632] (ADR-0049 declared = enforced): the `reference` TSDoc above has // always called the key REQUIRED on the relationship types, but the schema diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 3e50383e5b5..944f7a37308 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2048,6 +2048,91 @@ function refuseDishonestReferenceDeletedWhen( } } +/** Field types whose stored value the engine computes, so a filled value never stands. */ +const LOOKUP_AUTOFILL_COMPUTED_TYPES: ReadonlySet = new Set(['formula', 'summary', 'autonumber']); + +/** + * [#27945] Each `lookupAutofill` pair's `target` names a field of THIS object + * that an author writes: a pick fills it on the form, and the form saves it as + * an ordinary write. A target the write path drops or recomputes would show a + * value the save then loses, and a target named twice would have two writers + * on one pick — each parses and fills nothing that lasts (ADR-0049 declared = + * enforced). + * + * Refused, one located issue per offending pair at + * `['fields', , 'lookupAutofill', , 'target']`, when the target: + * + * - is the declaring lookup itself (a pick already writes it); + * - names no declared field of this object; + * - is `readonly: true` (a non-system write drops it from the payload); + * - is computed — `formula`, `summary`, `autonumber`, or a stored field with + * an `expression` (the engine writes it, so a client value never stands); + * - is named by an earlier pair of the same field. + * + * Judged here, in the object's `superRefine` beside + * {@link refuseDishonestReferenceDeletedWhen}: `FieldSchema` sees one field at + * a time and refuses only the half it can see (the key off a single-value + * relationship). The SOURCE half needs the referenced object, which only the + * stack holds — `collectLookupAutofillErrors` in `stack.zod.ts`. Judged at + * parse, so every door — `defineStack`, a raw `.parse()`, the save door — + * refuses it; the key is new, so no metadata at rest carries it. + */ +function refuseDishonestLookupAutofill( + ownName: unknown, + fields: unknown, + ctx: z.RefinementCtx, +): void { + if (fields === null || typeof fields !== 'object') return; + const objectName = typeof ownName === 'string' ? ownName : ''; + const fieldMap = fields as Record; + for (const [fieldName, fieldDef] of Object.entries(fieldMap)) { + const pairs = (fieldDef as { lookupAutofill?: unknown } | null)?.lookupAutofill; + if (!Array.isArray(pairs)) continue; + const seen = new Set(); + pairs.forEach((pair: unknown, i: number) => { + const target = (pair as { target?: unknown } | null)?.target; + if (typeof target !== 'string') return; + const path = ['fields', fieldName, 'lookupAutofill', i, 'target']; + const where = `Field \`${fieldName}\` on object \`${objectName}\` declares \`lookupAutofill[${i}].target: '${target}'\``; + const prescription = + '`lookupAutofill[].target` must name a field of this object, other than the lookup itself, that an ' + + 'author writes — not `readonly`, not computed — and each target once.'; + const refuse = (reason: string): void => { + ctx.addIssue({ code: 'custom', path, message: `${where}, ${reason} ${prescription}` }); + }; + if (target === fieldName) { + refuse('which is the lookup itself: a pick already writes the picked id there.'); + return; + } + if (!Object.prototype.hasOwnProperty.call(fieldMap, target)) { + refuse(`but the object declares no field \`${target}\`.`); + return; + } + const targetDef = fieldMap[target] as + | { type?: unknown; readonly?: unknown; expression?: unknown } + | null + | undefined; + if (typeof targetDef?.type === 'string' && LOOKUP_AUTOFILL_COMPUTED_TYPES.has(targetDef.type)) { + refuse(`a \`${targetDef.type}\` field: the engine computes its value, so a filled value never stands.`); + return; + } + if (targetDef?.expression !== undefined) { + refuse('a field computed from its `expression` on every write, so a filled value never stands.'); + return; + } + if (targetDef?.readonly === true) { + refuse('a `readonly` field: a non-system write drops it from the payload, so the filled value is never saved.'); + return; + } + if (seen.has(target)) { + refuse('a target an earlier pair of this field already fills: one pick would write it twice.'); + return; + } + seen.add(target); + }); + } +} + /** * [#23809, maintainer ruling on the card: 「同意」] The field an object's * `stageField` names declares a `kind` on EVERY option — `open`, `won` or @@ -3605,6 +3690,9 @@ const ObjectSchemaBase = strictObject( // predicate names a declared single-value `select` sibling and one of its // options — the same door: only the object holds the sibling map. refuseDishonestReferenceDeletedWhen(object.name, object.fields, ctx); + // [#27945] Each `lookupAutofill` target is a writable field of this object, + // named once — the same door: only the object holds the field map. + refuseDishonestLookupAutofill(object.name, object.fields, ctx); // [#23809] Every option of the field `stageField` names declares its // lifecycle `kind` — the same door: only the object knows which field is // its stage. diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index bd94d0979ae..03dbd16586b 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -41,6 +41,7 @@ import { controlledByParentMasterRefusal } from './data/controlled-by-parent-mas import { fieldGroupBodyTableRefusal, type NamedInboundRelationship } from './data/related-list-body-table'; import { externalBindingDatasourceRefusal } from './data/external-binding-datasource'; import { InlineGridColumnSchema } from './data/field.zod'; +import { AUDIT_FIELD_DEFS } from './data/injected-system-column-provenance'; import { pageComponentSlotPositions } from './ui/component.zod'; import { SeedSchema } from './data/seed.zod'; @@ -4087,6 +4088,135 @@ function collectHydratedInlineColumnErrors(config: ObjectStackDefinition): strin return errors; } +/** Target types that hold any single string, so any string-valued source fits them. */ +const LOOKUP_AUTOFILL_TEXT_TARGETS: ReadonlySet = new Set(['text', 'textarea', 'markdown', 'richtext', 'html']); +/** Source types whose value is one plain string. */ +const LOOKUP_AUTOFILL_STRING_SOURCES: ReadonlySet = new Set([ + 'text', 'textarea', 'markdown', 'richtext', 'html', 'email', 'url', 'phone', 'autonumber', +]); +/** The two amount types, which hold each other's value. */ +const LOOKUP_AUTOFILL_AMOUNT_TYPES: ReadonlySet = new Set(['number', 'currency']); +/** The relationship types, whose value is a record id of their `reference`. */ +const LOOKUP_AUTOFILL_RELATIONSHIP_TYPES: ReadonlySet = new Set(['lookup', 'master_detail']); +/** The option types that hold a list without declaring `multiple`. */ +const LOOKUP_AUTOFILL_INHERENTLY_MULTI_TYPES: ReadonlySet = new Set(['multiselect', 'checkboxes', 'tags']); +/** A `formula` source's value type, from its `returnType`. */ +const LOOKUP_AUTOFILL_FORMULA_VALUE_TYPE: Readonly> = { + number: 'number', currency: 'currency', text: 'text', boolean: 'boolean', date: 'date', +}; + +/** + * Why a `source` field's value cannot go into a `target` field, or `undefined` + * when it can (or when the source's value type is not known here). The rule is + * the stored value's shape, not the label: the same type and multiplicity, a + * string-valued source into a plain text target, `number` and `currency` into + * each other, and a relationship only into a relationship over the same + * object. + */ +function lookupAutofillTypeMismatch( + target: Record, + source: Record, +): string | undefined { + const targetType = target.type; + let sourceType = source.type; + if (sourceType === 'formula') { + sourceType = typeof source.returnType === 'string' ? LOOKUP_AUTOFILL_FORMULA_VALUE_TYPE[source.returnType] : undefined; + } + // A `summary`'s value type follows its operation and its rolled-up field, + // and a formula with no `returnType` declares none: not known here, so not + // judged — an unresolved type is not a wrong one. + if (typeof targetType !== 'string' || typeof sourceType !== 'string' || sourceType === 'summary') return undefined; + const isList = (def: Record, type: string): boolean => + def.multiple === true || LOOKUP_AUTOFILL_INHERENTLY_MULTI_TYPES.has(type); + const pairing = `\`${String(source.type)}\`${source.type === 'formula' ? ` returning \`${sourceType}\`` : ''} → \`${targetType}\``; + if (isList(target, targetType) !== isList(source, sourceType)) { + return `one holds a list and the other a single value (${pairing})`; + } + if (LOOKUP_AUTOFILL_RELATIONSHIP_TYPES.has(targetType) || LOOKUP_AUTOFILL_RELATIONSHIP_TYPES.has(sourceType)) { + if (LOOKUP_AUTOFILL_RELATIONSHIP_TYPES.has(targetType) && LOOKUP_AUTOFILL_RELATIONSHIP_TYPES.has(sourceType) + && target.reference === source.reference) return undefined; + const over = (def: Record): string => (typeof def.reference === 'string' ? ` over '${def.reference}'` : ''); + return `a record id fills only a relationship over the same object (${pairing.replace(' → ', `${over(source)} → `)}${over(target)})`; + } + if (targetType === sourceType) return undefined; + if (LOOKUP_AUTOFILL_TEXT_TARGETS.has(targetType) && LOOKUP_AUTOFILL_STRING_SOURCES.has(sourceType)) return undefined; + if (LOOKUP_AUTOFILL_AMOUNT_TYPES.has(targetType) && LOOKUP_AUTOFILL_AMOUNT_TYPES.has(sourceType)) return undefined; + return `the target cannot hold the source's value (${pairing})`; +} + +/** + * [#27945] Each `lookupAutofill` pair's `source` names a field of the object + * the lookup references, which the picked record carries, and whose value the + * pair's target can hold. A source the referenced object does not declare + * arrives as nothing, an `internal` one is omitted from every read, and a value + * of the wrong shape is refused by the save the pick fed — so each would parse + * and fill nothing, or fill something the save then refuses (ADR-0049 declared + * = enforced). + * + * Judged here because only the stack holds the referenced object; the target + * half (a writable field of the declaring object, named once) is the object + * parse's own (`refuseDishonestLookupAutofill` in `data/object.zod.ts`), which + * has already passed by the time this pass runs. The type rule is + * {@link lookupAutofillTypeMismatch}. + * + * Resolution is against the stack's own `objects`, merged with its + * `objectExtensions` by name: a referenced object this stack does not declare + * is not judged — an unresolved source is not a wrong one, the stance of + * {@link collectHydratedInlineColumnErrors}. An undeclared source that is one + * of the audit-provenance columns the platform injects (`created_at`, …) is + * not judged either: every record carries it without a declaration. + */ +function collectLookupAutofillErrors(config: ObjectStackDefinition): string[] { + const errors: string[] = []; + const fieldsByObject = new Map>(); + for (const obj of config.objects ?? []) { + if (!isRecord(obj) || !isRecord(obj.fields)) continue; + fieldsByObject.set(obj.name, { ...fieldsByObject.get(obj.name), ...obj.fields }); + } + for (const ext of (config as { objectExtensions?: unknown }).objectExtensions as unknown[] ?? []) { + if (!isRecord(ext) || typeof ext.extend !== 'string' || !isRecord(ext.fields)) continue; + fieldsByObject.set(ext.extend, { ...fieldsByObject.get(ext.extend), ...ext.fields }); + } + + for (const [objectName, fields] of fieldsByObject) { + for (const [fieldName, field] of Object.entries(fields)) { + if (!isRecord(field) || !Array.isArray(field.lookupAutofill) || typeof field.reference !== 'string') continue; + const referenced = fieldsByObject.get(field.reference); + if (!referenced) continue; + field.lookupAutofill.forEach((pair: unknown, i: number) => { + if (!isRecord(pair) || typeof pair.source !== 'string') return; + const where = `Object '${objectName}' field '${fieldName}' lookupAutofill[${i}].source`; + const source = hasOwnKey(referenced, pair.source) ? referenced[pair.source] : undefined; + if (!isRecord(source)) { + if (hasOwnKey(AUDIT_FIELD_DEFS, pair.source)) return; + errors.push( + `${where}: '${pair.source}' is not a field of '${field.reference}', the object this lookup references, ` + + `so a pick has no value to copy. Name a field '${field.reference}' declares.`, + ); + return; + } + if (source.internal === true) { + errors.push( + `${where}: '${field.reference}.${pair.source}' is \`internal\`, so no read returns it and a pick has no ` + + 'value to copy. Name a field the record carries.', + ); + return; + } + const target = typeof pair.target === 'string' && hasOwnKey(fields, pair.target) ? fields[pair.target] : undefined; + if (!isRecord(target)) return; + const mismatch = lookupAutofillTypeMismatch(target, source); + if (mismatch !== undefined) { + errors.push( + `${where}: '${field.reference}.${pair.source}' cannot fill '${objectName}.${String(pair.target)}' — ` + + `${mismatch}. Pair the target with a source whose value it holds.`, + ); + } + }); + } + } + return errors; +} + /** * [#24190] The child object a `record:line_items` block lists, resolved the * way objectui resolves it before `LineItemsPanel` hydrates its columns from @@ -4843,6 +4973,10 @@ function validateCrossReferences( // #24190 the line-items block's), the column schema's own verdict. errors.push(...collectHydratedInlineColumnErrors(config)); + // Validate each lookup-pick autofill source against the referenced object + // (#27945) — the half the object's own parse cannot see. + errors.push(...collectLookupAutofillErrors(config)); + // Validate seed data → object references. ARTIFACT-SCOPED (#18202). errors.push(...collectSeedDataObjectErrors(config, artifactScope)); diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index bc3220b14cb..89a664fa01e 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -375,6 +375,16 @@ const LEDGER: ReadonlyArray = [ key: 'percentDisplay', why: "planned, not yet rendered — a percent field's read-only face (`bar` or `plain`) parses, but the console's percent face draws the progress bar for every value until it reads the key, after the spec publishes. A form control offered today would let an author pick `plain` and still see the bar; the field form gains the row with the renderer half, as `useGrouping` did once it was enforced", }, + // #27945 — a lookup's pick autofill lands spec-first the same way: `planned` + // in the liveness ledger, read by no form or grid until the ui card's lookup + // widget writes the declared targets on pick. + { + kind: 'omit', + type: 'field', + path: ROOT_PATH, + key: 'lookupAutofill', + why: "planned, not yet read — a lookup's pick autofill pairs (`[{ target, source, overwrite? }]`) are validated at parse (a single-value relationship; each target a writable field of the same object, named once) and their sources at `os validate` / `os build`, but no form or grid fills a field from them until the ui card's lookup widget writes the declared targets on pick. A form control offered today would let an author declare a fill nothing performs", + }, { kind: 'omit', type: 'view',