Skip to content

Commit 649236e

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20583-refusal-conversions
2 parents 64bf48d + cb4c31d commit 649236e

9 files changed

Lines changed: 987 additions & 66 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): the import template (`GET /api/v1/data/:object/export?template=true`) puts ` *` on a column only when the import refuses a row that leaves it blank
6+
7+
Clause-②: no
8+
9+
- A required field whose option list marks an option `default: true` no longer gets ` *` in the header, and the instructions sheet lists it as not required. A blank cell in that column is imported as the marked option.
10+
- A required field that declares `defaultValue: null` now gets ` *`, unless one of its options is marked `default: true`: the import treats `null` as no default and refuses the blank. A required field whose default is `''`, or `[]` on a multi-valued field, now gets ` *` too: the required check refuses that default.
11+
- A required `system`, `readonly` or `autonumber` field named in `?fields=` no longer gets ` *`: the import does not refuse a blank in it.
12+
- Each dropdown's error title, which is the column header, is cut to 32 characters, the longest title Excel's data-validation dialog takes.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: the /automation create and update doors save the flow as a tenant row, so what they answer 200 for survives a restart, and the removal door deletes that row too (#20862)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata changes shape and nothing an author wrote is renamed or removed, so `objectstack migrate meta` has nothing to rewrite. What moves is which definition writes and removals the /automation doors accept: the ones the metadata store refuses are now refused there too. -->
10+
11+
**BREAKING**: shipped as `minor` under the launch-window convention. `POST /api/v1/automation` and `PUT /api/v1/automation/:name` now refuse a definition the metadata store refuses, which they used to register and answer `200` for. `DELETE /api/v1/automation/:name` now relays a store's refusal to delete the flow's row.
12+
13+
**What changed.** The create and update doors registered a flow in the automation engine and wrote no metadata row. The next boot binds flows from the stored metadata, so a flow created through `POST /automation` was gone after a restart, and an update through `PUT /automation/:name` to a flow stored through `/meta` lost to the stored definition. Both doors now save the definition through the metadata protocol's own `saveMetaItem`: the save `PUT /api/v1/meta/flow/:name` uses, and the one the clone door (`POST /automation/:name/clone`) already used. The row is env-wide and live (`active`), so the flow reads back on `/meta` and survives a cold boot. The three doors share one path.
14+
15+
- **Engine first, store second.** The engine's registration is still the first check. Its refusal is answered as before (`400 VALIDATION_FAILED`), and nothing is saved.
16+
- **A save the store refuses is relayed with its own code and status, and leaves no registration behind.** A create is withdrawn from the engine. An update puts back the definition the engine held, so a refused update does not take the flow down.
17+
- **`DELETE /automation/:name` deletes the tenant row too**, through `deleteMetaItem`, so a flow created through the door does not come back at the next boot. The engine's own removal refusal (`DELETE_RESTRICTED` / `409`) is still raised before the store is touched. A name with no stored row is removed as before. A delete the store refuses puts the definition back and relays the refusal.
18+
- **Unchanged:** the locked-base refusal on a packaged flow's name (`403 NOT_OVERRIDABLE`) and the refusal of a definition claiming a package's provenance (`422 INVALID_METADATA`) still answer first. A composition with no metadata protocol keeps the engine-only registration and removal it always had.
19+
20+
**Newly refused, because the metadata store refuses them** (measured on the showcase):
21+
22+
- A flow name with a leading underscore. `FlowSchema` admits it and the metadata item-name grammar does not, so the door answers `400 INVALID_REQUEST`. Rename the flow to a name that starts with a letter.
23+
- A definition a gating runtime publish rule refuses, such as a default edge that also carries a condition (`flow-default-edge-with-condition`). The door answers `422 INVALID_METADATA` with the rule's finding. Fix the definition as the finding says.
24+
25+
Such a flow could never be stored, so before this change it ran only until the next restart.
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#20862, ADR-0126 §2, ADR-0131 D6] The `/automation` definition doors keep
4+
// what they answer `200` for — over the real showcase composition, across a
5+
// cold boot on one database file.
6+
//
7+
// ## What was broken
8+
//
9+
// `POST /automation` and `PUT /automation/:name` registered the definition in
10+
// the engine and wrote no metadata row. The next boot binds flows from the
11+
// stored metadata, so a created flow was gone after a restart, and an update
12+
// to a flow stored through `/meta` was overwritten by the stored definition.
13+
// Measured on `origin/main` before the fix, through this file's own cases:
14+
// the created flow answered `404` after the cold boot, and the updated flow
15+
// served its stored label again.
16+
//
17+
// ## Why a booted stack
18+
//
19+
// The door-level pins sit beside the domain
20+
// (`packages/runtime/src/domains/automation-authoring-doors-durable.test.ts`).
21+
// What they cannot answer is whether, on the stack an operator runs, the door
22+
// reaches a protocol whose save lands a row the next boot binds. Only a
23+
// restart on the same file shows that.
24+
//
25+
// ## What each case pins
26+
//
27+
// - a created flow reads back on the metadata door and survives a cold boot;
28+
// - an update to a `/meta`-stored flow reads back there and survives it;
29+
// - a save the store refuses leaves no engine registration: a create is
30+
// withdrawn, and an update puts back the definition the engine held;
31+
// - a flow created and then removed through the door stays removed after the
32+
// cold boot (the removal door keeps pace with the create door);
33+
// - a packaged flow's name is still refused as a locked base (the control).
34+
35+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
36+
import showcaseStack from '@objectstack/example-showcase';
37+
import { bootStack, type VerifyStack } from '@objectstack/verify';
38+
import { ConnectorRestPlugin } from '@objectstack/connector-rest';
39+
import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi';
40+
import { ConnectorMcpPlugin } from '@objectstack/connector-mcp';
41+
import { fileURLToPath } from 'node:url';
42+
import { mkdtempSync, rmSync } from 'node:fs';
43+
import { tmpdir } from 'node:os';
44+
import { join } from 'node:path';
45+
46+
/** Package-relative connector refs resolve against the cwd — see the sibling boots. */
47+
const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url));
48+
49+
/** A flow the showcase package ships (`com.example.showcase`). */
50+
const SHIPPED = 'showcase_urgent_task_alert';
51+
/** Names no package ships, each used by one case. */
52+
const CREATED = 'dogfood_created_flow_20862';
53+
const META_STORED = 'dogfood_meta_stored_flow_20862';
54+
const REMOVED = 'dogfood_removed_flow_20862';
55+
/**
56+
* A name the engine registers and the store refuses: `FlowSchema.name` admits a
57+
* leading underscore, the metadata item-name grammar does not. So the engine's
58+
* registration succeeds and the save fails — a real store refusal, reached
59+
* through the public door.
60+
*/
61+
const UNSAVABLE = '_dogfood_unsavable_flow_20862';
62+
63+
const UPDATED_LABEL = 'Updated through the automation door';
64+
65+
const definition = (name: string, label = `Dogfood ${name}`) => ({
66+
name,
67+
label,
68+
type: 'autolaunched',
69+
nodes: [
70+
{ id: 'start', type: 'start', label: 'Start', config: {} },
71+
{ id: 'end', type: 'end', label: 'End' },
72+
],
73+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
74+
});
75+
76+
/**
77+
* A definition the engine registers and the store's publish gate refuses: a
78+
* default edge carrying a condition (`flow-default-edge-with-condition`, a
79+
* gating authoring rule the engine does not run).
80+
*/
81+
const gateRefused = (name: string) => ({
82+
...definition(name, 'Refused by the publish gate'),
83+
edges: [{ id: 'e1', source: 'start', target: 'end', isDefault: true, condition: 'true' }],
84+
});
85+
86+
interface DispatcherEnvelope {
87+
success?: boolean;
88+
data?: Record<string, unknown>;
89+
error?: { code?: string; message?: string };
90+
}
91+
const dataOf = (json: unknown) => (json as DispatcherEnvelope).data;
92+
const dispatcherCode = (json: unknown) => (json as DispatcherEnvelope).error?.code;
93+
94+
const plugins = () => [
95+
new ConnectorRestPlugin(),
96+
new ConnectorOpenApiPlugin(),
97+
new ConnectorMcpPlugin({ declarativeStdio: ['node'] }),
98+
];
99+
100+
async function boot(databaseFile: string): Promise<VerifyStack> {
101+
return bootStack(showcaseStack, { automation: true, databaseFile, extraPlugins: plugins() });
102+
}
103+
104+
describe('the /automation definition doors persist what they register (showcase, cold boot)', () => {
105+
let stack: VerifyStack;
106+
let token: string;
107+
let prevCwd: string;
108+
let dir: string;
109+
let dbFile: string;
110+
111+
const call = async (method: string, path: string, body?: unknown) => {
112+
const res = await stack.apiAs(token, method, path, body);
113+
const json: unknown = await res.json().catch(() => ({}));
114+
return { status: res.status, json };
115+
};
116+
/** `GET /meta/flow/:name` — the served document, unwrapped from whichever envelope carries it. */
117+
const metaRead = async (name: string) => {
118+
const read = await call('GET', `/meta/flow/${name}`);
119+
const json = read.json as Record<string, unknown>;
120+
const doc = (json?.data ?? json?.item ?? json) as Record<string, unknown>;
121+
return { status: read.status, doc: (doc?.item ?? doc) as Record<string, unknown> };
122+
};
123+
const engineLabel = async (name: string) => {
124+
const read = await call('GET', `/automation/${name}`);
125+
return { status: read.status, label: dataOf(read.json)?.label };
126+
};
127+
128+
beforeAll(async () => {
129+
prevCwd = process.cwd();
130+
process.chdir(SHOWCASE_DIR);
131+
dir = mkdtempSync(join(tmpdir(), 'dogfood-20862-'));
132+
dbFile = join(dir, 'showcase.db');
133+
stack = await boot(dbFile);
134+
token = await stack.signIn();
135+
}, 180_000);
136+
137+
afterAll(async () => {
138+
await stack?.stop();
139+
if (prevCwd) process.chdir(prevCwd);
140+
if (dir) rmSync(dir, { recursive: true, force: true });
141+
});
142+
143+
it('a flow created through the automation door reads back on the metadata door', async () => {
144+
const created = await call('POST', '/automation', definition(CREATED));
145+
expect(created.status, JSON.stringify(created.json)).toBe(200);
146+
147+
const served = await metaRead(CREATED);
148+
expect(served.status, JSON.stringify(served.doc)).toBe(200);
149+
expect(served.doc.label).toBe(`Dogfood ${CREATED}`);
150+
});
151+
152+
it('an update through the automation door to a /meta-stored flow reads back on the metadata door', async () => {
153+
const stored = await call('PUT', `/meta/flow/${META_STORED}`, definition(META_STORED, 'Stored through /meta'));
154+
expect(stored.status, JSON.stringify(stored.json)).toBe(200);
155+
156+
const updated = await call('PUT', `/automation/${META_STORED}`, definition(META_STORED, UPDATED_LABEL));
157+
expect(updated.status, JSON.stringify(updated.json)).toBe(200);
158+
159+
expect((await metaRead(META_STORED)).doc.label).toBe(UPDATED_LABEL);
160+
expect((await engineLabel(META_STORED)).label).toBe(UPDATED_LABEL);
161+
});
162+
163+
it('a create whose save the store refuses answers the store\'s refusal and leaves no engine registration', async () => {
164+
const created = await call('POST', '/automation', definition(UNSAVABLE));
165+
166+
expect(created.status, JSON.stringify(created.json)).toBe(400);
167+
expect(dispatcherCode(created.json)).toBe('INVALID_REQUEST');
168+
expect((await call('GET', `/automation/${UNSAVABLE}`)).status).toBe(404);
169+
});
170+
171+
it('an update whose save the store refuses answers the refusal and the engine keeps the definition it held', async () => {
172+
const updated = await call('PUT', `/automation/${META_STORED}`, gateRefused(META_STORED));
173+
174+
expect(updated.status, JSON.stringify(updated.json)).toBe(422);
175+
expect(dispatcherCode(updated.json)).toBe('INVALID_METADATA');
176+
expect(await engineLabel(META_STORED)).toEqual({ status: 200, label: UPDATED_LABEL });
177+
expect((await metaRead(META_STORED)).doc.label).toBe(UPDATED_LABEL);
178+
});
179+
180+
it('a flow created and then removed through the automation door is gone from the metadata door too', async () => {
181+
const created = await call('POST', '/automation', definition(REMOVED));
182+
expect(created.status, JSON.stringify(created.json)).toBe(200);
183+
184+
const removed = await call('DELETE', `/automation/${REMOVED}`);
185+
expect(removed.status, JSON.stringify(removed.json)).toBe(200);
186+
187+
expect((await call('GET', `/automation/${REMOVED}`)).status).toBe(404);
188+
expect((await metaRead(REMOVED)).status).toBe(404);
189+
});
190+
191+
it('control: a packaged flow\'s name is still refused as a locked base, on the update door and on a create onto it', async () => {
192+
const before = dataOf((await call('GET', `/automation/${SHIPPED}`)).json)!;
193+
194+
const put = await call('PUT', `/automation/${SHIPPED}`, { ...before, label: 'Edited in place' });
195+
expect(put.status, JSON.stringify(put.json)).toBe(403);
196+
expect(dispatcherCode(put.json)).toBe('NOT_OVERRIDABLE');
197+
198+
const post = await call('POST', '/automation', { ...before, label: 'Overwritten via create' });
199+
expect(post.status, JSON.stringify(post.json)).toBe(403);
200+
expect(dispatcherCode(post.json)).toBe('NOT_OVERRIDABLE');
201+
202+
expect(dataOf((await call('GET', `/automation/${SHIPPED}`)).json)).toEqual(before);
203+
});
204+
205+
it('after a cold boot on the same database file, every door\'s outcome is what it answered', async () => {
206+
await stack.stop();
207+
stack = await boot(dbFile);
208+
token = await stack.signIn();
209+
210+
// Independent facts about one restart, so each is reported on its own.
211+
// The created flow survives.
212+
expect.soft(await engineLabel(CREATED)).toEqual({ status: 200, label: `Dogfood ${CREATED}` });
213+
// The update survives: the stored definition no longer wins over it.
214+
expect.soft(await engineLabel(META_STORED)).toEqual({ status: 200, label: UPDATED_LABEL });
215+
// The refused create left nothing to bind, and the removed flow stays removed.
216+
expect.soft((await call('GET', `/automation/${UNSAVABLE}`)).status).toBe(404);
217+
expect.soft((await call('GET', `/automation/${REMOVED}`)).status).toBe(404);
218+
// The packaged flow is the one its package ships.
219+
expect.soft((await engineLabel(SHIPPED)).status).toBe(200);
220+
}, 180_000);
221+
});

0 commit comments

Comments
 (0)