Skip to content

Commit 85e1e4e

Browse files
os-zhuangclaude
andauthored
feat(rest): treatAsHistorical import option — skip the state machine for historical-data migration (#3479) (#3483)
Sibling of #3433 (seed exemption), one entry point over. #3165's `initialStates` enforced the FSM entry point on every INSERT, so importing established historical facts — already-`closed` tickets, `closed_won` deals, `completed` projects — was rejected row-by-row with `invalid_initial_state`, blocking the core data-migration path. Visible (per-row errors), unlike the silent seed case, but still a functional block on a legitimate use. - spec: `ExecutionContext.skipStateMachine` — general server-set flag (seedReplay's sibling) skipping the `state_machine` rule for a write; `ImportRequestSchema. treatAsHistorical` (default false) — the user-facing import option. - objectql: engine skips the state machine for `seedReplay` OR `skipStateMachine` (one `shouldSkipStateMachine` helper) — covers seed replay and historical import. - rest: the import runner sets `skipStateMachine` on the write context iff the request opts into `treatAsHistorical`; default off, so a normal import still walks the FSM. Import undo also carries it now (restoring a prior snapshot re-writes an earlier state that need not be a legal transition from the current one). - platform-objects: `sys_import_job.treat_as_historical` audit column (additive). Scope identical to the seed exemption: ONLY the `state_machine` rule is skipped; field shape / format / cross_field / script all still run. Regression tests (red-verified): engine.test — a `skipStateMachine` context bypasses the FSM (sibling of the seedReplay case); import-runner-historical — the option maps to `skipStateMachine` on the write context, gated (off by default), automation toggle independent. The objectui import-wizard checkbox is a separate follow-up. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 5524f84 commit 85e1e4e

13 files changed

Lines changed: 200 additions & 7 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/objectql": patch
4+
"@objectstack/rest": patch
5+
"@objectstack/platform-objects": patch
6+
---
7+
8+
feat(rest): `treatAsHistorical` import option — skip the state machine for historical-data migration (#3479)
9+
10+
Sibling of #3433 (seed exemption), one entry point over. #3165's `initialStates` enforced
11+
the FSM entry point on every INSERT, so importing established historical facts —
12+
a batch of already-`closed` tickets, `closed_won` deals, `completed` projects —
13+
was rejected row-by-row with `invalid_initial_state`, blocking the core
14+
data-migration path. Unlike the seed case it was visible (per-row errors), but it
15+
still functionally blocked a legitimate use.
16+
17+
- **spec**: `ExecutionContext.skipStateMachine` — a general, server-set flag (the
18+
seed-specific `seedReplay`'s sibling) that skips the `state_machine` rule for a
19+
write; `ImportRequestSchema.treatAsHistorical` (default `false`) — the user-facing
20+
import option.
21+
- **objectql**: the engine now skips the state machine for `seedReplay` OR
22+
`skipStateMachine` (one helper), covering both seed replay and historical import.
23+
- **rest**: the import runner sets `skipStateMachine` on the write context iff the
24+
request opts into `treatAsHistorical`; default off, so a normal import still walks
25+
the FSM (the strict behavior is the default). Import **undo** now also carries
26+
`skipStateMachine`, since restoring a prior snapshot re-writes an earlier state
27+
that need not be a legal transition from where the row is now.
28+
- **platform-objects**: `sys_import_job.treat_as_historical` audit column (additive).
29+
30+
Scope is identical to the seed exemption: ONLY the `state_machine` rule is skipped;
31+
field shape, `format`, `cross_field`, `script` all still run. The objectui import
32+
wizard checkbox is a separate follow-up.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ transitions: {
100100
- The check is **lenient where it cannot reason**: if the prior state is not described by the table (e.g. legacy or externally-written data), it does not block.
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.
103+
- **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.
103104

104105
### Conditional transitions
105106

content/docs/references/api/export.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ const result = CreateExportJobRequest.parse(data);
8282
| **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` || insert / update / upsert semantics |
8383
| **matchFields** | `string[]` | optional | Fields that identify an existing record (required for update/upsert) |
8484
| **runAutomations** | `boolean` || Fire triggers/hooks for each imported row (off by default for bulk) |
85+
| **treatAsHistorical** | `boolean` || Import as established historical facts: skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates (#3479). Off by default so a normal import still walks the FSM. |
8586
| **trimWhitespace** | `boolean` || Trim leading/trailing whitespace from string cells |
8687
| **nullValues** | `string[]` | optional | Strings treated as null/blank (besides empty string) |
8788
| **createMissingOptions** | `boolean` || Keep unmatched select values instead of failing the row |
@@ -370,6 +371,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo
370371
| **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` || insert / update / upsert semantics |
371372
| **matchFields** | `string[]` | optional | Fields that identify an existing record (required for update/upsert) |
372373
| **runAutomations** | `boolean` || Fire triggers/hooks for each imported row (off by default for bulk) |
374+
| **treatAsHistorical** | `boolean` || Import as established historical facts: skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates (#3479). Off by default so a normal import still walks the FSM. |
373375
| **trimWhitespace** | `boolean` || Trim leading/trailing whitespace from string cells |
374376
| **nullValues** | `string[]` | optional | Strings treated as null/blank (besides empty string) |
375377
| **createMissingOptions** | `boolean` || Keep unmatched select values instead of failing the row |

content/docs/references/kernel/execution-context.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ const result = ExecutionContext.parse(data);
6868
| **skipTriggers** | `boolean` | optional | |
6969
| **skipAutomations** | `boolean` | optional | |
7070
| **seedReplay** | `boolean` | optional | |
71+
| **skipStateMachine** | `boolean` | optional | |
7172
| **oauthScopes** | `string[]` | optional | |
7273
| **accessToken** | `string` | optional | |
7374
| **transaction** | `any` | optional | |

packages/objectql/src/engine.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,14 @@ describe('ObjectQL Engine', () => {
907907
expect(mockDriver.create).toHaveBeenCalledTimes(1);
908908
});
909909

910+
it('admits the same INSERT when the context carries skipStateMachine (#3479 historical import)', async () => {
911+
// The general flag — set by the REST import runner for a "historical"
912+
// import — takes the same engine path as seedReplay.
913+
vi.mocked(SchemaRegistry.getObject).mockReturnValue(approvalObject as any);
914+
await engine.insert('seed_approval', { status: 'approved' }, { context: { skipStateMachine: true } as any });
915+
expect(mockDriver.create).toHaveBeenCalledTimes(1);
916+
});
917+
910918
it('still enforces non-state_machine rules under seedReplay (scoped exemption)', async () => {
911919
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
912920
...approvalObject,

packages/objectql/src/engine.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,18 @@ function mergeReadContext(
219219
return { ...fromQuery, ...fromOptions };
220220
}
221221

222+
/**
223+
* True when this write is exempt from the `state_machine` validation rule —
224+
* both the insert `initialStates` entry check and the update `transitions`
225+
* check are skipped for it. Either the seed-specific `seedReplay` flag (#3433)
226+
* or the general `skipStateMachine` flag (#3479, set by the REST import runner
227+
* for a "historical" import) turns it off. Both are server-set, never
228+
* client-supplied.
229+
*/
230+
function shouldSkipStateMachine(ctx?: ExecutionContext): boolean {
231+
return ctx?.seedReplay === true || ctx?.skipStateMachine === true;
232+
}
233+
222234
/**
223235
* Engine Middleware (Onion model)
224236
*/
@@ -2569,7 +2581,7 @@ export class ObjectQL implements IDataEngine {
25692581
try {
25702582
normalizeMultiValueFields(schemaForValidation, rows[i]);
25712583
validateRecord(schemaForValidation, rows[i], 'insert');
2572-
evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: opCtx.context?.seedReplay === true });
2584+
evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context) });
25732585
} catch (e) {
25742586
if (!partialMode) throw e;
25752587
rowErrors[i] = e;
@@ -2859,7 +2871,7 @@ export class ObjectQL implements IDataEngine {
28592871
hookContext.input.data = stripReadonlyFields(updateSchema as any, preRo, suppliedKeys, this.logger) as any;
28602872
reportDroppedFields(preRo, hookContext.input.data as Record<string, unknown>, 'readonly');
28612873
}
2862-
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: opCtx.context?.seedReplay === true });
2874+
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context) });
28632875
result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
28642876
} else if (options?.multi && driver.updateMany) {
28652877
await this.encryptSecretFields(object, hookContext.input.data as Record<string, unknown>, opCtx.context, hookContext.input.options);
@@ -2927,7 +2939,7 @@ export class ObjectQL implements IDataEngine {
29272939
if (rulesNeedRows) {
29282940
for (const row of priorRows ?? []) {
29292941
try {
2930-
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, 'update', { previous: row, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: opCtx.context?.seedReplay === true });
2942+
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, 'update', { previous: row, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context) });
29312943
} catch (err) {
29322944
if (err instanceof ValidationError && row?.id != null) {
29332945
throw new ValidationError(err.fields.map((f) => ({ ...f, message: `${f.message} (record ${String(row.id)})` })));
@@ -2936,7 +2948,7 @@ export class ObjectQL implements IDataEngine {
29362948
}
29372949
}
29382950
} else {
2939-
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, 'update', { previous: null, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: opCtx.context?.seedReplay === true });
2951+
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, 'update', { previous: null, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context) });
29402952
}
29412953
result = await driver.updateMany(object, ast, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
29422954
} else {

packages/platform-objects/src/audit/sys-import-job.object.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const SysImportJob = ObjectSchema.create({
6464
),
6565
dry_run: Field.boolean({ label: 'Dry Run', required: false, defaultValue: false, group: 'Request' }),
6666
run_automations: Field.boolean({ label: 'Run Automations', required: false, defaultValue: false, group: 'Request' }),
67+
treat_as_historical: Field.boolean({ label: 'Treat As Historical', required: false, defaultValue: false, group: 'Request' }),
6768

6869
// ── outcome ──
6970
error: Field.textarea({ label: 'Fatal Error', required: false, group: 'Outcome' }),

packages/rest/src/import-prepare.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,9 @@ export interface PreparedImport {
173173
matchFields: string[];
174174
dryRun: boolean;
175175
runAutomations: boolean;
176+
/** #3479 — import established historical facts: skip the state_machine rule
177+
* so mid-lifecycle rows are not rejected by `initialStates`. */
178+
treatAsHistorical: boolean;
176179
trimWhitespace: boolean;
177180
nullValues?: string[];
178181
createMissingOptions: boolean;
@@ -256,6 +259,11 @@ export async function prepareImportRequest(
256259
// flag until #2922), so opt-out must be explicit — matches platform
257260
// convention (Salesforce runs triggers on import by default).
258261
const runAutomations = body?.runAutomations !== false;
262+
// Default OFF (opt-in): a normal import must still walk the state machine —
263+
// only an explicit "historical" import skips it (#3479), so mid-lifecycle
264+
// rows aren't rejected by `initialStates`. Unlike `runAutomations`, the safe
265+
// default is the strict one.
266+
const treatAsHistorical = body?.treatAsHistorical === true;
259267
const trimWhitespace = body?.trimWhitespace !== false;
260268
const nullValues: string[] | undefined = Array.isArray(body?.nullValues)
261269
? body.nullValues.filter((v: any) => typeof v === 'string')
@@ -392,6 +400,7 @@ export async function prepareImportRequest(
392400
ok: true,
393401
prepared: {
394402
rows, metaMap, writeMode, matchFields, dryRun, runAutomations,
403+
treatAsHistorical,
395404
trimWhitespace, nullValues, createMissingOptions, skipBlankMatchKey,
396405
},
397406
};
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #3479 — a "historical" import carries curated established facts (a batch of
5+
* already-`closed` tickets, `closed_won` deals), so it must skip the object's
6+
* `state_machine` rule: otherwise `initialStates` rejects every mid-lifecycle
7+
* row on insert. runImport owns the option→context mapping — it sets
8+
* `skipStateMachine` on the write context iff `treatAsHistorical` is set. A
9+
* normal import must NOT set it (the FSM still applies).
10+
*
11+
* The engine's actual exemption behavior (skipStateMachine → skip the rule) is
12+
* tested in @objectstack/objectql (engine.test.ts); this pins the wiring the
13+
* import runner owns.
14+
*/
15+
16+
import { describe, it, expect, vi } from 'vitest';
17+
import { runImport, type ImportProtocolLike } from './import-runner';
18+
import type { ExportFieldMeta } from './export-format.js';
19+
20+
const metaMap = new Map<string, ExportFieldMeta>([['name', { name: 'name', type: 'text' }]]);
21+
22+
const baseOpts = {
23+
objectName: 'ticket',
24+
metaMap,
25+
writeMode: 'insert' as const,
26+
matchFields: [] as string[],
27+
dryRun: false,
28+
runAutomations: false,
29+
trimWhitespace: true,
30+
createMissingOptions: false,
31+
skipBlankMatchKey: false,
32+
};
33+
34+
/** Provider mock that records the write context every persist call received. */
35+
function makeProvider() {
36+
const contexts: any[] = [];
37+
let idc = 0;
38+
const p: ImportProtocolLike = {
39+
findData: vi.fn(async () => []),
40+
createData: vi.fn(async (args: any) => {
41+
contexts.push(args.context);
42+
return { id: `d${++idc}`, ...args.data };
43+
}),
44+
updateData: vi.fn(async (args: any) => {
45+
contexts.push(args.context);
46+
return { id: args.id, ...args.data };
47+
}),
48+
createManyData: vi.fn(async (args: any) => {
49+
contexts.push(args.context);
50+
return { records: args.records.map((r: any) => ({ id: `d${++idc}`, ...r })) };
51+
}),
52+
};
53+
return { p, contexts };
54+
}
55+
56+
describe('runImport — historical import skips the state machine (#3479)', () => {
57+
it('sets skipStateMachine on the write context when treatAsHistorical is true', async () => {
58+
const { p, contexts } = makeProvider();
59+
const summary = await runImport({
60+
...baseOpts, p, rows: [{ name: 'a' }, { name: 'b' }], treatAsHistorical: true,
61+
});
62+
expect(summary.created).toBe(2);
63+
expect(contexts.length).toBeGreaterThan(0);
64+
for (const ctx of contexts) expect(ctx?.skipStateMachine).toBe(true);
65+
});
66+
67+
it('does NOT set skipStateMachine for a normal import (the FSM still applies)', async () => {
68+
const { p, contexts } = makeProvider();
69+
await runImport({ ...baseOpts, p, rows: [{ name: 'a' }], treatAsHistorical: false });
70+
expect(contexts.length).toBeGreaterThan(0);
71+
for (const ctx of contexts) expect(ctx?.skipStateMachine).toBeUndefined();
72+
});
73+
74+
it('defaults to off when the option is omitted (safe default — walk the FSM)', async () => {
75+
const { p, contexts } = makeProvider();
76+
await runImport({ ...baseOpts, p, rows: [{ name: 'a' }] });
77+
expect(contexts.length).toBeGreaterThan(0);
78+
for (const ctx of contexts) expect(ctx?.skipStateMachine).toBeUndefined();
79+
});
80+
81+
it('leaves the automation toggle independent (skipAutomations still tracks runAutomations)', async () => {
82+
const { p, contexts } = makeProvider();
83+
await runImport({ ...baseOpts, p, rows: [{ name: 'a' }], treatAsHistorical: true, runAutomations: true });
84+
for (const ctx of contexts) {
85+
expect(ctx?.skipStateMachine).toBe(true);
86+
expect(ctx?.skipAutomations).toBe(false); // runAutomations:true ⇒ don't skip
87+
}
88+
});
89+
});

packages/rest/src/import-runner.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ export interface RunImportOptions {
102102
matchFields: string[];
103103
dryRun: boolean;
104104
runAutomations: boolean;
105+
/** #3479 — treat rows as established historical facts: the write context
106+
* carries `skipStateMachine`, so mid-lifecycle values aren't rejected by the
107+
* object's `state_machine` `initialStates` (insert) / `transitions` (update).
108+
* Optional here (runner default is off); `prepareImportRequest` always sets it. */
109+
treatAsHistorical?: boolean;
105110
trimWhitespace: boolean;
106111
nullValues?: string[];
107112
createMissingOptions: boolean;
@@ -163,7 +168,7 @@ const yieldToEventLoop = (): Promise<void> =>
163168
export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
164169
const {
165170
p, objectName, environmentId, context, rows, metaMap,
166-
writeMode, matchFields, dryRun, runAutomations,
171+
writeMode, matchFields, dryRun, runAutomations, treatAsHistorical,
167172
trimWhitespace, nullValues, createMissingOptions, skipBlankMatchKey,
168173
onProgress, shouldCancel, captureUndo,
169174
} = opts;
@@ -262,7 +267,14 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
262267
return recs[0];
263268
};
264269

265-
const writeCtx = { ...(context ?? {}), skipAutomations: !runAutomations };
270+
const writeCtx = {
271+
...(context ?? {}),
272+
skipAutomations: !runAutomations,
273+
// #3479 — a "historical" import carries curated established facts, so the
274+
// engine skips the state_machine rule for these writes (initialStates on
275+
// insert, transitions on update). Default off: a normal import walks the FSM.
276+
...(treatAsHistorical ? { skipStateMachine: true } : {}),
277+
};
266278

267279
// Sparse-indexed by row position `i` (not push-only): CREATE rows are
268280
// resolved immediately but their write is deferred to a later batch flush,

0 commit comments

Comments
 (0)