From 2bd208e067442a2881964ac894b113b988c5b06f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 14:54:13 +0000 Subject: [PATCH] feat(spec,objectql,rest): publish the audit-provenance and import-coercion vocabularies (#3786, #4173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more hand-copied lists retired the same way: one spec export, derivation at every consumer, and a conformance test where derivation alone cannot reach. AUDIT_PROVENANCE_FIELDS (spec/data): the four columns applySystemFields injects on audit-tracked objects. The list existed in at least four copies across two repos — the registry's injection if-chain, the rule-validator's preserveAudit allowlist ("Kept in sync with the registry" — by nothing), and two objectui render surfaces. The registry's injection is now table-driven, keyed by the tuple with `satisfies Record` so an undeclared member is a compile error; the rule-validator derives its set from the same tuple; FIELD_GROUP_SYSTEM_FIELDS' audit prefix derives from it too. Injection is byte-identical — a conformance test pins every injected column's shape against the pre-refactor definitions. IMPORT_BOOLEAN_TRUE_TOKENS / IMPORT_BOOLEAN_FALSE_TOKENS / IMPORT_REFERENCE_TYPES (spec/data): the /import coercion vocabulary #4173 asked for. rest's import-coerce.ts derives BOOL_TRUE / BOOL_FALSE / REFERENCE_TYPES from them; IMPORT_REFERENCE_TYPES ships with the legacy 'reference' spelling included, retiring the `+ 'reference'` literal both ends carried separately. objectui's mirror retires once this publishes — the path is written in that file's own header (objectui#3043). Verified: spec 7142 tests green, six gates PASS; the objectql suites this diff touches (registry, rule-validator: 159 tests, incl. the new conformance case) pass; the objectql/rest failures visible locally reproduce identically on a clean tree (24 = 24, 1 = 1 — stale sibling dist, not this change). Closes #4173. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UXGj3Z5TmwSV6RK2oGc3cb --- .../audit-provenance-and-import-vocabulary.md | 48 ++++++++++ packages/objectql/src/registry.test.ts | 21 +++++ packages/objectql/src/registry.ts | 92 ++++++++++--------- .../objectql/src/validation/rule-validator.ts | 14 ++- packages/rest/src/import-coerce.ts | 17 +++- packages/spec/api-surface.json | 5 + .../spec/src/data/field-group-layout.test.ts | 25 ++++- packages/spec/src/data/field-group-layout.ts | 26 +++++- .../spec/src/data/import-coercion.test.ts | 54 +++++++++++ packages/spec/src/data/import-coercion.ts | 47 ++++++++++ packages/spec/src/data/index.ts | 3 + 11 files changed, 296 insertions(+), 56 deletions(-) create mode 100644 .changeset/audit-provenance-and-import-vocabulary.md create mode 100644 packages/spec/src/data/import-coercion.test.ts create mode 100644 packages/spec/src/data/import-coercion.ts diff --git a/.changeset/audit-provenance-and-import-vocabulary.md b/.changeset/audit-provenance-and-import-vocabulary.md new file mode 100644 index 0000000000..9f856407a3 --- /dev/null +++ b/.changeset/audit-provenance-and-import-vocabulary.md @@ -0,0 +1,48 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": patch +"@objectstack/rest": patch +--- + +feat(spec,objectql,rest): publish the audit-provenance and import-coercion vocabularies (#3786, #4173) + +Two more hand-copied lists retired the same way, each replaced by one spec +export and derivation at every consumer. + +**`AUDIT_PROVENANCE_FIELDS`** (`@objectstack/spec/data`, with the +`AuditProvenanceField` type) — the four columns `applySystemFields` injects on +every audit-tracked object: `created_at`, `created_by`, `updated_at`, +`updated_by`. That four-name list existed in at least four copies across two +repos: the registry's injection if-chain, the rule-validator's `preserveAudit` +allowlist ("Kept in sync with the registry's auto-injected audit fields" — by +nothing), and two objectui render surfaces. Now: + +- the registry's injection is table-driven, keyed by the tuple with a + `satisfies Record` clause — a name added to the spec + without a column definition (or vice versa) is a compile error, the + `APPROVER_VALUE_BINDINGS` discipline; +- the rule-validator's `AUDIT_TIMELINE_FIELDS` derives from the same tuple; +- `FIELD_GROUP_SYSTEM_FIELDS`' audit prefix derives from it too — one + declaration even inside the file that hosts both; +- objectui's `AUDIT_FIELD_BY_ROLE` already pins itself by subset assertion and + can import the tuple directly once this release is published. + +Injection behaviour is byte-identical — a conformance test pins every injected +column's shape against the pre-refactor definitions. + +**`IMPORT_BOOLEAN_TRUE_TOKENS` / `IMPORT_BOOLEAN_FALSE_TOKENS` / +`IMPORT_REFERENCE_TYPES`** (`@objectstack/spec/data`) — the `/import` coercion +vocabulary #4173 asked for. The server's `import-coerce.ts` now derives its +`BOOL_TRUE` / `BOOL_FALSE` / `REFERENCE_TYPES` from these instead of owning +them privately, and objectui's Import Wizard preview — which re-checks the same +contract client-side so a cell is flagged red exactly when the server would +reject it — can retire its pinned-inventory mirror once this release is +published (the retirement path is written in that file's own header). +`IMPORT_REFERENCE_TYPES` ships with the legacy `'reference'` spelling included, +retiring the `+ 'reference'` literal both ends carried separately. The tables' +own discipline is tested: sets disjoint, every token pre-normalized +(lower-case, trimmed), and the Chinese / check-mark spreadsheet-reality tokens +pinned by name. + +No behaviour change anywhere: every derived value is byte-identical to the +literal it replaces. diff --git a/packages/objectql/src/registry.test.ts b/packages/objectql/src/registry.test.ts index b61956eae4..297df834c1 100644 --- a/packages/objectql/src/registry.test.ts +++ b/packages/objectql/src/registry.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, warnStrippedLegacyApiMethods, computeFQN, parseFQN } from './registry'; +import { AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data'; describe('SchemaRegistry', () => { let registry: SchemaRegistry; @@ -590,6 +591,26 @@ describe('applySystemFields', () => { expect(out.fields.updated_at).toBeDefined(); }); + it('injects exactly the spec AUDIT_PROVENANCE_FIELDS, byte-identical to the pre-#3786 defs', () => { + // The injection table is keyed by the spec tuple with a `satisfies` + // exhaustiveness clause, so which columns exist cannot drift from the + // spec. This pins the OTHER half — that the refactor from four + // if-blocks to the table changed nothing about what gets injected. + const out = applySystemFields(baseLead, { multiTenant: false }); + for (const name of AUDIT_PROVENANCE_FIELDS) { + expect(out.fields[name], name).toBeDefined(); + expect(out.fields[name].system, `${name}.system`).toBe(true); + expect(out.fields[name].readonly, `${name}.readonly`).toBe(true); + expect(out.fields[name].required, `${name}.required`).toBe(false); + } + expect(out.fields.created_at.type).toBe('datetime'); + expect(out.fields.updated_at.type).toBe('datetime'); + expect(out.fields.created_by).toMatchObject({ type: 'lookup', reference: 'sys_user', label: 'Created By' }); + expect(out.fields.updated_by).toMatchObject({ type: 'lookup', reference: 'sys_user', label: 'Last Modified By' }); + expect(out.fields.created_at.label).toBe('Created At'); + expect(out.fields.updated_at.label).toBe('Last Modified At'); + }); + it('does NOT overwrite an author-declared organization_id', () => { const declared: any = { name: 'lead', diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index df6436a90f..7afa563383 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances, isTenancyDisabled, LEGACY_API_METHODS } from '@objectstack/spec/data'; +import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances, isTenancyDisabled, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS, type AuditProvenanceField } from '@objectstack/spec/data'; import { resolveMultiOrgEnabled, resolveSearchPinyinEnabled } from '@objectstack/types'; import { provisionSearchCompanion } from './search-companion.js'; import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema } from '@objectstack/spec/kernel'; @@ -233,6 +233,53 @@ export interface SchemaRegistryOptions { * ADD ownership — the failure mode we are eliminating — silently breaks * every owner-keyed feature. */ +/** + * Column definitions for the audit-provenance family, keyed by the spec's + * {@link AUDIT_PROVENANCE_FIELDS} tuple — the canonical declaration of WHICH + * columns exist (#3786). This table owns only WHAT each column looks like. + * + * The `satisfies` clause is the sync mechanism: a name added to the spec tuple + * without a definition here — or a definition for a name the spec dropped — is + * a compile error, not a silently diverging copy. Same discipline as the + * spec's `APPROVER_VALUE_BINDINGS`. + */ +const AUDIT_FIELD_DEFS = { + created_at: { + type: 'datetime', + label: 'Created At', + required: false, + readonly: true, + system: true, + description: 'Timestamp when the record was created (auto-populated by the driver).', + }, + created_by: { + type: 'lookup', + reference: 'sys_user', + label: 'Created By', + required: false, + readonly: true, + system: true, + description: 'User who created the record (populated when an authenticated session is present).', + }, + updated_at: { + type: 'datetime', + label: 'Last Modified At', + required: false, + readonly: true, + system: true, + description: 'Timestamp of the most recent modification (auto-populated by the driver).', + }, + updated_by: { + type: 'lookup', + reference: 'sys_user', + label: 'Last Modified By', + required: false, + readonly: true, + system: true, + description: 'User who last modified the record (populated when an authenticated session is present).', + }, +} satisfies Record>; + export function applySystemFields( schema: ServiceObject, opts: { multiTenant: boolean } @@ -314,47 +361,8 @@ export function applySystemFields( } if (wantAudit) { - if (!schema.fields?.created_at) { - additions.created_at = { - type: 'datetime', - label: 'Created At', - required: false, - readonly: true, - system: true, - description: 'Timestamp when the record was created (auto-populated by the driver).', - }; - } - if (!schema.fields?.created_by) { - additions.created_by = { - type: 'lookup', - reference: 'sys_user', - label: 'Created By', - required: false, - readonly: true, - system: true, - description: 'User who created the record (populated when an authenticated session is present).', - }; - } - if (!schema.fields?.updated_at) { - additions.updated_at = { - type: 'datetime', - label: 'Last Modified At', - required: false, - readonly: true, - system: true, - description: 'Timestamp of the most recent modification (auto-populated by the driver).', - }; - } - if (!schema.fields?.updated_by) { - additions.updated_by = { - type: 'lookup', - reference: 'sys_user', - label: 'Last Modified By', - required: false, - readonly: true, - system: true, - description: 'User who last modified the record (populated when an authenticated session is present).', - }; + for (const name of AUDIT_PROVENANCE_FIELDS) { + if (!schema.fields?.[name]) additions[name] = AUDIT_FIELD_DEFS[name]; } } diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 7549c5c40b..d2333fa7ef 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -77,6 +77,7 @@ import { ExpressionEngine } from '@objectstack/formula'; import type { Expression } from '@objectstack/spec'; +import { AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data'; import Ajv, { type ValidateFunction } from 'ajv'; import { ValidationError, @@ -380,15 +381,12 @@ export function stripReadonlyFields( /** * The audit / attribution family — the "original timeline" a historical import * (`preserveAudit`) is allowed to reinstate even though these columns are - * `system` + `readonly`. Kept in sync with the registry's auto-injected audit - * fields (`packages/objectql/src/registry.ts`). + * `system` + `readonly`. DERIVED from the spec's `AUDIT_PROVENANCE_FIELDS` + * (#3786) — the same tuple the registry's injection table is keyed by — so + * this set and the injected columns cannot drift apart. The old literal here + * carried a "kept in sync with the registry" comment and no mechanism. */ -const AUDIT_TIMELINE_FIELDS: ReadonlySet = new Set([ - 'created_at', - 'created_by', - 'updated_at', - 'updated_by', -]); +const AUDIT_TIMELINE_FIELDS: ReadonlySet = new Set(AUDIT_PROVENANCE_FIELDS); /** * Whether a caller-supplied `readonly` field may be REINSTATED by an opt-in diff --git a/packages/rest/src/import-coerce.ts b/packages/rest/src/import-coerce.ts index e564d72b9d..6743e4dc9c 100644 --- a/packages/rest/src/import-coerce.ts +++ b/packages/rest/src/import-coerce.ts @@ -38,8 +38,10 @@ import { NUMERIC_VALUE_TYPES as NUMBER_TYPES, BOOLEAN_VALUE_TYPES as BOOL_TYPES, FILE_REFERENCE_TYPES as FILE_TYPES, - REFERENCE_VALUE_TYPES, isMultiValueField as specIsMultiValueField, + IMPORT_BOOLEAN_TRUE_TOKENS, + IMPORT_BOOLEAN_FALSE_TOKENS, + IMPORT_REFERENCE_TYPES, } from '@objectstack/spec/data'; import type { FieldErrorCode } from '@objectstack/spec/api'; import { @@ -52,7 +54,10 @@ import { * reference class (ADR-0104 D1) plus `reference` — a legacy external-object * alias that is not an authorable `FieldType` and so stays a local extra. */ -const REFERENCE_TYPES = new Set([...REFERENCE_VALUE_TYPES, 'reference']); +// The spec now publishes the completed set (reference value types plus the +// legacy 'reference' spelling), so the `+ 'reference'` literal is retired on +// both ends (#4173). +const REFERENCE_TYPES = IMPORT_REFERENCE_TYPES; /** * Whether a field's stored value is an array. Delegates to the spec's @@ -177,8 +182,12 @@ function isBlank(value: unknown, nullValues?: string[]): boolean { // ── boolean ──────────────────────────────────────────────────────── -const BOOL_TRUE = new Set(['true', 't', 'yes', 'y', '1', 'on', '是', '对', '✓', '√']); -const BOOL_FALSE = new Set(['false', 'f', 'no', 'n', '0', 'off', '否', '错', '✗', '×']); +// DERIVED from the spec's import-coercion vocabulary (#4173): objectui's +// Import Wizard preview re-checks these same tables client-side, so both ends +// reading one export is what keeps a cell flagged red here exactly when the +// server rejects it. The literals used to live in this file alone. +const BOOL_TRUE = IMPORT_BOOLEAN_TRUE_TOKENS; +const BOOL_FALSE = IMPORT_BOOLEAN_FALSE_TOKENS; /** Parse a spreadsheet cell into a boolean, or `undefined` if unrecognised. */ export function parseBooleanCell(raw: unknown): boolean | undefined { diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 46a786dc84..f679c15dad 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -173,6 +173,7 @@ "API_METHOD_ORDER (const)", "API_OPERATION_ORDER (const)", "API_PRIMITIVES (const)", + "AUDIT_PROVENANCE_FIELDS (const)", "Address (type)", "AddressSchema (const)", "AddressValueSchema (const)", @@ -191,6 +192,7 @@ "ApiOperation (type)", "ApiOperationSchema (const)", "ApiPrimitive (type)", + "AuditProvenanceField (type)", "AuthoringKeySurface (type)", "AutonumberToken (type)", "BOOLEAN_VALUE_TYPES (const)", @@ -382,6 +384,9 @@ "HookEvent (const)", "HookEventType (type)", "HookSchema (const)", + "IMPORT_BOOLEAN_FALSE_TOKENS (const)", + "IMPORT_BOOLEAN_TRUE_TOKENS (const)", + "IMPORT_REFERENCE_TYPES (const)", "INSTANT_TYPES (const)", "IndexSchema (const)", "InstantValueSchema (const)", diff --git a/packages/spec/src/data/field-group-layout.test.ts b/packages/spec/src/data/field-group-layout.test.ts index 851ccabfbd..07fad8cabc 100644 --- a/packages/spec/src/data/field-group-layout.test.ts +++ b/packages/spec/src/data/field-group-layout.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { deriveFieldGroupLayout, FIELD_GROUP_SYSTEM_FIELDS } from './field-group-layout'; +import { deriveFieldGroupLayout, FIELD_GROUP_SYSTEM_FIELDS, AUDIT_PROVENANCE_FIELDS } from './field-group-layout'; describe('deriveFieldGroupLayout (ADR-0085 §5)', () => { const groupedDef = { @@ -115,3 +115,26 @@ describe('deriveFieldGroupLayout (ADR-0085 §5)', () => { expect(sections[0].label).toBe('billing'); }); }); + +/** + * The audit-provenance tuple (#3786) — the canonical four-name declaration the + * registry's injection table, the rule-validator's preserveAudit allowlist and + * objectui's AUDIT_FIELD_BY_ROLE all key off. Pinned exactly: this is a wire + * contract (stored column names), so any edit must be loud. + */ +describe('AUDIT_PROVENANCE_FIELDS', () => { + it('is exactly the four provenance columns, in injection order', () => { + expect([...AUDIT_PROVENANCE_FIELDS]).toEqual([ + 'created_at', 'created_by', 'updated_at', 'updated_by', + ]); + expect(Object.isFrozen(AUDIT_PROVENANCE_FIELDS)).toBe(true); + }); + + it('is a subset of FIELD_GROUP_SYSTEM_FIELDS', () => { + // Structural today (the superset spreads the tuple), but asserted anyway so + // a future refactor cannot quietly decouple them. + for (const f of AUDIT_PROVENANCE_FIELDS) { + expect(FIELD_GROUP_SYSTEM_FIELDS.has(f), f).toBe(true); + } + }); +}); diff --git a/packages/spec/src/data/field-group-layout.ts b/packages/spec/src/data/field-group-layout.ts index f7d4317447..dff0c823ed 100644 --- a/packages/spec/src/data/field-group-layout.ts +++ b/packages/spec/src/data/field-group-layout.ts @@ -48,14 +48,38 @@ export interface FieldGroupSection { fields: string[]; } +/** + * The audit-provenance family: the four columns `applySystemFields` + * (`@objectstack/objectql` registry) auto-injects on every audit-tracked + * business object, in injection order. + * + * THE canonical declaration (#3786). Before it existed this four-name list was + * hand-copied at least four times across two repos — the registry's injection + * if-chain, the rule-validator's `preserveAudit` allowlist, and two objectui + * render surfaces — each under a comment asking to be kept in sync with one of + * the others. The registry's injection table and the rule-validator now derive + * from this tuple (with `satisfies` making an undeclared member a compile + * error); objectui's `AUDIT_FIELD_BY_ROLE` pins itself to the superset below + * by subset assertion. + */ +export const AUDIT_PROVENANCE_FIELDS = Object.freeze([ + 'created_at', 'created_by', 'updated_at', 'updated_by', +] as const); + +/** One audit-provenance column name. */ +export type AuditProvenanceField = (typeof AUDIT_PROVENANCE_FIELDS)[number]; + /** * Audit/system fields excluded from the derived UNGROUPED bucket (they carry * no business meaning in a default layout). A field an author explicitly * assigns to a group is kept. Exported so renderers filtering flat layouts * agree with the derivation. + * + * The audit prefix derives from {@link AUDIT_PROVENANCE_FIELDS} — one + * declaration even inside this file. */ export const FIELD_GROUP_SYSTEM_FIELDS: ReadonlySet = new Set([ - 'created_at', 'created_by', 'updated_at', 'updated_by', + ...AUDIT_PROVENANCE_FIELDS, 'organization_id', 'tenant_id', 'is_deleted', 'deleted_at', ]); diff --git a/packages/spec/src/data/import-coercion.test.ts b/packages/spec/src/data/import-coercion.test.ts new file mode 100644 index 0000000000..726b824b71 --- /dev/null +++ b/packages/spec/src/data/import-coercion.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The import-coercion vocabulary's own discipline (#4173). + * + * Both consumers — the server's `/import` coercion and objectui's Import + * Wizard preview — compare after `trim().toLowerCase()`, so a token that is + * not already lower-case and trimmed could never match and would be dead + * weight that READS as an accepted spelling. And a token in both boolean sets + * would make a cell simultaneously truthy and falsy, with the winner decided + * by check order at each consumer. + */ + +import { describe, it, expect } from 'vitest'; + +import { + IMPORT_BOOLEAN_TRUE_TOKENS, + IMPORT_BOOLEAN_FALSE_TOKENS, + IMPORT_REFERENCE_TYPES, +} from './import-coercion'; +import { REFERENCE_VALUE_TYPES } from './field-value.zod'; + +describe('import boolean tokens (#4173)', () => { + it('both sets are non-empty and disjoint', () => { + expect(IMPORT_BOOLEAN_TRUE_TOKENS.size).toBeGreaterThan(0); + expect(IMPORT_BOOLEAN_FALSE_TOKENS.size).toBeGreaterThan(0); + for (const t of IMPORT_BOOLEAN_TRUE_TOKENS) { + expect(IMPORT_BOOLEAN_FALSE_TOKENS.has(t), `'${t}' is in both sets`).toBe(false); + } + }); + + it('every token is already lower-case and trimmed', () => { + for (const t of [...IMPORT_BOOLEAN_TRUE_TOKENS, ...IMPORT_BOOLEAN_FALSE_TOKENS]) { + expect(t, `token '${t}' must be pre-normalized`).toBe(t.trim().toLowerCase()); + expect(t.length).toBeGreaterThan(0); + } + }); + + it('keeps the spreadsheet-reality entries', () => { + // The Chinese and check-mark tokens are the reason this table exists at + // all — real spreadsheets carry them. Losing one is a silent behaviour + // change at BOTH consumers, so pin them by name. + for (const t of ['是', '对', '✓', '√']) expect(IMPORT_BOOLEAN_TRUE_TOKENS.has(t), t).toBe(true); + for (const t of ['否', '错', '✗', '×']) expect(IMPORT_BOOLEAN_FALSE_TOKENS.has(t), t).toBe(true); + }); +}); + +describe('import reference types (#4173)', () => { + it('is the reference value types plus the legacy generic spelling, exactly', () => { + expect([...IMPORT_REFERENCE_TYPES].sort()).toEqual( + [...REFERENCE_VALUE_TYPES, 'reference'].sort(), + ); + }); +}); diff --git a/packages/spec/src/data/import-coercion.ts b/packages/spec/src/data/import-coercion.ts new file mode 100644 index 0000000000..02a290c9ec --- /dev/null +++ b/packages/spec/src/data/import-coercion.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **The import-coercion vocabulary** (#4173, closing out #3786). + * + * Two ends re-check the same contract: the server's `/import` coercion + * (`import-coerce.ts` in `@objectstack/rest`) decides what a spreadsheet cell + * may contain, and objectui's Import Wizard preview re-checks it client-side so + * a cell is flagged red exactly when the server would reject it. Until #4173 + * the token tables lived only in the server file, so the preview carried a + * hand-copy behind a pinned-inventory tripwire — the last hand-copied list the + * #3786 sweep left standing, guarded but not derived. + * + * Constants only — the coercion LOGIC (trimming, number handling, id + * resolution) stays in `@objectstack/rest`, per the no-business-logic rule for + * this package. Every token here must be lower-case and trimmed, because both + * consumers compare after `trim().toLowerCase()`; `import-coercion.test.ts` + * enforces that, plus disjointness of the two boolean sets. + */ + +import { REFERENCE_VALUE_TYPES } from './field-value.zod'; + +/** + * Truthy tokens the boolean cell coercion accepts (compared after + * `trim().toLowerCase()`). The Chinese and check-mark entries exist because + * real spreadsheets carry 是/对 and ✓/√ — see the server's `parseBooleanCell`. + */ +export const IMPORT_BOOLEAN_TRUE_TOKENS: ReadonlySet = new Set([ + 'true', 't', 'yes', 'y', '1', 'on', '是', '对', '✓', '√', +]); + +/** Falsy tokens the boolean cell coercion accepts. */ +export const IMPORT_BOOLEAN_FALSE_TOKENS: ReadonlySet = new Set([ + 'false', 'f', 'no', 'n', '0', 'off', '否', '错', '✗', '×', +]); + +/** + * Field types the server resolves from display text to record ids during + * `/import` — the spec's reference-shaped value types plus the generic + * `'reference'` spelling legacy metadata still carries. Both ends used to + * append `'reference'` to {@link REFERENCE_VALUE_TYPES} themselves; publishing + * the completed set retires those two literals. + */ +export const IMPORT_REFERENCE_TYPES: ReadonlySet = new Set([ + ...REFERENCE_VALUE_TYPES, + 'reference', +]); diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index a62c27a047..8009b4af8b 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -25,6 +25,9 @@ export * from './field.zod'; export * from './authoring-key-lint'; // Field runtime value-shape contract (ADR-0104 D1) export * from './field-value.zod'; +// Import-coercion vocabulary (#4173): the boolean/reference token tables the +// server's /import coercion and objectui's Import Wizard preview both check. +export * from './import-coercion'; export * from './autonumber-format'; export * from './validation.zod'; export * from './hook.zod';