diff --git a/.changeset/multi-package-artifact-build.md b/.changeset/multi-package-artifact-build.md new file mode 100644 index 0000000000..dae7548f35 --- /dev/null +++ b/.changeset/multi-package-artifact-build.md @@ -0,0 +1,61 @@ +--- +"@objectstack/spec": minor +"@objectstack/cli": minor +"@objectstack/objectql": patch +--- + +feat(cli,spec): compile a project of N packages into one `packages[]` artifact, with the assembled package body declared (#14439, closes #14242) + +ADR-0130 D4's producer side. A product can now be split into modules without +renaming a single object: N ordinary `defineStack` packages, one project-level +`composeStacks([...], { manifest: 'preserve' })`, one compiled artifact that +carries them all. + +**`@objectstack/spec` — the assembled package body has its own declaration.** +`ArtifactPackageEntrySchema` describes a package at AUTHORING time, where +`manifest.objects` is an array of glob patterns. What the ADR-0130 load path +registers is an ASSEMBLED body whose `objects` are definitions, so a full parse +of a real artifact entry was refused (`manifest.objects.0: expected string, +received object`) and the loader could gate the wrapper only. #14242 recorded +three roads and the maintainer took **B** (2026-09-02): the assembled stage is +now declared as `AssembledPackageBodySchema`, carried by `ArtifactPackageSchema`, +and `ObjectStackDefinitionSchema.packages` refers to that. ⛔ Road C — widening +`ManifestSchema.objects` into a union of both spellings — was rejected by name: +a union that accepts both stages makes neither stage checkable. + +The body's collection keys are DERIVED from the same table the stack schema's +composition rules come from, never transcribed, so a metadata family added to +the stack reaches package bodies on the day it lands. + +`composeStacks(..., { manifest: 'preserve' })` now folds each input stack's own +metadata onto its manifest instead of preserving the identity alone. +Composition is the last point at which per-package attribution exists — the +composed stack flattens every collection to the top level — so a package list +built without it names N packages that own nothing. + +**Accept-set change, in one direction.** A `packages[]` entry whose body carries +authoring-time glob patterns where the assembled stage carries definitions is +now REFUSED — at `defineStack`, at `os build`, and at load. Nothing in the field +produces that shape: `packages[]` had no producer at all before this change. +Write the package's metadata in its own `defineStack` and let composition +assemble it. + +**`@objectstack/cli` — `os build` / `os compile` read `packages[]`.** When the +loaded definition carries one, the same lowering walks every package body (an +un-lowered handler is a `function` value that `JSON.stringify` drops without a +word, and a `packages`-carrying artifact is registered THROUGH that list), the +same author-time rule table runs once per package, and one artifact JSON is +written whose `packages[i]` are assembled bodies. A single-package project is +untouched: no `packages` key is minted, and neither new branch runs. + +**`@objectstack/objectql` — the load gate parses the whole entry.** The +wrapper-only gate was a narrow accommodation of the mismatch above; with the +assembled stage declared, a malformed package body is refused at the seam that +would otherwise register it owning nothing. + + diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx index 39f9b6f476..15801e4e24 100644 --- a/content/docs/getting-started/examples.mdx +++ b/content/docs/getting-started/examples.mdx @@ -352,6 +352,88 @@ OS_ARTIFACT_PATH=./dist/objectstack.json os start --- +## A project is a multi-package artifact + +`composeStacks` above **flattens**: N stacks go in, one package identity comes +out, and the other N−1 are discarded. That is right when several stacks are +assembled into one published package — and wrong when a product wants internal +module boundaries, because flattening is exactly the boundary being asked for. + +The alternative costs no object rename: **compile per package, ship one +artifact, keep N package manifests inside it.** Pass `manifest: 'preserve'` +and every input keeps its identity. + +```typescript +// objectstack.config.ts — the PROJECT +import { composeStacks } from '@objectstack/spec'; +import coreStack from './src/packages/core/index.js'; +import ordersStack from './src/packages/orders/index.js'; + +export default composeStacks([ordersStack, coreStack], { manifest: 'preserve' }); +``` + +Each input is an ordinary `defineStack` package, legal on its own: + +```typescript +// src/packages/orders/index.ts — a MODULE of the same artifact +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { + id: 'com.example.multi.orders', + name: 'Orders', + namespace: 'crm', // the SAME namespace as the app package + version: '1.0.0', + type: 'module', + dependencies: { 'com.example.multi.core': '^1.0.0' }, + }, + objects: [ + { + name: 'crm_order', // no rename: still `crm_*` + label: 'Order', + sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Order Number' }, + account: { type: 'lookup', label: 'Account', reference: 'crm_account' }, + }, + }, + ], +}); +``` + +`os build` compiles that project into **one** `dist/objectstack.json` carrying a +`packages[]` list — one entry per package, each holding that package's own +manifest fields and the metadata it owns. `os dev` boots the same shape straight +from source, and `GET /api/v1/packages` on a running instance lists every +package in the artifact. + +The rules worth knowing before you split a product this way: + +- **One `type: 'app'` package.** The consumer installs and opens one thing; the + rest are `type: 'module'` (or `'plugin'`) shipped inside it. Each package is + still held to the single-app rule on its own. +- **Share the namespace deliberately.** Two packages in one artifact may own the + same namespace — that is what buys the split without renaming, since an + object's `name` is also its table, REST path, formula token and saved-view + key. Two packages defining the same object *name* are refused. +- **Declare `dependencies` when one package extends another.** Registration + order inside the artifact is resolved topologically from those declarations, + never from the order of the array — a module that adds fields to another + package's object must register after it, and getting that wrong is silent. +- **Cross-package lookups are fine; cross-package navigation is not.** A field + may reference an object another package owns. An app's own `navigation` may + not point outside its package — inject into another package's app with + `navigationContributions` instead. +- **One artifact, one version.** Everything inside ships, installs and upgrades + together; you cannot hot-fix one module on its own. A module that needs its + own release cadence belongs in its own artifact. + +A worked example lives in +[`examples/app-multi-package`](https://github.com/objectstack-ai/objectstack/tree/main/examples/app-multi-package): +two packages, one namespace, one artifact. + +--- + ## Project Structure Conventions All examples follow the same pattern. The recommended project layout — used by diff --git a/examples/app-multi-package/README.md b/examples/app-multi-package/README.md new file mode 100644 index 0000000000..f930c1187f --- /dev/null +++ b/examples/app-multi-package/README.md @@ -0,0 +1,24 @@ +# app-multi-package — one artifact, two packages + +The producer-side fixture for [ADR-0130](../../docs/adr/0130-release-artifact-as-co-ownership-boundary.md) +D4: a project whose release artifact carries **two** packages that share one +namespace, so the product splits into modules without renaming a single object. + +| package | type | namespace | owns | +| --- | --- | --- | --- | +| `com.example.multi.core` | `app` | `crm` | `crm_account`, the `multi_crm` app | +| `com.example.multi.orders` | `module` | `crm` | `crm_order` (lookup → `crm_account`) | + +```bash +pnpm --filter @objectstack/example-multi-package build # → dist/objectstack.json with packages[] +pnpm --filter @objectstack/example-multi-package dev # boots the same shape from source +``` + +The artifact's `packages[]` is what `ObjectQL.registerApp` iterates — each entry +is one package ASSEMBLED (manifest fields plus the collections that package +owns), declared by `AssembledPackageBodySchema`. `GET /api/v1/packages` on a +booted instance lists both rows. + +`orders` carries **no `scope` key** on purpose; the App's navigation lives with +the App package because a package's own navigation may not point at a foreign +object, while cross-package lookups (which `crm_order.account` is) are accepted. diff --git a/examples/app-multi-package/objectstack.config.ts b/examples/app-multi-package/objectstack.config.ts new file mode 100644 index 0000000000..44af42e3cf --- /dev/null +++ b/examples/app-multi-package/objectstack.config.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { composeStacks } from '@objectstack/spec'; + +import coreStack from './src/packages/core/index.js'; +import ordersStack from './src/packages/orders/index.js'; + +/** + * A PROJECT of N packages, compiled into ONE release artifact (ADR-0130 D4). + * + * ## The authoring shape — there is only one, deliberately + * + * N ordinary `defineStack` packages, each legal on its own (each still under + * ADR-0019's single-app rule), plus this project-level config composing them + * with `manifest: 'preserve'`. ⛔ No second spelling exists and none should be + * invented: `preserve` is the only thing that separates "one artifact carrying + * N packages" from the pick-one composition every other `manifest` strategy + * performs, which flattens N package identities down to one and is exactly the + * loss ADR-0130 was written about. + * + * ## What `preserve` produces + * + * The composed stack the platform has always produced — every collection + * flattened to the top level — PLUS `packages[]`, one entry per input stack, + * each carrying that package ASSEMBLED (its manifest fields with the + * collections it owns written over them). The flattened top level is what the + * metadata service reads; `packages[]` is what `ObjectQL.registerApp` + * registers, package by package, in dependency-topological order — which is + * where per-package ownership comes from. Without the list, a two-package + * artifact would install two package records owning nothing at all. + * + * ## Why the module is listed FIRST + * + * Deliberately backwards, and it is a property this fixture holds rather than + * an accident: `orders` declares `dependencies: { 'com.example.multi.core' }`, + * and the load path sorts `packages[]` through `resolvePluginOrder` — the + * platform's ONE topological sorter (ADR-0130 D5, ADR-0116) — so `core` + * registers first whatever slot it occupies here. An artifact that only worked + * because someone put the packages in the right order would be the failure + * ADR-0116 exists about, and it fails SILENTLY: nothing throws, the extension + * simply does not take effect. + * + * The order also settles the ARTIFACT's own identity: `preserve` is additive, + * so the singular `manifest` is still picked by the default `'last'` rule and + * the artifact identifies as its consumer-facing App (ADR-0019 D1), not as one + * of its modules. + * + * `os build` compiles this file into one `dist/objectstack.json`; `os dev` + * boots the same shape straight from source. + */ +export default composeStacks([ordersStack, coreStack], { manifest: 'preserve' }); diff --git a/examples/app-multi-package/package.json b/examples/app-multi-package/package.json new file mode 100644 index 0000000000..656f9725d4 --- /dev/null +++ b/examples/app-multi-package/package.json @@ -0,0 +1,27 @@ +{ + "name": "@objectstack/example-multi-package", + "version": "0.0.1", + "description": "One release artifact carrying TWO packages that share a namespace (ADR-0130 D4) — the producer-side fixture for `packages[]`", + "license": "Apache-2.0", + "private": true, + "main": "./objectstack.config.ts", + "types": "./objectstack.config.ts", + "exports": { + ".": "./objectstack.config.ts", + "./objectstack.config": "./objectstack.config.ts" + }, + "scripts": { + "dev": "objectstack dev", + "start": "objectstack start", + "build": "objectstack build", + "validate": "objectstack validate", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@objectstack/spec": "workspace:*" + }, + "devDependencies": { + "@objectstack/cli": "workspace:*", + "typescript": "^6.0.3" + } +} diff --git a/examples/app-multi-package/src/packages/core/index.ts b/examples/app-multi-package/src/packages/core/index.ts new file mode 100644 index 0000000000..5508dac9b0 --- /dev/null +++ b/examples/app-multi-package/src/packages/core/index.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineStack } from '@objectstack/spec'; + +/** + * `com.example.multi.core` — the consumer-facing App of this artifact + * (ADR-0019 D1: the App is the only thing a consumer installs and opens). + * + * It owns `crm_account` and the one app. Everything else this product ships is + * a MODULE inside the same artifact, sharing this package's namespace so a + * split costs no object rename (ADR-0129 D1–D2: the object `name` IS the table + * name, the REST path, the formula token and the saved-view key). + */ +export default defineStack({ + manifest: { + id: 'com.example.multi.core', + name: 'Multi-Package Core', + namespace: 'crm', + version: '1.0.0', + type: 'app', + description: 'The App half of a two-package release artifact (ADR-0130 D4)', + engines: { protocol: '^17' }, + }, + + objects: [ + { + name: 'crm_account', + label: 'Account', + pluralLabel: 'Accounts', + // ADR-0090 D1 — the org-wide default is an authored decision, never an + // accident: the runtime fails closed to 'private', and a rule refuses the + // silence rather than letting the fallback stand in for a choice. + sharingModel: 'private', + fields: { + name: { name: 'name', type: 'text', label: 'Account Name', required: true }, + industry: { name: 'industry', type: 'text', label: 'Industry' }, + }, + }, + ], + + apps: [ + { + name: 'multi_crm', + label: 'Multi-Package CRM', + description: 'Accounts, plus whatever modules this artifact delivers alongside', + navigation: [ + { id: 'nav_accounts', type: 'object', objectName: 'crm_account', label: 'Accounts', icon: 'building' }, + ], + }, + ], +}); diff --git a/examples/app-multi-package/src/packages/orders/index.ts b/examples/app-multi-package/src/packages/orders/index.ts new file mode 100644 index 0000000000..a53b70dfc7 --- /dev/null +++ b/examples/app-multi-package/src/packages/orders/index.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineStack } from '@objectstack/spec'; + +/** + * `com.example.multi.orders` — a MODULE of the same artifact (ADR-0019 D2's + * "internal contribution" tier: shipped inside an App, never browsed or + * installed on its own). + * + * Two properties this fixture exists to hold, both load-bearing: + * + * - It declares the **same namespace** as the App package. That is what + * ADR-0130 D1 buys: co-ownership of one namespace inside one artifact, so + * `crm_order` keeps its name instead of becoming `orders_order`. + * - It carries **no `scope` key**. `ManifestSchema.scope` defaults to + * `'project'`, so a scope-less module is the row that separates the server's + * writability verdict from a client-side `scope !== 'project'` heuristic + * (ADR-0070 D2 / ADR-0130 Consequences row 6). + * + * `crm_order.account` looks up an object this package does NOT own. That is + * legal and is the whole point of the split: cross-package lookups are accepted + * (ADR-0130 §1.5), while a package's own app navigation pointing at a foreign + * object is not — which is why the navigation lives with the App package. + */ +export default defineStack({ + manifest: { + id: 'com.example.multi.orders', + name: 'Multi-Package Orders', + namespace: 'crm', + version: '1.0.0', + type: 'module', + description: 'The Module half of a two-package release artifact (ADR-0130 D4)', + engines: { protocol: '^17' }, + // The App package this module extends. `resolveArtifactPackageOrder` reads + // it as the topological edge that registers core BEFORE orders (ADR-0130 + // D5, ADR-0116's one sorter) — the array order below is not what decides. + dependencies: { 'com.example.multi.core': '^1.0.0' }, + }, + + objects: [ + { + name: 'crm_order', + label: 'Order', + pluralLabel: 'Orders', + // ADR-0090 D1 — the org-wide default is an authored decision, never an + // accident: the runtime fails closed to 'private', and a rule refuses the + // silence rather than letting the fallback stand in for a choice. + sharingModel: 'private', + fields: { + name: { name: 'name', type: 'text', label: 'Order Number', required: true }, + account: { + name: 'account', + type: 'lookup', + label: 'Account', + reference: 'crm_account', + }, + amount: { name: 'amount', type: 'currency', label: 'Amount' }, + }, + }, + ], +}); diff --git a/examples/app-multi-package/tsconfig.json b/examples/app-multi-package/tsconfig.json new file mode 100644 index 0000000000..69000c4735 --- /dev/null +++ b/examples/app-multi-package/tsconfig.json @@ -0,0 +1,54 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + // `lib` and `types` are here as a CONSEQUENCE of the `paths` block below, + // not as a preference — the same correction `packages/qa/downstream-contract` + // made for the same reason. Putting `packages/spec/src` into this program + // means tsc now checks spec's own source files, which are written against + // spec's own environment (`packages/spec/tsconfig.json`: `lib` ES + DOM + + // DOM.Iterable, `types` node). Without them the first run reported two + // `TS2591: Cannot find name 'process'` in `spec/src/shared/lazy-schema.ts` + // — a verdict about THIS package's compiler environment, not about the + // fixture. Mirroring spec's own environment keeps every red here + // attributable to this app's own metadata. + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "skipLibCheck": true, + // `outDir` / `rootDir` are absent and `noEmit` is set as a CONSEQUENCE of + // the `paths` block below — the same correction examples/app-crm and + // examples/app-showcase made for the same reason. tsc emits nothing for + // this app either way (`typecheck` is `tsc --noEmit`; the app is BUILT by + // the ObjectStack CLI, not by tsc), but an emit-shaped config still + // enforces the output-tree rules: `rootDir` is inferred from the `include` + // roots, so spec's source — pulled in by an import, never a root — + // produced a wall of `TS6059: File '.../packages/spec/src/...' is not + // under 'rootDir'` that would drown any real error this typecheck exists + // to print. + "noEmit": true, + // + // Without this rule `@objectstack/spec` resolves through the workspace + // link to `packages/spec/dist/*.d.ts` — a BUILD ARTIFACT — so + // `tsc --noEmit` would grade this project against whatever was last built + // rather than against the source in this checkout, and the dangerous + // direction is SILENT: a `dist` merely BEHIND the source type-checks GREEN + // against old declarations. This app is the fixture for a brand-new spec + // surface (ADR-0130 D4's `packages[]`), which makes that exactly the + // reading it must not be able to give. `pnpm check:type-source-resolution` + // is the gate; it wants the `paths` rule, not a registry entry. + // + // Two keys, and the star follows a separator: a tsconfig `paths` key + // without a star is an EXACT match, and the `@objectstack/spec*` spelling + // would fold every subpath onto the root target and type-check green + // against the wrong module. + "paths": { + "@objectstack/spec": ["../../packages/spec/src/index.ts"], + "@objectstack/spec/*": ["../../packages/spec/src/*/index.ts"] + } + }, + "include": ["src/**/*", "objectstack.config.ts"] +} diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 6ea30e7e2e..c1f3d0453f 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -43,6 +43,53 @@ import { } from '../utils/format.js'; import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js'; +/** + * The artifact's package entries, as `{ index, id, body }` (ADR-0130 D4). + * + * Reads the PARSED stack, so what is walked here is exactly what the artifact + * will carry — `ArtifactPackageSchema` has already judged every entry by the + * time this runs, which is why nothing here re-checks the shape. + */ +function artifactPackages(parsed: Record): Array<{ + index: number; + id: string; + body: Record; +}> { + const declared = parsed.packages; + if (!Array.isArray(declared)) return []; + return declared.map((entry, index) => { + const body = (entry as { manifest?: Record }).manifest ?? {}; + const id = typeof body.id === 'string' && body.id !== '' + ? body.id + : (typeof body.name === 'string' ? body.name : `packages[${index}]`); + return { index, id, body }; + }); +} + +/** + * One assembled package body, re-read as the STACK it was assembled from. + * + * The author-time rules read a stack: collections at the top level and the + * package identity under `manifest`. An assembled body is that same content + * with the manifest fields flattened over the top (`{ ...manifest, ...stack }` + * — `AppPlugin`'s shape, declared by `AssembledPackageBodySchema`), so undoing + * the flatten is one key: the body IS its own manifest. + * + * ⛔ No key list is transcribed here on purpose. Splitting the body back into + * "manifest fields" and "collections" would need a second copy of the key set + * `AssembledPackageBodySchema` derives, and the rules do not need the split — + * they read collections off the top level (already there) and identity off + * `manifest` (a superset of the manifest, and `ManifestSchema` is an open + * object). + */ +function packageBodyAsStack(body: Record): Record { + return { ...body, manifest: body }; +} + +/** Identity of one finding, for the per-package de-duplication below. */ +const findingKey = (f: { rule: string; where: string; path: string; message: string }): string => + `${f.rule}\u0000${f.where}\u0000${f.path}\u0000${f.message}`; + export default class Compile extends Command { static override description = 'Compile ObjectStack configuration to JSON artifact'; @@ -328,6 +375,77 @@ export default class Compile extends Command { this.exit(1); } + // 3b-ii. [ADR-0130 D4] The SAME rule table, once per PACKAGE. + // + // A project is N `defineStack` packages composed with + // `composeStacks(…, { manifest: 'preserve' })`. Composition FLATTENS + // every collection to the top level, so the run above judges the + // union — which is strictly more permissive than the packages it was + // built from. A rule that asks "does this stack's app navigation + // point at an object this stack defines?" is satisfied by the union + // for a package that carries neither half; per package it is not. + // Since the artifact registers PER PACKAGE (D4/D5), the per-package + // answer is the one the runtime will live with. + // + // ⛔ The same `runAuthoringRules('build', …)` call, not a copy: which + // rules run on a build is `lint/authoring-rules.ts`' single table + // (#4409), and a second call site choosing its own subset is how + // `os build` came to be the weakest of the three authoring gates in + // the first place. + // + // DE-DUPLICATED against the union run, because the union contains + // every package's items: without this, a two-package project reports + // every finding twice and the author cannot tell a real per-package + // finding from an echo. What survives the filter is exactly the set + // the union could not see. + const packageEntries = artifactPackages(result.data as Record); + if (packageEntries.length > 0) { + if (!flags.json) { + printStep(`Running author-time rules per package (${packageEntries.length})...`); + } + const alreadyReported = new Set(findings.map(findingKey)); + const perPackageErrors: Array<{ package: string } & typeof ruleErrors[number]> = []; + for (const pkg of packageEntries) { + const asStack = packageBodyAsStack(pkg.body); + const pkgFindings = runAuthoringRules('build', { + normalized: asStack, + parsed: asStack, + sduiManifest: resolveSduiManifest(), + }).filter((f) => !alreadyReported.has(findingKey(f))); + for (const f of pkgFindings) alreadyReported.add(findingKey(f)); + const split = splitBySeverity(pkgFindings); + ruleAdvisories = [ + ...ruleAdvisories, + ...split.advisories.map((a) => ({ ...a, where: `package '${pkg.id}' — ${a.where}` })), + ]; + perPackageErrors.push( + ...split.errors.map((e) => ({ ...e, package: pkg.id, where: `package '${pkg.id}' — ${e.where}` })), + ); + } + if (perPackageErrors.length > 0) { + if (flags.json) { + await emitJson( + { + success: false, + error: 'author-time rules failed for one or more packages', + issues: perPackageErrors, + warnings: warningsSoFar(), + conversions: conversionNotices, + }, + 0, + { compact: true }, + ); + this.exit(1); + } + console.log(''); + printError( + `Author-time rules failed inside the artifact's packages (${perPackageErrors.length} issue${perPackageErrors.length > 1 ? 's' : ''})`, + ); + printAuthoringRuleErrors(perPackageErrors, { remedy: JSON_FULL_LIST_REMEDY }); + this.exit(1); + } + } + // 3c. [#3366] Installable-provider preflight. Every capability the app // DECLARES in `requires: [...]` must have a provider resolvable in the // active edition. A `requires` entry whose provider has NO installable diff --git a/packages/cli/src/utils/lower-callables.ts b/packages/cli/src/utils/lower-callables.ts index 933a484fb9..fe8fd31c84 100644 --- a/packages/cli/src/utils/lower-callables.ts +++ b/packages/cli/src/utils/lower-callables.ts @@ -112,123 +112,182 @@ export function lowerCallables(input: Record): LoweringResult { } } - // Shallow clone the top level — we only mutate the slots we touch. - const lowered: Record = { ...input }; + /** + * ref → the callable it names, keyed by function IDENTITY. + * + * [ADR-0130 D4] A multi-package artifact carries the SAME callable twice — + * once in the composed stack's flattened collections and once in the package + * body that owns it (`composeStacks(…, { manifest: 'preserve' })` assembles + * from the same authored objects, so the two copies are the same function + * OBJECT, not two functions that look alike). Registering it twice would + * bundle one implementation under two refs and leave the artifact's two + * copies pointing at different names for one handler. Identity dedup gives + * both copies the ref the first pass minted. + * + * ⛔ Single-package artifacts are untouched by this: with no `packages` key + * nothing is walked twice, so no callable is ever looked up here more than + * once and the ref names are exactly the ones this function minted before. + */ + const refByFn = new Map(); - // 1. Lower `bundle.hooks[*].handler` - if (Array.isArray(lowered.hooks)) { - lowered.hooks = (lowered.hooks as unknown[]).map((raw) => { - if (!isPlainObject(raw)) return raw; - const hook = { ...raw }; - if (typeof hook.handler === 'function') { - const name = typeof hook.name === 'string' && hook.name.length > 0 - ? hook.name - : 'anon_hook'; - const ref = uniqueName(name, taken); - taken.add(ref); - functions[ref] = hook.handler as AnyFn; + /** Register one callable and answer its ref, reusing the ref if it has one. */ + function register(fn: AnyFn, base: string): string { + const seen = refByFn.get(fn); + if (seen !== undefined) return seen; + const ref = uniqueName(base, taken); + taken.add(ref); + functions[ref] = fn; + refByFn.set(fn, ref); + return ref; + } + + /** + * Lower ONE stack-shaped body: the composed stack itself, or one assembled + * package body inside an ADR-0130 D4 artifact. + * + * The per-package walk is this same function called again — not a copy of it + * — so a lowering rule added for the stack applies to package bodies on the + * day it lands. Copying the walk is how the two would come to disagree, and + * the disagreement would be silent in the worst way: an un-lowered handler is + * a `function` value, and `JSON.stringify` drops it, shipping an artifact + * whose package hooks simply never fire. + */ + function lowerBody(input: Record): Record { + // Shallow clone the top level — we only mutate the slots we touch. + const lowered: Record = { ...input }; + + // 1. Lower `bundle.hooks[*].handler` + if (Array.isArray(lowered.hooks)) { + lowered.hooks = (lowered.hooks as unknown[]).map((raw) => { + if (!isPlainObject(raw)) return raw; + const hook = { ...raw }; + if (typeof hook.handler === 'function') { + const name = typeof hook.name === 'string' && hook.name.length > 0 + ? hook.name + : 'anon_hook'; + const ref = register(hook.handler as AnyFn, name); - // Extract metadata body unless the user already provided one. - if (!hook.body) { - const body = tryExtractBody(hook.handler as AnyFn, `hook '${name}'`); - if (body) hook.body = body; + // Extract metadata body unless the user already provided one. + if (!hook.body) { + const body = tryExtractBody(hook.handler as AnyFn, `hook '${name}'`); + if (body) hook.body = body; + } + hook.handler = ref; } - hook.handler = ref; - } - return hook; - }); - } + return hook; + }); + } + + // 1b. Lower inline action handlers found inside `objects[*].actions[*]` + // and `actions[*]`. Only `target: fn` — the `execute` alias was removed + // in protocol 17 (#3855) and is left for the parse to reject by name. + if (Array.isArray(lowered.objects)) { + lowered.objects = (lowered.objects as unknown[]).map((rawObj) => { + if (!isPlainObject(rawObj)) return rawObj; + const obj = { ...rawObj }; + if (Array.isArray(obj.actions)) { + obj.actions = (obj.actions as unknown[]).map((rawAct) => + lowerActionCallable(rawAct, register, tryExtractBody, `${String(obj.name ?? 'object')}`), + ); + } + return obj; + }); + } + if (Array.isArray((lowered as any).actions)) { + (lowered as any).actions = ((lowered as any).actions as unknown[]).map((rawAct) => + lowerActionCallable(rawAct, register, tryExtractBody, 'global'), + ); + } - // 1b. Lower inline action handlers found inside `objects[*].actions[*]` - // and `actions[*]`. Only `target: fn` — the `execute` alias was removed - // in protocol 17 (#3855) and is left for the parse to reject by name. - if (Array.isArray(lowered.objects)) { - lowered.objects = (lowered.objects as unknown[]).map((rawObj) => { - if (!isPlainObject(rawObj)) return rawObj; - const obj = { ...rawObj }; - if (Array.isArray(obj.actions)) { - obj.actions = (obj.actions as unknown[]).map((rawAct) => - lowerActionCallable(rawAct, taken, functions, tryExtractBody, `${String(obj.name ?? 'object')}`), - ); + // 2. Lower top-level `functions` (map or array of records). + // The runtime already merges this map into the engine's resolver, so + // we keep the same shape after lowering — just replace fn refs with + // serialisable handler-name strings + register the originals. + const fnsField = (lowered as { functions?: unknown }).functions; + if (Array.isArray(fnsField)) { + const arr = fnsField.map((entry) => { + if (!isPlainObject(entry)) return entry; + const next = { ...entry }; + if (typeof next.handler === 'function') { + const name = typeof next.name === 'string' && next.name.length > 0 + ? next.name + : 'anon_fn'; + const ref = register(next.handler as AnyFn, name); + next.name = ref; + next.handler = ref; + } + return next; + }); + (lowered as Record).functions = arr; + } else if (isPlainObject(fnsField)) { + const out: Record = {}; + for (const [key, value] of Object.entries(fnsField)) { + if (typeof value === 'function') { + const ref = register(value as AnyFn, key); + out[ref] = ref; + } else if (isPlainObject(value) && typeof value.handler === 'function') { + // A DECLARED entry (`{ handler, effect: 'writes' }`, #4396). Lower the + // callable exactly like the bare form and keep the declaration beside + // it, so what the function said about itself survives into the + // artifact — dropping it here would silently un-declare the function + // on every built deployment while it kept working from source. + const ref = register(value.handler as AnyFn, key); + out[ref] = { ...value, handler: ref }; + } else { + // NOTHING ELSE IS THIS STEP'S TO JUDGE (#7318). Everything that is not + // a callable to lower rides through under its own key, untouched, and + // `FlowFunctionEntrySchema` decides whether it is legal. + // + // Two kinds of value arrive here, and passing both through is the same + // decision, not a compromise between two: + // + // ALREADY LOWERED — a bare ref (`'scoreLead'`, #4343) or a lowered + // declaration (`{ handler: 'scoreLead', effect: 'writes' }`, #4976). + // Both are shapes the schema accepts, so lowering a lowered stack + // must be a no-op: same key set, same declarations. Rebuilding the + // map around a fixed list of recognised shapes made that false — the + // lowered declaration matched none of them and was deleted, so a + // second pass (a re-lowered artifact, a fixture that lowers what it + // read back) silently un-declared the writer the FIRST pass had + // carefully kept. + // + // MALFORMED — the headless husk `{ effect: 'writes' }` that a plain + // `JSON.stringify(stack)` leaves where a declaration was (#6293). + // Deleting it here erased the evidence BEFORE the parse: the artifact + // came out `functions: {}` and validated green, so the build shipped + // an app missing the function instead of refusing. Handed on, it + // reaches `FlowFunctionEntrySchema`, which names it — `invalid_union` + // on this key — and `objectstack build` fails where it should. + out[key] = value; + } } - return obj; - }); - } - if (Array.isArray((lowered as any).actions)) { - (lowered as any).actions = ((lowered as any).actions as unknown[]).map((rawAct) => - lowerActionCallable(rawAct, taken, functions, tryExtractBody, 'global'), - ); + (lowered as Record).functions = out; + } + + return lowered; } - // 2. Lower top-level `functions` (map or array of records). - // The runtime already merges this map into the engine's resolver, so - // we keep the same shape after lowering — just replace fn refs with - // serialisable handler-name strings + register the originals. - const fnsField = (lowered as { functions?: unknown }).functions; - if (Array.isArray(fnsField)) { - const arr = fnsField.map((entry) => { - if (!isPlainObject(entry)) return entry; - const next = { ...entry }; - if (typeof next.handler === 'function') { - const name = typeof next.name === 'string' && next.name.length > 0 - ? next.name - : 'anon_fn'; - const ref = uniqueName(name, taken); - taken.add(ref); - functions[ref] = next.handler as AnyFn; - next.name = ref; - next.handler = ref; - } - return next; + const lowered = lowerBody(input); + + // 3. [ADR-0130 D4] The artifact's own package bodies. + // + // A `packages`-carrying artifact is registered THROUGH that list — the + // load path reads `packages` when present and the top level only when it + // is absent — so a callable that is lowered at the top level and left raw + // inside `packages[i].manifest` is a callable the runtime never gets. The + // failure is silent end to end: `JSON.stringify` drops a `function` value + // without a word, the artifact validates, the build exits 0, and the hook + // is simply not there at boot. + // + // Entries that are not `{ manifest: }` ride through untouched: + // the shape is `ArtifactPackageSchema`'s to judge, at the parse that runs + // after this step, and swallowing a malformed entry here would consume the + // evidence before the refusal that names it. + if (Array.isArray(lowered.packages)) { + lowered.packages = (lowered.packages as unknown[]).map((entry) => { + if (!isPlainObject(entry) || !isPlainObject(entry.manifest)) return entry; + return { ...entry, manifest: lowerBody(entry.manifest as Record) }; }); - (lowered as Record).functions = arr; - } else if (isPlainObject(fnsField)) { - const out: Record = {}; - for (const [key, value] of Object.entries(fnsField)) { - if (typeof value === 'function') { - const ref = uniqueName(key, taken); - taken.add(ref); - functions[ref] = value as AnyFn; - out[ref] = ref; - } else if (isPlainObject(value) && typeof value.handler === 'function') { - // A DECLARED entry (`{ handler, effect: 'writes' }`, #4396). Lower the - // callable exactly like the bare form and keep the declaration beside - // it, so what the function said about itself survives into the - // artifact — dropping it here would silently un-declare the function - // on every built deployment while it kept working from source. - const ref = uniqueName(key, taken); - taken.add(ref); - functions[ref] = value.handler as AnyFn; - out[ref] = { ...value, handler: ref }; - } else { - // NOTHING ELSE IS THIS STEP'S TO JUDGE (#7318). Everything that is not - // a callable to lower rides through under its own key, untouched, and - // `FlowFunctionEntrySchema` decides whether it is legal. - // - // Two kinds of value arrive here, and passing both through is the same - // decision, not a compromise between two: - // - // ALREADY LOWERED — a bare ref (`'scoreLead'`, #4343) or a lowered - // declaration (`{ handler: 'scoreLead', effect: 'writes' }`, #4976). - // Both are shapes the schema accepts, so lowering a lowered stack - // must be a no-op: same key set, same declarations. Rebuilding the - // map around a fixed list of recognised shapes made that false — the - // lowered declaration matched none of them and was deleted, so a - // second pass (a re-lowered artifact, a fixture that lowers what it - // read back) silently un-declared the writer the FIRST pass had - // carefully kept. - // - // MALFORMED — the headless husk `{ effect: 'writes' }` that a plain - // `JSON.stringify(stack)` leaves where a declaration was (#6293). - // Deleting it here erased the evidence BEFORE the parse: the artifact - // came out `functions: {}` and validated green, so the build shipped - // an app missing the function instead of refusing. Handed on, it - // reaches `FlowFunctionEntrySchema`, which names it — `invalid_union` - // on this key — and `objectstack build` fails where it should. - out[key] = value; - } - } - (lowered as Record).functions = out; } return { @@ -255,8 +314,7 @@ export function lowerCallables(input: Record): LoweringResult { */ function lowerActionCallable( raw: unknown, - taken: Set, - functions: Record, + register: (fn: AnyFn, base: string) => string, tryExtract: (fn: AnyFn, label: string) => { language: 'js'; source: string; capabilities: string[] } | null, ownerLabel: string, ): unknown { @@ -269,9 +327,7 @@ function lowerActionCallable( // alias is deliberately left in place so the parse rejects it by name. if (typeof action.target !== 'function') return action; const fn = action.target as AnyFn; - const ref = uniqueName(baseName, taken); - taken.add(ref); - functions[ref] = fn; + const ref = register(fn, baseName); if (!action.body) { const body = tryExtract(fn, `action '${baseName}'`); if (body) action.body = body; diff --git a/packages/cli/test/build-json-failure-conversions.e2e.test.ts b/packages/cli/test/build-json-failure-conversions.e2e.test.ts index 32482c63e1..62012ce7c8 100644 --- a/packages/cli/test/build-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/build-json-failure-conversions.e2e.test.ts @@ -420,10 +420,14 @@ describe('#12125 — the contract is exhaustive over `compile.ts`, not just over expect(found[1]).toContain('conversions: conversionNotices'); }); - it('all 10 `emitJson` exits carry `conversions` — 9 failure exits and the success payload', () => { + it('all 11 `emitJson` exits carry `conversions` — 10 failure exits and the success payload', () => { const literals = payloadLiterals(SRC); - expect(literals, 'the `emitJson` exit count moved — a new exit must carry `conversions` too').toHaveLength(10); - expect(literals.filter((p) => p.includes('success: false'))).toHaveLength(9); + // The tenth failure exit is ADR-0130 D4's per-package author-time rule leg + // (#14439): a multi-package artifact runs the same rule table once per + // package, and its refusal is an exit like any other — which is exactly + // what this count exists to notice. + expect(literals, 'the `emitJson` exit count moved — a new exit must carry `conversions` too').toHaveLength(11); + expect(literals.filter((p) => p.includes('success: false'))).toHaveLength(10); expect(literals.filter((p) => p.includes('success: true'))).toHaveLength(1); const bare = literals.filter((p) => !p.includes('conversions:')); diff --git a/packages/cli/test/build-json-failure-warnings.e2e.test.ts b/packages/cli/test/build-json-failure-warnings.e2e.test.ts index a59ad6c615..6d165f29bd 100644 --- a/packages/cli/test/build-json-failure-warnings.e2e.test.ts +++ b/packages/cli/test/build-json-failure-warnings.e2e.test.ts @@ -499,13 +499,18 @@ describe('#11772 — the contract is exhaustive over `compile.ts`, not just over expect(found[1]).toContain('warnings: warningsSoFar()'); }); - it('all 10 `emitJson` exits carry `warnings` — 9 failure exits and the success payload', () => { + it('all 11 `emitJson` exits carry `warnings` — 10 failure exits and the success payload', () => { const literals = payloadLiterals(SRC); // The enumeration measured on this card, three MORE than the filing card's // table listed: it missed the protocol-parse exit, the `--no-runtime-bundle` // refusal, and the bottom catch-all. - expect(literals, 'the `emitJson` exit count moved — a new exit must carry `warnings` too').toHaveLength(10); - expect(literals.filter((p) => p.includes('success: false'))).toHaveLength(9); + // + // The tenth failure exit is ADR-0130 D4's per-package author-time rule leg + // (#14439): a multi-package artifact runs the same rule table once per + // package, and its refusal is an exit like any other — which is exactly + // what this count exists to notice. + expect(literals, 'the `emitJson` exit count moved — a new exit must carry `warnings` too').toHaveLength(11); + expect(literals.filter((p) => p.includes('success: false'))).toHaveLength(10); expect(literals.filter((p) => p.includes('success: true'))).toHaveLength(1); const bare = literals.filter((p) => !p.includes('warnings:')); diff --git a/packages/cli/test/build-multi-package-artifact.e2e.test.ts b/packages/cli/test/build-multi-package-artifact.e2e.test.ts new file mode 100644 index 0000000000..98e8755b97 --- /dev/null +++ b/packages/cli/test/build-multi-package-artifact.e2e.test.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0130 D4 producer side — `os build` compiles a project of N packages into + * ONE artifact carrying `packages[]` (#14439), and the assembled bodies it + * writes are what the load path registers. + * + * ## The three parse seams, and why the compile one is pinned HERE + * + * An assembled `packages[]` body has to survive three parses, all of them + * `ObjectStackDefinitionSchema`: + * + * 1. authoring — `defineStack` (pinned in `@objectstack/spec`) + * 2. compile — `compile.ts`'s `ObjectStackDefinitionSchema.safeParse` of the + * LOWERED stack, which is what this file runs end to end + * 3. load — the metadata service's artifact door + * + * Seam 2 is the only one that can be measured through the real command, with + * the real lowering and the real writer, which is what these tests do: they run + * `os build` in a temp project and read the artifact off disk. + * + * ## What would be green without this file + * + * Three failures, each silent end to end: + * + * - **No `packages[]` written at all.** `os build` had zero handling of the + * key; a project of N packages compiled to one flat artifact, and the + * registration that ADR-0130 exists for simply never happened. + * - **Handlers inside a package body dropped.** A `packages`-carrying artifact + * is registered THROUGH that list, so a callable lowered at the top level + * and left raw inside `packages[i].manifest` is a callable the runtime never + * gets — `JSON.stringify` drops a `function` value without a word, the + * artifact validates, the build exits 0, and the hook is not there at boot. + * - **A malformed body accepted.** Authoring globs where definitions belong + * name no files in a compiled artifact, so the package installs owning + * nothing. + */ + +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 { + 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), + }); + }, + ); + }); +} + +/** + * A two-package project, written the way `composeStacks(…, { manifest: + * 'preserve' })` writes it: the collections flattened to the top level AND the + * same content assembled onto each package body, sharing one callable object. + * + * Hand-written rather than composed so this file resolves nothing from the + * workspace — the shape is what is under test, and `composeStacks` producing it + * is pinned where that function lives. + */ +const CONFIG_MULTI = ` +const stampOrder = async () => { return { ok: true }; }; + +const account = { + name: 'mp_account', label: 'Account', sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name' } }, +}; +const order = { + name: 'mp_order', label: 'Order', sharingModel: 'private', + fields: { name: { type: 'text', label: 'Number' } }, +}; +const orderHook = { name: 'mp_order_before_insert', object: 'mp_order', events: ['beforeInsert'], handler: stampOrder }; + +const coreManifest = { id: 'com.example.mp.core', name: 'core', version: '1.0.0', type: 'app', namespace: 'mp' }; +const ordersManifest = { + id: 'com.example.mp.orders', name: 'orders', version: '1.0.0', type: 'module', namespace: 'mp', + dependencies: { 'com.example.mp.core': '^1.0.0' }, +}; + +export default { + manifest: coreManifest, + objects: [account, order], + hooks: [orderHook], + packages: [ + { manifest: { ...ordersManifest, objects: [order], hooks: [orderHook] } }, + { manifest: { ...coreManifest, objects: [account] } }, + ], +}; +`; + +/** The same project with ONE package and no `packages` key — the D7 branch. */ +const CONFIG_SINGLE = ` +const stampOrder = async () => { return { ok: true }; }; +export default { + manifest: { id: 'com.example.mp.solo', name: 'solo', version: '1.0.0', type: 'app', namespace: 'mp' }, + objects: [ + { name: 'mp_order', label: 'Order', sharingModel: 'private', fields: { name: { type: 'text', label: 'Number' } } }, + ], + hooks: [{ name: 'mp_order_before_insert', object: 'mp_order', events: ['beforeInsert'], handler: stampOrder }], +}; +`; + +/** A package body still carrying the AUTHORING manifest's glob patterns. */ +const CONFIG_GLOBS = ` +export default { + manifest: { id: 'com.example.mp.core', name: 'core', version: '1.0.0', type: 'app', namespace: 'mp' }, + objects: [ + { name: 'mp_account', label: 'Account', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name' } } }, + ], + packages: [ + { manifest: { id: 'com.example.mp.core', name: 'core', version: '1.0.0', type: 'app', namespace: 'mp', objects: ['./src/objects/*.object.ts'] } }, + ], +}; +`; + +interface Artifact { + manifest?: { id?: string }; + objects?: Array<{ name: string }>; + hooks?: Array<{ name: string; handler?: unknown; body?: unknown }>; + packages?: Array<{ manifest: { id: string; objects?: Array<{ name: string }>; hooks?: Array<{ name: string; handler?: unknown }> } }>; +} + +const dirs: Record = {}; +let root = ''; + +const artifactOf = (dir: string): Artifact => + JSON.parse(readFileSync(join(dir, 'dist', 'objectstack.json'), 'utf8')) as Artifact; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-multi-package-')); + for (const [name, config] of Object.entries({ multi: CONFIG_MULTI, single: CONFIG_SINGLE, globs: CONFIG_GLOBS })) { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'objectstack.config.ts'), config); + dirs[name] = dir; + } +}); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +describe('ADR-0130 D4 — `os build` emits one artifact carrying `packages[]`', () => { + let built: Run; + + beforeAll(async () => { + built = await runCli(['build'], dirs.multi); + }, 180_000); + + it('the fixture reaches the code path: exit 0, and the per-package leg ran', () => { + // Asserted before anything is read off the artifact: a build that failed + // would let every "the artifact does not contain X" assertion below pass + // for the wrong reason. + expect(built.code, `${built.stdout}\n${built.stderr}`).toBe(0); + expect(built.stdout).toContain('Running author-time rules per package (2)'); + }); + + it('writes `packages[]`, each entry an ASSEMBLED body owning its own metadata', () => { + const artifact = artifactOf(dirs.multi); + expect(artifact.packages?.map((p) => p.manifest.id)).toEqual([ + 'com.example.mp.orders', + 'com.example.mp.core', + ]); + // Per-package ownership is the whole point: without it a two-package + // artifact installs two package records owning nothing. + expect(artifact.packages?.[0].manifest.objects?.map((o) => o.name)).toEqual(['mp_order']); + expect(artifact.packages?.[1].manifest.objects?.map((o) => o.name)).toEqual(['mp_account']); + }); + + it('keeps the flattened top level — `packages[]` is ADDITIVE', () => { + // The metadata service's artifact door iterates the top-level collections, + // so dropping them in favour of `packages[]` would leave a booted instance + // with no views, flows or permission sets at all. + const artifact = artifactOf(dirs.multi); + expect(artifact.objects?.map((o) => o.name).sort()).toEqual(['mp_account', 'mp_order']); + }); + + it('LOWERS the callables inside a package body — under the SAME ref as the top level', () => { + // The silent-drop defect: an un-lowered handler is a `function` value and + // `JSON.stringify` removes the key without a word. Two assertions, because + // "it is a string" and "it is the SAME string" fail differently — a second + // ref would bundle one implementation twice and leave the artifact's two + // copies naming different handlers. + const artifact = artifactOf(dirs.multi); + const topRef = artifact.hooks?.[0]?.handler; + const pkgRef = artifact.packages?.[0].manifest.hooks?.[0]?.handler; + + expect(typeof topRef).toBe('string'); + expect(typeof pkgRef).toBe('string'); + expect(pkgRef).toBe(topRef); + }); + + it('refuses a package body carrying authoring GLOBS, naming the path', async () => { + const run = await runCli(['build', '--json'], dirs.globs); + expect(run.code).toBe(1); + const payload = JSON.parse(run.stdout) as { success: boolean; errors?: Array<{ path?: unknown[] }> }; + expect(payload.success).toBe(false); + const paths = (payload.errors ?? []).map((e) => (e.path ?? []).join('.')); + expect(paths).toContain('packages.0.manifest.objects.0'); + }, 180_000); +}); + +describe('ADR-0130 D7 — the single-package path is untouched', () => { + it('emits NO `packages` key, and the ref names are the ones the lowering always minted', async () => { + const run = await runCli(['build'], dirs.single); + expect(run.code, `${run.stdout}\n${run.stderr}`).toBe(0); + // The negative half of D7 at the compile door: a build that started minting + // an empty `packages: []` for every single-package project would change + // what every existing artifact carries, and the load path reads the key's + // PRESENCE as the branch selector. + const artifact = artifactOf(dirs.single); + expect(artifact.packages).toBeUndefined(); + expect('packages' in artifact).toBe(false); + + // Ref identity: the hook lowers to its own name, not to a de-duplicated + // `…__2`. Nothing is walked twice on this path, so nothing can collide — + // asserted rather than argued, because the collision would be invisible in + // the artifact (a valid ref that simply is not the one it used to be). + expect(artifact.hooks?.[0]?.handler).toBe('mp_order_before_insert'); + + // The per-package leg does not run at all — no packages, no second pass. + expect(run.stdout).not.toContain('per package'); + }, 180_000); +}); diff --git a/packages/objectql/src/artifact-load-path.test.ts b/packages/objectql/src/artifact-load-path.test.ts index 6fbe10bd80..d09f7ce8f8 100644 --- a/packages/objectql/src/artifact-load-path.test.ts +++ b/packages/objectql/src/artifact-load-path.test.ts @@ -258,7 +258,7 @@ describe('ADR-0130 D5 — the ordering behaviours are INHERITED from resolvePlug }); }); -describe('ADR-0130 D4 — the entry WRAPPER is refused from its one declaration', () => { +describe('ADR-0130 D4 — the entry is refused from its one declaration', () => { // Rejection assertions carry the ADR-0112 envelope — `code` AND `status` — // never a bare "it throws": a bare throw assertion stays green on an // unrelated `Error` from somewhere else in the path. @@ -292,10 +292,12 @@ describe('ADR-0130 D4 — the entry WRAPPER is refused from its one declaration' }); it('accepts an assembled package body whose `objects` are definitions, not globs', () => { - // The load path receives assembled payloads: `ManifestSchema.objects` is - // `z.array(z.string())` (glob patterns), so a FULL body parse would refuse - // exactly what this path exists to register. The wrapper is judged; the body - // is the authoring door's job. + // The load path receives assembled payloads. This used to be a wrapper-only + // gate because `ManifestSchema.objects` is `z.array(z.string())` (glob + // patterns) and a FULL parse against it refused exactly what this path + // exists to register — the #14242 mismatch. Since road B the body has its + // own declaration (`AssembledPackageBodySchema`), so the whole entry is + // parsed and THIS payload is what it was declared to accept. const ordered = resolveArtifactPackageOrder({ packages: [{ manifest: basePackage() }], }) as PackageBody[]; @@ -303,6 +305,41 @@ describe('ADR-0130 D4 — the entry WRAPPER is refused from its one declaration' expect(ordered[0].objects?.[0]?.name).toBe('crm_account'); }); + it('refuses a body carrying authoring GLOBS where definitions belong (#14242 B)', () => { + // The refusal the full parse buys, and the one the wrapper-only gate could + // not give: a compiled artifact has no files left to glob, so this package + // would install owning nothing while every gate stayed green. + let caught: (Error & { code?: string; status?: number }) | undefined; + try { + resolveArtifactPackageOrder({ + packages: [{ manifest: { ...basePackage(), objects: ['./src/objects/*.object.ts'] } }], + }); + } catch (e) { caught = e as Error & { code?: string; status?: number }; } + expect(caught).toBeDefined(); + expect(caught?.code).toBe('INVALID_ARTIFACT_PACKAGE_ENTRY'); + expect(caught?.status).toBe(422); + // The message names the STAGE, not just the type mismatch — an author + // reading "expected object, received string" learns nothing about which of + // the two package-body spellings this seam wanted. + expect(caught?.message).toContain('manifest.objects.0'); + expect(caught?.message).toContain('DEFINITIONS'); + }); + + it('refuses a body whose collection is malformed at the item level', () => { + // Not the same finding as the glob one: this is a definition-shaped entry + // that is not a valid definition. Both are body issues the wrapper-only + // gate let through to `registerApp`, which would have registered a + // half-formed object rather than refusing. + let caught: (Error & { code?: string; status?: number }) | undefined; + try { + resolveArtifactPackageOrder({ + packages: [{ manifest: { ...basePackage(), objects: [{ label: 'no name' }] } }], + }); + } catch (e) { caught = e as Error & { code?: string; status?: number }; } + expect(caught?.code).toBe('INVALID_ARTIFACT_PACKAGE_ENTRY'); + expect(caught?.status).toBe(422); + }); + it('hands back the caller\'s own body — no defaults applied, no keys stripped', () => { // A parsed clone would arrive carrying `defaultDatasource: 'default'` and // `scope: 'project'`, and would have dropped keys `ManifestSchema` does not diff --git a/packages/objectql/src/artifact-packages.ts b/packages/objectql/src/artifact-packages.ts index cddd565692..44300c90eb 100644 --- a/packages/objectql/src/artifact-packages.ts +++ b/packages/objectql/src/artifact-packages.ts @@ -20,45 +20,47 @@ * the caller's ORIGINAL object in that branch rather than a copy or a * re-validated clone: the bytes `registerApp` receives must not move. * - * ## The wrapper shape is NOT re-derived here (ADR-0116's lesson) + * ## The entry shape is NOT re-derived here (ADR-0116's lesson) * - * `ArtifactPackageEntrySchema` (`@objectstack/spec`, `stack.zod.ts`) is the sole + * `ArtifactPackageSchema` (`@objectstack/spec`, `stack.zod.ts`) is the sole * declaration of what one entry looks like — a wrapper object carrying the - * manifest under `manifest:`, the structural position D4 reserves so a future + * package under `manifest:`, the structural position D4 reserves so a future * `{ ref, integrity }` external segment is an additive key rather than a - * reshape. This module imports and applies that schema instead of duck-typing - * the wrapper: a second declaration of one shape is exactly the drift ADR-0116 - * exists about. - * - * ⛔ The schema is consulted as a **gate on the WRAPPER, and only the wrapper**, - * and the body handed to `registerApp` is the caller's original - * `entry.manifest`, never a parsed clone. Two measured reasons, both load-bearing: - * - * 1. **A parsed clone is not the authored body.** `ManifestSchema` carries - * defaults (`defaultDatasource: 'default'`, `scope: 'project'`) and Zod - * strips undeclared keys, so registering `parsed.data.manifest` would put - * different bytes into the registry than the singular-`manifest` branch does - * for the same authored package. D7 pins that those two branches do not - * disagree. - * 2. **`ManifestSchema` cannot express an assembled package body.** Its - * `objects` key is `z.array(z.string())` — GLOB PATTERNS (`manifest.zod.ts`) - * — while what reaches this load path is an assembled payload whose - * `objects` are object DEFINITIONS (`AppPlugin` flattens the artifact into - * `{ ...bundle.manifest, ...bundle }` before `manifest.register()`, and - * `ObjectQL.registerApp` iterates those bodies). Measured against the - * landed schema: `ArtifactPackageEntrySchema.safeParse` on such a payload - * fails with `manifest.objects.0: expected string, received object`. - * Refusing on that would refuse exactly the artifacts this path exists to - * register. - * - * So issues INSIDE the manifest body are not this seam's verdict to give — body - * validation lives at the authoring/publish doors (`defineStack`, `os validate`, - * `os compile`'s `ObjectStackDefinitionSchema.safeParse`), which is also where - * the singular-`manifest` branch has always had it. What this seam does own is - * the wrapper: an entry must be `{ manifest: … }`. ⚠️ That the entry schema's - * body half cannot describe the payload the load path registers is a real - * tension in the landed D4 surface, recorded on the card rather than papered - * over here — widening it is a spec decision, not a loader's. + * reshape, with the body half declared as the ASSEMBLED package body. This + * module imports and applies that schema instead of duck-typing either half: a + * second declaration of one shape is exactly the drift ADR-0116 exists about. + * + * ### The gate is a FULL parse now — what changed, and why it could not be + * + * This seam used to apply `ArtifactPackageEntrySchema` to the WRAPPER only, + * filtering the verdict down to issues at the entry root or on `manifest` + * itself. That was a deliberate, narrow accommodation of a real surface defect + * rather than a choice: `ArtifactPackageEntrySchema.manifest` is + * `ManifestSchema`, whose `objects` key is `z.array(z.string())` — GLOB + * PATTERNS (`manifest.zod.ts`) — while what reaches this path is an ASSEMBLED + * payload whose `objects` are object DEFINITIONS. Measured against that + * schema, `safeParse` of a real entry failed with + * `manifest.objects.0: expected string, received object`, so a full parse + * would have refused exactly the artifacts this path exists to register. + * + * The maintainer settled that surface on 2026-09-02 (road **B**): the + * assembled/registered form has its own declaration, + * `AssembledPackageBodySchema`, and `ArtifactPackageSchema` is the entry that + * carries it. Both stages are now describable, so this seam parses the WHOLE + * entry — a package body whose collections are the wrong shape (globs where + * definitions belong, a permission-capability list where permission sets + * belong) is refused HERE, at the seam that would otherwise register it and + * silently own nothing. ⛔ Road C — widening `ManifestSchema.objects` to accept + * both spellings — was rejected by name: a union that accepts both stages makes + * neither stage checkable. + * + * ⛔ The body handed to `registerApp` is still the caller's original + * `entry.manifest`, never `verdict.data.manifest`. The parse is a GATE, and the + * reason is unchanged by road B: `ManifestSchema` carries defaults + * (`defaultDatasource: 'default'`, `scope: 'project'`) and Zod strips + * undeclared keys, so registering a parsed clone would put different bytes into + * the registry than the singular-`manifest` branch does for the same authored + * package. D7 pins that those two branches do not disagree. * * ## Ordering reuses the ONE sorter (D5) * @@ -101,7 +103,7 @@ */ import { resolvePluginOrder, type OrderablePlugin } from '@objectstack/core'; -import { ArtifactPackageEntrySchema } from '@objectstack/spec'; +import { ArtifactPackageSchema } from '@objectstack/spec'; /** * Refusals raised by {@link resolveArtifactPackageOrder}, as ADR-0112 envelopes @@ -110,6 +112,16 @@ import { ArtifactPackageEntrySchema } from '@objectstack/spec'; */ export type ArtifactPackageError = Error & { code: string; status: number }; +/** + * How many Zod issues one refusal quotes before it starts counting instead. + * + * A single wrong-shaped collection raises one issue per element, so an + * uncapped message can run to hundreds of lines for one mistake. The cap is on + * the QUOTED issues only — the count that follows names the remainder, so the + * message never trails off the way a bare `slice` would. + */ +const MAX_REPORTED_ENTRY_ISSUES = 5; + function refuse(code: string, message: string): ArtifactPackageError { const err = new Error(message) as ArtifactPackageError; err.code = code; @@ -174,36 +186,40 @@ export function resolveArtifactPackageOrder(artifact: unknown): unknown[] { const nodes = new Map(); declared.forEach((entry: unknown, index: number) => { - // The wrapper contract, read off its ONE declaration rather than - // duck-typed. The mistake this catches is the one the schema's own - // `history` text exists for: a manifest body inlined straight onto the - // array element instead of wrapped as `{ manifest: { … } }`. - // - // WRAPPER-LEVEL issues only — an issue at the entry root (`strictObject`'s - // `unrecognized_keys` for an inlined body, or a non-object entry) or on - // `manifest` itself (absent, or not an object). Issues DEEPER than that - // describe the manifest body, which this seam deliberately does not judge; - // see the module header for the measurement behind that line. - const verdict = ArtifactPackageEntrySchema.safeParse(entry); - const wrapperIssues = verdict.success - ? [] - : verdict.error.issues.filter( - (i) => i.path.length === 0 || (i.path.length === 1 && i.path[0] === 'manifest'), - ); - if (wrapperIssues.length > 0) { + // The entry contract, read off its ONE declaration rather than duck-typed, + // and parsed WHOLE (#14242 B — see the module header for what this replaced + // and why the narrow version was not a choice). Two classes of mistake are + // caught by the same call: the WRAPPER one the schema's `history` text + // exists for — a body inlined straight onto the array element instead of + // wrapped as `{ manifest: { … } }` — and a malformed BODY, most sharply a + // manifest still carrying authoring-time glob patterns where the assembled + // stage carries definitions. + const verdict = ArtifactPackageSchema.safeParse(entry); + if (!verdict.success) { + // Capped: a body whose whole `objects` array is the wrong shape raises one + // issue per element, and a refusal nobody can read is a refusal nobody + // acts on. The count says how many were withheld rather than trailing off. + const issues = verdict.error.issues; + const shown = issues.slice(0, MAX_REPORTED_ENTRY_ISSUES); throw refuse( 'INVALID_ARTIFACT_PACKAGE_ENTRY', `Release artifact \`packages[${index}]\` is not a package entry (ADR-0130 D4): ` - + wrapperIssues.map((i) => `${i.path.join('.') || ''}: ${i.message}`).join('; ') + + shown.map((i) => `${i.path.join('.') || ''}: ${i.message}`).join('; ') + + (issues.length > shown.length ? ` (+${issues.length - shown.length} more)` : '') + '. Each entry is a WRAPPER object carrying its package under `manifest:` — ' + 'wrap an inlined body as `{ manifest: { … } }`. The key position is reserved ' - + 'so a future external-segment form is an additive key rather than a reshape.', + + 'so a future external-segment form is an additive key rather than a reshape. ' + + 'The body under `manifest:` is the ASSEMBLED package body ' + + '(`AssembledPackageBodySchema`): its `objects` / `datasources` are ' + + 'DEFINITIONS, not the authoring manifest\'s glob patterns — a compiled ' + + 'artifact has no files left to glob.', ); } // ⛔ The ORIGINAL body, never `verdict.data.manifest` — see the module - // header: the schema is a gate here, and a parsed clone carries defaults - // and drops undeclared keys the singular-`manifest` branch keeps. + // header: the schema is a GATE here even now that it parses the whole + // entry, because a parsed clone carries defaults and drops undeclared keys + // the singular-`manifest` branch keeps. const manifest = (entry as { manifest?: unknown }).manifest; const id = artifactPackageId(manifest); diff --git a/packages/objectql/src/registry-invalidate.test.ts b/packages/objectql/src/registry-invalidate.test.ts index 7d7c808863..40920cacbb 100644 --- a/packages/objectql/src/registry-invalidate.test.ts +++ b/packages/objectql/src/registry-invalidate.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { SchemaRegistry } from './registry'; -import type { ServiceObject } from '@objectstack/spec'; +import type { ServiceObject } from '@objectstack/spec/data'; function makeObject(name: string, label = name): ServiceObject { return { diff --git a/packages/objectql/test-typecheck-debt.json b/packages/objectql/test-typecheck-debt.json index f55ed55ab3..9371b022a2 100644 --- a/packages/objectql/test-typecheck-debt.json +++ b/packages/objectql/test-typecheck-debt.json @@ -137,7 +137,7 @@ "TS2322: Type '…' is not assignable to type 'QueryInput'.": 2 }, "src/registry-invalidate.test.ts": { - "TS2459: Module '\"@objectstack/spec\"' declares 'ServiceObject' locally, but it is not exported.": 1 + "TS2352: Conversion of type '…' to type '…' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.": 1 }, "src/registry.test.ts": { "TS18048: 'out.enable' is possibly 'undefined'.": 7, diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 917f92982e..4cf6ef338f 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -14,6 +14,7 @@ "@objectstack/connector-openapi": "workspace:*", "@objectstack/connector-rest": "workspace:*", "@objectstack/example-crm": "workspace:*", + "@objectstack/example-multi-package": "workspace:*", "@objectstack/example-showcase": "workspace:*", "@objectstack/mcp": "workspace:*", "@objectstack/metadata": "workspace:*", diff --git a/packages/qa/dogfood/test/multi-package-artifact.dogfood.test.ts b/packages/qa/dogfood/test/multi-package-artifact.dogfood.test.ts new file mode 100644 index 0000000000..2031a6ad59 --- /dev/null +++ b/packages/qa/dogfood/test/multi-package-artifact.dogfood.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// GOLDEN REGRESSION — ADR-0130 D4: one release artifact carrying TWO packages +// that share a namespace, booted for real and read back through the HTTP door +// a consumer reads. +// +// ## What would be green without this file +// +// Before this card, `packages[]` could be produced by nothing: `composeStacks(…, +// { manifest: 'preserve' })` folded N package IDENTITIES into the list, the +// load path iterated it, and every schema pin agreed — yet a two-package +// artifact installed two package records owning NOTHING, because the entries +// carried manifests with no metadata on them and the flattened top level (which +// does carry the metadata) is read only when `packages` is ABSENT. Every unit +// pin around that hole stayed green; only a boot notices. +// +// So this file asserts the two halves together, on one booted stack: +// +// 1. `GET /api/v1/packages` lists BOTH package rows — the artifact's +// co-ownership declaration reached the registry (D1/D4). +// 2. Each package OWNS its own object — `crm_account` stamped to the App +// package, `crm_order` to the module — which is the half a manifest-only +// `packages[]` cannot deliver and the half that makes the split worth +// anything (Studio scope, per-module context budget, a sellable unit). +// +// Boots a fixture stack of its own, so it stays out of `SHARED_SHOWCASE`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import multiPackageStack from '@objectstack/example-multi-package'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const CORE = 'com.example.multi.core'; +const ORDERS = 'com.example.multi.orders'; + +/** One row of `GET /api/v1/packages`, as far as these pins read it. */ +interface PackageRow { + manifest?: { id?: string; type?: string; namespace?: string; scope?: string }; + writable?: boolean; +} + +describe('dogfood: one artifact, two co-owning packages (ADR-0130 D4)', () => { + let stack: VerifyStack; + let token: string; + let rows: PackageRow[]; + + beforeAll(async () => { + stack = await bootStack(multiPackageStack); + token = await stack.signIn(); + const res = await stack.apiAs(token, 'GET', '/packages'); + expect(res.status, 'GET /api/v1/packages').toBe(200); + // `sendOk(res, { packages, total })` — the shape read off the handler, not + // guessed: a `?? []` fallback over a key this door does not send would turn + // "the door answered something else" into "there are no packages", and this + // file's whole subject is a list that is supposed to have two rows in it. + const body = (await res.json()) as { data?: { packages?: PackageRow[]; total?: number } }; + expect(Array.isArray(body.data?.packages), `GET /packages answered ${JSON.stringify(body).slice(0, 400)}`).toBe(true); + rows = body.data?.packages ?? []; + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('lists BOTH packages of the artifact', () => { + const ids = rows.map((r) => r.manifest?.id).filter((id): id is string => typeof id === 'string'); + // Narrowed to this artifact's own packages: a booted kernel installs its + // platform packages too, and asserting the whole list would pin the + // kernel's boot composition instead of this artifact's registration. + expect(ids.filter((id) => id.startsWith('com.example.multi.')).sort()).toEqual([CORE, ORDERS]); + }); + + it('carries both package TYPES — one app, one module, one namespace', () => { + const core = rows.find((r) => r.manifest?.id === CORE); + const orders = rows.find((r) => r.manifest?.id === ORDERS); + + expect(core?.manifest?.type).toBe('app'); + expect(orders?.manifest?.type).toBe('module'); + // The co-ownership ADR-0130 D1 is about: two packages, ONE namespace, and + // no object renamed to buy the boundary (ADR-0129 D1–D2). + expect(core?.manifest?.namespace).toBe('crm'); + expect(orders?.manifest?.namespace).toBe('crm'); + }); + + it('both rows are read-only — the server\'s own verdict, not a scope heuristic', () => { + // ADR-0070 D2 / ADR-0130 Consequences row 6: a package booted from an + // artifact through `registerApp` is read-only whatever its scope says, + // because `isWritablePackage` reads `engine.manifests` FIRST. The module is + // the row that separates that verdict from Studio's client-side + // `scope !== 'project'` heuristic — it is authored with no `scope` key at + // all, and a client rule reading the row alone cannot tell it from a + // Studio-created writable base. + const core = rows.find((r) => r.manifest?.id === CORE); + const orders = rows.find((r) => r.manifest?.id === ORDERS); + + // Asserted as a boolean, not as falsiness: `undefined` is what this row + // carried before the verdict shipped, and `expect(...).toBeFalsy()` would + // read a missing key as a passing answer. + expect(core?.writable).toBe(false); + expect(orders?.writable).toBe(false); + }); + + it('each object is owned by the package that declared it — not by the artifact', async () => { + // The half a manifest-only `packages[]` cannot deliver. `_packageId` is the + // stamp `registerApp` writes per manifest, so this is the co-ownership + // claim measured where the registry actually holds it. + const ql = await stack.kernel.getServiceAsync<{ + registry: { getObject(name: string): { _packageId?: string } | undefined }; + }>('objectql'); + + expect(ql.registry.getObject('crm_account')?._packageId).toBe(CORE); + expect(ql.registry.getObject('crm_order')?._packageId).toBe(ORDERS); + }); + + it('the shared namespace is owned by BOTH packages, not taken by one', async () => { + // ADR-0130 D1's whole subject, read off the structure that always supported + // it: `namespaceRegistry` is `Map>`, and the + // install gate used to refuse the second package into an owned namespace. + const ql = await stack.kernel.getServiceAsync<{ + registry: { getNamespaceOwners(ns: string): string[] }; + }>('objectql'); + + expect([...ql.registry.getNamespaceOwners('crm')].sort()).toEqual([CORE, ORDERS]); + }); +}); diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index af2bf3cfd8..9dc62d7d5d 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -9,9 +9,15 @@ "AUDIENCE_ANCHOR_POSITIONS (const)", "Agent (type)", "ApplyConversionsOptions (interface)", + "ArtifactPackage (type)", "ArtifactPackageEntry (type)", "ArtifactPackageEntryParsed (type)", "ArtifactPackageEntrySchema (const)", + "ArtifactPackageParsed (type)", + "ArtifactPackageSchema (const)", + "AssembledPackageBody (type)", + "AssembledPackageBodyParsed (type)", + "AssembledPackageBodySchema (const)", "AssembledViewArtifact (type)", "AssembledViewArtifactParsed (type)", "AssembledViewArtifactSchema (const)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index 404969d9f0..53cc05063c 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -9,9 +9,15 @@ "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)", + "ArtifactPackage": "src/stack.zod.ts#ArtifactPackage (type)", "ArtifactPackageEntry": "src/stack.zod.ts#ArtifactPackageEntry (type)", "ArtifactPackageEntryParsed": "src/stack.zod.ts#ArtifactPackageEntryParsed (type)", "ArtifactPackageEntrySchema": "src/stack.zod.ts#ArtifactPackageEntrySchema (const)", + "ArtifactPackageParsed": "src/stack.zod.ts#ArtifactPackageParsed (type)", + "ArtifactPackageSchema": "src/stack.zod.ts#ArtifactPackageSchema (const)", + "AssembledPackageBody": "src/stack.zod.ts#AssembledPackageBody (type)", + "AssembledPackageBodyParsed": "src/stack.zod.ts#AssembledPackageBodyParsed (type)", + "AssembledPackageBodySchema": "src/stack.zod.ts#AssembledPackageBodySchema (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)", diff --git a/packages/spec/src/assembled-package-body.test.ts b/packages/spec/src/assembled-package-body.test.ts new file mode 100644 index 0000000000..294e934001 --- /dev/null +++ b/packages/spec/src/assembled-package-body.test.ts @@ -0,0 +1,253 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0130 D4 · #14242 road **B** — the ASSEMBLED package body has its own + * declaration, and the artifact's `packages[]` refers to it. + * + * ## The defect this closes, stated as the measurement that found it + * + * `ArtifactPackageEntrySchema` wraps its body as `manifest: ManifestSchema`, + * whose `objects` is `z.array(z.string())` — GLOB PATTERNS, the authoring-time + * shape. What the ADR-0130 load path registers is an ASSEMBLED payload whose + * `objects` are object DEFINITIONS. So a full parse of a real artifact entry + * was refused: + * + * ArtifactPackageEntrySchema.safeParse({ manifest: assembledBody }) + * → success: false + * → manifest.objects.0: Invalid input: expected string, received object + * + * One schema was being asked to describe two lifecycle stages of one noun, and + * the load path could only gate the wrapper as a result. The maintainer settled + * it on 2026-09-02 (road B): declare the assembled stage as well. ⛔ Road C — + * widening `ManifestSchema.objects` into a union of both spellings — was + * rejected by name, because a union that accepts both stages makes NEITHER + * stage checkable. + * + * ## What this file pins, and why each half is load-bearing + * + * 1. **The key set is DERIVED, not transcribed.** The assembled body carries + * every metadata collection the stack schema declares. Asserting a hand- + * written list here would be a second transcription of the very thing the + * implementation refuses to transcribe; instead the DIFFERENCE between the + * two schemas is pinned against the artifact-envelope keys, so a new stack + * collection is either on the body or fails this file. + * 2. **Both stages still refuse the other's spelling** — the whole point of + * declaring two schemas rather than one tolerant one. + * 3. **Composition is where assembly happens**, because it is the last moment + * per-package attribution exists: the composed stack flattens every + * collection to the top level, and a flattened array cannot say which + * package each item came from. + */ + +import { describe, it, expect } from 'vitest'; + +import { + ArtifactPackageEntrySchema, + ArtifactPackageSchema, + AssembledPackageBodySchema, + ObjectStackDefinitionSchema, + composeStacks, + defineStack, + type ObjectStackDefinition, +} from './stack.zod'; +import { ManifestSchema } from './kernel/manifest.zod'; + +/** + * The keys that belong to the ARTIFACT or the DEPLOYMENT, never to one package + * inside the artifact — the only stack keys the assembled body may lack. + * + * Spelled out here on purpose: this list is the reviewable half of the + * derivation. The implementation derives the body's key set mechanically, so + * what a reader cannot see there is which keys were deliberately left OUT — + * and this test is where a newly added stack key has to be classified. + */ +const ARTIFACT_ENVELOPE_KEYS = [ + 'manifest', // the artifact's own identity (ADR-0130 D6 — one artifact, one version) + 'packages', // the artifact carries packages; a package does not carry packages + 'api', // deployment configuration read by `objectstack serve`/`dev` + 'server', // deployment configuration + 'i18n', // one artifact, one supported-locale declaration + 'runtimeModule', // written by the compiler, per ARTIFACT + 'onEnable', // one bundle, one lifecycle hook (AppPlugin invokes a single one) +].sort(); + +const shapeKeys = (schema: unknown): string[] => + Object.keys((schema as { shape: Record }).shape).sort(); + +// ─── Fixtures ─────────────────────────────────────────────────────── + +const coreManifest = { + id: 'com.example.multi.core', + name: 'core', + version: '1.0.0', + type: 'app' as const, + namespace: 'crm', +}; + +const ordersManifest = { + id: 'com.example.multi.orders', + name: 'orders', + version: '1.0.0', + type: 'module' as const, + namespace: 'crm', + dependencies: { 'com.example.multi.core': '^1.0.0' }, +}; + +const accountObject = { + name: 'crm_account', + label: 'Account', + sharingModel: 'private' as const, + fields: { name: { name: 'name', type: 'text' as const, label: 'Name' } }, +}; + +const orderObject = { + name: 'crm_order', + label: 'Order', + sharingModel: 'private' as const, + fields: { name: { name: 'name', type: 'text' as const, label: 'Number' } }, +}; + +const coreStack = () => defineStack({ manifest: coreManifest, objects: [accountObject] }); +const ordersStack = () => defineStack({ manifest: ordersManifest, objects: [orderObject] }); + +// ─── 1. The key set is derived ────────────────────────────────────── + +describe('#14242 B — the assembled body carries the stack schema\'s collections, derived', () => { + it('lacks exactly the artifact-envelope keys, and nothing else', () => { + const stackKeys = shapeKeys(ObjectStackDefinitionSchema); + const bodyKeys = shapeKeys(AssembledPackageBodySchema); + + // A positive first: the instruments see real key sets, so the set + // difference below is a measurement rather than two empties agreeing. + expect(stackKeys.length).toBeGreaterThan(30); + expect(bodyKeys.length).toBeGreaterThan(30); + + expect(stackKeys.filter((k) => !bodyKeys.includes(k))).toEqual(ARTIFACT_ENVELOPE_KEYS); + }); + + it('carries every manifest field too, minus the three the assembled stage overrides', () => { + const bodyKeys = shapeKeys(AssembledPackageBodySchema); + const manifestKeys = shapeKeys(ManifestSchema); + + // Nothing from the manifest half is dropped: the assembled body is the + // manifest PLUS collections, and an identity field that vanished here would + // be a package the registry could not name. + expect(manifestKeys.filter((k) => !bodyKeys.includes(k))).toEqual([]); + + // …and where the two halves declare the same key, the collection wins — + // which is `AppPlugin`'s flatten order (`{ ...manifest, ...bundle }`) + // stated as a declaration rather than re-derived at three seams. + for (const overridden of ['objects', 'datasources', 'permissions']) { + expect(manifestKeys, `${overridden} is a manifest key`).toContain(overridden); + expect(bodyKeys, `${overridden} is on the assembled body`).toContain(overridden); + } + }); +}); + +// ─── 2. Each stage refuses the other's spelling ───────────────────── + +describe('#14242 B — two stages, two declarations, neither tolerant of the other', () => { + const assembledBody = { ...coreManifest, objects: [accountObject] }; + const globBody = { ...coreManifest, objects: ['./src/objects/*.object.ts'] }; + + it('the ASSEMBLED entry accepts a body whose `objects` are definitions', () => { + const verdict = ArtifactPackageSchema.safeParse({ manifest: assembledBody }); + expect(verdict.success).toBe(true); + }); + + it('the AUTHORING entry still refuses that same body — the mismatch #14242 measured', () => { + // Kept as a live measurement rather than prose: it is the reason two + // declarations exist, and a widened authoring schema (road C) would turn + // this green without anyone noticing the stages had merged. + const verdict = ArtifactPackageEntrySchema.safeParse({ manifest: assembledBody }); + expect(verdict.success).toBe(false); + if (verdict.success) return; + expect(verdict.error.issues.map((i) => i.path.join('.'))).toContain('manifest.objects.0'); + }); + + it('the ASSEMBLED entry refuses authoring GLOBS where definitions belong', () => { + // The refusal the load gate exists for: a compiled artifact has no files + // left to glob, so a glob here names nothing and would register an empty + // package in silence. + const verdict = ArtifactPackageSchema.safeParse({ manifest: globBody }); + expect(verdict.success).toBe(false); + if (verdict.success) return; + expect(verdict.error.issues.map((i) => i.path.join('.'))).toContain('manifest.objects.0'); + }); + + it('a manifest-only entry is a valid assembled body — a package with no collections', () => { + // What a hand-written entry is, and why one `packages` key can serve the + // authoring and artifact stages without a union: the authoring form is an + // INSTANCE of the assembled form, not a second branch of it. + expect(ArtifactPackageSchema.safeParse({ manifest: coreManifest }).success).toBe(true); + expect(ArtifactPackageEntrySchema.safeParse({ manifest: coreManifest }).success).toBe(true); + }); + + it('still refuses an inlined body — the wrapper position D4 reserves is intact', () => { + expect(ArtifactPackageSchema.safeParse(assembledBody).success).toBe(false); + }); + + it('the body schema is not `strict` — `ManifestSchema` has never had that door', () => { + // Stated as a pin because it is a deliberate choice, not an oversight: this + // change adds a SHAPE gate on the collections, not a new unknown-key + // refusal on a manifest surface that is open by design. + expect(AssembledPackageBodySchema.safeParse({ ...coreManifest, somethingUndeclared: 1 }).success).toBe(true); + }); +}); + +// ─── 3. Composition assembles, and the artifact schema takes it ───── + +describe("ADR-0130 D4 — `manifest: 'preserve'` assembles each input stack", () => { + const composed = (): ObjectStackDefinition => + composeStacks([ordersStack(), coreStack()], { manifest: 'preserve' }); + + it('carries each package\'s OWN collections, not the flattened union', () => { + const entries = (composed() as { packages?: { manifest: Record }[] }).packages ?? []; + expect(entries).toHaveLength(2); + + const byId = new Map(entries.map((e) => [e.manifest.id as string, e.manifest])); + expect((byId.get('com.example.multi.core')?.objects as { name: string }[]).map((o) => o.name)) + .toEqual(['crm_account']); + expect((byId.get('com.example.multi.orders')?.objects as { name: string }[]).map((o) => o.name)) + .toEqual(['crm_order']); + + // The flattened top level still carries BOTH — preserve is additive, and + // the metadata service's artifact door reads exactly that top level. + expect((composed().objects ?? []).map((o) => o.name).sort()).toEqual(['crm_account', 'crm_order']); + }); + + it('SEAM 1 — `defineStack` accepts the composed project', () => { + // The authoring door full-parses `packages[]` through + // `ObjectStackDefinitionSchema`. Before the assembled declaration this + // refused a two-package config with `packages.0.manifest.objects.0: + // Expected string but received object` — measured on `bd0ee2fb` by an + // `os dev` boot — which is why the seam is pinned and not assumed. + expect(() => defineStack(composed() as never)).not.toThrow(); + }); + + it('SEAM 2/3 — the artifact schema parses it, and no collection is STRIPPED', () => { + // `os compile` (`ObjectStackDefinitionSchema.safeParse`) and the metadata + // service's artifact door (`_parseAndRegisterArtifact` → + // `ObjectStackDefinitionSchema.parse`) are the same parse. Survival is + // asserted, not just success: an undeclared key parses green and is + // SILENTLY DROPPED, so "it parsed" says nothing about what came out. + const result = ObjectStackDefinitionSchema.safeParse(composed()); + expect(result.success).toBe(true); + if (!result.success) return; + + const parsed = result.data.packages ?? []; + expect(parsed.map((p) => p.manifest.id)).toEqual([ + 'com.example.multi.orders', + 'com.example.multi.core', + ]); + // An assembled body is `Record` at the TYPE level (see the + // note above `AssembledPackageBodySchema` — a named or element-precise + // static type there leaked the whole stack declaration into every + // consumer of `@objectstack/spec/system` and OOM'd their type-checks); + // narrow at the point of use, as every reader of an assembled body does. + const objectNames = (objects: unknown): string[] | undefined => + (objects as Array<{ name: string }> | undefined)?.map((o) => o.name); + expect(objectNames(parsed[1].manifest.objects)).toEqual(['crm_account']); + expect(objectNames(parsed[0].manifest.objects)).toEqual(['crm_order']); + }); +}); diff --git a/packages/spec/src/stack-artifact-packages.test.ts b/packages/spec/src/stack-artifact-packages.test.ts index 5d4a1f4311..defb91d13b 100644 --- a/packages/spec/src/stack-artifact-packages.test.ts +++ b/packages/spec/src/stack-artifact-packages.test.ts @@ -202,7 +202,10 @@ describe('ADR-0130 D4 — `packages` carries N manifests', () => { expect(result.success).toBe(true); if (!result.success) return; - expect(result.data.packages?.[0].manifest.engines?.protocol).toBe('>=18 <19'); + // An assembled body is `Record` at the type level (see the + // note above `AssembledPackageBodySchema`); narrow at the point of use. + const body = result.data.packages?.[0].manifest as { engines?: { protocol?: string } } | undefined; + expect(body?.engines?.protocol).toBe('>=18 <19'); }); }); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index d8411f4574..9855490cf1 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -159,12 +159,30 @@ function applyApiEndpointGates( } /** - * One package carried by a release artifact (ADR-0130 D4). + * One package entry as an AUTHOR writes it — manifest only (ADR-0130 D4). * * A release artifact MAY carry N package manifests: everything inside one * artifact is delivered atomically by one publisher, and that joint delivery IS - * the co-ownership declaration (ADR-0130 D1). This schema is the ELEMENT of the - * artifact's `packages` list. + * the co-ownership declaration (ADR-0130 D1). + * + * ## This is the AUTHORING stage, not the artifact's element type + * + * The element type of `packages` on {@link ObjectStackDefinitionSchema} is + * {@link ArtifactPackageSchema}, whose body half is the ASSEMBLED package body + * — manifest fields plus the metadata collections that package owns, which is + * what `ObjectQL.registerApp` iterates. This schema stays at the stage its body + * half can actually describe: `ManifestSchema`, whose `objects` are glob + * PATTERNS. #14242 measured that a full parse of a real artifact entry against + * it is refused (`manifest.objects.0: expected string, received object`), and + * the maintainer's decision (2026-09-02, road B) was to declare the assembled + * stage beside this one rather than widen either into a union that could check + * neither. + * + * So: this schema for what an author hands `composeStacks`, + * {@link ArtifactPackageSchema} for what the artifact carries and the load path + * registers. An authoring entry is a valid instance of the assembled one + * whenever its manifest declares no globs — a package identity with no + * collections. * * ## ⛔ The entry is a WRAPPER object, and that is the whole point of its shape * @@ -213,125 +231,37 @@ export const ArtifactPackageEntrySchema = lazySchema(() => strictObject({ + 'wrap it as `{ manifest: { … } }`.', }, { manifest: ManifestSchema.describe('The package manifest this artifact entry carries'), -}).describe('One package carried by a release artifact (ADR-0130 D4)')); +}).describe('One package entry as authored — manifest only (ADR-0130 D4)')); export type ArtifactPackageEntry = z.input; /** Post-parse shape of {@link ArtifactPackageEntry} — defaults applied, transforms run (ADR-0122). */ export type ArtifactPackageEntryParsed = z.infer; /** - * ObjectStack Ecosystem Definition - * - * This schema represents the "Full Stack" definition of a project or environment. - * It is used for: - * 1. Project Export/Import (YAML/JSON dumps) - * 2. IDE Validation (IntelliSense) - * 3. Runtime Bootstrapping (In-memory loading) - * 4. Platform Reflection (API & Capabilities Discovery) - */ -/** - * 1. DEFINITION PROTOCOL (Static) - * ---------------------------------------------------------------------- - * Describes the "Blueprint" or "Source Code" of an ObjectStack Plugin/Project. - * This represents the complete declarative state of the application. + * Every metadata COLLECTION `ObjectStackDefinitionSchema` declares, as one + * named shape. + * + * ## Why the collections are a const rather than written inline + * + * Two surfaces need this exact key set and must not be able to disagree about + * it: the stack definition (spread in below) and {@link + * AssembledPackageBodySchema}, the assembled form of ONE package inside a + * release artifact (ADR-0130 D4). Declaring the collections twice is the drift + * ADR-0116 exists about, and here it would be silent in the worst direction — + * a metadata family added to the stack and forgotten on the package body would + * make the load gate refuse every multi-package artifact that uses it, naming a + * key its author correctly wrote. + * + * ⛔ The artifact ENVELOPE keys stay on the schema below and are deliberately + * NOT in here: `manifest`, `packages`, `api`, `server`, `i18n`, `runtimeModule` + * and `onEnable` describe the artifact or the deployment, not one package + * inside it. `packages` additionally CANNOT live here — its element schema is + * built from this shape, so putting it in the shape would make the declaration + * circular. * - * Usage: - * - Developers write this in files locally. - * - AI Agents generate this to create apps. - * - CI Tools deploy this to the server. - */ -/* - * #8687 — the TOP-LEVEL door is `strictObject` like every inner authorable - * surface the #4001 campaign closed. Before this, an unknown top-level key - * parsed green and was silently dropped: a `flow`-for-`flows` typo (or the - * stale `approvalProcesses`) shipped an artifact missing that whole metadata - * family, with `os validate` — even `--strict` — exiting 0, because the - * `defineStack:` diagnostic was printed at load, outside the warning tally. - * - * The near-miss guidance that used to arrive through `lintUnknownStackKeys` - * (`objectz` → "did you mean `objects`?") now arrives through the refusal - * itself: `strictObject`'s error map suggests the closest declared key, and - * the lint deliberately goes quiet on a strict schema (its own posture rule — - * see `kernel/metadata-authoring-lint.ts`), so there is one voice, not two. - * - * The `guidance` entries are the curated half: retired/never-keys where a - * rename suggestion would be wrong. `storage` mirrors `STACK_KEY_GUIDANCE` - * (`data/authoring-key-lint.ts`), which stays exported for the generic lint - * API but no longer fires for this surface. + * @internal */ -export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ - surface: 'this stack definition', - history: - 'Until this surface was closed (the outermost door), an unknown top-level stack ' - + 'key parsed green and its value was silently dropped — a one-character typo could ship ' - + 'an artifact missing a whole metadata family while `os validate` exited 0. The declared ' - + 'keys are enumerated by `ObjectStackDefinitionSchema` (@objectstack/spec, stack.zod.ts) ' - + 'and in the stack-definition reference docs.', - guidance: { - storage: - 'the file-storage backend is a deployment concern, not an application declaration. ' - + 'Configure it with the OS_STORAGE_* environment variables, or per-deployment in Setup → ' - + 'Settings → Storage (which also holds credentials — a stack definition would commit them ' - + 'to git and to any published artifact).', - approvals: - 'approvals are not a top-level collection (ADR-0019): author an approval as a flow with ' - + 'one or more Approval nodes, in `flows`.', - approvalProcesses: - 'approvals are not a top-level collection (ADR-0019, standalone `approvals` removed in ' - + '7.4): author an approval as a flow with one or more Approval nodes, in `flows`.', - workflows: - 'there is no top-level `workflows` collection (ADR-0020): a record state machine is a ' - + '`state_machine` validation rule on the object it governs.', - portals: - 'the top-level `portals` collection was removed — nothing ever consumed it. ' - + 'Author external-user UI with `apps`/`views` plus positions and permission sets.', - themes: - '`themes` was removed in @objectstack/spec 17.1 (ADR-0049) — authored themes ' - + 'were parsed and stored, but no framework package ever read them back, no first-party ' - + 'app mounted the spec-aware theme provider, and nothing selected an active theme, so ' - + 'a declared theme changed nothing on screen. Delete the key. To colour the shipped ' - + 'console, set `app.branding.primaryColor` / `accentColor` — the one live colour ' - + 'surface (it drives `--primary`, `--accent` and their derived variables).', - onDisable: - 'no kernel, runtime or service ever called `onDisable` (the uninvoked lifecycle ' - + 'family is retired), so a value written here goes nowhere. Do teardown inside the ' - + 'resources `onEnable` acquires.', - }, -}, { - /** System Configuration */ - manifest: ManifestSchema.optional().describe('Project Package Configuration'), - - /** - * The artifact's package list (ADR-0130 D4) — **optional, and additive**. - * - * A release artifact MAY carry N package manifests so a product can be split - * into modules **without renaming a single object** (which 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). - * - * ## Read BOTH shapes — the schema shape IS the compatibility mechanism - * - * - `packages` present → iterate it. - * - `packages` absent → treat `manifest` (singular) as a **single-element - * list**. - * - * `manifest` is therefore RETAINED, not replaced. A replacement would break - * every artifact already built and on disk at every customer; the read-both - * rule is the term ADR-0130's whole compatibility claim rests on, which is - * why D4 states it as the schema decision rather than an implementation note. - * An existing single-`manifest` artifact takes the second branch and its - * behaviour is unchanged. - * - * ⚠️ This declares the SHAPE. The load path that iterates it — topologically - * ordered through the one sorter, `resolvePluginOrder` (ADR-0130 D5, - * ADR-0116) — and the `installPackage` co-ownership gate (ADR-0130 D1/D3) are - * separate, dependent changes. Until they land, a multi-package artifact - * parses and carries its list; nothing downstream iterates it yet. - */ - packages: z.array(ArtifactPackageEntrySchema).optional() - .describe('Package manifests carried by this release artifact (ADR-0130 D4)'), - +const STACK_DEFINITION_COLLECTIONS_SHAPE = { datasources: z.array(DatasourceSchema).optional().describe('External Data Connections'), /** @@ -758,89 +688,556 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ plugins: z.array(z.unknown()).optional().describe('Plugins to load'), /** - * Required Capabilities - * - * Declarative dependency on platform-provided capabilities. The - * runtime resolves each name to a built-in service plugin and - * loads it automatically — no need to construct the plugin in - * `plugins[]` or pass `--preset` flags at the CLI level. - * - * Built-in capability names (mapped in `@objectstack/cli`): - * `ai` → AIServicePlugin (`@objectstack/service-ai`) - * `ai-studio` → AIStudioPlugin (`@objectstack/service-ai-studio`; implies `ai`) - * `automation` → AutomationServicePlugin (+ default node packs) - * `analytics` → AnalyticsServicePlugin - * `audit` → AuditPlugin - * `i18n` → I18nPlugin - * - * INTENT, not presence (#1597). Listing a capability here is an explicit - * declaration that this app REQUIRES it, so the platform resolves it - * fail-fast at startup: if the provider package is not installed (or its - * plugin throws while starting), boot ABORTS with a clear error instead of - * silently degrading. This is the opposite of "load it if the package happens - * to be installed" — a capability the app merely bundles but does NOT list - * here is loaded best-effort (absent ⇒ quiet skip), and tier gating remains an - * orthogonal deny (a capability whose tier is off never loads, whatever the - * intent). Use this for the AI service too: `requires: ['ai']` makes a missing - * `@objectstack/service-ai` a hard boot error rather than a broken-but-booted app. + * Required Capabilities + * + * Declarative dependency on platform-provided capabilities. The + * runtime resolves each name to a built-in service plugin and + * loads it automatically — no need to construct the plugin in + * `plugins[]` or pass `--preset` flags at the CLI level. + * + * Built-in capability names (mapped in `@objectstack/cli`): + * `ai` → AIServicePlugin (`@objectstack/service-ai`) + * `ai-studio` → AIStudioPlugin (`@objectstack/service-ai-studio`; implies `ai`) + * `automation` → AutomationServicePlugin (+ default node packs) + * `analytics` → AnalyticsServicePlugin + * `audit` → AuditPlugin + * `i18n` → I18nPlugin + * + * INTENT, not presence (#1597). Listing a capability here is an explicit + * declaration that this app REQUIRES it, so the platform resolves it + * fail-fast at startup: if the provider package is not installed (or its + * plugin throws while starting), boot ABORTS with a clear error instead of + * silently degrading. This is the opposite of "load it if the package happens + * to be installed" — a capability the app merely bundles but does NOT list + * here is loaded best-effort (absent ⇒ quiet skip), and tier gating remains an + * orthogonal deny (a capability whose tier is off never loads, whatever the + * intent). Use this for the AI service too: `requires: ['ai']` makes a missing + * `@objectstack/service-ai` a hard boot error rather than a broken-but-booted app. + * + * Tokens must be members of the platform vocabulary + * (`PLATFORM_CAPABILITY_TOKENS`, canonical kebab-case). An UNKNOWN token — a + * typo or stale reference no runtime provides — is a `defineStack` **error**, + * not a silent no-op (framework#3265). The legacy camelCase spellings + * `aiStudio`/`aiSeat` were deprecated aliases in the prior release and were + * removed in framework#3308 — use `ai-studio`/`ai-seat`. + * + * If a capability is also provided explicitly via `plugins[]`, the + * explicit instance wins (and the resolver does not double-register). + * + * @example + * ```ts + * defineStack({ + * manifest: { ... }, + * requires: ['ai', 'automation', 'analytics'], + * objects: [...], + * }); + * ``` + */ + requires: z.array(z.string()).optional().describe('Capability names this stack requires from the platform (canonical kebab-case tokens from PLATFORM_CAPABILITY_TOKENS; an unknown token is a defineStack error, declared-but-missing ⇒ fail-fast at startup)'), + + /** + * Plugin tier presets to auto-register (e.g. `core`, `ai`, `ui`, `auth`). + * Overrides the `--preset` flag; omit to use the preset default. Set a list + * WITHOUT `ai` to run without the AI service (Community-Edition deployments). + */ + tiers: z.array(z.string()).optional().describe('Plugin tier presets to enable; overrides --preset'), + + /** + * DevPlugins: Development Capabilities + * List of plugins to load ONLY in development environment. + * Equivalent to `devDependencies` in package.json. + * Useful for loading dev-tools, mock data generators, or referencing local sibling packages for debugging. + */ + devPlugins: z.array(z.union([ManifestSchema, z.string()])).optional().describe('Plugins to load only in development (CLI dev command)'), + + /** + * Compiled Runtime Bundle Reference + * + * Path (relative to the JSON artifact) to a sibling ESM module emitted + * by `objectstack build`. The module exports `{ functions: Record }` + * containing every inline `Hook.handler` (and top-level `functions` map + * entry) that was lowered to a string ref during compilation. + * + * Runtimes (StandaloneStack, multi-tenant artifact-bind path) MUST + * dynamic-import this file on boot and merge `module.functions` into + * `bundle.functions` before `bindHooks(...)` runs — otherwise every + * declarative hook will fail to resolve its handler. + * + * The two-product layout (JSON + ESM) is the canonical build artifact + * shape for the platform. Authoring tools (`defineStack`, Studio + * inline editor) must NOT set this field directly; it is populated + * exclusively by the compiler. + * + * @example "./objectstack-runtime.7a70cd6576d17ff6.mjs" + */ + runtimeModule: z.string().optional().describe('Path (relative to the artifact JSON) of the compiled runtime ESM bundle. Set by `objectstack build`; do not author by hand.'), +}; + + +/** + * How {@link composeStacks} treats one top-level key (#5005). + * + * - `'concat'` — array collection; concatenated in stack order. + * - `'single'` — one scalar/object value; identical declarations pass + * through, differing ones are a composition ERROR. + * - `'manifest'` — chosen by the `manifest` option. + * - `'objects'` — merged by the `objectConflict` strategy. + * - `'functions'` — named-handler collection; merged by name. + * @internal + */ +type ComposeDisposition = 'concat' | 'single' | 'manifest' | 'objects' | 'functions'; + +/** + * The composition rule for EVERY top-level key of `ObjectStackDefinition` + * (#5005). + * + * ## Why a total table and not a list + * + * `composeStacks` used to build its result from an empty object by filling in + * `manifest`, `i18n`, `objects` and a hand-maintained array whitelist. Anything + * absent from that whitelist was not "left alone" — it was **deleted**, with no + * error, no warning, and no way for a consumer to tell "the composer dropped it" + * apart from "the author never wrote it". Composition is the platform's + * app-packaging / install story, so that silence reached real security config: + * `api.enforceProjectMembership` (the per-environment 403 gate) and, as of + * #4910, `server.security.rateLimit` both vanished the moment a stack was + * composed with any other one. Seven declared array collections + * (`datasourceMapping`, `datasets`, `jobs`, `emailTemplates`, `docs`, `books`, + * `tiers`) and the whole `functions` handler map went the same way; `tools` + * escaped the same fate only because ADR-0109 noticed and patched the list. + * + * A whitelist makes forgetting the default. This table makes it a **type + * error**: it `satisfies Record` — the stack schema's + * own declared key set, read off `STACK_DEFINITION_COLLECTIONS_SHAPE` plus the + * two envelope keys — so a new top-level key does not compile until someone + * states what composing it means. It is additionally `as const`, because + * {@link AssembledPackageBodyKey} derives the assembled package body's key set + * from these literal dispositions rather than transcribing them. That is + * the structural half of the fix; {@link composeStacks} carries the runtime + * half (an undeclared key warns rather than disappearing), so a key that + * reaches composition without a rule — via `strict: false`, or a raw object — + * still reports itself. + * + * ## Note on `i18n` (#5051) + * + * `i18n` carried a last-wins of its own through #5005 — the one key here that + * already had a deliberate, working strategy, and #5005's subject was keys that + * got *dropped*. That left it as the only top-level key still resolving a + * disagreement by silent override: the very shape the maintainer rejected for + * `api`/`server` — an earlier stack's declaration overwritten without a word by + * whoever composes after it. #5051 closed the inconsistency — `i18n` is + * `'single'` like every other non-array configuration key. Which locales an + * application supports is not a detail a composer may pick for the author: the + * `translations` bundles each stack ships are written against its own + * `supportedLocales`, so overriding one stack's declaration leaves the other + * stack's bundles addressing locales the composed app no longer admits. + * + * @internal + */ +type StackDefinitionKey = 'manifest' | 'packages' | keyof typeof STACK_DEFINITION_COLLECTIONS_SHAPE; + +const COMPOSE_KEY_DISPOSITIONS = { + // ── Bespoke strategies (unchanged by #5005) ── + manifest: 'manifest', + objects: 'objects', + functions: 'functions', + + // ── Array collections — concatenated in stack order ── + // ADR-0130 D4's artifact package list. Not a metadata collection like the + // rest of this block — it carries package MANIFESTS, not authored metadata — + // but its composition rule is the same one for the same reason: composing two + // stacks that each carry package entries must yield BOTH publishers' entries, + // since dropping one would lose a package the composed artifact still + // delivers. Declared here in the change that declares the key, as the table's + // docblock requires. + // + // ⚠️ This disposition governs stacks that already CARRY a `packages` list. + // It does not, on its own, repair `manifest:`'s deliberate pick-one semantics + // above (`selectManifest`, first/last): two stacks that each declare only the + // SINGULAR `manifest` would still lose N−1 of them. Folding those in is the + // `manifest: 'preserve'` option (ADR-0130 follow-up row 3), which is opt-in + // and composes `packages` itself — see `preservePackageEntries`. Concat stays + // the rule for every other strategy, and preserve's own output concatenates + // in stack order too, so the two agree rather than compete. + packages: 'concat', + datasources: 'concat', + datasourceMapping: 'concat', + translations: 'concat', + objectExtensions: 'concat', + apps: 'concat', + views: 'concat', + // [#5320] Machine-assembled channel (never authorable — the schema types it + // `never`, so no authored stack reaches composition carrying it). Assembled + // manifests are not `composeStacks` inputs today; if two ever were, their + // non-container view artifacts would concatenate like every other collection. + viewItems: 'concat', + pages: 'concat', + dashboards: 'concat', + reports: 'concat', + datasets: 'concat', + actions: 'concat', + // `themes` left this table with the key (#10485) — the total-record type is + // what forces this comment to move in lockstep with the schema. + flows: 'concat', + jobs: 'concat', + emailTemplates: 'concat', + docs: 'concat', + books: 'concat', + positions: 'concat', + permissions: 'concat', + capabilities: 'concat', + sharingRules: 'concat', + apis: 'concat', + webhooks: 'concat', + agents: 'concat', + tools: 'concat', + skills: 'concat', + hooks: 'concat', + mappings: 'concat', + analyticsCubes: 'concat', + connectors: 'concat', + data: 'concat', + plugins: 'concat', + requires: 'concat', + tiers: 'concat', + devPlugins: 'concat', + + // ── Single-valued configuration — same value passes, difference throws ── + api: 'single', + server: 'single', + runtimeModule: 'single', + // #8687: declared alongside the strict close (it was undeclared-but-honoured + // before, so composition never saw it through a parsed stack). One bundle + // gets one `onEnable` (`AppPlugin` invokes a single hook at start()); two + // stacks shipping DIFFERENT hooks cannot be merged without inventing an + // execution order neither author wrote — refuse and name them, like + // `api`/`server`. Multi-app hosts keep per-app hooks by composing PLUGINS + // (each AppPlugin carries its own bundle), not by folding stacks into one. + onEnable: 'single', + // #5051: the last key still on last-wins; aligned here, see the note above. + i18n: 'single', +} as const satisfies Record; + +/** + * All array fields on `ObjectStackDefinition` that are simply concatenated. + * Derived from {@link COMPOSE_KEY_DISPOSITIONS} so the two cannot drift. + * @internal + */ +const CONCAT_ARRAY_FIELDS = (Object.keys(COMPOSE_KEY_DISPOSITIONS) as (keyof ObjectStackDefinition)[]) + .filter((key) => COMPOSE_KEY_DISPOSITIONS[key] === 'concat'); + + +/** + * The keys an ASSEMBLED package body carries beyond its manifest fields. + * + * DERIVED from {@link COMPOSE_KEY_DISPOSITIONS}, never transcribed: that table + * is total over the stack schema's declared keys, so a metadata family added to + * the stack arrives here without anyone remembering to add it. Transcribing the + * list instead would fail SILENTLY and in the worst direction — the load gate + * would refuse a multi-package artifact naming a key its author correctly + * wrote, and the refusal would look like a defect in the author's metadata. + * + * `packages` is the one collection excluded: an artifact carries packages, a + * package inside it does not carry packages of its own (ADR-0130 D1 — the + * artifact is the co-ownership boundary, and nesting would put a second + * boundary inside the first). + * + * @internal + */ +type AssembledPackageBodyKey = Exclude<{ + [K in keyof typeof COMPOSE_KEY_DISPOSITIONS]: + (typeof COMPOSE_KEY_DISPOSITIONS)[K] extends 'concat' | 'objects' | 'functions' ? K : never; +}[keyof typeof COMPOSE_KEY_DISPOSITIONS], 'packages'>; + +/** The dispositions that mark a stack key as a metadata COLLECTION. @internal */ +const ASSEMBLED_PACKAGE_BODY_DISPOSITIONS: readonly string[] = ['concat', 'objects', 'functions']; + +/** + * Runtime half of {@link AssembledPackageBodyKey}: the collection members of + * {@link STACK_DEFINITION_COLLECTIONS_SHAPE}, read off the same disposition + * table the type is derived from. + * + * The return type is `Pick`, which is what makes the derivation mechanical + * rather than a promise: `Pick` requires every derived key to exist on that + * shape, so a collection declared in the disposition table but living outside + * the collections shape is a COMPILE error here, not a key that quietly goes + * missing from every assembled package body. + * + * @internal + */ +function assembledPackageBodyShape(): Pick { + const shape: Record = {}; + for (const [key, disposition] of Object.entries(COMPOSE_KEY_DISPOSITIONS)) { + if (key === 'packages') continue; + if (!ASSEMBLED_PACKAGE_BODY_DISPOSITIONS.includes(disposition)) continue; + shape[key] = (STACK_DEFINITION_COLLECTIONS_SHAPE as Record)[key]; + } + return shape as Pick; +} + +/** + * One package as it is ASSEMBLED into a release artifact, and as the load path + * registers it (ADR-0130 D4; #14242 decision B, maintainer 2026-09-02). + * + * ## The two stages this schema exists to separate + * + * `ManifestSchema` describes a package at AUTHORING time: its `objects` key is + * `z.array(z.string())` — GLOB PATTERNS naming the files a file-based loader + * should read. What reaches the ADR-0130 load path is an ASSEMBLED payload + * whose `objects` are object DEFINITIONS: `composeStacks(…, { manifest: + * 'preserve' })` folds each input stack's own metadata onto its manifest, + * `AppPlugin` flattens an artifact into `{ ...bundle.manifest, ...bundle }` + * before calling `manifest.register()`, and `ObjectQL.registerApp` iterates + * those bodies as definitions. + * + * One schema was being asked to describe both stages, so a full parse of a real + * artifact entry REFUSED it (`manifest.objects.0: expected string, received + * object`) and the load path could only gate the wrapper. #14242 recorded three + * roads and the maintainer took **B**: give the assembled form its own + * declaration, so both stages are describable and a load-time consumer has + * something correct to parse against. ⛔ Widening `ManifestSchema.objects` to a + * union of both spellings (road C) was REJECTED by name: a union that accepts + * both stages makes neither stage checkable, which is the tolerance-at-the- + * consumer shape this repository refuses (Prime Directive #12). + * + * ## What it is, mechanically + * + * The manifest's own fields, plus every metadata COLLECTION the stack schema + * declares — the key set derived from one table rather than transcribed (see + * {@link AssembledPackageBodyKey}). Where the two halves declare the SAME key, + * the collection wins, because that is what the assembled payload actually + * carries and what `registerApp` reads: + * + * | key | manifest (authoring) | assembled body | + * | --- | --- | --- | + * | `objects` | glob patterns | object definitions | + * | `datasources` | glob patterns | datasource definitions | + * | `permissions` | required-capability list (ADR-0025) | permission sets | + * + * That precedence is not chosen here: it is `AppPlugin`'s flatten order + * (`{ ...manifest, ...bundle }`) stated as a declaration. A manifest key the + * assembled stage overrides therefore has no expression in an assembled body — + * write it in the package's own stack, where the collection form is read. + * + * NOT `strictObject`: `ManifestSchema` is an open object, and this schema is + * that surface plus collections rather than a new door. The gate it enables is + * the one #14242 asked for — a body whose collections are the wrong SHAPE is + * refused, loudly, at the seam that registers it — not a new unknown-key + * refusal on a manifest that has never had one. + */ +/* + * ANNOTATED, not inferred — and annotated with a STRUCTURAL type, not a named + * one. Both halves were measured on #14513, neither is a preference. + * + * Not inferred: inferring it emits the manifest and all ~35 collection + * declarations a SECOND time inside `ObjectStackDefinitionSchema`'s own + * inferred type, which `tsc` refuses to serialize at all: + * + * TS7056: The inferred type of this node exceeds the maximum length the + * compiler will serialize. An explicit type annotation is needed. + * + * Structural, not named: this schema is the element type of the stack + * schema's `packages` key, so whatever is written here is printed INSIDE + * `ObjectStackDefinitionSchema`'s declaration — and that declaration is + * embedded by every schema that carries a stack (`system/environment-artifact.zod.ts` + * among them). The declaration bundler inlines STRUCTURAL types into each chunk + * that embeds them, but a NAMED type alias can only be imported from the chunk + * that declares it. Two earlier shapes of this annotation named an alias + * (`z.ZodType`), and the + * measured consequence was not local: `stack.zod` became a shared chunk that + * the `environment-artifact` chunk imports, so every consumer of + * `@objectstack/spec/system` started loading the whole stack schema + * declaration it never loaded before — `packages/qa/http-conformance`'s + * type-check program went from 691,580 to 734,202 lines of definitions + * (+42,622, the size of `ObjectStackDefinitionSchema`'s declaration), from + * 4.47 GB to 4.88 GB of heap, and past the 4096 MB ceiling + * `scripts/check-type-check-coverage.mjs` pins as CI's. A first attempt that + * ALSO referenced `typeof STACK_DEFINITION_COLLECTIONS_SHAPE` per key from the + * alias emitted the collections shape a second time on top (21,443 lines). + * + * `Record` on both sides is therefore the whole of the static + * contract: an assembled body is an object. The two aliases below are derived + * FROM this annotation (ADR-0122), never the other way round, so no name can + * re-enter the stack schema's printed type. What a consumer loses is the + * static field typing inside an assembled body; the RUNTIME schema still + * carries the manifest's every field plus every collection's full declaration + * (the key set derived from `COMPOSE_KEY_DISPOSITIONS`, see + * `assembledPackageBodyShape`), so a wrong-shaped body is refused exactly as + * before. Readers of assembled bodies (`compile.ts`, `artifact-packages.ts`) + * already treat them as records and narrow at the point of use — the honest + * shape for a payload whose collections are attributed per package only at + * composition time. + */ +export const AssembledPackageBodySchema: z.ZodType, Record> = + lazySchema(() => + ManifestSchema.extend(assembledPackageBodyShape()) + .describe('One package as assembled into a release artifact (ADR-0130 D4)')); + +/** + * The assembled package body as authored — derived from the schema's + * annotation above, deliberately structural (see the note there). + */ +export type AssembledPackageBody = z.input; +/** Post-parse shape of {@link AssembledPackageBody} — defaults applied, transforms run (ADR-0122). */ +export type AssembledPackageBodyParsed = z.infer; + +/** + * One package carried by a release artifact, in its ASSEMBLED form — the + * element type of `packages` on {@link ObjectStackDefinitionSchema}. + * + * Same WRAPPER as {@link ArtifactPackageEntrySchema} — the body sits under + * `manifest:`, the structural position ADR-0130 D4 reserves so a future + * `{ ref, integrity }` external segment is an ADDITIVE key rather than a + * reshape — with the body half declared at the stage the artifact is actually + * in: {@link AssembledPackageBodySchema}. + * + * The authoring entry is a valid instance of this one: a body carrying manifest + * fields and no collections parses green, which is exactly what a hand-written + * `{ manifest: { id, name, version, type } }` entry is. What it does NOT admit + * is an authoring manifest whose `objects` are globs — refused here, on + * purpose, because a glob in a compiled artifact names files nobody will read. + */ +export const ArtifactPackageSchema = lazySchema(() => strictObject({ + surface: 'an assembled artifact package entry', + history: + 'The entry is a wrapper object whose package body lives under `manifest:` — the ' + + 'structural position ADR-0130 D4 reserves so a future `{ ref, integrity }` external ' + + 'segment is an additive key rather than a reshape. An inlined body (`id`, `name`, ' + + '`version`, `objects`, … written directly on the array element) is therefore refused: ' + + 'wrap it as `{ manifest: { … } }`.', +}, { + manifest: AssembledPackageBodySchema.describe('The assembled package body this artifact entry carries'), +}).describe('One package carried by a release artifact, assembled (ADR-0130 D4)')); + +export type ArtifactPackage = z.input; +/** Post-parse shape of {@link ArtifactPackage} — defaults applied, transforms run (ADR-0122). */ +export type ArtifactPackageParsed = z.infer; + +/** + * ObjectStack Ecosystem Definition + * + * This schema represents the "Full Stack" definition of a project or environment. + * It is used for: + * 1. Project Export/Import (YAML/JSON dumps) + * 2. IDE Validation (IntelliSense) + * 3. Runtime Bootstrapping (In-memory loading) + * 4. Platform Reflection (API & Capabilities Discovery) + */ +/** + * 1. DEFINITION PROTOCOL (Static) + * ---------------------------------------------------------------------- + * Describes the "Blueprint" or "Source Code" of an ObjectStack Plugin/Project. + * This represents the complete declarative state of the application. + * + * Usage: + * - Developers write this in files locally. + * - AI Agents generate this to create apps. + * - CI Tools deploy this to the server. + */ +/* + * #8687 — the TOP-LEVEL door is `strictObject` like every inner authorable + * surface the #4001 campaign closed. Before this, an unknown top-level key + * parsed green and was silently dropped: a `flow`-for-`flows` typo (or the + * stale `approvalProcesses`) shipped an artifact missing that whole metadata + * family, with `os validate` — even `--strict` — exiting 0, because the + * `defineStack:` diagnostic was printed at load, outside the warning tally. + * + * The near-miss guidance that used to arrive through `lintUnknownStackKeys` + * (`objectz` → "did you mean `objects`?") now arrives through the refusal + * itself: `strictObject`'s error map suggests the closest declared key, and + * the lint deliberately goes quiet on a strict schema (its own posture rule — + * see `kernel/metadata-authoring-lint.ts`), so there is one voice, not two. + * + * The `guidance` entries are the curated half: retired/never-keys where a + * rename suggestion would be wrong. `storage` mirrors `STACK_KEY_GUIDANCE` + * (`data/authoring-key-lint.ts`), which stays exported for the generic lint + * API but no longer fires for this surface. + */ +export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ + surface: 'this stack definition', + history: + 'Until this surface was closed (the outermost door), an unknown top-level stack ' + + 'key parsed green and its value was silently dropped — a one-character typo could ship ' + + 'an artifact missing a whole metadata family while `os validate` exited 0. The declared ' + + 'keys are enumerated by `ObjectStackDefinitionSchema` (@objectstack/spec, stack.zod.ts) ' + + 'and in the stack-definition reference docs.', + guidance: { + storage: + 'the file-storage backend is a deployment concern, not an application declaration. ' + + 'Configure it with the OS_STORAGE_* environment variables, or per-deployment in Setup → ' + + 'Settings → Storage (which also holds credentials — a stack definition would commit them ' + + 'to git and to any published artifact).', + approvals: + 'approvals are not a top-level collection (ADR-0019): author an approval as a flow with ' + + 'one or more Approval nodes, in `flows`.', + approvalProcesses: + 'approvals are not a top-level collection (ADR-0019, standalone `approvals` removed in ' + + '7.4): author an approval as a flow with one or more Approval nodes, in `flows`.', + workflows: + 'there is no top-level `workflows` collection (ADR-0020): a record state machine is a ' + + '`state_machine` validation rule on the object it governs.', + portals: + 'the top-level `portals` collection was removed — nothing ever consumed it. ' + + 'Author external-user UI with `apps`/`views` plus positions and permission sets.', + themes: + '`themes` was removed in @objectstack/spec 17.1 (ADR-0049) — authored themes ' + + 'were parsed and stored, but no framework package ever read them back, no first-party ' + + 'app mounted the spec-aware theme provider, and nothing selected an active theme, so ' + + 'a declared theme changed nothing on screen. Delete the key. To colour the shipped ' + + 'console, set `app.branding.primaryColor` / `accentColor` — the one live colour ' + + 'surface (it drives `--primary`, `--accent` and their derived variables).', + onDisable: + 'no kernel, runtime or service ever called `onDisable` (the uninvoked lifecycle ' + + 'family is retired), so a value written here goes nowhere. Do teardown inside the ' + + 'resources `onEnable` acquires.', + }, +}, { + /** System Configuration */ + manifest: ManifestSchema.optional().describe('Project Package Configuration'), + + /** + * The artifact's package list (ADR-0130 D4) — **optional, and additive**. * - * Tokens must be members of the platform vocabulary - * (`PLATFORM_CAPABILITY_TOKENS`, canonical kebab-case). An UNKNOWN token — a - * typo or stale reference no runtime provides — is a `defineStack` **error**, - * not a silent no-op (framework#3265). The legacy camelCase spellings - * `aiStudio`/`aiSeat` were deprecated aliases in the prior release and were - * removed in framework#3308 — use `ai-studio`/`ai-seat`. + * A release artifact MAY carry N package manifests so a product can be split + * into modules **without renaming a single object** (which 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). * - * If a capability is also provided explicitly via `plugins[]`, the - * explicit instance wins (and the resolver does not double-register). + * ## Read BOTH shapes — the schema shape IS the compatibility mechanism * - * @example - * ```ts - * defineStack({ - * manifest: { ... }, - * requires: ['ai', 'automation', 'analytics'], - * objects: [...], - * }); - * ``` - */ - requires: z.array(z.string()).optional().describe('Capability names this stack requires from the platform (canonical kebab-case tokens from PLATFORM_CAPABILITY_TOKENS; an unknown token is a defineStack error, declared-but-missing ⇒ fail-fast at startup)'), - - /** - * Plugin tier presets to auto-register (e.g. `core`, `ai`, `ui`, `auth`). - * Overrides the `--preset` flag; omit to use the preset default. Set a list - * WITHOUT `ai` to run without the AI service (Community-Edition deployments). - */ - tiers: z.array(z.string()).optional().describe('Plugin tier presets to enable; overrides --preset'), - - /** - * DevPlugins: Development Capabilities - * List of plugins to load ONLY in development environment. - * Equivalent to `devDependencies` in package.json. - * Useful for loading dev-tools, mock data generators, or referencing local sibling packages for debugging. - */ - devPlugins: z.array(z.union([ManifestSchema, z.string()])).optional().describe('Plugins to load only in development (CLI dev command)'), - - /** - * Compiled Runtime Bundle Reference + * - `packages` present → iterate it. + * - `packages` absent → treat `manifest` (singular) as a **single-element + * list**. * - * Path (relative to the JSON artifact) to a sibling ESM module emitted - * by `objectstack build`. The module exports `{ functions: Record }` - * containing every inline `Hook.handler` (and top-level `functions` map - * entry) that was lowered to a string ref during compilation. + * `manifest` is therefore RETAINED, not replaced. A replacement would break + * every artifact already built and on disk at every customer; the read-both + * rule is the term ADR-0130's whole compatibility claim rests on, which is + * why D4 states it as the schema decision rather than an implementation note. + * An existing single-`manifest` artifact takes the second branch and its + * behaviour is unchanged. * - * Runtimes (StandaloneStack, multi-tenant artifact-bind path) MUST - * dynamic-import this file on boot and merge `module.functions` into - * `bundle.functions` before `bindHooks(...)` runs — otherwise every - * declarative hook will fail to resolve its handler. + * ## The element is the ASSEMBLED entry (#14242 B) * - * The two-product layout (JSON + ESM) is the canonical build artifact - * shape for the platform. Authoring tools (`defineStack`, Studio - * inline editor) must NOT set this field directly; it is populated - * exclusively by the compiler. + * Each entry is `{ manifest: }` — manifest fields + * plus the metadata collections that package owns, which is the payload + * `ObjectQL.registerApp` iterates. This key is read at LOAD time, so the + * authoring-time {@link ArtifactPackageEntrySchema} (whose `manifest.objects` + * are glob patterns) cannot describe it: that mismatch was #14242, and the + * maintainer's decision (2026-09-02) was to declare the assembled stage + * rather than widen the authoring one. A hand-written manifest-only entry + * still parses — it is an assembled body carrying no collections. * - * @example "./objectstack-runtime.7a70cd6576d17ff6.mjs" + * `composeStacks([...], { manifest: 'preserve' })` is what produces this list + * from N authored stacks; `os build` / `os compile` writes it into the + * artifact, and the load path registers each entry in dependency-topological + * order (ADR-0130 D5, ADR-0116's one sorter). */ - runtimeModule: z.string().optional().describe('Path (relative to the artifact JSON) of the compiled runtime ESM bundle. Set by `objectstack build`; do not author by hand.'), + packages: z.array(ArtifactPackageSchema).optional() + .describe('Assembled package bodies carried by this release artifact (ADR-0130 D4)'), + + ...STACK_DEFINITION_COLLECTIONS_SHAPE, }).superRefine(applyApiEndpointGates)); export type ObjectStackDefinition = z.input; @@ -1919,7 +2316,12 @@ export const ComposeStacksOptionsSchema = lazySchema(() => z.object({ * `'preserve'` composes the artifact's package list instead of discarding it: * every input stack contributes its packages to `packages` (ADR-0130 D4), in * stack order, each element the `{ manifest: … }` wrapper object - * {@link ArtifactPackageEntrySchema} declares. + * {@link ArtifactPackageSchema} declares — its body half being that stack + * ASSEMBLED (manifest fields plus the collections that package owns), which + * is the payload `ObjectQL.registerApp` iterates. Assembling here is not an + * implementation detail of preserve: composition is the last moment at which + * per-package attribution exists at all, since the composed stack flattens + * every collection to the top level. * * Which entries a stack contributes is **D4's read-both rule applied to the * inputs**, not a second rule invented here: @@ -1956,151 +2358,6 @@ export type ComposeStacksOptions = z.input; /** Post-parse shape of {@link ComposeStacksOptions} — defaults applied, transforms run (ADR-0122). */ export type ComposeStacksOptionsParsed = z.infer; -/** - * How {@link composeStacks} treats one top-level key (#5005). - * - * - `'concat'` — array collection; concatenated in stack order. - * - `'single'` — one scalar/object value; identical declarations pass - * through, differing ones are a composition ERROR. - * - `'manifest'` — chosen by the `manifest` option. - * - `'objects'` — merged by the `objectConflict` strategy. - * - `'functions'` — named-handler collection; merged by name. - * @internal - */ -type ComposeDisposition = 'concat' | 'single' | 'manifest' | 'objects' | 'functions'; - -/** - * The composition rule for EVERY top-level key of `ObjectStackDefinition` - * (#5005). - * - * ## Why a total table and not a list - * - * `composeStacks` used to build its result from an empty object by filling in - * `manifest`, `i18n`, `objects` and a hand-maintained array whitelist. Anything - * absent from that whitelist was not "left alone" — it was **deleted**, with no - * error, no warning, and no way for a consumer to tell "the composer dropped it" - * apart from "the author never wrote it". Composition is the platform's - * app-packaging / install story, so that silence reached real security config: - * `api.enforceProjectMembership` (the per-environment 403 gate) and, as of - * #4910, `server.security.rateLimit` both vanished the moment a stack was - * composed with any other one. Seven declared array collections - * (`datasourceMapping`, `datasets`, `jobs`, `emailTemplates`, `docs`, `books`, - * `tiers`) and the whole `functions` handler map went the same way; `tools` - * escaped the same fate only because ADR-0109 noticed and patched the list. - * - * A whitelist makes forgetting the default. This table makes it a **type - * error**: it is `Record< keyof ObjectStackDefinition, … >`, so a new top-level - * key does not compile until someone states what composing it means. That is - * the structural half of the fix; {@link composeStacks} carries the runtime - * half (an undeclared key warns rather than disappearing), so a key that - * reaches composition without a rule — via `strict: false`, or a raw object — - * still reports itself. - * - * ## Note on `i18n` (#5051) - * - * `i18n` carried a last-wins of its own through #5005 — the one key here that - * already had a deliberate, working strategy, and #5005's subject was keys that - * got *dropped*. That left it as the only top-level key still resolving a - * disagreement by silent override: the very shape the maintainer rejected for - * `api`/`server` — an earlier stack's declaration overwritten without a word by - * whoever composes after it. #5051 closed the inconsistency — `i18n` is - * `'single'` like every other non-array configuration key. Which locales an - * application supports is not a detail a composer may pick for the author: the - * `translations` bundles each stack ships are written against its own - * `supportedLocales`, so overriding one stack's declaration leaves the other - * stack's bundles addressing locales the composed app no longer admits. - * - * @internal - */ -const COMPOSE_KEY_DISPOSITIONS: Record = { - // ── Bespoke strategies (unchanged by #5005) ── - manifest: 'manifest', - objects: 'objects', - functions: 'functions', - - // ── Array collections — concatenated in stack order ── - // ADR-0130 D4's artifact package list. Not a metadata collection like the - // rest of this block — it carries package MANIFESTS, not authored metadata — - // but its composition rule is the same one for the same reason: composing two - // stacks that each carry package entries must yield BOTH publishers' entries, - // since dropping one would lose a package the composed artifact still - // delivers. Declared here in the change that declares the key, as the table's - // docblock requires. - // - // ⚠️ This disposition governs stacks that already CARRY a `packages` list. - // It does not, on its own, repair `manifest:`'s deliberate pick-one semantics - // above (`selectManifest`, first/last): two stacks that each declare only the - // SINGULAR `manifest` would still lose N−1 of them. Folding those in is the - // `manifest: 'preserve'` option (ADR-0130 follow-up row 3), which is opt-in - // and composes `packages` itself — see `preservePackageEntries`. Concat stays - // the rule for every other strategy, and preserve's own output concatenates - // in stack order too, so the two agree rather than compete. - packages: 'concat', - datasources: 'concat', - datasourceMapping: 'concat', - translations: 'concat', - objectExtensions: 'concat', - apps: 'concat', - views: 'concat', - // [#5320] Machine-assembled channel (never authorable — the schema types it - // `never`, so no authored stack reaches composition carrying it). Assembled - // manifests are not `composeStacks` inputs today; if two ever were, their - // non-container view artifacts would concatenate like every other collection. - viewItems: 'concat', - pages: 'concat', - dashboards: 'concat', - reports: 'concat', - datasets: 'concat', - actions: 'concat', - // `themes` left this table with the key (#10485) — the total-record type is - // what forces this comment to move in lockstep with the schema. - flows: 'concat', - jobs: 'concat', - emailTemplates: 'concat', - docs: 'concat', - books: 'concat', - positions: 'concat', - permissions: 'concat', - capabilities: 'concat', - sharingRules: 'concat', - apis: 'concat', - webhooks: 'concat', - agents: 'concat', - tools: 'concat', - skills: 'concat', - hooks: 'concat', - mappings: 'concat', - analyticsCubes: 'concat', - connectors: 'concat', - data: 'concat', - plugins: 'concat', - requires: 'concat', - tiers: 'concat', - devPlugins: 'concat', - - // ── Single-valued configuration — same value passes, difference throws ── - api: 'single', - server: 'single', - runtimeModule: 'single', - // #8687: declared alongside the strict close (it was undeclared-but-honoured - // before, so composition never saw it through a parsed stack). One bundle - // gets one `onEnable` (`AppPlugin` invokes a single hook at start()); two - // stacks shipping DIFFERENT hooks cannot be merged without inventing an - // execution order neither author wrote — refuse and name them, like - // `api`/`server`. Multi-app hosts keep per-app hooks by composing PLUGINS - // (each AppPlugin carries its own bundle), not by folding stacks into one. - onEnable: 'single', - // #5051: the last key still on last-wins; aligned here, see the note above. - i18n: 'single', -}; - -/** - * All array fields on `ObjectStackDefinition` that are simply concatenated. - * Derived from {@link COMPOSE_KEY_DISPOSITIONS} so the two cannot drift. - * @internal - */ -const CONCAT_ARRAY_FIELDS = (Object.keys(COMPOSE_KEY_DISPOSITIONS) as (keyof ObjectStackDefinition)[]) - .filter((key) => COMPOSE_KEY_DISPOSITIONS[key] === 'concat'); /** * Name a stack the way its author would recognise it (#5005). @@ -2380,11 +2637,14 @@ function selectManifest( * twice in the first place. * * Entries are emitted as the `{ manifest: … }` wrapper object - * {@link ArtifactPackageEntrySchema} declares, never a flat inlined manifest - * body: that wrapper is the structural position ADR-0130 D4 reserves so a - * future `{ ref, integrity }` external segment stays an ADDITIVE key. ⛔ The - * shape is not re-derived here — a second declaration of one shape is the drift - * ADR-0116 exists about. + * {@link ArtifactPackageSchema} declares, never a flat inlined body: that + * wrapper is the structural position ADR-0130 D4 reserves so a future + * `{ ref, integrity }` external segment stays an ADDITIVE key. ⛔ The shape is + * not re-derived here — a second declaration of one shape is the drift ADR-0116 + * exists about. A stack that ALREADY carries `packages` contributes those + * entries untouched: they are assembled bodies already, and re-assembling one + * would fold the composed stack's flattened collections onto a package that + * does not own them. * * A `packages` value that is not an array cannot be iterated; `defineStack` * rejects that shape, so it is reachable only via `strict: false` or a @@ -2394,19 +2654,61 @@ function selectManifest( * * @internal */ -function preservePackageEntries(stacks: ObjectStackDefinition[]): ArtifactPackageEntry[] { - const entries: ArtifactPackageEntry[] = []; +function preservePackageEntries(stacks: ObjectStackDefinition[]): ArtifactPackage[] { + const entries: ArtifactPackage[] = []; for (const stack of stacks) { const declared = (stack as Record).packages; if (Array.isArray(declared)) { - entries.push(...(declared as ArtifactPackageEntry[])); + entries.push(...(declared as ArtifactPackage[])); continue; } - if (stack.manifest) entries.push({ manifest: stack.manifest }); + if (stack.manifest) entries.push({ manifest: assemblePackageBody(stack) }); } return entries; } +/** + * Fold ONE input stack into the assembled package body the artifact carries + * (ADR-0130 D4; #14242 B). + * + * ## Why this is composition's job and nowhere else's + * + * Composition is the last moment at which per-package attribution EXISTS. The + * composed stack flattens every collection to the top level — that is what + * makes it one loadable stack — and a flattened `objects` array says nothing + * about which package each object came from. Reconstructing the split + * downstream (in `os build`, or at load) is not a harder version of this + * function; it is impossible, because the information is gone. So the assembly + * happens here, while both halves are still in hand. + * + * ## The shape, and why it is this one + * + * `{ ...manifest, ...collections }` — manifest fields flattened, then the + * stack's own collections written over them. That is not a choice made here: it + * is `AppPlugin`'s flatten (`{ ...bundle.manifest, ...bundle }`) and what + * `ObjectQL.registerApp` reads, stated as data instead of re-derived at three + * seams. {@link AssembledPackageBodySchema} is its declaration and + * {@link ObjectStackDefinitionSchema}'s `packages` key parses against it, so a + * body this function builds and a body the load path accepts cannot drift. + * + * ⚠️ ADDITIVE, like `'preserve'` itself: the composed stack keeps its flattened + * collections, so every consumer that reads the artifact's top level — the + * metadata service's artifact door among them — sees exactly what it saw + * before. What `packages` adds is per-package OWNERSHIP at registration, which + * is the whole of ADR-0130 D1: without it, a composed multi-package artifact + * registers N package records owning nothing at all. + * + * @internal + */ +function assemblePackageBody(stack: ObjectStackDefinition): AssembledPackageBody { + const source = stack as Record; + const body: Record = { ...(stack.manifest as Record | undefined) }; + for (const key of Object.keys(assembledPackageBodyShape())) { + if (source[key] !== undefined) body[key] = source[key]; + } + return body as AssembledPackageBody; +} + /** * Declaratively compose multiple stack definitions into a single unified stack. * @@ -2443,9 +2745,9 @@ function preservePackageEntries(stacks: ObjectStackDefinition[]): ArtifactPackag * // Merge strategy — fields from later stacks are shallow-merged * const combined = composeStacks([crm, todo], { objectConflict: 'merge' }); * - * // Preserve — one artifact carrying BOTH package identities (ADR-0130 D4) + * // Preserve — one artifact carrying BOTH packages, each assembled (ADR-0130 D4) * const artifact = composeStacks([crm, cpq], { manifest: 'preserve' }); - * artifact.packages; // [{ manifest: crmManifest }, { manifest: cpqManifest }] + * artifact.packages; // [{ manifest: { ...crmManifest, objects: [...] } }, …] * ``` */ export function composeStacks( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbcbaf470d..ae5159bb5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,19 @@ importers: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + examples/app-multi-package: + dependencies: + '@objectstack/spec': + specifier: workspace:* + version: link:../../packages/spec + devDependencies: + '@objectstack/cli': + specifier: workspace:* + version: link:../../packages/cli + typescript: + specifier: ^6.0.3 + version: 6.0.3 + examples/app-showcase: dependencies: '@modelcontextprotocol/sdk': @@ -1934,6 +1947,9 @@ importers: '@objectstack/example-crm': specifier: workspace:* version: link:../../../examples/app-crm + '@objectstack/example-multi-package': + specifier: workspace:* + version: link:../../../examples/app-multi-package '@objectstack/example-showcase': specifier: workspace:* version: link:../../../examples/app-showcase diff --git a/scripts/check-stack-collection-maps.mjs b/scripts/check-stack-collection-maps.mjs index 591ed0c8c9..a53ce66b4b 100644 --- a/scripts/check-stack-collection-maps.mjs +++ b/scripts/check-stack-collection-maps.mjs @@ -356,12 +356,51 @@ export function stackCollections(stackSource) { if (!options) return null; const shape = sliceBody(stackSource, '{', options.end + 1); if (!shape) return null; - return objectEntries(shape.body) + + // [#14439] The shape may SPREAD a named const rather than writing every key + // inline: ADR-0130 D4's assembled package body is built from the same + // collections the stack declares, so those collections were lifted into + // `STACK_DEFINITION_COLLECTIONS_SHAPE` and both surfaces read that one const + // (a second transcription is the drift ADR-0116 exists about). + // + // Resolved by NAME, in the same source, and an unresolvable spread returns + // `null` rather than "no collections". That direction matters: this gate's + // caller treats an empty set as a hard failure precisely because an empty set + // reconciles perfectly against every site, so a spread this function could not + // follow must reach that refusal instead of silently shrinking the set. + const bodies = [shape.body]; + for (const ident of spreadIdentifiers(shape.body)) { + const spread = sliceBody(stackSource, `const ${ident} = {`); + if (!spread) return null; + bodies.push(spread.body); + } + + return bodies + .flatMap((body) => objectEntries(body)) .filter(({ value }) => /^z\.array\(\s*[A-Za-z_$][\w$]*Schema\s*\)/.test(value)) .map(({ key }) => key) .filter((key) => !NON_COLLECTION_ARRAY_KEYS.has(key)); } +/** + * The identifiers an object literal body spreads (`...IDENT,`). + * + * Line-anchored on purpose: a `...` inside a nested value (a default object, a + * template) is not a shape spread, and matching one would send this gate + * looking for a const that does not exist and refusing a healthy tree. + * + * @param body - an object-literal body, as `sliceBody` returns it + * @returns the spread identifiers, in source order, without duplicates + */ +export function spreadIdentifiers(body) { + const found = []; + for (const line of body.split('\n')) { + const m = /^\s*\.\.\.([A-Za-z_$][\w$]*)\s*,?\s*$/.exec(line); + if (m && !found.includes(m[1])) found.push(m[1]); + } + return found; +} + // ─────────────────────────────────────────────────────────────────────────── // Reconciliation -- pure // ─────────────────────────────────────────────────────────────────────────── @@ -880,7 +919,14 @@ let selfTestReachedVerdict = false; function selfTest() { const failures = []; + // COUNTED, not transcribed. The pass line used to carry a hand-written + // number, and it had already drifted one below the real count by the time + // #14439 added three assertions to this block — a self-test that misreports + // how much it asserted is the same class of defect as the sites this gate + // reconciles. + let asserted = 0; const eq = (label, actual, expected) => { + asserted += 1; const a = JSON.stringify(actual); const e = JSON.stringify(expected); if (a !== e) failures.push(`${label}\n expected ${e}\n actual ${a}`); @@ -913,6 +959,37 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ stackCollections(stack).includes('packages'), false, ); + // [#14439] The spread form: the collections live in a named const the shape + // spreads in. Both directions are driven — resolved, and unresolvable. + const spreadStack = ` +const STACK_DEFINITION_COLLECTIONS_SHAPE = { + objects: z.array(ObjectSchema).optional().describe('Business Objects'), + // a commented-out collection must NOT count: + // ghosts: z.array(GhostSchema).optional(), + plugins: z.array(z.unknown()).optional(), + data: z.array(SeedSchema).optional(), +}; + +export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ + surface: 'this stack definition', +}, { + manifest: ManifestSchema.optional(), + packages: z.array(ArtifactPackageSchema).optional(), + ...STACK_DEFINITION_COLLECTIONS_SHAPE, +}).superRefine(gates)); +`; + eq('stackCollections() follows a spread into a named shape const', stackCollections(spreadStack), ['objects', 'data']); + eq( + 'stackCollections() REFUSES (null) when a spread names a const it cannot find', + stackCollections(spreadStack.replace('const STACK_DEFINITION_COLLECTIONS_SHAPE = {', 'const SOMETHING_ELSE = {')), + null, + ); + eq( + 'spreadIdentifiers() takes only line-anchored spreads', + spreadIdentifiers(' a: 1,\n ...SHAPE,\n b: { ...inner },\n ...OTHER\n'), + ['SHAPE', 'OTHER'], + ); + eq('stackCollections() returns null when the anchor is gone', stackCollections('export const Other = 1;'), null); eq( 'stackCollections() returns null when the shape argument is missing', @@ -997,7 +1074,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ for (const f of failures) console.error(` • ${f}\n`); return 1; } - console.log('✓ check-stack-collection-maps --self-test: 16 assertions over synthetic sources'); + console.log(`✓ check-stack-collection-maps --self-test: ${asserted} assertions over synthetic sources`); selfTestReachedVerdict = true; return 0; } diff --git a/scripts/i18n-coverage-baseline.json b/scripts/i18n-coverage-baseline.json index 566be636bb..285cc3e550 100644 --- a/scripts/i18n-coverage-baseline.json +++ b/scripts/i18n-coverage-baseline.json @@ -1,5 +1,6 @@ { "examples/app-crm/objectstack.config.ts": 89, + "examples/app-multi-package/objectstack.config.ts": 0, "examples/app-showcase/objectstack.config.ts": 393, "examples/app-todo/objectstack.config.ts": 120, "packages/platform-objects/scripts/i18n-extract.config.ts": 0,