Skip to content

Commit 8d895ff

Browse files
authored
feat(spec,objectql,rest): publish the audit-provenance and import-coercion vocabularies (#3786, #4173) (#4197)
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, previously copied at least four times across two repos. The registry's injection is now table-driven, keyed by the tuple with `satisfies Record<AuditProvenanceField, …>` so an undeclared member is a compile error; the rule-validator's AUDIT_TIMELINE_FIELDS derives 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. 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 includes the legacy 'reference' spelling, retiring the `+ 'reference'` literal both ends carried separately. objectui's mirror retires once this publishes (objectui#3043 wrote the path). Closes #4173.
1 parent 4475c59 commit 8d895ff

11 files changed

Lines changed: 296 additions & 56 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": patch
4+
"@objectstack/rest": patch
5+
---
6+
7+
feat(spec,objectql,rest): publish the audit-provenance and import-coercion vocabularies (#3786, #4173)
8+
9+
Two more hand-copied lists retired the same way, each replaced by one spec
10+
export and derivation at every consumer.
11+
12+
**`AUDIT_PROVENANCE_FIELDS`** (`@objectstack/spec/data`, with the
13+
`AuditProvenanceField` type) — the four columns `applySystemFields` injects on
14+
every audit-tracked object: `created_at`, `created_by`, `updated_at`,
15+
`updated_by`. That four-name list existed in at least four copies across two
16+
repos: the registry's injection if-chain, the rule-validator's `preserveAudit`
17+
allowlist ("Kept in sync with the registry's auto-injected audit fields" — by
18+
nothing), and two objectui render surfaces. Now:
19+
20+
- the registry's injection is table-driven, keyed by the tuple with a
21+
`satisfies Record<AuditProvenanceField, …>` clause — a name added to the spec
22+
without a column definition (or vice versa) is a compile error, the
23+
`APPROVER_VALUE_BINDINGS` discipline;
24+
- the rule-validator's `AUDIT_TIMELINE_FIELDS` derives from the same tuple;
25+
- `FIELD_GROUP_SYSTEM_FIELDS`' audit prefix derives from it too — one
26+
declaration even inside the file that hosts both;
27+
- objectui's `AUDIT_FIELD_BY_ROLE` already pins itself by subset assertion and
28+
can import the tuple directly once this release is published.
29+
30+
Injection behaviour is byte-identical — a conformance test pins every injected
31+
column's shape against the pre-refactor definitions.
32+
33+
**`IMPORT_BOOLEAN_TRUE_TOKENS` / `IMPORT_BOOLEAN_FALSE_TOKENS` /
34+
`IMPORT_REFERENCE_TYPES`** (`@objectstack/spec/data`) — the `/import` coercion
35+
vocabulary #4173 asked for. The server's `import-coerce.ts` now derives its
36+
`BOOL_TRUE` / `BOOL_FALSE` / `REFERENCE_TYPES` from these instead of owning
37+
them privately, and objectui's Import Wizard preview — which re-checks the same
38+
contract client-side so a cell is flagged red exactly when the server would
39+
reject it — can retire its pinned-inventory mirror once this release is
40+
published (the retirement path is written in that file's own header).
41+
`IMPORT_REFERENCE_TYPES` ships with the legacy `'reference'` spelling included,
42+
retiring the `+ 'reference'` literal both ends carried separately. The tables'
43+
own discipline is tested: sets disjoint, every token pre-normalized
44+
(lower-case, trimmed), and the Chinese / check-mark spreadsheet-reality tokens
45+
pinned by name.
46+
47+
No behaviour change anywhere: every derived value is byte-identical to the
48+
literal it replaces.

packages/objectql/src/registry.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, warnStrippedLegacyApiMethods, computeFQN, parseFQN } from './registry';
3+
import { AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data';
34

45
describe('SchemaRegistry', () => {
56
let registry: SchemaRegistry;
@@ -590,6 +591,26 @@ describe('applySystemFields', () => {
590591
expect(out.fields.updated_at).toBeDefined();
591592
});
592593

594+
it('injects exactly the spec AUDIT_PROVENANCE_FIELDS, byte-identical to the pre-#3786 defs', () => {
595+
// The injection table is keyed by the spec tuple with a `satisfies`
596+
// exhaustiveness clause, so which columns exist cannot drift from the
597+
// spec. This pins the OTHER half — that the refactor from four
598+
// if-blocks to the table changed nothing about what gets injected.
599+
const out = applySystemFields(baseLead, { multiTenant: false });
600+
for (const name of AUDIT_PROVENANCE_FIELDS) {
601+
expect(out.fields[name], name).toBeDefined();
602+
expect(out.fields[name].system, `${name}.system`).toBe(true);
603+
expect(out.fields[name].readonly, `${name}.readonly`).toBe(true);
604+
expect(out.fields[name].required, `${name}.required`).toBe(false);
605+
}
606+
expect(out.fields.created_at.type).toBe('datetime');
607+
expect(out.fields.updated_at.type).toBe('datetime');
608+
expect(out.fields.created_by).toMatchObject({ type: 'lookup', reference: 'sys_user', label: 'Created By' });
609+
expect(out.fields.updated_by).toMatchObject({ type: 'lookup', reference: 'sys_user', label: 'Last Modified By' });
610+
expect(out.fields.created_at.label).toBe('Created At');
611+
expect(out.fields.updated_at.label).toBe('Last Modified At');
612+
});
613+
593614
it('does NOT overwrite an author-declared organization_id', () => {
594615
const declared: any = {
595616
name: 'lead',

packages/objectql/src/registry.ts

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances, isTenancyDisabled, LEGACY_API_METHODS } from '@objectstack/spec/data';
3+
import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances, isTenancyDisabled, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS, type AuditProvenanceField } from '@objectstack/spec/data';
44
import { resolveMultiOrgEnabled, resolveSearchPinyinEnabled } from '@objectstack/types';
55
import { provisionSearchCompanion } from './search-companion.js';
66
import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema } from '@objectstack/spec/kernel';
@@ -233,6 +233,53 @@ export interface SchemaRegistryOptions {
233233
* ADD ownership — the failure mode we are eliminating — silently breaks
234234
* every owner-keyed feature.
235235
*/
236+
/**
237+
* Column definitions for the audit-provenance family, keyed by the spec's
238+
* {@link AUDIT_PROVENANCE_FIELDS} tuple — the canonical declaration of WHICH
239+
* columns exist (#3786). This table owns only WHAT each column looks like.
240+
*
241+
* The `satisfies` clause is the sync mechanism: a name added to the spec tuple
242+
* without a definition here — or a definition for a name the spec dropped — is
243+
* a compile error, not a silently diverging copy. Same discipline as the
244+
* spec's `APPROVER_VALUE_BINDINGS`.
245+
*/
246+
const AUDIT_FIELD_DEFS = {
247+
created_at: {
248+
type: 'datetime',
249+
label: 'Created At',
250+
required: false,
251+
readonly: true,
252+
system: true,
253+
description: 'Timestamp when the record was created (auto-populated by the driver).',
254+
},
255+
created_by: {
256+
type: 'lookup',
257+
reference: 'sys_user',
258+
label: 'Created By',
259+
required: false,
260+
readonly: true,
261+
system: true,
262+
description: 'User who created the record (populated when an authenticated session is present).',
263+
},
264+
updated_at: {
265+
type: 'datetime',
266+
label: 'Last Modified At',
267+
required: false,
268+
readonly: true,
269+
system: true,
270+
description: 'Timestamp of the most recent modification (auto-populated by the driver).',
271+
},
272+
updated_by: {
273+
type: 'lookup',
274+
reference: 'sys_user',
275+
label: 'Last Modified By',
276+
required: false,
277+
readonly: true,
278+
system: true,
279+
description: 'User who last modified the record (populated when an authenticated session is present).',
280+
},
281+
} satisfies Record<AuditProvenanceField, Record<string, unknown>>;
282+
236283
export function applySystemFields(
237284
schema: ServiceObject,
238285
opts: { multiTenant: boolean }
@@ -314,47 +361,8 @@ export function applySystemFields(
314361
}
315362

316363
if (wantAudit) {
317-
if (!schema.fields?.created_at) {
318-
additions.created_at = {
319-
type: 'datetime',
320-
label: 'Created At',
321-
required: false,
322-
readonly: true,
323-
system: true,
324-
description: 'Timestamp when the record was created (auto-populated by the driver).',
325-
};
326-
}
327-
if (!schema.fields?.created_by) {
328-
additions.created_by = {
329-
type: 'lookup',
330-
reference: 'sys_user',
331-
label: 'Created By',
332-
required: false,
333-
readonly: true,
334-
system: true,
335-
description: 'User who created the record (populated when an authenticated session is present).',
336-
};
337-
}
338-
if (!schema.fields?.updated_at) {
339-
additions.updated_at = {
340-
type: 'datetime',
341-
label: 'Last Modified At',
342-
required: false,
343-
readonly: true,
344-
system: true,
345-
description: 'Timestamp of the most recent modification (auto-populated by the driver).',
346-
};
347-
}
348-
if (!schema.fields?.updated_by) {
349-
additions.updated_by = {
350-
type: 'lookup',
351-
reference: 'sys_user',
352-
label: 'Last Modified By',
353-
required: false,
354-
readonly: true,
355-
system: true,
356-
description: 'User who last modified the record (populated when an authenticated session is present).',
357-
};
364+
for (const name of AUDIT_PROVENANCE_FIELDS) {
365+
if (!schema.fields?.[name]) additions[name] = AUDIT_FIELD_DEFS[name];
358366
}
359367
}
360368

packages/objectql/src/validation/rule-validator.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777

7878
import { ExpressionEngine } from '@objectstack/formula';
7979
import type { Expression } from '@objectstack/spec';
80+
import { AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data';
8081
import Ajv, { type ValidateFunction } from 'ajv';
8182
import {
8283
ValidationError,
@@ -380,15 +381,12 @@ export function stripReadonlyFields(
380381
/**
381382
* The audit / attribution family — the "original timeline" a historical import
382383
* (`preserveAudit`) is allowed to reinstate even though these columns are
383-
* `system` + `readonly`. Kept in sync with the registry's auto-injected audit
384-
* fields (`packages/objectql/src/registry.ts`).
384+
* `system` + `readonly`. DERIVED from the spec's `AUDIT_PROVENANCE_FIELDS`
385+
* (#3786) — the same tuple the registry's injection table is keyed by — so
386+
* this set and the injected columns cannot drift apart. The old literal here
387+
* carried a "kept in sync with the registry" comment and no mechanism.
385388
*/
386-
const AUDIT_TIMELINE_FIELDS: ReadonlySet<string> = new Set([
387-
'created_at',
388-
'created_by',
389-
'updated_at',
390-
'updated_by',
391-
]);
389+
const AUDIT_TIMELINE_FIELDS: ReadonlySet<string> = new Set(AUDIT_PROVENANCE_FIELDS);
392390

393391
/**
394392
* Whether a caller-supplied `readonly` field may be REINSTATED by an opt-in

packages/rest/src/import-coerce.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ import {
3838
NUMERIC_VALUE_TYPES as NUMBER_TYPES,
3939
BOOLEAN_VALUE_TYPES as BOOL_TYPES,
4040
FILE_REFERENCE_TYPES as FILE_TYPES,
41-
REFERENCE_VALUE_TYPES,
4241
isMultiValueField as specIsMultiValueField,
42+
IMPORT_BOOLEAN_TRUE_TOKENS,
43+
IMPORT_BOOLEAN_FALSE_TOKENS,
44+
IMPORT_REFERENCE_TYPES,
4345
} from '@objectstack/spec/data';
4446
import type { FieldErrorCode } from '@objectstack/spec/api';
4547
import {
@@ -52,7 +54,10 @@ import {
5254
* reference class (ADR-0104 D1) plus `reference` — a legacy external-object
5355
* alias that is not an authorable `FieldType` and so stays a local extra.
5456
*/
55-
const REFERENCE_TYPES = new Set([...REFERENCE_VALUE_TYPES, 'reference']);
57+
// The spec now publishes the completed set (reference value types plus the
58+
// legacy 'reference' spelling), so the `+ 'reference'` literal is retired on
59+
// both ends (#4173).
60+
const REFERENCE_TYPES = IMPORT_REFERENCE_TYPES;
5661

5762
/**
5863
* 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 {
177182

178183
// ── boolean ────────────────────────────────────────────────────────
179184

180-
const BOOL_TRUE = new Set(['true', 't', 'yes', 'y', '1', 'on', '是', '对', '✓', '√']);
181-
const BOOL_FALSE = new Set(['false', 'f', 'no', 'n', '0', 'off', '否', '错', '✗', '×']);
185+
// DERIVED from the spec's import-coercion vocabulary (#4173): objectui's
186+
// Import Wizard preview re-checks these same tables client-side, so both ends
187+
// reading one export is what keeps a cell flagged red here exactly when the
188+
// server rejects it. The literals used to live in this file alone.
189+
const BOOL_TRUE = IMPORT_BOOLEAN_TRUE_TOKENS;
190+
const BOOL_FALSE = IMPORT_BOOLEAN_FALSE_TOKENS;
182191

183192
/** Parse a spreadsheet cell into a boolean, or `undefined` if unrecognised. */
184193
export function parseBooleanCell(raw: unknown): boolean | undefined {

packages/spec/api-surface.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@
173173
"API_METHOD_ORDER (const)",
174174
"API_OPERATION_ORDER (const)",
175175
"API_PRIMITIVES (const)",
176+
"AUDIT_PROVENANCE_FIELDS (const)",
176177
"Address (type)",
177178
"AddressSchema (const)",
178179
"AddressValueSchema (const)",
@@ -191,6 +192,7 @@
191192
"ApiOperation (type)",
192193
"ApiOperationSchema (const)",
193194
"ApiPrimitive (type)",
195+
"AuditProvenanceField (type)",
194196
"AuthoringKeySurface (type)",
195197
"AutonumberToken (type)",
196198
"BOOLEAN_VALUE_TYPES (const)",
@@ -382,6 +384,9 @@
382384
"HookEvent (const)",
383385
"HookEventType (type)",
384386
"HookSchema (const)",
387+
"IMPORT_BOOLEAN_FALSE_TOKENS (const)",
388+
"IMPORT_BOOLEAN_TRUE_TOKENS (const)",
389+
"IMPORT_REFERENCE_TYPES (const)",
385390
"INSTANT_TYPES (const)",
386391
"IndexSchema (const)",
387392
"InstantValueSchema (const)",

packages/spec/src/data/field-group-layout.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { deriveFieldGroupLayout, FIELD_GROUP_SYSTEM_FIELDS } from './field-group-layout';
4+
import { deriveFieldGroupLayout, FIELD_GROUP_SYSTEM_FIELDS, AUDIT_PROVENANCE_FIELDS } from './field-group-layout';
55

66
describe('deriveFieldGroupLayout (ADR-0085 §5)', () => {
77
const groupedDef = {
@@ -115,3 +115,26 @@ describe('deriveFieldGroupLayout (ADR-0085 §5)', () => {
115115
expect(sections[0].label).toBe('billing');
116116
});
117117
});
118+
119+
/**
120+
* The audit-provenance tuple (#3786) — the canonical four-name declaration the
121+
* registry's injection table, the rule-validator's preserveAudit allowlist and
122+
* objectui's AUDIT_FIELD_BY_ROLE all key off. Pinned exactly: this is a wire
123+
* contract (stored column names), so any edit must be loud.
124+
*/
125+
describe('AUDIT_PROVENANCE_FIELDS', () => {
126+
it('is exactly the four provenance columns, in injection order', () => {
127+
expect([...AUDIT_PROVENANCE_FIELDS]).toEqual([
128+
'created_at', 'created_by', 'updated_at', 'updated_by',
129+
]);
130+
expect(Object.isFrozen(AUDIT_PROVENANCE_FIELDS)).toBe(true);
131+
});
132+
133+
it('is a subset of FIELD_GROUP_SYSTEM_FIELDS', () => {
134+
// Structural today (the superset spreads the tuple), but asserted anyway so
135+
// a future refactor cannot quietly decouple them.
136+
for (const f of AUDIT_PROVENANCE_FIELDS) {
137+
expect(FIELD_GROUP_SYSTEM_FIELDS.has(f), f).toBe(true);
138+
}
139+
});
140+
});

packages/spec/src/data/field-group-layout.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,38 @@ export interface FieldGroupSection {
4848
fields: string[];
4949
}
5050

51+
/**
52+
* The audit-provenance family: the four columns `applySystemFields`
53+
* (`@objectstack/objectql` registry) auto-injects on every audit-tracked
54+
* business object, in injection order.
55+
*
56+
* THE canonical declaration (#3786). Before it existed this four-name list was
57+
* hand-copied at least four times across two repos — the registry's injection
58+
* if-chain, the rule-validator's `preserveAudit` allowlist, and two objectui
59+
* render surfaces — each under a comment asking to be kept in sync with one of
60+
* the others. The registry's injection table and the rule-validator now derive
61+
* from this tuple (with `satisfies` making an undeclared member a compile
62+
* error); objectui's `AUDIT_FIELD_BY_ROLE` pins itself to the superset below
63+
* by subset assertion.
64+
*/
65+
export const AUDIT_PROVENANCE_FIELDS = Object.freeze([
66+
'created_at', 'created_by', 'updated_at', 'updated_by',
67+
] as const);
68+
69+
/** One audit-provenance column name. */
70+
export type AuditProvenanceField = (typeof AUDIT_PROVENANCE_FIELDS)[number];
71+
5172
/**
5273
* Audit/system fields excluded from the derived UNGROUPED bucket (they carry
5374
* no business meaning in a default layout). A field an author explicitly
5475
* assigns to a group is kept. Exported so renderers filtering flat layouts
5576
* agree with the derivation.
77+
*
78+
* The audit prefix derives from {@link AUDIT_PROVENANCE_FIELDS} — one
79+
* declaration even inside this file.
5680
*/
5781
export const FIELD_GROUP_SYSTEM_FIELDS: ReadonlySet<string> = new Set([
58-
'created_at', 'created_by', 'updated_at', 'updated_by',
82+
...AUDIT_PROVENANCE_FIELDS,
5983
'organization_id', 'tenant_id', 'is_deleted', 'deleted_at',
6084
]);
6185

0 commit comments

Comments
 (0)