Skip to content

Commit 7085f90

Browse files
hotlongclaude
andauthored
feat(cli+spec): compile a project of N packages into one packages[] artifact, with the assembled package body declared (ADR-0130 D4 producer, #14242 B) (#14513)
* wip(spec): declare the assembled package body (ADR-0130 D4, #14242 B) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * wip(cli+objectql): full-parse load gate, per-package lower and rules Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * wip(examples+qa): app-multi-package fixture and its dogfood boot Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * wip(tests): seam pins for the assembled package body Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * docs+changeset: a project is a multi-package artifact Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * test(qa): assert the server writable verdict on both artifact packages (#14430 landed) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * fix(tooling): follow the stack shape spread; baseline the new example Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * fix(examples): type the multi-package fixture against spec SOURCE Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * fix(spec): keep the assembled-body aliases key-precise but element-loose — the per-key mapped alias emitted the collections shape a second time and OOM'd the type-check-debt re-measure (#14439) `AssembledPackageBody` / `AssembledPackageBodyParsed` referenced `(typeof STACK_DEFINITION_COLLECTIONS_SHAPE)[K]` per collection key. Because those aliases are exported, the declaration emit wrote the shape const into `stack.zod-*.d.ts` a second time (21,443 lines beside the 42,449 the stack schema already inlines), and every consumer program re-inferred all ~35 collection input/output types once more. `Type Check · debt ledger` on the PR went red: the `qa/http-conformance` TEST_DEBT re-measure exceeded the 4096 MB ceiling the gate pins as CI's, while the merge-queue run on the base passed the same step. Reproduced locally (exit 3) on b4b9732. The aliases now keep the DERIVED key set (`AssembledPackageBodyKey`, still read off `COMPOSE_KEY_DISPOSITIONS`) and type each collection as `unknown`. Nothing exported references the shape const any more, so the second copy is not emitted and the per-key inference does not run. `assembledPackageBodyShape()` keeps its `Pick<typeof STACK_DEFINITION_COLLECTIONS_SHAPE, K>` return type (internal, not emitted), so a disposition key missing from the collections shape is still a compile error, and the RUNTIME schema still carries every collection's full declaration — refusals are unchanged. One spec pin narrows its element access at the point of use, as every reader of an assembled body already does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * fix(spec): annotate the assembled package body with a STRUCTURAL type so the stack schema's printed declaration carries no named alias (#14439) A named type alias inside `ObjectStackDefinitionSchema`'s printed type (`manifest: z.ZodType<AssembledPackageBodyParsed, AssembledPackageBody>` on the `packages` element) can only be IMPORTED by the declaration bundler, never inlined. `system/environment-artifact.zod.ts` embeds the stack type, so the bundler turned `stack.zod` into a shared chunk and gave the `environment-artifact` chunk an import edge into it: every consumer of `@objectstack/spec/system` started loading the entire stack schema declaration it never loaded before. Measured on the `qa/http-conformance` TEST_DEBT re-measure program, same shape as the gate builds it, 8 GB cap so the peak is measured rather than hit: base `53d36892` 691,580 lines of definitions / 4,473,321 K heap; PR head `3878a582` 734,202 (+42,622 — the size of the stack schema's declaration) / 4,875,249 K — over the 4096 MB ceiling `scripts/check-type-check-coverage.mjs` pins as CI's, which is the red `Type Check · debt ledger` lane on both earlier pushes. `AssembledPackageBodySchema` is now annotated `z.ZodType<Record<string, unknown>, Record<string, unknown>>` and the two ADR-0122 aliases are derived FROM the schema (`z.input` / `z.infer`), so nothing named can re-enter the stack schema's printed type. The runtime schema is unchanged: manifest fields plus every collection, key set still derived from `COMPOSE_KEY_DISPOSITIONS`. What consumers lose is static field typing inside an assembled body, which the PR's readers (`compile.ts`, `artifact-packages.ts`) never relied on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aaa4e65 commit 7085f90

27 files changed

Lines changed: 2213 additions & 534 deletions
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": minor
4+
"@objectstack/objectql": patch
5+
---
6+
7+
feat(cli,spec): compile a project of N packages into one `packages[]` artifact, with the assembled package body declared (#14439, closes #14242)
8+
9+
ADR-0130 D4's producer side. A product can now be split into modules without
10+
renaming a single object: N ordinary `defineStack` packages, one project-level
11+
`composeStacks([...], { manifest: 'preserve' })`, one compiled artifact that
12+
carries them all.
13+
14+
**`@objectstack/spec` — the assembled package body has its own declaration.**
15+
`ArtifactPackageEntrySchema` describes a package at AUTHORING time, where
16+
`manifest.objects` is an array of glob patterns. What the ADR-0130 load path
17+
registers is an ASSEMBLED body whose `objects` are definitions, so a full parse
18+
of a real artifact entry was refused (`manifest.objects.0: expected string,
19+
received object`) and the loader could gate the wrapper only. #14242 recorded
20+
three roads and the maintainer took **B** (2026-09-02): the assembled stage is
21+
now declared as `AssembledPackageBodySchema`, carried by `ArtifactPackageSchema`,
22+
and `ObjectStackDefinitionSchema.packages` refers to that. ⛔ Road C — widening
23+
`ManifestSchema.objects` into a union of both spellings — was rejected by name:
24+
a union that accepts both stages makes neither stage checkable.
25+
26+
The body's collection keys are DERIVED from the same table the stack schema's
27+
composition rules come from, never transcribed, so a metadata family added to
28+
the stack reaches package bodies on the day it lands.
29+
30+
`composeStacks(..., { manifest: 'preserve' })` now folds each input stack's own
31+
metadata onto its manifest instead of preserving the identity alone.
32+
Composition is the last point at which per-package attribution exists — the
33+
composed stack flattens every collection to the top level — so a package list
34+
built without it names N packages that own nothing.
35+
36+
**Accept-set change, in one direction.** A `packages[]` entry whose body carries
37+
authoring-time glob patterns where the assembled stage carries definitions is
38+
now REFUSED — at `defineStack`, at `os build`, and at load. Nothing in the field
39+
produces that shape: `packages[]` had no producer at all before this change.
40+
Write the package's metadata in its own `defineStack` and let composition
41+
assemble it.
42+
43+
**`@objectstack/cli``os build` / `os compile` read `packages[]`.** When the
44+
loaded definition carries one, the same lowering walks every package body (an
45+
un-lowered handler is a `function` value that `JSON.stringify` drops without a
46+
word, and a `packages`-carrying artifact is registered THROUGH that list), the
47+
same author-time rule table runs once per package, and one artifact JSON is
48+
written whose `packages[i]` are assembled bodies. A single-package project is
49+
untouched: no `packages` key is minted, and neither new branch runs.
50+
51+
**`@objectstack/objectql` — the load gate parses the whole entry.** The
52+
wrapper-only gate was a narrow accommodation of the mismatch above; with the
53+
assembled stage declared, a malformed package body is refused at the seam that
54+
would otherwise register it owning nothing.
55+
56+
<!-- adr-0087: not-required (no-migration-prescription) The narrowing has no
57+
FROM → TO for an author to apply: no path produced a `packages[]` artifact
58+
before this change, so no authored or stored metadata carries the refused
59+
shape. The authoring spelling that replaces it is not a rename of a key but
60+
the ordinary `defineStack` + `composeStacks` route the artifact is compiled
61+
from. -->

content/docs/getting-started/examples.mdx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,88 @@ OS_ARTIFACT_PATH=./dist/objectstack.json os start
352352

353353
---
354354

355+
## A project is a multi-package artifact
356+
357+
`composeStacks` above **flattens**: N stacks go in, one package identity comes
358+
out, and the other N−1 are discarded. That is right when several stacks are
359+
assembled into one published package — and wrong when a product wants internal
360+
module boundaries, because flattening is exactly the boundary being asked for.
361+
362+
The alternative costs no object rename: **compile per package, ship one
363+
artifact, keep N package manifests inside it.** Pass `manifest: 'preserve'`
364+
and every input keeps its identity.
365+
366+
```typescript
367+
// objectstack.config.ts — the PROJECT
368+
import { composeStacks } from '@objectstack/spec';
369+
import coreStack from './src/packages/core/index.js';
370+
import ordersStack from './src/packages/orders/index.js';
371+
372+
export default composeStacks([ordersStack, coreStack], { manifest: 'preserve' });
373+
```
374+
375+
Each input is an ordinary `defineStack` package, legal on its own:
376+
377+
```typescript
378+
// src/packages/orders/index.ts — a MODULE of the same artifact
379+
import { defineStack } from '@objectstack/spec';
380+
381+
export default defineStack({
382+
manifest: {
383+
id: 'com.example.multi.orders',
384+
name: 'Orders',
385+
namespace: 'crm', // the SAME namespace as the app package
386+
version: '1.0.0',
387+
type: 'module',
388+
dependencies: { 'com.example.multi.core': '^1.0.0' },
389+
},
390+
objects: [
391+
{
392+
name: 'crm_order', // no rename: still `crm_*`
393+
label: 'Order',
394+
sharingModel: 'private',
395+
fields: {
396+
name: { type: 'text', label: 'Order Number' },
397+
account: { type: 'lookup', label: 'Account', reference: 'crm_account' },
398+
},
399+
},
400+
],
401+
});
402+
```
403+
404+
`os build` compiles that project into **one** `dist/objectstack.json` carrying a
405+
`packages[]` list — one entry per package, each holding that package's own
406+
manifest fields and the metadata it owns. `os dev` boots the same shape straight
407+
from source, and `GET /api/v1/packages` on a running instance lists every
408+
package in the artifact.
409+
410+
The rules worth knowing before you split a product this way:
411+
412+
- **One `type: 'app'` package.** The consumer installs and opens one thing; the
413+
rest are `type: 'module'` (or `'plugin'`) shipped inside it. Each package is
414+
still held to the single-app rule on its own.
415+
- **Share the namespace deliberately.** Two packages in one artifact may own the
416+
same namespace — that is what buys the split without renaming, since an
417+
object's `name` is also its table, REST path, formula token and saved-view
418+
key. Two packages defining the same object *name* are refused.
419+
- **Declare `dependencies` when one package extends another.** Registration
420+
order inside the artifact is resolved topologically from those declarations,
421+
never from the order of the array — a module that adds fields to another
422+
package's object must register after it, and getting that wrong is silent.
423+
- **Cross-package lookups are fine; cross-package navigation is not.** A field
424+
may reference an object another package owns. An app's own `navigation` may
425+
not point outside its package — inject into another package's app with
426+
`navigationContributions` instead.
427+
- **One artifact, one version.** Everything inside ships, installs and upgrades
428+
together; you cannot hot-fix one module on its own. A module that needs its
429+
own release cadence belongs in its own artifact.
430+
431+
A worked example lives in
432+
[`examples/app-multi-package`](https://github.com/objectstack-ai/objectstack/tree/main/examples/app-multi-package):
433+
two packages, one namespace, one artifact.
434+
435+
---
436+
355437
## Project Structure Conventions
356438

357439
All examples follow the same pattern. The recommended project layout — used by
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# app-multi-package — one artifact, two packages
2+
3+
The producer-side fixture for [ADR-0130](../../docs/adr/0130-release-artifact-as-co-ownership-boundary.md)
4+
D4: a project whose release artifact carries **two** packages that share one
5+
namespace, so the product splits into modules without renaming a single object.
6+
7+
| package | type | namespace | owns |
8+
| --- | --- | --- | --- |
9+
| `com.example.multi.core` | `app` | `crm` | `crm_account`, the `multi_crm` app |
10+
| `com.example.multi.orders` | `module` | `crm` | `crm_order` (lookup → `crm_account`) |
11+
12+
```bash
13+
pnpm --filter @objectstack/example-multi-package build # → dist/objectstack.json with packages[]
14+
pnpm --filter @objectstack/example-multi-package dev # boots the same shape from source
15+
```
16+
17+
The artifact's `packages[]` is what `ObjectQL.registerApp` iterates — each entry
18+
is one package ASSEMBLED (manifest fields plus the collections that package
19+
owns), declared by `AssembledPackageBodySchema`. `GET /api/v1/packages` on a
20+
booted instance lists both rows.
21+
22+
`orders` carries **no `scope` key** on purpose; the App's navigation lives with
23+
the App package because a package's own navigation may not point at a foreign
24+
object, while cross-package lookups (which `crm_order.account` is) are accepted.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { composeStacks } from '@objectstack/spec';
4+
5+
import coreStack from './src/packages/core/index.js';
6+
import ordersStack from './src/packages/orders/index.js';
7+
8+
/**
9+
* A PROJECT of N packages, compiled into ONE release artifact (ADR-0130 D4).
10+
*
11+
* ## The authoring shape — there is only one, deliberately
12+
*
13+
* N ordinary `defineStack` packages, each legal on its own (each still under
14+
* ADR-0019's single-app rule), plus this project-level config composing them
15+
* with `manifest: 'preserve'`. ⛔ No second spelling exists and none should be
16+
* invented: `preserve` is the only thing that separates "one artifact carrying
17+
* N packages" from the pick-one composition every other `manifest` strategy
18+
* performs, which flattens N package identities down to one and is exactly the
19+
* loss ADR-0130 was written about.
20+
*
21+
* ## What `preserve` produces
22+
*
23+
* The composed stack the platform has always produced — every collection
24+
* flattened to the top level — PLUS `packages[]`, one entry per input stack,
25+
* each carrying that package ASSEMBLED (its manifest fields with the
26+
* collections it owns written over them). The flattened top level is what the
27+
* metadata service reads; `packages[]` is what `ObjectQL.registerApp`
28+
* registers, package by package, in dependency-topological order — which is
29+
* where per-package ownership comes from. Without the list, a two-package
30+
* artifact would install two package records owning nothing at all.
31+
*
32+
* ## Why the module is listed FIRST
33+
*
34+
* Deliberately backwards, and it is a property this fixture holds rather than
35+
* an accident: `orders` declares `dependencies: { 'com.example.multi.core' }`,
36+
* and the load path sorts `packages[]` through `resolvePluginOrder` — the
37+
* platform's ONE topological sorter (ADR-0130 D5, ADR-0116) — so `core`
38+
* registers first whatever slot it occupies here. An artifact that only worked
39+
* because someone put the packages in the right order would be the failure
40+
* ADR-0116 exists about, and it fails SILENTLY: nothing throws, the extension
41+
* simply does not take effect.
42+
*
43+
* The order also settles the ARTIFACT's own identity: `preserve` is additive,
44+
* so the singular `manifest` is still picked by the default `'last'` rule and
45+
* the artifact identifies as its consumer-facing App (ADR-0019 D1), not as one
46+
* of its modules.
47+
*
48+
* `os build` compiles this file into one `dist/objectstack.json`; `os dev`
49+
* boots the same shape straight from source.
50+
*/
51+
export default composeStacks([ordersStack, coreStack], { manifest: 'preserve' });
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "@objectstack/example-multi-package",
3+
"version": "0.0.1",
4+
"description": "One release artifact carrying TWO packages that share a namespace (ADR-0130 D4) — the producer-side fixture for `packages[]`",
5+
"license": "Apache-2.0",
6+
"private": true,
7+
"main": "./objectstack.config.ts",
8+
"types": "./objectstack.config.ts",
9+
"exports": {
10+
".": "./objectstack.config.ts",
11+
"./objectstack.config": "./objectstack.config.ts"
12+
},
13+
"scripts": {
14+
"dev": "objectstack dev",
15+
"start": "objectstack start",
16+
"build": "objectstack build",
17+
"validate": "objectstack validate",
18+
"typecheck": "tsc --noEmit"
19+
},
20+
"dependencies": {
21+
"@objectstack/spec": "workspace:*"
22+
},
23+
"devDependencies": {
24+
"@objectstack/cli": "workspace:*",
25+
"typescript": "^6.0.3"
26+
}
27+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { defineStack } from '@objectstack/spec';
4+
5+
/**
6+
* `com.example.multi.core` — the consumer-facing App of this artifact
7+
* (ADR-0019 D1: the App is the only thing a consumer installs and opens).
8+
*
9+
* It owns `crm_account` and the one app. Everything else this product ships is
10+
* a MODULE inside the same artifact, sharing this package's namespace so a
11+
* split costs no object rename (ADR-0129 D1–D2: the object `name` IS the table
12+
* name, the REST path, the formula token and the saved-view key).
13+
*/
14+
export default defineStack({
15+
manifest: {
16+
id: 'com.example.multi.core',
17+
name: 'Multi-Package Core',
18+
namespace: 'crm',
19+
version: '1.0.0',
20+
type: 'app',
21+
description: 'The App half of a two-package release artifact (ADR-0130 D4)',
22+
engines: { protocol: '^17' },
23+
},
24+
25+
objects: [
26+
{
27+
name: 'crm_account',
28+
label: 'Account',
29+
pluralLabel: 'Accounts',
30+
// ADR-0090 D1 — the org-wide default is an authored decision, never an
31+
// accident: the runtime fails closed to 'private', and a rule refuses the
32+
// silence rather than letting the fallback stand in for a choice.
33+
sharingModel: 'private',
34+
fields: {
35+
name: { name: 'name', type: 'text', label: 'Account Name', required: true },
36+
industry: { name: 'industry', type: 'text', label: 'Industry' },
37+
},
38+
},
39+
],
40+
41+
apps: [
42+
{
43+
name: 'multi_crm',
44+
label: 'Multi-Package CRM',
45+
description: 'Accounts, plus whatever modules this artifact delivers alongside',
46+
navigation: [
47+
{ id: 'nav_accounts', type: 'object', objectName: 'crm_account', label: 'Accounts', icon: 'building' },
48+
],
49+
},
50+
],
51+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { defineStack } from '@objectstack/spec';
4+
5+
/**
6+
* `com.example.multi.orders` — a MODULE of the same artifact (ADR-0019 D2's
7+
* "internal contribution" tier: shipped inside an App, never browsed or
8+
* installed on its own).
9+
*
10+
* Two properties this fixture exists to hold, both load-bearing:
11+
*
12+
* - It declares the **same namespace** as the App package. That is what
13+
* ADR-0130 D1 buys: co-ownership of one namespace inside one artifact, so
14+
* `crm_order` keeps its name instead of becoming `orders_order`.
15+
* - It carries **no `scope` key**. `ManifestSchema.scope` defaults to
16+
* `'project'`, so a scope-less module is the row that separates the server's
17+
* writability verdict from a client-side `scope !== 'project'` heuristic
18+
* (ADR-0070 D2 / ADR-0130 Consequences row 6).
19+
*
20+
* `crm_order.account` looks up an object this package does NOT own. That is
21+
* legal and is the whole point of the split: cross-package lookups are accepted
22+
* (ADR-0130 §1.5), while a package's own app navigation pointing at a foreign
23+
* object is not — which is why the navigation lives with the App package.
24+
*/
25+
export default defineStack({
26+
manifest: {
27+
id: 'com.example.multi.orders',
28+
name: 'Multi-Package Orders',
29+
namespace: 'crm',
30+
version: '1.0.0',
31+
type: 'module',
32+
description: 'The Module half of a two-package release artifact (ADR-0130 D4)',
33+
engines: { protocol: '^17' },
34+
// The App package this module extends. `resolveArtifactPackageOrder` reads
35+
// it as the topological edge that registers core BEFORE orders (ADR-0130
36+
// D5, ADR-0116's one sorter) — the array order below is not what decides.
37+
dependencies: { 'com.example.multi.core': '^1.0.0' },
38+
},
39+
40+
objects: [
41+
{
42+
name: 'crm_order',
43+
label: 'Order',
44+
pluralLabel: 'Orders',
45+
// ADR-0090 D1 — the org-wide default is an authored decision, never an
46+
// accident: the runtime fails closed to 'private', and a rule refuses the
47+
// silence rather than letting the fallback stand in for a choice.
48+
sharingModel: 'private',
49+
fields: {
50+
name: { name: 'name', type: 'text', label: 'Order Number', required: true },
51+
account: {
52+
name: 'account',
53+
type: 'lookup',
54+
label: 'Account',
55+
reference: 'crm_account',
56+
},
57+
amount: { name: 'amount', type: 'currency', label: 'Amount' },
58+
},
59+
},
60+
],
61+
});

0 commit comments

Comments
 (0)