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
63 changes: 63 additions & 0 deletions .changeset/adr0130-d4-artifact-packages-list.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/spec": minor
---

feat(spec): the release artifact may carry N package manifests — optional `packages[]` on `ObjectStackDefinitionSchema` (ADR-0130 D4, #14161)

`ObjectStackDefinitionSchema` gains an **optional** `packages` key: an array of
package entries, so one release artifact can deliver a product split into
modules **without renaming a single object**. Renaming is what separate
namespaces would cost — the object `name` IS the table name, the REST path, the
formula token and the saved-view key (ADR-0129 D1–D2) — and rename-on-install is
ADR-0048's standing non-goal.

**`manifest` (singular) is RETAINED, not replaced**, and both shapes are read:
`packages` present → iterate it; `packages` absent → treat `manifest` as a
single-element list. A replacement would break every artifact already built and
sitting on disk at every customer, which is why ADR-0130 states the read-both
rule as the schema decision rather than an implementation note: the schema shape
IS the compatibility mechanism.

**Each entry is a wrapper object** — `{ manifest: { … } }` — never the manifest
body inlined flat as the array element. That position is reserved deliberately,
at schema time: when a future external-segment form lands it is
`{ ref, integrity }`, an **additive key on an existing object**, rather than a
reshape that would have to bolt transport keys onto the shared `ManifestSchema`
and make every required manifest field optional. ⛔ Segmented loading itself is
**not** implemented and is an explicit ADR-0130 Non-goal. Forward compatibility
rides the mechanism that already exists, `manifest.engines.protocol` (ADR-0025);
⛔ no new version-negotiation mechanism is introduced.

**Graded `minor`: a pure widening, with no accept-set narrowing anywhere.** The
new key is optional, no existing key changed shape, and nothing that parsed
before is refused now. Measured rather than asserted — the acceptance criterion
was that existing single-`manifest` artifacts do not move, and both halves are
pinned:

- schema layer (`packages/spec/src/stack-artifact-packages.test.ts`): parsing a
single-`manifest` artifact adds **no** top-level key, materialises no
`packages` list, and the serialised result contains no `"packages"`. The
near-miss this guards is a `.default([])`, which would have rewritten every
project's artifact on its next build;
- compiler (`packages/cli/test/compile-artifact-packages.e2e.test.ts`): the
artifact `os build` writes for a single-package project has the exact
top-level key set it had before.

**No `@objectstack/cli` release is graded, and that is a measurement, not an
omission.** `os compile` / `os build` needed **no source change** to align:
`normalizeStackInput`, `lowerCallables` and the artifact write each shallow-clone
the top level, and the validation step parses with this very schema, so the new
key flows through end to end. What moved is the CLI's accept set, and it moved
**entirely through this package** — the CLI ships no changed line and takes the
new behaviour with its `@objectstack/spec` bump. The pass-through was verified by
compiling real projects in the e2e file above rather than read off the source,
because "it works by construction" is exactly the claim that stops being true the
day someone adds a whitelist to one of those three steps.

⚠️ This ships the **shape** only. The load path that iterates the list in
dependency-topological order (ADR-0130 D5, through the one sorter
`resolvePluginOrder`) and the `installPackage` co-ownership gate with its
install-time object-name uniqueness check (D1/D3, which ADR-0130 requires to land
as one inseparable change) are separate, dependent cards. Until they land, a
multi-package artifact parses and carries its list and nothing downstream
iterates it — so authoring `packages` today registers no extra package.
199 changes: 199 additions & 0 deletions packages/cli/test/compile-artifact-packages.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0130 D4 — `os compile` / `os build` write side, measured on the artifact
* the commands actually put on disk.
*
* D4's acceptance has two halves and they pull in opposite directions, which is
* why both are measured here from real runs rather than argued from the source:
*
* 1. A single-package project keeps writing `manifest` EXACTLY as today. The
* default compile output does not move — no new key, no reordering, no
* materialised empty list. This is the half a schema change breaks by
* accident (a `.default([])` on the new key would rewrite every project's
* artifact on its next build), so it is pinned as an exact top-level key
* set, not as a spot check.
*
* 2. An artifact that DOES declare `packages` survives the whole pipeline —
* `normalizeStackInput` → `lowerCallables` → `ObjectStackDefinitionSchema`
* → `JSON.stringify(finalBundle)`. Each of those steps shallow-clones the
* top level, so the key passes through *by construction*; construction is
* exactly the kind of claim that stops being true when someone adds a
* whitelist to one of the three, and nothing would have failed.
*
* ⛔ A green run here does NOT mean a multi-package artifact installs. The load
* path that iterates the list (ADR-0130 D5, topologically ordered through
* `resolvePluginOrder`) and the `installPackage` co-ownership gate (D1/D3) are
* separate, dependent cards. This file pins what the COMPILER writes.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');

interface Run {
code: number;
stdout: string;
stderr: string;
}

function runCli(args: string[], cwd: string): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(
TSX,
[CLI, ...args],
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
(err, stdout, stderr) => {
resolvePromise({
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
stdout: String(stdout),
stderr: String(stderr),
});
},
);
});
}

function payloadOf(run: Run, label: string): Record<string, unknown> {
try {
return JSON.parse(run.stdout) as Record<string, unknown>;
} catch {
throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`);
}
}

/** Today's shape: one package, declared through the singular `manifest`. */
const CONFIG_SINGLE = `
export default {
manifest: { id: 'com.example.solo', name: 'solo', version: '1.0.0', type: 'app', namespace: 'solo' },
objects: [
{
name: 'solo_ticket',
label: 'Ticket',
sharingModel: 'private',
fields: { title: { type: 'text', label: 'Title' } },
},
],
};
`;

/** ADR-0130 D4: the artifact carries two co-owning packages, wrapper form. */
const CONFIG_MULTI = `
export default {
manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' },
packages: [
{ manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' } },
{ manifest: { id: 'com.example.crm.cpq', name: 'cpq', version: '1.0.0', type: 'module', namespace: 'crm' } },
],
objects: [
{
name: 'crm_account',
label: 'Account',
sharingModel: 'private',
fields: { name: { type: 'text', label: 'Name' } },
},
],
};
`;

/**
* The reservation, violated: the manifest body inlined flat as the array
* element. Must be refused at the compile door, not written to an artifact.
*/
const CONFIG_FLATTENED = `
export default {
packages: [
{ id: 'com.example.flat', name: 'flat', version: '1.0.0', type: 'app', namespace: 'flat' },
],
objects: [],
};
`;

const dirs: Record<string, string> = {};
let root = '';

beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'os-d4-packages-'));
for (const [name, source] of Object.entries({
single: CONFIG_SINGLE,
multi: CONFIG_MULTI,
flattened: CONFIG_FLATTENED,
})) {
const dir = join(root, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'objectstack.config.ts'), source);
dirs[name] = dir;
}
});

afterAll(() => {
if (root) rmSync(root, { recursive: true, force: true });
});

const artifactOf = (payload: Record<string, unknown>): Record<string, unknown> =>
JSON.parse(readFileSync(String(payload.output), 'utf8')) as Record<string, unknown>;

describe('ADR-0130 D4 — the default (single-package) compile output does not move', () => {
it('writes `manifest` and NO `packages` key', async () => {
const run = await runCli(['build', '--json'], dirs.single);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.solo');
// The exact key set, so a materialised `"packages": []` — the near-miss
// this criterion exists for — fails here rather than being noticed by a
// customer diffing their artifact.
expect(Object.keys(artifact).sort()).toEqual(['manifest', 'objects']);
expect(readFileSync(String(payloadOf(run, 'os build --json').output), 'utf8')).not.toContain('"packages"');
}, 180_000);
});

describe('ADR-0130 D4 — an artifact declaring `packages` compiles and keeps it', () => {
it('carries both package manifests through to the written artifact, in order', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));
const packages = artifact.packages as { manifest: { id: string; type: string } }[];

expect(Array.isArray(packages), 'the compiler dropped the `packages` key').toBe(true);
expect(packages.map((p) => p.manifest.id)).toEqual([
'com.example.crm',
'com.example.crm.cpq',
]);
// The wrapper survives as a wrapper — not flattened, not unwrapped.
expect(packages[0]).toEqual({ manifest: expect.objectContaining({ id: 'com.example.crm' }) });
expect(packages[1].manifest.type).toBe('module');
}, 180_000);

it('keeps the singular `manifest` beside it — retained, not replaced', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.crm');
}, 180_000);
});

describe('ADR-0130 D4 — the compile door refuses a flattened entry', () => {
it('exits non-zero rather than writing an artifact in the unreserved shape', async () => {
const run = await runCli(['build', '--json'], dirs.flattened);

expect(run.code, `expected a refusal, got exit 0:\n${run.stdout}`).not.toBe(0);
const payload = payloadOf(run, 'os build --json');
expect(payload.success).toBe(false);

// The refusal must point at the offending entry, so the author can find it
// in an artifact with N packages.
const errors = JSON.stringify(payload.errors ?? payload.error ?? '');
expect(errors).toContain('packages');
}, 180_000);
});
3 changes: 3 additions & 0 deletions packages/spec/api-surface/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent (type)",
"ApplyConversionsOptions (interface)",
"ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact (type)",
"AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema (const)",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent": "src/ai/agent.zod.ts#Agent (type)",
"ApplyConversionsOptions": "src/conversions/apply.ts#ApplyConversionsOptions (interface)",
"ArtifactPackageEntry": "src/stack.zod.ts#ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed": "src/stack.zod.ts#ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema": "src/stack.zod.ts#ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)",
"AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
63 changes: 63 additions & 0 deletions .changeset/adr0130-d4-artifact-packages-list.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/spec": minor
---

feat(spec): the release artifact may carry N package manifests — optional `packages[]` on `ObjectStackDefinitionSchema` (ADR-0130 D4, #14161)

`ObjectStackDefinitionSchema` gains an **optional** `packages` key: an array of
package entries, so one release artifact can deliver a product split into
modules **without renaming a single object**. Renaming is what separate
namespaces would cost — the object `name` IS the table name, the REST path, the
formula token and the saved-view key (ADR-0129 D1–D2) — and rename-on-install is
ADR-0048's standing non-goal.

**`manifest` (singular) is RETAINED, not replaced**, and both shapes are read:
`packages` present → iterate it; `packages` absent → treat `manifest` as a
single-element list. A replacement would break every artifact already built and
sitting on disk at every customer, which is why ADR-0130 states the read-both
rule as the schema decision rather than an implementation note: the schema shape
IS the compatibility mechanism.

**Each entry is a wrapper object** — `{ manifest: { … } }` — never the manifest
body inlined flat as the array element. That position is reserved deliberately,
at schema time: when a future external-segment form lands it is
`{ ref, integrity }`, an **additive key on an existing object**, rather than a
reshape that would have to bolt transport keys onto the shared `ManifestSchema`
and make every required manifest field optional. ⛔ Segmented loading itself is
**not** implemented and is an explicit ADR-0130 Non-goal. Forward compatibility
rides the mechanism that already exists, `manifest.engines.protocol` (ADR-0025);
⛔ no new version-negotiation mechanism is introduced.

**Graded `minor`: a pure widening, with no accept-set narrowing anywhere.** The
new key is optional, no existing key changed shape, and nothing that parsed
before is refused now. Measured rather than asserted — the acceptance criterion
was that existing single-`manifest` artifacts do not move, and both halves are
pinned:

- schema layer (`packages/spec/src/stack-artifact-packages.test.ts`): parsing a
single-`manifest` artifact adds **no** top-level key, materialises no
`packages` list, and the serialised result contains no `"packages"`. The
near-miss this guards is a `.default([])`, which would have rewritten every
project's artifact on its next build;
- compiler (`packages/cli/test/compile-artifact-packages.e2e.test.ts`): the
artifact `os build` writes for a single-package project has the exact
top-level key set it had before.

**No `@objectstack/cli` release is graded, and that is a measurement, not an
omission.** `os compile` / `os build` needed **no source change** to align:
`normalizeStackInput`, `lowerCallables` and the artifact write each shallow-clone
the top level, and the validation step parses with this very schema, so the new
key flows through end to end. What moved is the CLI's accept set, and it moved
**entirely through this package** — the CLI ships no changed line and takes the
new behaviour with its `@objectstack/spec` bump. The pass-through was verified by
compiling real projects in the e2e file above rather than read off the source,
because "it works by construction" is exactly the claim that stops being true the
day someone adds a whitelist to one of those three steps.

⚠️ This ships the **shape** only. The load path that iterates the list in
dependency-topological order (ADR-0130 D5, through the one sorter
`resolvePluginOrder`) and the `installPackage` co-ownership gate with its
install-time object-name uniqueness check (D1/D3, which ADR-0130 requires to land
as one inseparable change) are separate, dependent cards. Until they land, a
multi-package artifact parses and carries its list and nothing downstream
iterates it — so authoring `packages` today registers no extra package.
199 changes: 199 additions & 0 deletions packages/cli/test/compile-artifact-packages.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0130 D4 — `os compile` / `os build` write side, measured on the artifact
* the commands actually put on disk.
*
* D4's acceptance has two halves and they pull in opposite directions, which is
* why both are measured here from real runs rather than argued from the source:
*
* 1. A single-package project keeps writing `manifest` EXACTLY as today. The
* default compile output does not move — no new key, no reordering, no
* materialised empty list. This is the half a schema change breaks by
* accident (a `.default([])` on the new key would rewrite every project's
* artifact on its next build), so it is pinned as an exact top-level key
* set, not as a spot check.
*
* 2. An artifact that DOES declare `packages` survives the whole pipeline —
* `normalizeStackInput` → `lowerCallables` → `ObjectStackDefinitionSchema`
* → `JSON.stringify(finalBundle)`. Each of those steps shallow-clones the
* top level, so the key passes through *by construction*; construction is
* exactly the kind of claim that stops being true when someone adds a
* whitelist to one of the three, and nothing would have failed.
*
* ⛔ A green run here does NOT mean a multi-package artifact installs. The load
* path that iterates the list (ADR-0130 D5, topologically ordered through
* `resolvePluginOrder`) and the `installPackage` co-ownership gate (D1/D3) are
* separate, dependent cards. This file pins what the COMPILER writes.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');

interface Run {
code: number;
stdout: string;
stderr: string;
}

function runCli(args: string[], cwd: string): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(
TSX,
[CLI, ...args],
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
(err, stdout, stderr) => {
resolvePromise({
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
stdout: String(stdout),
stderr: String(stderr),
});
},
);
});
}

function payloadOf(run: Run, label: string): Record<string, unknown> {
try {
return JSON.parse(run.stdout) as Record<string, unknown>;
} catch {
throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`);
}
}

/** Today's shape: one package, declared through the singular `manifest`. */
const CONFIG_SINGLE = `
export default {
manifest: { id: 'com.example.solo', name: 'solo', version: '1.0.0', type: 'app', namespace: 'solo' },
objects: [
{
name: 'solo_ticket',
label: 'Ticket',
sharingModel: 'private',
fields: { title: { type: 'text', label: 'Title' } },
},
],
};
`;

/** ADR-0130 D4: the artifact carries two co-owning packages, wrapper form. */
const CONFIG_MULTI = `
export default {
manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' },
packages: [
{ manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' } },
{ manifest: { id: 'com.example.crm.cpq', name: 'cpq', version: '1.0.0', type: 'module', namespace: 'crm' } },
],
objects: [
{
name: 'crm_account',
label: 'Account',
sharingModel: 'private',
fields: { name: { type: 'text', label: 'Name' } },
},
],
};
`;

/**
* The reservation, violated: the manifest body inlined flat as the array
* element. Must be refused at the compile door, not written to an artifact.
*/
const CONFIG_FLATTENED = `
export default {
packages: [
{ id: 'com.example.flat', name: 'flat', version: '1.0.0', type: 'app', namespace: 'flat' },
],
objects: [],
};
`;

const dirs: Record<string, string> = {};
let root = '';

beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'os-d4-packages-'));
for (const [name, source] of Object.entries({
single: CONFIG_SINGLE,
multi: CONFIG_MULTI,
flattened: CONFIG_FLATTENED,
})) {
const dir = join(root, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'objectstack.config.ts'), source);
dirs[name] = dir;
}
});

afterAll(() => {
if (root) rmSync(root, { recursive: true, force: true });
});

const artifactOf = (payload: Record<string, unknown>): Record<string, unknown> =>
JSON.parse(readFileSync(String(payload.output), 'utf8')) as Record<string, unknown>;

describe('ADR-0130 D4 — the default (single-package) compile output does not move', () => {
it('writes `manifest` and NO `packages` key', async () => {
const run = await runCli(['build', '--json'], dirs.single);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.solo');
// The exact key set, so a materialised `"packages": []` — the near-miss
// this criterion exists for — fails here rather than being noticed by a
// customer diffing their artifact.
expect(Object.keys(artifact).sort()).toEqual(['manifest', 'objects']);
expect(readFileSync(String(payloadOf(run, 'os build --json').output), 'utf8')).not.toContain('"packages"');
}, 180_000);
});

describe('ADR-0130 D4 — an artifact declaring `packages` compiles and keeps it', () => {
it('carries both package manifests through to the written artifact, in order', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));
const packages = artifact.packages as { manifest: { id: string; type: string } }[];

expect(Array.isArray(packages), 'the compiler dropped the `packages` key').toBe(true);
expect(packages.map((p) => p.manifest.id)).toEqual([
'com.example.crm',
'com.example.crm.cpq',
]);
// The wrapper survives as a wrapper — not flattened, not unwrapped.
expect(packages[0]).toEqual({ manifest: expect.objectContaining({ id: 'com.example.crm' }) });
expect(packages[1].manifest.type).toBe('module');
}, 180_000);

it('keeps the singular `manifest` beside it — retained, not replaced', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.crm');
}, 180_000);
});

describe('ADR-0130 D4 — the compile door refuses a flattened entry', () => {
it('exits non-zero rather than writing an artifact in the unreserved shape', async () => {
const run = await runCli(['build', '--json'], dirs.flattened);

expect(run.code, `expected a refusal, got exit 0:\n${run.stdout}`).not.toBe(0);
const payload = payloadOf(run, 'os build --json');
expect(payload.success).toBe(false);

// The refusal must point at the offending entry, so the author can find it
// in an artifact with N packages.
const errors = JSON.stringify(payload.errors ?? payload.error ?? '');
expect(errors).toContain('packages');
}, 180_000);
});
3 changes: 3 additions & 0 deletions packages/spec/api-surface/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent (type)",
"ApplyConversionsOptions (interface)",
"ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact (type)",
"AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema (const)",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent": "src/ai/agent.zod.ts#Agent (type)",
"ApplyConversionsOptions": "src/conversions/apply.ts#ApplyConversionsOptions (interface)",
"ArtifactPackageEntry": "src/stack.zod.ts#ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed": "src/stack.zod.ts#ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema": "src/stack.zod.ts#ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)",
"AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
63 changes: 63 additions & 0 deletions .changeset/adr0130-d4-artifact-packages-list.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/spec": minor
---

feat(spec): the release artifact may carry N package manifests — optional `packages[]` on `ObjectStackDefinitionSchema` (ADR-0130 D4, #14161)

`ObjectStackDefinitionSchema` gains an **optional** `packages` key: an array of
package entries, so one release artifact can deliver a product split into
modules **without renaming a single object**. Renaming is what separate
namespaces would cost — the object `name` IS the table name, the REST path, the
formula token and the saved-view key (ADR-0129 D1–D2) — and rename-on-install is
ADR-0048's standing non-goal.

**`manifest` (singular) is RETAINED, not replaced**, and both shapes are read:
`packages` present → iterate it; `packages` absent → treat `manifest` as a
single-element list. A replacement would break every artifact already built and
sitting on disk at every customer, which is why ADR-0130 states the read-both
rule as the schema decision rather than an implementation note: the schema shape
IS the compatibility mechanism.

**Each entry is a wrapper object** — `{ manifest: { … } }` — never the manifest
body inlined flat as the array element. That position is reserved deliberately,
at schema time: when a future external-segment form lands it is
`{ ref, integrity }`, an **additive key on an existing object**, rather than a
reshape that would have to bolt transport keys onto the shared `ManifestSchema`
and make every required manifest field optional. ⛔ Segmented loading itself is
**not** implemented and is an explicit ADR-0130 Non-goal. Forward compatibility
rides the mechanism that already exists, `manifest.engines.protocol` (ADR-0025);
⛔ no new version-negotiation mechanism is introduced.

**Graded `minor`: a pure widening, with no accept-set narrowing anywhere.** The
new key is optional, no existing key changed shape, and nothing that parsed
before is refused now. Measured rather than asserted — the acceptance criterion
was that existing single-`manifest` artifacts do not move, and both halves are
pinned:

- schema layer (`packages/spec/src/stack-artifact-packages.test.ts`): parsing a
single-`manifest` artifact adds **no** top-level key, materialises no
`packages` list, and the serialised result contains no `"packages"`. The
near-miss this guards is a `.default([])`, which would have rewritten every
project's artifact on its next build;
- compiler (`packages/cli/test/compile-artifact-packages.e2e.test.ts`): the
artifact `os build` writes for a single-package project has the exact
top-level key set it had before.

**No `@objectstack/cli` release is graded, and that is a measurement, not an
omission.** `os compile` / `os build` needed **no source change** to align:
`normalizeStackInput`, `lowerCallables` and the artifact write each shallow-clone
the top level, and the validation step parses with this very schema, so the new
key flows through end to end. What moved is the CLI's accept set, and it moved
**entirely through this package** — the CLI ships no changed line and takes the
new behaviour with its `@objectstack/spec` bump. The pass-through was verified by
compiling real projects in the e2e file above rather than read off the source,
because "it works by construction" is exactly the claim that stops being true the
day someone adds a whitelist to one of those three steps.

⚠️ This ships the **shape** only. The load path that iterates the list in
dependency-topological order (ADR-0130 D5, through the one sorter
`resolvePluginOrder`) and the `installPackage` co-ownership gate with its
install-time object-name uniqueness check (D1/D3, which ADR-0130 requires to land
as one inseparable change) are separate, dependent cards. Until they land, a
multi-package artifact parses and carries its list and nothing downstream
iterates it — so authoring `packages` today registers no extra package.
199 changes: 199 additions & 0 deletions packages/cli/test/compile-artifact-packages.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0130 D4 — `os compile` / `os build` write side, measured on the artifact
* the commands actually put on disk.
*
* D4's acceptance has two halves and they pull in opposite directions, which is
* why both are measured here from real runs rather than argued from the source:
*
* 1. A single-package project keeps writing `manifest` EXACTLY as today. The
* default compile output does not move — no new key, no reordering, no
* materialised empty list. This is the half a schema change breaks by
* accident (a `.default([])` on the new key would rewrite every project's
* artifact on its next build), so it is pinned as an exact top-level key
* set, not as a spot check.
*
* 2. An artifact that DOES declare `packages` survives the whole pipeline —
* `normalizeStackInput` → `lowerCallables` → `ObjectStackDefinitionSchema`
* → `JSON.stringify(finalBundle)`. Each of those steps shallow-clones the
* top level, so the key passes through *by construction*; construction is
* exactly the kind of claim that stops being true when someone adds a
* whitelist to one of the three, and nothing would have failed.
*
* ⛔ A green run here does NOT mean a multi-package artifact installs. The load
* path that iterates the list (ADR-0130 D5, topologically ordered through
* `resolvePluginOrder`) and the `installPackage` co-ownership gate (D1/D3) are
* separate, dependent cards. This file pins what the COMPILER writes.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');

interface Run {
code: number;
stdout: string;
stderr: string;
}

function runCli(args: string[], cwd: string): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(
TSX,
[CLI, ...args],
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
(err, stdout, stderr) => {
resolvePromise({
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
stdout: String(stdout),
stderr: String(stderr),
});
},
);
});
}

function payloadOf(run: Run, label: string): Record<string, unknown> {
try {
return JSON.parse(run.stdout) as Record<string, unknown>;
} catch {
throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`);
}
}

/** Today's shape: one package, declared through the singular `manifest`. */
const CONFIG_SINGLE = `
export default {
manifest: { id: 'com.example.solo', name: 'solo', version: '1.0.0', type: 'app', namespace: 'solo' },
objects: [
{
name: 'solo_ticket',
label: 'Ticket',
sharingModel: 'private',
fields: { title: { type: 'text', label: 'Title' } },
},
],
};
`;

/** ADR-0130 D4: the artifact carries two co-owning packages, wrapper form. */
const CONFIG_MULTI = `
export default {
manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' },
packages: [
{ manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' } },
{ manifest: { id: 'com.example.crm.cpq', name: 'cpq', version: '1.0.0', type: 'module', namespace: 'crm' } },
],
objects: [
{
name: 'crm_account',
label: 'Account',
sharingModel: 'private',
fields: { name: { type: 'text', label: 'Name' } },
},
],
};
`;

/**
* The reservation, violated: the manifest body inlined flat as the array
* element. Must be refused at the compile door, not written to an artifact.
*/
const CONFIG_FLATTENED = `
export default {
packages: [
{ id: 'com.example.flat', name: 'flat', version: '1.0.0', type: 'app', namespace: 'flat' },
],
objects: [],
};
`;

const dirs: Record<string, string> = {};
let root = '';

beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'os-d4-packages-'));
for (const [name, source] of Object.entries({
single: CONFIG_SINGLE,
multi: CONFIG_MULTI,
flattened: CONFIG_FLATTENED,
})) {
const dir = join(root, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'objectstack.config.ts'), source);
dirs[name] = dir;
}
});

afterAll(() => {
if (root) rmSync(root, { recursive: true, force: true });
});

const artifactOf = (payload: Record<string, unknown>): Record<string, unknown> =>
JSON.parse(readFileSync(String(payload.output), 'utf8')) as Record<string, unknown>;

describe('ADR-0130 D4 — the default (single-package) compile output does not move', () => {
it('writes `manifest` and NO `packages` key', async () => {
const run = await runCli(['build', '--json'], dirs.single);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.solo');
// The exact key set, so a materialised `"packages": []` — the near-miss
// this criterion exists for — fails here rather than being noticed by a
// customer diffing their artifact.
expect(Object.keys(artifact).sort()).toEqual(['manifest', 'objects']);
expect(readFileSync(String(payloadOf(run, 'os build --json').output), 'utf8')).not.toContain('"packages"');
}, 180_000);
});

describe('ADR-0130 D4 — an artifact declaring `packages` compiles and keeps it', () => {
it('carries both package manifests through to the written artifact, in order', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));
const packages = artifact.packages as { manifest: { id: string; type: string } }[];

expect(Array.isArray(packages), 'the compiler dropped the `packages` key').toBe(true);
expect(packages.map((p) => p.manifest.id)).toEqual([
'com.example.crm',
'com.example.crm.cpq',
]);
// The wrapper survives as a wrapper — not flattened, not unwrapped.
expect(packages[0]).toEqual({ manifest: expect.objectContaining({ id: 'com.example.crm' }) });
expect(packages[1].manifest.type).toBe('module');
}, 180_000);

it('keeps the singular `manifest` beside it — retained, not replaced', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.crm');
}, 180_000);
});

describe('ADR-0130 D4 — the compile door refuses a flattened entry', () => {
it('exits non-zero rather than writing an artifact in the unreserved shape', async () => {
const run = await runCli(['build', '--json'], dirs.flattened);

expect(run.code, `expected a refusal, got exit 0:\n${run.stdout}`).not.toBe(0);
const payload = payloadOf(run, 'os build --json');
expect(payload.success).toBe(false);

// The refusal must point at the offending entry, so the author can find it
// in an artifact with N packages.
const errors = JSON.stringify(payload.errors ?? payload.error ?? '');
expect(errors).toContain('packages');
}, 180_000);
});
3 changes: 3 additions & 0 deletions packages/spec/api-surface/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent (type)",
"ApplyConversionsOptions (interface)",
"ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact (type)",
"AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema (const)",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent": "src/ai/agent.zod.ts#Agent (type)",
"ApplyConversionsOptions": "src/conversions/apply.ts#ApplyConversionsOptions (interface)",
"ArtifactPackageEntry": "src/stack.zod.ts#ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed": "src/stack.zod.ts#ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema": "src/stack.zod.ts#ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)",
"AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
63 changes: 63 additions & 0 deletions .changeset/adr0130-d4-artifact-packages-list.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/spec": minor
---

feat(spec): the release artifact may carry N package manifests — optional `packages[]` on `ObjectStackDefinitionSchema` (ADR-0130 D4, #14161)

`ObjectStackDefinitionSchema` gains an **optional** `packages` key: an array of
package entries, so one release artifact can deliver a product split into
modules **without renaming a single object**. Renaming is what separate
namespaces would cost — the object `name` IS the table name, the REST path, the
formula token and the saved-view key (ADR-0129 D1–D2) — and rename-on-install is
ADR-0048's standing non-goal.

**`manifest` (singular) is RETAINED, not replaced**, and both shapes are read:
`packages` present → iterate it; `packages` absent → treat `manifest` as a
single-element list. A replacement would break every artifact already built and
sitting on disk at every customer, which is why ADR-0130 states the read-both
rule as the schema decision rather than an implementation note: the schema shape
IS the compatibility mechanism.

**Each entry is a wrapper object** — `{ manifest: { … } }` — never the manifest
body inlined flat as the array element. That position is reserved deliberately,
at schema time: when a future external-segment form lands it is
`{ ref, integrity }`, an **additive key on an existing object**, rather than a
reshape that would have to bolt transport keys onto the shared `ManifestSchema`
and make every required manifest field optional. ⛔ Segmented loading itself is
**not** implemented and is an explicit ADR-0130 Non-goal. Forward compatibility
rides the mechanism that already exists, `manifest.engines.protocol` (ADR-0025);
⛔ no new version-negotiation mechanism is introduced.

**Graded `minor`: a pure widening, with no accept-set narrowing anywhere.** The
new key is optional, no existing key changed shape, and nothing that parsed
before is refused now. Measured rather than asserted — the acceptance criterion
was that existing single-`manifest` artifacts do not move, and both halves are
pinned:

- schema layer (`packages/spec/src/stack-artifact-packages.test.ts`): parsing a
single-`manifest` artifact adds **no** top-level key, materialises no
`packages` list, and the serialised result contains no `"packages"`. The
near-miss this guards is a `.default([])`, which would have rewritten every
project's artifact on its next build;
- compiler (`packages/cli/test/compile-artifact-packages.e2e.test.ts`): the
artifact `os build` writes for a single-package project has the exact
top-level key set it had before.

**No `@objectstack/cli` release is graded, and that is a measurement, not an
omission.** `os compile` / `os build` needed **no source change** to align:
`normalizeStackInput`, `lowerCallables` and the artifact write each shallow-clone
the top level, and the validation step parses with this very schema, so the new
key flows through end to end. What moved is the CLI's accept set, and it moved
**entirely through this package** — the CLI ships no changed line and takes the
new behaviour with its `@objectstack/spec` bump. The pass-through was verified by
compiling real projects in the e2e file above rather than read off the source,
because "it works by construction" is exactly the claim that stops being true the
day someone adds a whitelist to one of those three steps.

⚠️ This ships the **shape** only. The load path that iterates the list in
dependency-topological order (ADR-0130 D5, through the one sorter
`resolvePluginOrder`) and the `installPackage` co-ownership gate with its
install-time object-name uniqueness check (D1/D3, which ADR-0130 requires to land
as one inseparable change) are separate, dependent cards. Until they land, a
multi-package artifact parses and carries its list and nothing downstream
iterates it — so authoring `packages` today registers no extra package.
199 changes: 199 additions & 0 deletions packages/cli/test/compile-artifact-packages.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0130 D4 — `os compile` / `os build` write side, measured on the artifact
* the commands actually put on disk.
*
* D4's acceptance has two halves and they pull in opposite directions, which is
* why both are measured here from real runs rather than argued from the source:
*
* 1. A single-package project keeps writing `manifest` EXACTLY as today. The
* default compile output does not move — no new key, no reordering, no
* materialised empty list. This is the half a schema change breaks by
* accident (a `.default([])` on the new key would rewrite every project's
* artifact on its next build), so it is pinned as an exact top-level key
* set, not as a spot check.
*
* 2. An artifact that DOES declare `packages` survives the whole pipeline —
* `normalizeStackInput` → `lowerCallables` → `ObjectStackDefinitionSchema`
* → `JSON.stringify(finalBundle)`. Each of those steps shallow-clones the
* top level, so the key passes through *by construction*; construction is
* exactly the kind of claim that stops being true when someone adds a
* whitelist to one of the three, and nothing would have failed.
*
* ⛔ A green run here does NOT mean a multi-package artifact installs. The load
* path that iterates the list (ADR-0130 D5, topologically ordered through
* `resolvePluginOrder`) and the `installPackage` co-ownership gate (D1/D3) are
* separate, dependent cards. This file pins what the COMPILER writes.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');

interface Run {
code: number;
stdout: string;
stderr: string;
}

function runCli(args: string[], cwd: string): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(
TSX,
[CLI, ...args],
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
(err, stdout, stderr) => {
resolvePromise({
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
stdout: String(stdout),
stderr: String(stderr),
});
},
);
});
}

function payloadOf(run: Run, label: string): Record<string, unknown> {
try {
return JSON.parse(run.stdout) as Record<string, unknown>;
} catch {
throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`);
}
}

/** Today's shape: one package, declared through the singular `manifest`. */
const CONFIG_SINGLE = `
export default {
manifest: { id: 'com.example.solo', name: 'solo', version: '1.0.0', type: 'app', namespace: 'solo' },
objects: [
{
name: 'solo_ticket',
label: 'Ticket',
sharingModel: 'private',
fields: { title: { type: 'text', label: 'Title' } },
},
],
};
`;

/** ADR-0130 D4: the artifact carries two co-owning packages, wrapper form. */
const CONFIG_MULTI = `
export default {
manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' },
packages: [
{ manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' } },
{ manifest: { id: 'com.example.crm.cpq', name: 'cpq', version: '1.0.0', type: 'module', namespace: 'crm' } },
],
objects: [
{
name: 'crm_account',
label: 'Account',
sharingModel: 'private',
fields: { name: { type: 'text', label: 'Name' } },
},
],
};
`;

/**
* The reservation, violated: the manifest body inlined flat as the array
* element. Must be refused at the compile door, not written to an artifact.
*/
const CONFIG_FLATTENED = `
export default {
packages: [
{ id: 'com.example.flat', name: 'flat', version: '1.0.0', type: 'app', namespace: 'flat' },
],
objects: [],
};
`;

const dirs: Record<string, string> = {};
let root = '';

beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'os-d4-packages-'));
for (const [name, source] of Object.entries({
single: CONFIG_SINGLE,
multi: CONFIG_MULTI,
flattened: CONFIG_FLATTENED,
})) {
const dir = join(root, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'objectstack.config.ts'), source);
dirs[name] = dir;
}
});

afterAll(() => {
if (root) rmSync(root, { recursive: true, force: true });
});

const artifactOf = (payload: Record<string, unknown>): Record<string, unknown> =>
JSON.parse(readFileSync(String(payload.output), 'utf8')) as Record<string, unknown>;

describe('ADR-0130 D4 — the default (single-package) compile output does not move', () => {
it('writes `manifest` and NO `packages` key', async () => {
const run = await runCli(['build', '--json'], dirs.single);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.solo');
// The exact key set, so a materialised `"packages": []` — the near-miss
// this criterion exists for — fails here rather than being noticed by a
// customer diffing their artifact.
expect(Object.keys(artifact).sort()).toEqual(['manifest', 'objects']);
expect(readFileSync(String(payloadOf(run, 'os build --json').output), 'utf8')).not.toContain('"packages"');
}, 180_000);
});

describe('ADR-0130 D4 — an artifact declaring `packages` compiles and keeps it', () => {
it('carries both package manifests through to the written artifact, in order', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));
const packages = artifact.packages as { manifest: { id: string; type: string } }[];

expect(Array.isArray(packages), 'the compiler dropped the `packages` key').toBe(true);
expect(packages.map((p) => p.manifest.id)).toEqual([
'com.example.crm',
'com.example.crm.cpq',
]);
// The wrapper survives as a wrapper — not flattened, not unwrapped.
expect(packages[0]).toEqual({ manifest: expect.objectContaining({ id: 'com.example.crm' }) });
expect(packages[1].manifest.type).toBe('module');
}, 180_000);

it('keeps the singular `manifest` beside it — retained, not replaced', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.crm');
}, 180_000);
});

describe('ADR-0130 D4 — the compile door refuses a flattened entry', () => {
it('exits non-zero rather than writing an artifact in the unreserved shape', async () => {
const run = await runCli(['build', '--json'], dirs.flattened);

expect(run.code, `expected a refusal, got exit 0:\n${run.stdout}`).not.toBe(0);
const payload = payloadOf(run, 'os build --json');
expect(payload.success).toBe(false);

// The refusal must point at the offending entry, so the author can find it
// in an artifact with N packages.
const errors = JSON.stringify(payload.errors ?? payload.error ?? '');
expect(errors).toContain('packages');
}, 180_000);
});
3 changes: 3 additions & 0 deletions packages/spec/api-surface/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent (type)",
"ApplyConversionsOptions (interface)",
"ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact (type)",
"AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema (const)",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent": "src/ai/agent.zod.ts#Agent (type)",
"ApplyConversionsOptions": "src/conversions/apply.ts#ApplyConversionsOptions (interface)",
"ArtifactPackageEntry": "src/stack.zod.ts#ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed": "src/stack.zod.ts#ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema": "src/stack.zod.ts#ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)",
"AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
63 changes: 63 additions & 0 deletions .changeset/adr0130-d4-artifact-packages-list.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/spec": minor
---

feat(spec): the release artifact may carry N package manifests — optional `packages[]` on `ObjectStackDefinitionSchema` (ADR-0130 D4, #14161)

`ObjectStackDefinitionSchema` gains an **optional** `packages` key: an array of
package entries, so one release artifact can deliver a product split into
modules **without renaming a single object**. Renaming is what separate
namespaces would cost — the object `name` IS the table name, the REST path, the
formula token and the saved-view key (ADR-0129 D1–D2) — and rename-on-install is
ADR-0048's standing non-goal.

**`manifest` (singular) is RETAINED, not replaced**, and both shapes are read:
`packages` present → iterate it; `packages` absent → treat `manifest` as a
single-element list. A replacement would break every artifact already built and
sitting on disk at every customer, which is why ADR-0130 states the read-both
rule as the schema decision rather than an implementation note: the schema shape
IS the compatibility mechanism.

**Each entry is a wrapper object** — `{ manifest: { … } }` — never the manifest
body inlined flat as the array element. That position is reserved deliberately,
at schema time: when a future external-segment form lands it is
`{ ref, integrity }`, an **additive key on an existing object**, rather than a
reshape that would have to bolt transport keys onto the shared `ManifestSchema`
and make every required manifest field optional. ⛔ Segmented loading itself is
**not** implemented and is an explicit ADR-0130 Non-goal. Forward compatibility
rides the mechanism that already exists, `manifest.engines.protocol` (ADR-0025);
⛔ no new version-negotiation mechanism is introduced.

**Graded `minor`: a pure widening, with no accept-set narrowing anywhere.** The
new key is optional, no existing key changed shape, and nothing that parsed
before is refused now. Measured rather than asserted — the acceptance criterion
was that existing single-`manifest` artifacts do not move, and both halves are
pinned:

- schema layer (`packages/spec/src/stack-artifact-packages.test.ts`): parsing a
single-`manifest` artifact adds **no** top-level key, materialises no
`packages` list, and the serialised result contains no `"packages"`. The
near-miss this guards is a `.default([])`, which would have rewritten every
project's artifact on its next build;
- compiler (`packages/cli/test/compile-artifact-packages.e2e.test.ts`): the
artifact `os build` writes for a single-package project has the exact
top-level key set it had before.

**No `@objectstack/cli` release is graded, and that is a measurement, not an
omission.** `os compile` / `os build` needed **no source change** to align:
`normalizeStackInput`, `lowerCallables` and the artifact write each shallow-clone
the top level, and the validation step parses with this very schema, so the new
key flows through end to end. What moved is the CLI's accept set, and it moved
**entirely through this package** — the CLI ships no changed line and takes the
new behaviour with its `@objectstack/spec` bump. The pass-through was verified by
compiling real projects in the e2e file above rather than read off the source,
because "it works by construction" is exactly the claim that stops being true the
day someone adds a whitelist to one of those three steps.

⚠️ This ships the **shape** only. The load path that iterates the list in
dependency-topological order (ADR-0130 D5, through the one sorter
`resolvePluginOrder`) and the `installPackage` co-ownership gate with its
install-time object-name uniqueness check (D1/D3, which ADR-0130 requires to land
as one inseparable change) are separate, dependent cards. Until they land, a
multi-package artifact parses and carries its list and nothing downstream
iterates it — so authoring `packages` today registers no extra package.
199 changes: 199 additions & 0 deletions packages/cli/test/compile-artifact-packages.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0130 D4 — `os compile` / `os build` write side, measured on the artifact
* the commands actually put on disk.
*
* D4's acceptance has two halves and they pull in opposite directions, which is
* why both are measured here from real runs rather than argued from the source:
*
* 1. A single-package project keeps writing `manifest` EXACTLY as today. The
* default compile output does not move — no new key, no reordering, no
* materialised empty list. This is the half a schema change breaks by
* accident (a `.default([])` on the new key would rewrite every project's
* artifact on its next build), so it is pinned as an exact top-level key
* set, not as a spot check.
*
* 2. An artifact that DOES declare `packages` survives the whole pipeline —
* `normalizeStackInput` → `lowerCallables` → `ObjectStackDefinitionSchema`
* → `JSON.stringify(finalBundle)`. Each of those steps shallow-clones the
* top level, so the key passes through *by construction*; construction is
* exactly the kind of claim that stops being true when someone adds a
* whitelist to one of the three, and nothing would have failed.
*
* ⛔ A green run here does NOT mean a multi-package artifact installs. The load
* path that iterates the list (ADR-0130 D5, topologically ordered through
* `resolvePluginOrder`) and the `installPackage` co-ownership gate (D1/D3) are
* separate, dependent cards. This file pins what the COMPILER writes.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');

interface Run {
code: number;
stdout: string;
stderr: string;
}

function runCli(args: string[], cwd: string): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(
TSX,
[CLI, ...args],
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
(err, stdout, stderr) => {
resolvePromise({
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
stdout: String(stdout),
stderr: String(stderr),
});
},
);
});
}

function payloadOf(run: Run, label: string): Record<string, unknown> {
try {
return JSON.parse(run.stdout) as Record<string, unknown>;
} catch {
throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`);
}
}

/** Today's shape: one package, declared through the singular `manifest`. */
const CONFIG_SINGLE = `
export default {
manifest: { id: 'com.example.solo', name: 'solo', version: '1.0.0', type: 'app', namespace: 'solo' },
objects: [
{
name: 'solo_ticket',
label: 'Ticket',
sharingModel: 'private',
fields: { title: { type: 'text', label: 'Title' } },
},
],
};
`;

/** ADR-0130 D4: the artifact carries two co-owning packages, wrapper form. */
const CONFIG_MULTI = `
export default {
manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' },
packages: [
{ manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' } },
{ manifest: { id: 'com.example.crm.cpq', name: 'cpq', version: '1.0.0', type: 'module', namespace: 'crm' } },
],
objects: [
{
name: 'crm_account',
label: 'Account',
sharingModel: 'private',
fields: { name: { type: 'text', label: 'Name' } },
},
],
};
`;

/**
* The reservation, violated: the manifest body inlined flat as the array
* element. Must be refused at the compile door, not written to an artifact.
*/
const CONFIG_FLATTENED = `
export default {
packages: [
{ id: 'com.example.flat', name: 'flat', version: '1.0.0', type: 'app', namespace: 'flat' },
],
objects: [],
};
`;

const dirs: Record<string, string> = {};
let root = '';

beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'os-d4-packages-'));
for (const [name, source] of Object.entries({
single: CONFIG_SINGLE,
multi: CONFIG_MULTI,
flattened: CONFIG_FLATTENED,
})) {
const dir = join(root, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'objectstack.config.ts'), source);
dirs[name] = dir;
}
});

afterAll(() => {
if (root) rmSync(root, { recursive: true, force: true });
});

const artifactOf = (payload: Record<string, unknown>): Record<string, unknown> =>
JSON.parse(readFileSync(String(payload.output), 'utf8')) as Record<string, unknown>;

describe('ADR-0130 D4 — the default (single-package) compile output does not move', () => {
it('writes `manifest` and NO `packages` key', async () => {
const run = await runCli(['build', '--json'], dirs.single);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.solo');
// The exact key set, so a materialised `"packages": []` — the near-miss
// this criterion exists for — fails here rather than being noticed by a
// customer diffing their artifact.
expect(Object.keys(artifact).sort()).toEqual(['manifest', 'objects']);
expect(readFileSync(String(payloadOf(run, 'os build --json').output), 'utf8')).not.toContain('"packages"');
}, 180_000);
});

describe('ADR-0130 D4 — an artifact declaring `packages` compiles and keeps it', () => {
it('carries both package manifests through to the written artifact, in order', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));
const packages = artifact.packages as { manifest: { id: string; type: string } }[];

expect(Array.isArray(packages), 'the compiler dropped the `packages` key').toBe(true);
expect(packages.map((p) => p.manifest.id)).toEqual([
'com.example.crm',
'com.example.crm.cpq',
]);
// The wrapper survives as a wrapper — not flattened, not unwrapped.
expect(packages[0]).toEqual({ manifest: expect.objectContaining({ id: 'com.example.crm' }) });
expect(packages[1].manifest.type).toBe('module');
}, 180_000);

it('keeps the singular `manifest` beside it — retained, not replaced', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.crm');
}, 180_000);
});

describe('ADR-0130 D4 — the compile door refuses a flattened entry', () => {
it('exits non-zero rather than writing an artifact in the unreserved shape', async () => {
const run = await runCli(['build', '--json'], dirs.flattened);

expect(run.code, `expected a refusal, got exit 0:\n${run.stdout}`).not.toBe(0);
const payload = payloadOf(run, 'os build --json');
expect(payload.success).toBe(false);

// The refusal must point at the offending entry, so the author can find it
// in an artifact with N packages.
const errors = JSON.stringify(payload.errors ?? payload.error ?? '');
expect(errors).toContain('packages');
}, 180_000);
});
3 changes: 3 additions & 0 deletions packages/spec/api-surface/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent (type)",
"ApplyConversionsOptions (interface)",
"ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact (type)",
"AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema (const)",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent": "src/ai/agent.zod.ts#Agent (type)",
"ApplyConversionsOptions": "src/conversions/apply.ts#ApplyConversionsOptions (interface)",
"ArtifactPackageEntry": "src/stack.zod.ts#ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed": "src/stack.zod.ts#ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema": "src/stack.zod.ts#ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)",
"AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
63 changes: 63 additions & 0 deletions .changeset/adr0130-d4-artifact-packages-list.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/spec": minor
---

feat(spec): the release artifact may carry N package manifests — optional `packages[]` on `ObjectStackDefinitionSchema` (ADR-0130 D4, #14161)

`ObjectStackDefinitionSchema` gains an **optional** `packages` key: an array of
package entries, so one release artifact can deliver a product split into
modules **without renaming a single object**. Renaming is what separate
namespaces would cost — the object `name` IS the table name, the REST path, the
formula token and the saved-view key (ADR-0129 D1–D2) — and rename-on-install is
ADR-0048's standing non-goal.

**`manifest` (singular) is RETAINED, not replaced**, and both shapes are read:
`packages` present → iterate it; `packages` absent → treat `manifest` as a
single-element list. A replacement would break every artifact already built and
sitting on disk at every customer, which is why ADR-0130 states the read-both
rule as the schema decision rather than an implementation note: the schema shape
IS the compatibility mechanism.

**Each entry is a wrapper object** — `{ manifest: { … } }` — never the manifest
body inlined flat as the array element. That position is reserved deliberately,
at schema time: when a future external-segment form lands it is
`{ ref, integrity }`, an **additive key on an existing object**, rather than a
reshape that would have to bolt transport keys onto the shared `ManifestSchema`
and make every required manifest field optional. ⛔ Segmented loading itself is
**not** implemented and is an explicit ADR-0130 Non-goal. Forward compatibility
rides the mechanism that already exists, `manifest.engines.protocol` (ADR-0025);
⛔ no new version-negotiation mechanism is introduced.

**Graded `minor`: a pure widening, with no accept-set narrowing anywhere.** The
new key is optional, no existing key changed shape, and nothing that parsed
before is refused now. Measured rather than asserted — the acceptance criterion
was that existing single-`manifest` artifacts do not move, and both halves are
pinned:

- schema layer (`packages/spec/src/stack-artifact-packages.test.ts`): parsing a
single-`manifest` artifact adds **no** top-level key, materialises no
`packages` list, and the serialised result contains no `"packages"`. The
near-miss this guards is a `.default([])`, which would have rewritten every
project's artifact on its next build;
- compiler (`packages/cli/test/compile-artifact-packages.e2e.test.ts`): the
artifact `os build` writes for a single-package project has the exact
top-level key set it had before.

**No `@objectstack/cli` release is graded, and that is a measurement, not an
omission.** `os compile` / `os build` needed **no source change** to align:
`normalizeStackInput`, `lowerCallables` and the artifact write each shallow-clone
the top level, and the validation step parses with this very schema, so the new
key flows through end to end. What moved is the CLI's accept set, and it moved
**entirely through this package** — the CLI ships no changed line and takes the
new behaviour with its `@objectstack/spec` bump. The pass-through was verified by
compiling real projects in the e2e file above rather than read off the source,
because "it works by construction" is exactly the claim that stops being true the
day someone adds a whitelist to one of those three steps.

⚠️ This ships the **shape** only. The load path that iterates the list in
dependency-topological order (ADR-0130 D5, through the one sorter
`resolvePluginOrder`) and the `installPackage` co-ownership gate with its
install-time object-name uniqueness check (D1/D3, which ADR-0130 requires to land
as one inseparable change) are separate, dependent cards. Until they land, a
multi-package artifact parses and carries its list and nothing downstream
iterates it — so authoring `packages` today registers no extra package.
199 changes: 199 additions & 0 deletions packages/cli/test/compile-artifact-packages.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0130 D4 — `os compile` / `os build` write side, measured on the artifact
* the commands actually put on disk.
*
* D4's acceptance has two halves and they pull in opposite directions, which is
* why both are measured here from real runs rather than argued from the source:
*
* 1. A single-package project keeps writing `manifest` EXACTLY as today. The
* default compile output does not move — no new key, no reordering, no
* materialised empty list. This is the half a schema change breaks by
* accident (a `.default([])` on the new key would rewrite every project's
* artifact on its next build), so it is pinned as an exact top-level key
* set, not as a spot check.
*
* 2. An artifact that DOES declare `packages` survives the whole pipeline —
* `normalizeStackInput` → `lowerCallables` → `ObjectStackDefinitionSchema`
* → `JSON.stringify(finalBundle)`. Each of those steps shallow-clones the
* top level, so the key passes through *by construction*; construction is
* exactly the kind of claim that stops being true when someone adds a
* whitelist to one of the three, and nothing would have failed.
*
* ⛔ A green run here does NOT mean a multi-package artifact installs. The load
* path that iterates the list (ADR-0130 D5, topologically ordered through
* `resolvePluginOrder`) and the `installPackage` co-ownership gate (D1/D3) are
* separate, dependent cards. This file pins what the COMPILER writes.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');

interface Run {
code: number;
stdout: string;
stderr: string;
}

function runCli(args: string[], cwd: string): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(
TSX,
[CLI, ...args],
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
(err, stdout, stderr) => {
resolvePromise({
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
stdout: String(stdout),
stderr: String(stderr),
});
},
);
});
}

function payloadOf(run: Run, label: string): Record<string, unknown> {
try {
return JSON.parse(run.stdout) as Record<string, unknown>;
} catch {
throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`);
}
}

/** Today's shape: one package, declared through the singular `manifest`. */
const CONFIG_SINGLE = `
export default {
manifest: { id: 'com.example.solo', name: 'solo', version: '1.0.0', type: 'app', namespace: 'solo' },
objects: [
{
name: 'solo_ticket',
label: 'Ticket',
sharingModel: 'private',
fields: { title: { type: 'text', label: 'Title' } },
},
],
};
`;

/** ADR-0130 D4: the artifact carries two co-owning packages, wrapper form. */
const CONFIG_MULTI = `
export default {
manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' },
packages: [
{ manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' } },
{ manifest: { id: 'com.example.crm.cpq', name: 'cpq', version: '1.0.0', type: 'module', namespace: 'crm' } },
],
objects: [
{
name: 'crm_account',
label: 'Account',
sharingModel: 'private',
fields: { name: { type: 'text', label: 'Name' } },
},
],
};
`;

/**
* The reservation, violated: the manifest body inlined flat as the array
* element. Must be refused at the compile door, not written to an artifact.
*/
const CONFIG_FLATTENED = `
export default {
packages: [
{ id: 'com.example.flat', name: 'flat', version: '1.0.0', type: 'app', namespace: 'flat' },
],
objects: [],
};
`;

const dirs: Record<string, string> = {};
let root = '';

beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'os-d4-packages-'));
for (const [name, source] of Object.entries({
single: CONFIG_SINGLE,
multi: CONFIG_MULTI,
flattened: CONFIG_FLATTENED,
})) {
const dir = join(root, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'objectstack.config.ts'), source);
dirs[name] = dir;
}
});

afterAll(() => {
if (root) rmSync(root, { recursive: true, force: true });
});

const artifactOf = (payload: Record<string, unknown>): Record<string, unknown> =>
JSON.parse(readFileSync(String(payload.output), 'utf8')) as Record<string, unknown>;

describe('ADR-0130 D4 — the default (single-package) compile output does not move', () => {
it('writes `manifest` and NO `packages` key', async () => {
const run = await runCli(['build', '--json'], dirs.single);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.solo');
// The exact key set, so a materialised `"packages": []` — the near-miss
// this criterion exists for — fails here rather than being noticed by a
// customer diffing their artifact.
expect(Object.keys(artifact).sort()).toEqual(['manifest', 'objects']);
expect(readFileSync(String(payloadOf(run, 'os build --json').output), 'utf8')).not.toContain('"packages"');
}, 180_000);
});

describe('ADR-0130 D4 — an artifact declaring `packages` compiles and keeps it', () => {
it('carries both package manifests through to the written artifact, in order', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));
const packages = artifact.packages as { manifest: { id: string; type: string } }[];

expect(Array.isArray(packages), 'the compiler dropped the `packages` key').toBe(true);
expect(packages.map((p) => p.manifest.id)).toEqual([
'com.example.crm',
'com.example.crm.cpq',
]);
// The wrapper survives as a wrapper — not flattened, not unwrapped.
expect(packages[0]).toEqual({ manifest: expect.objectContaining({ id: 'com.example.crm' }) });
expect(packages[1].manifest.type).toBe('module');
}, 180_000);

it('keeps the singular `manifest` beside it — retained, not replaced', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.crm');
}, 180_000);
});

describe('ADR-0130 D4 — the compile door refuses a flattened entry', () => {
it('exits non-zero rather than writing an artifact in the unreserved shape', async () => {
const run = await runCli(['build', '--json'], dirs.flattened);

expect(run.code, `expected a refusal, got exit 0:\n${run.stdout}`).not.toBe(0);
const payload = payloadOf(run, 'os build --json');
expect(payload.success).toBe(false);

// The refusal must point at the offending entry, so the author can find it
// in an artifact with N packages.
const errors = JSON.stringify(payload.errors ?? payload.error ?? '');
expect(errors).toContain('packages');
}, 180_000);
});
3 changes: 3 additions & 0 deletions packages/spec/api-surface/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent (type)",
"ApplyConversionsOptions (interface)",
"ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact (type)",
"AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema (const)",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent": "src/ai/agent.zod.ts#Agent (type)",
"ApplyConversionsOptions": "src/conversions/apply.ts#ApplyConversionsOptions (interface)",
"ArtifactPackageEntry": "src/stack.zod.ts#ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed": "src/stack.zod.ts#ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema": "src/stack.zod.ts#ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)",
"AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
63 changes: 63 additions & 0 deletions .changeset/adr0130-d4-artifact-packages-list.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/spec": minor
---

feat(spec): the release artifact may carry N package manifests — optional `packages[]` on `ObjectStackDefinitionSchema` (ADR-0130 D4, #14161)

`ObjectStackDefinitionSchema` gains an **optional** `packages` key: an array of
package entries, so one release artifact can deliver a product split into
modules **without renaming a single object**. Renaming is what separate
namespaces would cost — the object `name` IS the table name, the REST path, the
formula token and the saved-view key (ADR-0129 D1–D2) — and rename-on-install is
ADR-0048's standing non-goal.

**`manifest` (singular) is RETAINED, not replaced**, and both shapes are read:
`packages` present → iterate it; `packages` absent → treat `manifest` as a
single-element list. A replacement would break every artifact already built and
sitting on disk at every customer, which is why ADR-0130 states the read-both
rule as the schema decision rather than an implementation note: the schema shape
IS the compatibility mechanism.

**Each entry is a wrapper object** — `{ manifest: { … } }` — never the manifest
body inlined flat as the array element. That position is reserved deliberately,
at schema time: when a future external-segment form lands it is
`{ ref, integrity }`, an **additive key on an existing object**, rather than a
reshape that would have to bolt transport keys onto the shared `ManifestSchema`
and make every required manifest field optional. ⛔ Segmented loading itself is
**not** implemented and is an explicit ADR-0130 Non-goal. Forward compatibility
rides the mechanism that already exists, `manifest.engines.protocol` (ADR-0025);
⛔ no new version-negotiation mechanism is introduced.

**Graded `minor`: a pure widening, with no accept-set narrowing anywhere.** The
new key is optional, no existing key changed shape, and nothing that parsed
before is refused now. Measured rather than asserted — the acceptance criterion
was that existing single-`manifest` artifacts do not move, and both halves are
pinned:

- schema layer (`packages/spec/src/stack-artifact-packages.test.ts`): parsing a
single-`manifest` artifact adds **no** top-level key, materialises no
`packages` list, and the serialised result contains no `"packages"`. The
near-miss this guards is a `.default([])`, which would have rewritten every
project's artifact on its next build;
- compiler (`packages/cli/test/compile-artifact-packages.e2e.test.ts`): the
artifact `os build` writes for a single-package project has the exact
top-level key set it had before.

**No `@objectstack/cli` release is graded, and that is a measurement, not an
omission.** `os compile` / `os build` needed **no source change** to align:
`normalizeStackInput`, `lowerCallables` and the artifact write each shallow-clone
the top level, and the validation step parses with this very schema, so the new
key flows through end to end. What moved is the CLI's accept set, and it moved
**entirely through this package** — the CLI ships no changed line and takes the
new behaviour with its `@objectstack/spec` bump. The pass-through was verified by
compiling real projects in the e2e file above rather than read off the source,
because "it works by construction" is exactly the claim that stops being true the
day someone adds a whitelist to one of those three steps.

⚠️ This ships the **shape** only. The load path that iterates the list in
dependency-topological order (ADR-0130 D5, through the one sorter
`resolvePluginOrder`) and the `installPackage` co-ownership gate with its
install-time object-name uniqueness check (D1/D3, which ADR-0130 requires to land
as one inseparable change) are separate, dependent cards. Until they land, a
multi-package artifact parses and carries its list and nothing downstream
iterates it — so authoring `packages` today registers no extra package.
199 changes: 199 additions & 0 deletions packages/cli/test/compile-artifact-packages.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0130 D4 — `os compile` / `os build` write side, measured on the artifact
* the commands actually put on disk.
*
* D4's acceptance has two halves and they pull in opposite directions, which is
* why both are measured here from real runs rather than argued from the source:
*
* 1. A single-package project keeps writing `manifest` EXACTLY as today. The
* default compile output does not move — no new key, no reordering, no
* materialised empty list. This is the half a schema change breaks by
* accident (a `.default([])` on the new key would rewrite every project's
* artifact on its next build), so it is pinned as an exact top-level key
* set, not as a spot check.
*
* 2. An artifact that DOES declare `packages` survives the whole pipeline —
* `normalizeStackInput` → `lowerCallables` → `ObjectStackDefinitionSchema`
* → `JSON.stringify(finalBundle)`. Each of those steps shallow-clones the
* top level, so the key passes through *by construction*; construction is
* exactly the kind of claim that stops being true when someone adds a
* whitelist to one of the three, and nothing would have failed.
*
* ⛔ A green run here does NOT mean a multi-package artifact installs. The load
* path that iterates the list (ADR-0130 D5, topologically ordered through
* `resolvePluginOrder`) and the `installPackage` co-ownership gate (D1/D3) are
* separate, dependent cards. This file pins what the COMPILER writes.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');

interface Run {
code: number;
stdout: string;
stderr: string;
}

function runCli(args: string[], cwd: string): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(
TSX,
[CLI, ...args],
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
(err, stdout, stderr) => {
resolvePromise({
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
stdout: String(stdout),
stderr: String(stderr),
});
},
);
});
}

function payloadOf(run: Run, label: string): Record<string, unknown> {
try {
return JSON.parse(run.stdout) as Record<string, unknown>;
} catch {
throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`);
}
}

/** Today's shape: one package, declared through the singular `manifest`. */
const CONFIG_SINGLE = `
export default {
manifest: { id: 'com.example.solo', name: 'solo', version: '1.0.0', type: 'app', namespace: 'solo' },
objects: [
{
name: 'solo_ticket',
label: 'Ticket',
sharingModel: 'private',
fields: { title: { type: 'text', label: 'Title' } },
},
],
};
`;

/** ADR-0130 D4: the artifact carries two co-owning packages, wrapper form. */
const CONFIG_MULTI = `
export default {
manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' },
packages: [
{ manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' } },
{ manifest: { id: 'com.example.crm.cpq', name: 'cpq', version: '1.0.0', type: 'module', namespace: 'crm' } },
],
objects: [
{
name: 'crm_account',
label: 'Account',
sharingModel: 'private',
fields: { name: { type: 'text', label: 'Name' } },
},
],
};
`;

/**
* The reservation, violated: the manifest body inlined flat as the array
* element. Must be refused at the compile door, not written to an artifact.
*/
const CONFIG_FLATTENED = `
export default {
packages: [
{ id: 'com.example.flat', name: 'flat', version: '1.0.0', type: 'app', namespace: 'flat' },
],
objects: [],
};
`;

const dirs: Record<string, string> = {};
let root = '';

beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'os-d4-packages-'));
for (const [name, source] of Object.entries({
single: CONFIG_SINGLE,
multi: CONFIG_MULTI,
flattened: CONFIG_FLATTENED,
})) {
const dir = join(root, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'objectstack.config.ts'), source);
dirs[name] = dir;
}
});

afterAll(() => {
if (root) rmSync(root, { recursive: true, force: true });
});

const artifactOf = (payload: Record<string, unknown>): Record<string, unknown> =>
JSON.parse(readFileSync(String(payload.output), 'utf8')) as Record<string, unknown>;

describe('ADR-0130 D4 — the default (single-package) compile output does not move', () => {
it('writes `manifest` and NO `packages` key', async () => {
const run = await runCli(['build', '--json'], dirs.single);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.solo');
// The exact key set, so a materialised `"packages": []` — the near-miss
// this criterion exists for — fails here rather than being noticed by a
// customer diffing their artifact.
expect(Object.keys(artifact).sort()).toEqual(['manifest', 'objects']);
expect(readFileSync(String(payloadOf(run, 'os build --json').output), 'utf8')).not.toContain('"packages"');
}, 180_000);
});

describe('ADR-0130 D4 — an artifact declaring `packages` compiles and keeps it', () => {
it('carries both package manifests through to the written artifact, in order', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));
const packages = artifact.packages as { manifest: { id: string; type: string } }[];

expect(Array.isArray(packages), 'the compiler dropped the `packages` key').toBe(true);
expect(packages.map((p) => p.manifest.id)).toEqual([
'com.example.crm',
'com.example.crm.cpq',
]);
// The wrapper survives as a wrapper — not flattened, not unwrapped.
expect(packages[0]).toEqual({ manifest: expect.objectContaining({ id: 'com.example.crm' }) });
expect(packages[1].manifest.type).toBe('module');
}, 180_000);

it('keeps the singular `manifest` beside it — retained, not replaced', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.crm');
}, 180_000);
});

describe('ADR-0130 D4 — the compile door refuses a flattened entry', () => {
it('exits non-zero rather than writing an artifact in the unreserved shape', async () => {
const run = await runCli(['build', '--json'], dirs.flattened);

expect(run.code, `expected a refusal, got exit 0:\n${run.stdout}`).not.toBe(0);
const payload = payloadOf(run, 'os build --json');
expect(payload.success).toBe(false);

// The refusal must point at the offending entry, so the author can find it
// in an artifact with N packages.
const errors = JSON.stringify(payload.errors ?? payload.error ?? '');
expect(errors).toContain('packages');
}, 180_000);
});
3 changes: 3 additions & 0 deletions packages/spec/api-surface/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent (type)",
"ApplyConversionsOptions (interface)",
"ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact (type)",
"AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema (const)",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent": "src/ai/agent.zod.ts#Agent (type)",
"ApplyConversionsOptions": "src/conversions/apply.ts#ApplyConversionsOptions (interface)",
"ArtifactPackageEntry": "src/stack.zod.ts#ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed": "src/stack.zod.ts#ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema": "src/stack.zod.ts#ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)",
"AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
63 changes: 63 additions & 0 deletions .changeset/adr0130-d4-artifact-packages-list.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/spec": minor
---

feat(spec): the release artifact may carry N package manifests — optional `packages[]` on `ObjectStackDefinitionSchema` (ADR-0130 D4, #14161)

`ObjectStackDefinitionSchema` gains an **optional** `packages` key: an array of
package entries, so one release artifact can deliver a product split into
modules **without renaming a single object**. Renaming is what separate
namespaces would cost — the object `name` IS the table name, the REST path, the
formula token and the saved-view key (ADR-0129 D1–D2) — and rename-on-install is
ADR-0048's standing non-goal.

**`manifest` (singular) is RETAINED, not replaced**, and both shapes are read:
`packages` present → iterate it; `packages` absent → treat `manifest` as a
single-element list. A replacement would break every artifact already built and
sitting on disk at every customer, which is why ADR-0130 states the read-both
rule as the schema decision rather than an implementation note: the schema shape
IS the compatibility mechanism.

**Each entry is a wrapper object** — `{ manifest: { … } }` — never the manifest
body inlined flat as the array element. That position is reserved deliberately,
at schema time: when a future external-segment form lands it is
`{ ref, integrity }`, an **additive key on an existing object**, rather than a
reshape that would have to bolt transport keys onto the shared `ManifestSchema`
and make every required manifest field optional. ⛔ Segmented loading itself is
**not** implemented and is an explicit ADR-0130 Non-goal. Forward compatibility
rides the mechanism that already exists, `manifest.engines.protocol` (ADR-0025);
⛔ no new version-negotiation mechanism is introduced.

**Graded `minor`: a pure widening, with no accept-set narrowing anywhere.** The
new key is optional, no existing key changed shape, and nothing that parsed
before is refused now. Measured rather than asserted — the acceptance criterion
was that existing single-`manifest` artifacts do not move, and both halves are
pinned:

- schema layer (`packages/spec/src/stack-artifact-packages.test.ts`): parsing a
single-`manifest` artifact adds **no** top-level key, materialises no
`packages` list, and the serialised result contains no `"packages"`. The
near-miss this guards is a `.default([])`, which would have rewritten every
project's artifact on its next build;
- compiler (`packages/cli/test/compile-artifact-packages.e2e.test.ts`): the
artifact `os build` writes for a single-package project has the exact
top-level key set it had before.

**No `@objectstack/cli` release is graded, and that is a measurement, not an
omission.** `os compile` / `os build` needed **no source change** to align:
`normalizeStackInput`, `lowerCallables` and the artifact write each shallow-clone
the top level, and the validation step parses with this very schema, so the new
key flows through end to end. What moved is the CLI's accept set, and it moved
**entirely through this package** — the CLI ships no changed line and takes the
new behaviour with its `@objectstack/spec` bump. The pass-through was verified by
compiling real projects in the e2e file above rather than read off the source,
because "it works by construction" is exactly the claim that stops being true the
day someone adds a whitelist to one of those three steps.

⚠️ This ships the **shape** only. The load path that iterates the list in
dependency-topological order (ADR-0130 D5, through the one sorter
`resolvePluginOrder`) and the `installPackage` co-ownership gate with its
install-time object-name uniqueness check (D1/D3, which ADR-0130 requires to land
as one inseparable change) are separate, dependent cards. Until they land, a
multi-package artifact parses and carries its list and nothing downstream
iterates it — so authoring `packages` today registers no extra package.
199 changes: 199 additions & 0 deletions packages/cli/test/compile-artifact-packages.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0130 D4 — `os compile` / `os build` write side, measured on the artifact
* the commands actually put on disk.
*
* D4's acceptance has two halves and they pull in opposite directions, which is
* why both are measured here from real runs rather than argued from the source:
*
* 1. A single-package project keeps writing `manifest` EXACTLY as today. The
* default compile output does not move — no new key, no reordering, no
* materialised empty list. This is the half a schema change breaks by
* accident (a `.default([])` on the new key would rewrite every project's
* artifact on its next build), so it is pinned as an exact top-level key
* set, not as a spot check.
*
* 2. An artifact that DOES declare `packages` survives the whole pipeline —
* `normalizeStackInput` → `lowerCallables` → `ObjectStackDefinitionSchema`
* → `JSON.stringify(finalBundle)`. Each of those steps shallow-clones the
* top level, so the key passes through *by construction*; construction is
* exactly the kind of claim that stops being true when someone adds a
* whitelist to one of the three, and nothing would have failed.
*
* ⛔ A green run here does NOT mean a multi-package artifact installs. The load
* path that iterates the list (ADR-0130 D5, topologically ordered through
* `resolvePluginOrder`) and the `installPackage` co-ownership gate (D1/D3) are
* separate, dependent cards. This file pins what the COMPILER writes.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');

interface Run {
code: number;
stdout: string;
stderr: string;
}

function runCli(args: string[], cwd: string): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(
TSX,
[CLI, ...args],
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
(err, stdout, stderr) => {
resolvePromise({
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
stdout: String(stdout),
stderr: String(stderr),
});
},
);
});
}

function payloadOf(run: Run, label: string): Record<string, unknown> {
try {
return JSON.parse(run.stdout) as Record<string, unknown>;
} catch {
throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`);
}
}

/** Today's shape: one package, declared through the singular `manifest`. */
const CONFIG_SINGLE = `
export default {
manifest: { id: 'com.example.solo', name: 'solo', version: '1.0.0', type: 'app', namespace: 'solo' },
objects: [
{
name: 'solo_ticket',
label: 'Ticket',
sharingModel: 'private',
fields: { title: { type: 'text', label: 'Title' } },
},
],
};
`;

/** ADR-0130 D4: the artifact carries two co-owning packages, wrapper form. */
const CONFIG_MULTI = `
export default {
manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' },
packages: [
{ manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' } },
{ manifest: { id: 'com.example.crm.cpq', name: 'cpq', version: '1.0.0', type: 'module', namespace: 'crm' } },
],
objects: [
{
name: 'crm_account',
label: 'Account',
sharingModel: 'private',
fields: { name: { type: 'text', label: 'Name' } },
},
],
};
`;

/**
* The reservation, violated: the manifest body inlined flat as the array
* element. Must be refused at the compile door, not written to an artifact.
*/
const CONFIG_FLATTENED = `
export default {
packages: [
{ id: 'com.example.flat', name: 'flat', version: '1.0.0', type: 'app', namespace: 'flat' },
],
objects: [],
};
`;

const dirs: Record<string, string> = {};
let root = '';

beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'os-d4-packages-'));
for (const [name, source] of Object.entries({
single: CONFIG_SINGLE,
multi: CONFIG_MULTI,
flattened: CONFIG_FLATTENED,
})) {
const dir = join(root, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'objectstack.config.ts'), source);
dirs[name] = dir;
}
});

afterAll(() => {
if (root) rmSync(root, { recursive: true, force: true });
});

const artifactOf = (payload: Record<string, unknown>): Record<string, unknown> =>
JSON.parse(readFileSync(String(payload.output), 'utf8')) as Record<string, unknown>;

describe('ADR-0130 D4 — the default (single-package) compile output does not move', () => {
it('writes `manifest` and NO `packages` key', async () => {
const run = await runCli(['build', '--json'], dirs.single);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.solo');
// The exact key set, so a materialised `"packages": []` — the near-miss
// this criterion exists for — fails here rather than being noticed by a
// customer diffing their artifact.
expect(Object.keys(artifact).sort()).toEqual(['manifest', 'objects']);
expect(readFileSync(String(payloadOf(run, 'os build --json').output), 'utf8')).not.toContain('"packages"');
}, 180_000);
});

describe('ADR-0130 D4 — an artifact declaring `packages` compiles and keeps it', () => {
it('carries both package manifests through to the written artifact, in order', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0);

const artifact = artifactOf(payloadOf(run, 'os build --json'));
const packages = artifact.packages as { manifest: { id: string; type: string } }[];

expect(Array.isArray(packages), 'the compiler dropped the `packages` key').toBe(true);
expect(packages.map((p) => p.manifest.id)).toEqual([
'com.example.crm',
'com.example.crm.cpq',
]);
// The wrapper survives as a wrapper — not flattened, not unwrapped.
expect(packages[0]).toEqual({ manifest: expect.objectContaining({ id: 'com.example.crm' }) });
expect(packages[1].manifest.type).toBe('module');
}, 180_000);

it('keeps the singular `manifest` beside it — retained, not replaced', async () => {
const run = await runCli(['build', '--json'], dirs.multi);
const artifact = artifactOf(payloadOf(run, 'os build --json'));

expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.crm');
}, 180_000);
});

describe('ADR-0130 D4 — the compile door refuses a flattened entry', () => {
it('exits non-zero rather than writing an artifact in the unreserved shape', async () => {
const run = await runCli(['build', '--json'], dirs.flattened);

expect(run.code, `expected a refusal, got exit 0:\n${run.stdout}`).not.toBe(0);
const payload = payloadOf(run, 'os build --json');
expect(payload.success).toBe(false);

// The refusal must point at the offending entry, so the author can find it
// in an artifact with N packages.
const errors = JSON.stringify(payload.errors ?? payload.error ?? '');
expect(errors).toContain('packages');
}, 180_000);
});
3 changes: 3 additions & 0 deletions packages/spec/api-surface/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent (type)",
"ApplyConversionsOptions (interface)",
"ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact (type)",
"AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema (const)",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/root.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@
"AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent": "src/ai/agent.zod.ts#Agent (type)",
"ApplyConversionsOptions": "src/conversions/apply.ts#ApplyConversionsOptions (interface)",
"ArtifactPackageEntry": "src/stack.zod.ts#ArtifactPackageEntry (type)",
"ArtifactPackageEntryParsed": "src/stack.zod.ts#ArtifactPackageEntryParsed (type)",
"ArtifactPackageEntrySchema": "src/stack.zod.ts#ArtifactPackageEntrySchema (const)",
"AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)",
"AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)",
"AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)",
Expand Down
Loading
Loading