Skip to content

Commit e621291

Browse files
os-samclaude
andauthored
feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) (#14191)
* feat(spec): optional `packages[]` on the artifact schema, both shapes read (ADR-0130 D4) `ObjectStackDefinitionSchema` gains an optional `packages` key carrying package entries, so one release artifact can deliver a product split into modules with zero object renames. `manifest` (singular) is retained and both shapes are read: `packages` present -> iterate; absent -> `manifest` is a single-element list. Each entry is a wrapper object (`{ manifest }`), never a flattened manifest body, so a future `{ ref, integrity }` external segment is an additive key rather than a reshape. Segmented loading itself is an ADR-0130 Non-goal and is not built. `os compile` / `os build` needed no source change: normalize, lower and write all shallow-clone the top level and validation parses with this schema. Verified by compiling real projects rather than read off the source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015adLit3ZYASJiXwxKG78Wi * fix(spec): pair the entry alias with its parsed state (ADR-0122) and refresh baselines `check:spec-parsed-alias` (lint.yml "Spec type-alias convention gate") refused `ArtifactPackageEntry` for having no name for its post-parse shape: ManifestSchema applies defaults, so `z.input` and `z.infer` genuinely differ and a consumer holding a parse result had no type to hold it in. Declares `ArtifactPackageEntryParsed`. Also types the two `warnSpy.mock.calls` callbacks — `tsconfig.test.json` compiles the test files this package's plain `typecheck` skips, and `check:test-typecheck` flagged both as TS7006. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015adLit3ZYASJiXwxKG78Wi --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 24b6635 commit e621291

7 files changed

Lines changed: 739 additions & 3 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): the release artifact may carry N package manifests — optional `packages[]` on `ObjectStackDefinitionSchema` (ADR-0130 D4, #14161)
6+
7+
`ObjectStackDefinitionSchema` gains an **optional** `packages` key: an array of
8+
package entries, so one release artifact can deliver a product split into
9+
modules **without renaming a single object**. Renaming is what separate
10+
namespaces would cost — the object `name` IS the table name, the REST path, the
11+
formula token and the saved-view key (ADR-0129 D1–D2) — and rename-on-install is
12+
ADR-0048's standing non-goal.
13+
14+
**`manifest` (singular) is RETAINED, not replaced**, and both shapes are read:
15+
`packages` present → iterate it; `packages` absent → treat `manifest` as a
16+
single-element list. A replacement would break every artifact already built and
17+
sitting on disk at every customer, which is why ADR-0130 states the read-both
18+
rule as the schema decision rather than an implementation note: the schema shape
19+
IS the compatibility mechanism.
20+
21+
**Each entry is a wrapper object**`{ manifest: { … } }` — never the manifest
22+
body inlined flat as the array element. That position is reserved deliberately,
23+
at schema time: when a future external-segment form lands it is
24+
`{ ref, integrity }`, an **additive key on an existing object**, rather than a
25+
reshape that would have to bolt transport keys onto the shared `ManifestSchema`
26+
and make every required manifest field optional. ⛔ Segmented loading itself is
27+
**not** implemented and is an explicit ADR-0130 Non-goal. Forward compatibility
28+
rides the mechanism that already exists, `manifest.engines.protocol` (ADR-0025);
29+
⛔ no new version-negotiation mechanism is introduced.
30+
31+
**Graded `minor`: a pure widening, with no accept-set narrowing anywhere.** The
32+
new key is optional, no existing key changed shape, and nothing that parsed
33+
before is refused now. Measured rather than asserted — the acceptance criterion
34+
was that existing single-`manifest` artifacts do not move, and both halves are
35+
pinned:
36+
37+
- schema layer (`packages/spec/src/stack-artifact-packages.test.ts`): parsing a
38+
single-`manifest` artifact adds **no** top-level key, materialises no
39+
`packages` list, and the serialised result contains no `"packages"`. The
40+
near-miss this guards is a `.default([])`, which would have rewritten every
41+
project's artifact on its next build;
42+
- compiler (`packages/cli/test/compile-artifact-packages.e2e.test.ts`): the
43+
artifact `os build` writes for a single-package project has the exact
44+
top-level key set it had before.
45+
46+
**No `@objectstack/cli` release is graded, and that is a measurement, not an
47+
omission.** `os compile` / `os build` needed **no source change** to align:
48+
`normalizeStackInput`, `lowerCallables` and the artifact write each shallow-clone
49+
the top level, and the validation step parses with this very schema, so the new
50+
key flows through end to end. What moved is the CLI's accept set, and it moved
51+
**entirely through this package** — the CLI ships no changed line and takes the
52+
new behaviour with its `@objectstack/spec` bump. The pass-through was verified by
53+
compiling real projects in the e2e file above rather than read off the source,
54+
because "it works by construction" is exactly the claim that stops being true the
55+
day someone adds a whitelist to one of those three steps.
56+
57+
⚠️ This ships the **shape** only. The load path that iterates the list in
58+
dependency-topological order (ADR-0130 D5, through the one sorter
59+
`resolvePluginOrder`) and the `installPackage` co-ownership gate with its
60+
install-time object-name uniqueness check (D1/D3, which ADR-0130 requires to land
61+
as one inseparable change) are separate, dependent cards. Until they land, a
62+
multi-package artifact parses and carries its list and nothing downstream
63+
iterates it — so authoring `packages` today registers no extra package.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0130 D4 — `os compile` / `os build` write side, measured on the artifact
5+
* the commands actually put on disk.
6+
*
7+
* D4's acceptance has two halves and they pull in opposite directions, which is
8+
* why both are measured here from real runs rather than argued from the source:
9+
*
10+
* 1. A single-package project keeps writing `manifest` EXACTLY as today. The
11+
* default compile output does not move — no new key, no reordering, no
12+
* materialised empty list. This is the half a schema change breaks by
13+
* accident (a `.default([])` on the new key would rewrite every project's
14+
* artifact on its next build), so it is pinned as an exact top-level key
15+
* set, not as a spot check.
16+
*
17+
* 2. An artifact that DOES declare `packages` survives the whole pipeline —
18+
* `normalizeStackInput` → `lowerCallables` → `ObjectStackDefinitionSchema`
19+
* → `JSON.stringify(finalBundle)`. Each of those steps shallow-clones the
20+
* top level, so the key passes through *by construction*; construction is
21+
* exactly the kind of claim that stops being true when someone adds a
22+
* whitelist to one of the three, and nothing would have failed.
23+
*
24+
* ⛔ A green run here does NOT mean a multi-package artifact installs. The load
25+
* path that iterates the list (ADR-0130 D5, topologically ordered through
26+
* `resolvePluginOrder`) and the `installPackage` co-ownership gate (D1/D3) are
27+
* separate, dependent cards. This file pins what the COMPILER writes.
28+
*/
29+
30+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
31+
import { execFile } from 'node:child_process';
32+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
33+
import { tmpdir } from 'node:os';
34+
import { join, resolve } from 'node:path';
35+
import { fileURLToPath } from 'node:url';
36+
import { childEnv } from './helpers/serve-process.js';
37+
38+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
39+
const CLI = resolve(HERE, '../bin/run-dev.js');
40+
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
41+
42+
interface Run {
43+
code: number;
44+
stdout: string;
45+
stderr: string;
46+
}
47+
48+
function runCli(args: string[], cwd: string): Promise<Run> {
49+
return new Promise((resolvePromise) => {
50+
execFile(
51+
TSX,
52+
[CLI, ...args],
53+
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
54+
(err, stdout, stderr) => {
55+
resolvePromise({
56+
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
57+
stdout: String(stdout),
58+
stderr: String(stderr),
59+
});
60+
},
61+
);
62+
});
63+
}
64+
65+
function payloadOf(run: Run, label: string): Record<string, unknown> {
66+
try {
67+
return JSON.parse(run.stdout) as Record<string, unknown>;
68+
} catch {
69+
throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`);
70+
}
71+
}
72+
73+
/** Today's shape: one package, declared through the singular `manifest`. */
74+
const CONFIG_SINGLE = `
75+
export default {
76+
manifest: { id: 'com.example.solo', name: 'solo', version: '1.0.0', type: 'app', namespace: 'solo' },
77+
objects: [
78+
{
79+
name: 'solo_ticket',
80+
label: 'Ticket',
81+
sharingModel: 'private',
82+
fields: { title: { type: 'text', label: 'Title' } },
83+
},
84+
],
85+
};
86+
`;
87+
88+
/** ADR-0130 D4: the artifact carries two co-owning packages, wrapper form. */
89+
const CONFIG_MULTI = `
90+
export default {
91+
manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' },
92+
packages: [
93+
{ manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' } },
94+
{ manifest: { id: 'com.example.crm.cpq', name: 'cpq', version: '1.0.0', type: 'module', namespace: 'crm' } },
95+
],
96+
objects: [
97+
{
98+
name: 'crm_account',
99+
label: 'Account',
100+
sharingModel: 'private',
101+
fields: { name: { type: 'text', label: 'Name' } },
102+
},
103+
],
104+
};
105+
`;
106+
107+
/**
108+
* The reservation, violated: the manifest body inlined flat as the array
109+
* element. Must be refused at the compile door, not written to an artifact.
110+
*/
111+
const CONFIG_FLATTENED = `
112+
export default {
113+
packages: [
114+
{ id: 'com.example.flat', name: 'flat', version: '1.0.0', type: 'app', namespace: 'flat' },
115+
],
116+
objects: [],
117+
};
118+
`;
119+
120+
const dirs: Record<string, string> = {};
121+
let root = '';
122+
123+
beforeAll(() => {
124+
root = mkdtempSync(join(tmpdir(), 'os-d4-packages-'));
125+
for (const [name, source] of Object.entries({
126+
single: CONFIG_SINGLE,
127+
multi: CONFIG_MULTI,
128+
flattened: CONFIG_FLATTENED,
129+
})) {
130+
const dir = join(root, name);
131+
mkdirSync(dir, { recursive: true });
132+
writeFileSync(join(dir, 'objectstack.config.ts'), source);
133+
dirs[name] = dir;
134+
}
135+
});
136+
137+
afterAll(() => {
138+
if (root) rmSync(root, { recursive: true, force: true });
139+
});
140+
141+
const artifactOf = (payload: Record<string, unknown>): Record<string, unknown> =>
142+
JSON.parse(readFileSync(String(payload.output), 'utf8')) as Record<string, unknown>;
143+
144+
describe('ADR-0130 D4 — the default (single-package) compile output does not move', () => {
145+
it('writes `manifest` and NO `packages` key', async () => {
146+
const run = await runCli(['build', '--json'], dirs.single);
147+
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);
148+
149+
const artifact = artifactOf(payloadOf(run, 'os build --json'));
150+
151+
expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.solo');
152+
// The exact key set, so a materialised `"packages": []` — the near-miss
153+
// this criterion exists for — fails here rather than being noticed by a
154+
// customer diffing their artifact.
155+
expect(Object.keys(artifact).sort()).toEqual(['manifest', 'objects']);
156+
expect(readFileSync(String(payloadOf(run, 'os build --json').output), 'utf8')).not.toContain('"packages"');
157+
}, 180_000);
158+
});
159+
160+
describe('ADR-0130 D4 — an artifact declaring `packages` compiles and keeps it', () => {
161+
it('carries both package manifests through to the written artifact, in order', async () => {
162+
const run = await runCli(['build', '--json'], dirs.multi);
163+
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);
164+
165+
const artifact = artifactOf(payloadOf(run, 'os build --json'));
166+
const packages = artifact.packages as { manifest: { id: string; type: string } }[];
167+
168+
expect(Array.isArray(packages), 'the compiler dropped the `packages` key').toBe(true);
169+
expect(packages.map((p) => p.manifest.id)).toEqual([
170+
'com.example.crm',
171+
'com.example.crm.cpq',
172+
]);
173+
// The wrapper survives as a wrapper — not flattened, not unwrapped.
174+
expect(packages[0]).toEqual({ manifest: expect.objectContaining({ id: 'com.example.crm' }) });
175+
expect(packages[1].manifest.type).toBe('module');
176+
}, 180_000);
177+
178+
it('keeps the singular `manifest` beside it — retained, not replaced', async () => {
179+
const run = await runCli(['build', '--json'], dirs.multi);
180+
const artifact = artifactOf(payloadOf(run, 'os build --json'));
181+
182+
expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.crm');
183+
}, 180_000);
184+
});
185+
186+
describe('ADR-0130 D4 — the compile door refuses a flattened entry', () => {
187+
it('exits non-zero rather than writing an artifact in the unreserved shape', async () => {
188+
const run = await runCli(['build', '--json'], dirs.flattened);
189+
190+
expect(run.code, `expected a refusal, got exit 0:\n${run.stdout}`).not.toBe(0);
191+
const payload = payloadOf(run, 'os build --json');
192+
expect(payload.success).toBe(false);
193+
194+
// The refusal must point at the offending entry, so the author can find it
195+
// in an artifact with N packages.
196+
const errors = JSON.stringify(payload.errors ?? payload.error ?? '');
197+
expect(errors).toContain('packages');
198+
}, 180_000);
199+
});

packages/spec/api-surface/root.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
"AUDIENCE_ANCHOR_POSITIONS (const)",
1010
"Agent (type)",
1111
"ApplyConversionsOptions (interface)",
12+
"ArtifactPackageEntry (type)",
13+
"ArtifactPackageEntryParsed (type)",
14+
"ArtifactPackageEntrySchema (const)",
1215
"AssembledViewArtifact (type)",
1316
"AssembledViewArtifactParsed (type)",
1417
"AssembledViewArtifactSchema (const)",

packages/spec/export-origins/root.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
"AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)",
1010
"Agent": "src/ai/agent.zod.ts#Agent (type)",
1111
"ApplyConversionsOptions": "src/conversions/apply.ts#ApplyConversionsOptions (interface)",
12+
"ArtifactPackageEntry": "src/stack.zod.ts#ArtifactPackageEntry (type)",
13+
"ArtifactPackageEntryParsed": "src/stack.zod.ts#ArtifactPackageEntryParsed (type)",
14+
"ArtifactPackageEntrySchema": "src/stack.zod.ts#ArtifactPackageEntrySchema (const)",
1215
"AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)",
1316
"AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)",
1417
"AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)",

0 commit comments

Comments
 (0)