Skip to content

Commit 1598568

Browse files
authored
docs+test: harden treatAsHistorical audit-timeline follow-ups (#3493) (#3509)
Follow-up to #3497. Documents the audit-timeline half of treatAsHistorical in the state-machine protocol doc (was only the FSM-skip half), and adds an engine.test.ts assertion that buildDriverOptions threads context.preserveAudit into the driver options — the one untested wire that makes the driver's updated_at stamp reachable from a real write. Docs + test only; empty changeset (releases nothing).
1 parent 083c414 commit 1598568

3 files changed

Lines changed: 22 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
docs(objectql): document that `treatAsHistorical` also preserves the original audit timeline (#3493). Docs-only — releases nothing, so this changeset is intentionally empty.

content/docs/protocol/objectql/state-machine.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ transitions: {
101101
- Only a rule with `severity: 'error'` (the default) blocks the write; `warning`/`info` are logged.
102102
- **Seed writes are exempt** (#3433). Curated seed data — package bootstrap fixtures, marketplace templates, per-org replay, all loaded by `SeedLoaderService` — is a snapshot of established facts, not a record walking its lifecycle, so it bypasses the `state_machine` rule entirely: a seed may be born mid-lifecycle (a `completed` project, a `closed_won` opportunity) and neither `initialStates` (insert) nor `transitions` (update) is enforced. Every *other* validation still runs, so a seed must still satisfy field shape, `format`, `script`, and the rest. `os lint` warns when a seeded value is not a state the machine declares, so a typo is still caught before boot.
103103
- **A "historical" data import is exempt too** (#3479). Migrating established facts — a batch of already-`closed` tickets, `closed_won` deals — is the same "snapshot, not a lifecycle event" situation. Set `treatAsHistorical: true` on the import request (default **off**) and the runner puts `skipStateMachine` on the write context, so `initialStates` doesn't reject those mid-lifecycle rows. A normal import leaves it off and still walks the FSM — the strict behavior is the default, so the exemption is always an explicit opt-in.
104+
- **`treatAsHistorical` also preserves the original audit timeline** (#3493). Skipping the FSM is only half of migrating established facts; the other half is keeping *when* they happened and *who* did them. Under the same flag the write context also carries `preserveAudit`, which (1) makes `updated_at` / `updated_by` **client-preferred** — a supplied historical last-modified survives instead of being stamped with the import instant, symmetric with how `created_at` / `created_by` already behave on insert — and (2) admits a **whitelist** through the static-`readonly` write strip: the audit/timestamp family plus author-declared business `readonly` fields (`closed_at`, `resolved_by`, …), so an `upsert` refresh no longer silently drops them. Platform-managed `system` columns outside that family (`organization_id` and other tenancy/generated columns) stay stripped — a historical import reinstates facts, it does not forge tenancy. Like the FSM exemption this is opt-in: a normal write still auto-stamps `updated_at`/`updated_by` and strips `readonly` exactly as before, and permissions / RLS / field-level security are unchanged.
104105

105106
### Conditional transitions
106107

packages/objectql/src/engine.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,23 @@ describe('ObjectQL Engine', () => {
572572
await engine.count('task', {}, { context: { tenantId: 't-cnt' } as any });
573573
expect((mockDriver.count as any).mock.calls.at(-1)?.[2]).toMatchObject({ tenantId: 't-cnt' });
574574
});
575+
576+
// #3493 — a historical import sets `preserveAudit` on the write context;
577+
// `buildDriverOptions` must thread it into the DRIVER options so the SQL
578+
// driver keeps a supplied `updated_at` instead of force-stamping now. It
579+
// is the SAME shared builder on read and write, so `find` is the cheap
580+
// observable of the wire (as with `tenantId` above); the driver's actual
581+
// honoring of the option lives in @objectstack/driver-sql, and the audit
582+
// hook + readonly whitelist in plugin.integration.test.ts.
583+
it('threads preserveAudit from the context into the driver options (#3493)', async () => {
584+
await engine.find('task', { filters: [] }, { context: { preserveAudit: true } as any });
585+
expect(lastFindOpts()).toMatchObject({ preserveAudit: true });
586+
});
587+
588+
it('does NOT set preserveAudit when the context omits it (opt-in)', async () => {
589+
await engine.find('task', { filters: [] }, { context: { tenantId: 't' } as any });
590+
expect(lastFindOpts()?.preserveAudit).toBeUndefined();
591+
});
575592
});
576593

577594
describe('tenancy.enabled:false objects are platform-global (#3249, ADR-0066)', () => {

0 commit comments

Comments
 (0)