Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/audit-provenance-and-import-vocabulary.md
Original file line number Diff line number Diff line change
@@ -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<AuditProvenanceField, …>` 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.
21 changes: 21 additions & 0 deletions packages/objectql/src/registry.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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',
Expand Down
92 changes: 50 additions & 42 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<AuditProvenanceField, Record<string, unknown>>;

export function applySystemFields(
schema: ServiceObject,
opts: { multiTenant: boolean }
Expand Down Expand Up @@ -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];
}
}

Expand Down
14 changes: 6 additions & 8 deletions packages/objectql/src/validation/rule-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> = new Set([
'created_at',
'created_by',
'updated_at',
'updated_by',
]);
const AUDIT_TIMELINE_FIELDS: ReadonlySet<string> = new Set(AUDIT_PROVENANCE_FIELDS);

/**
* Whether a caller-supplied `readonly` field may be REINSTATED by an opt-in
Expand Down
17 changes: 13 additions & 4 deletions packages/rest/src/import-coerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand All @@ -191,6 +192,7 @@
"ApiOperation (type)",
"ApiOperationSchema (const)",
"ApiPrimitive (type)",
"AuditProvenanceField (type)",
"AuthoringKeySurface (type)",
"AutonumberToken (type)",
"BOOLEAN_VALUE_TYPES (const)",
Expand Down Expand Up @@ -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)",
Expand Down
25 changes: 24 additions & 1 deletion packages/spec/src/data/field-group-layout.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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);
}
});
});
26 changes: 25 additions & 1 deletion packages/spec/src/data/field-group-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set([
'created_at', 'created_by', 'updated_at', 'updated_by',
...AUDIT_PROVENANCE_FIELDS,
'organization_id', 'tenant_id', 'is_deleted', 'deleted_at',
]);

Expand Down
Loading
Loading