Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/multi-package-artifact-build.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) The narrowing has no
FROM → TO for an author to apply: no path produced a `packages[]` artifact
before this change, so no authored or stored metadata carries the refused
shape. The authoring spelling that replaces it is not a rename of a key but
the ordinary `defineStack` + `composeStacks` route the artifact is compiled
from. -->
82 changes: 82 additions & 0 deletions content/docs/getting-started/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions examples/app-multi-package/README.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions examples/app-multi-package/objectstack.config.ts
Original file line number Diff line number Diff line change
@@ -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' });
27 changes: 27 additions & 0 deletions examples/app-multi-package/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
51 changes: 51 additions & 0 deletions examples/app-multi-package/src/packages/core/index.ts
Original file line number Diff line number Diff line change
@@ -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' },
],
},
],
});
61 changes: 61 additions & 0 deletions examples/app-multi-package/src/packages/orders/index.ts
Original file line number Diff line number Diff line change
@@ -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' },
},
},
],
});
Loading
Loading