From 968c6700948369a96f8c93c0b780cece05e76b38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:48:43 +0000 Subject: [PATCH 1/2] feat(objectql,core): a declared dependency co-owns its parent's namespace, and both hydrates sort by it (ADR-0141 D5) The install gate's co-owner set gains a second source beside the artifact: a package declaring an installed X that holds its namespace, a sitting owner declaring the incoming package, and a sibling under one declared parent. The object-name check reads the same set and words its refusal per source. The sys_packages hydrate and the install-local rehydrate order their batch by manifest.dependencies through resolvePluginOrder; a cycle keeps source order for its members and is logged. Docs and changeset included. Also carries the D2 review notes: an undecided union with a readable installed version reports unreadable-range, and install-local no longer counts the artifact wrapper's id as delivered. Claude-Session: https://claude.ai/code/session_01NndsH9Qm7k6AS2i2fWbjNA Co-authored-by: Claude --- .../25792-dependency-declared-co-ownership.md | 34 ++++ content/docs/deployment/app-dependencies.mdx | 46 ++++- content/docs/protocol/kernel/plugin-spec.mdx | 16 +- ...e-install-local-dependency-missing.test.ts | 12 ++ .../src/marketplace-install-local-plugin.ts | 46 ++++- ...lace-install-local-rehydrate-order.test.ts | 97 +++++++++++ packages/core/src/artifact-packages.ts | 18 ++ packages/core/src/hydration-order.test.ts | 79 +++++++++ packages/core/src/hydration-order.ts | 156 +++++++++++++++++ packages/core/src/index.ts | 5 + .../protocol-handshake-dependencies.test.ts | 18 +- .../metadata-core/src/protocol-handshake.ts | 18 +- .../registry-artifact-co-ownership.test.ts | 158 ++++++++++++++++++ packages/objectql/src/registry.ts | 120 +++++++++++-- .../src/hydration-dependency-order.test.ts | 69 ++++++++ .../services/service-package/src/index.ts | 28 +++- 16 files changed, 880 insertions(+), 40 deletions(-) create mode 100644 .changeset/25792-dependency-declared-co-ownership.md create mode 100644 packages/cloud-connection/src/marketplace-install-local-rehydrate-order.test.ts create mode 100644 packages/core/src/hydration-order.test.ts create mode 100644 packages/core/src/hydration-order.ts create mode 100644 packages/services/service-package/src/hydration-dependency-order.test.ts diff --git a/.changeset/25792-dependency-declared-co-ownership.md b/.changeset/25792-dependency-declared-co-ownership.md new file mode 100644 index 00000000000..988ae0600e4 --- /dev/null +++ b/.changeset/25792-dependency-declared-co-ownership.md @@ -0,0 +1,34 @@ +--- +'@objectstack/core': patch +'@objectstack/objectql': patch +'@objectstack/service-package': patch +'@objectstack/cloud-connection': patch +'@objectstack/metadata-core': patch +--- + +feat(install): a package that declares an installed app in `manifest.dependencies` may share that app's namespace, and a boot hydrates installed packages in dependency order (ADR-0141 D5) + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +**Namespace co-ownership by declaration.** The install gate (`SchemaRegistry.installPackage`) used to admit packages into one namespace only when one release artifact delivered them together. A module shipped as its own artifact that uses its app's namespace was refused `422 NAMESPACE_CONFLICT` beside the installed app, whatever it declared. Now an installed package P co-owns namespace N with: + +- an installed X that P declares in `manifest.dependencies`, when both use N; +- a sitting owner of N that declares P, which is the same relation read from the other end, so an upgrade or reinstall of the app beside its modules is admitted; +- a sibling S, when S and P each declare one installed package that holds N (two modules of one app, neither declaring the other). + +Only direct declarations count. The shared parent must be installed and must hold N itself. + +The object-name check reads the same co-owner set. A co-owner by declaration that defines an object name another co-owner already owns is refused `422 DUPLICATE_ARTIFACT_OBJECT_NAME` before any DDL. Examples: a module redefining its app's object, a sibling redefining the other sibling's object, or an app upgrade newly defining a name one of its modules owns. The message is worded per source (same artifact, declared dependency, sibling). `NAMESPACE_CONFLICT` now names the declaration as the fix for a package that extends the app holding the namespace. + +**Hydrate order.** The `sys_packages` hydrate and the install-local `kernel:ready` rehydrate used to register in source-listing order. Now they sort their batch by `manifest.dependencies`, with declared ids as optional edges, through `resolvePluginOrder`. Before this, a module that hydrated before the app whose namespace it shares could be refused and stay absent for the process lifetime. In the install-local rehydrate, each ledger entry is one node, so a declared id resolves to the entry that carries that package. A dependency cycle never stops a hydrate: its members keep source order and an error names them. + +**BREAKING** (an install-acceptance narrowing of the unreleased ADR-0141 D2 check) at `POST /api/v1/marketplace/install-local`. A multi-package artifact's own wrapper id no longer counts as delivered unless one of its packages carries it. A module declaring the wrapper id is judged like any other entry, because it would be `absent` on its first standalone install. `DeclaredDependencyCheck.deliveredIds` is removed with it. Also, an undecided range union whose installed version is readable is now reported `found: 'unreadable-range'` instead of `'unreadable-version'`; for example, `<5 || latest` against `7.0.0`. + +**What changes for you.** + +- A module that extends an app and uses its namespace declares the app in `manifest.dependencies`, and then installs beside it as its own artifact. Two modules of one app need not declare each other. +- Every package of the app's own artifact that uses the namespace should declare the app too. +- New exports: `@objectstack/core` exports `orderHydrationBatch` and `declaredDependencyIds`. diff --git a/content/docs/deployment/app-dependencies.mdx b/content/docs/deployment/app-dependencies.mdx index 673beb9aaa6..3e17c51e125 100644 --- a/content/docs/deployment/app-dependencies.mdx +++ b/content/docs/deployment/app-dependencies.mdx @@ -40,9 +40,12 @@ The declaration does two things: reference, not the data: users still see the other app's records under that app's own permission sets and sharing rules. - **It is checked before install** — below. +- **It lets your package share the depended-on app's namespace** — below. Every entry is required; there is no optional arm. Declare only the packages -your app actually references. +your app actually references. Two modules of one app need not declare each +other: each declares the app, and that is enough for both to install, in either +order, and to share the app's namespace. ## The version-range grammar @@ -125,7 +128,11 @@ The check runs at those install acts only. It does not run: - **at boot.** A restart re-registers what an install already admitted, while the registry is still filling; it never refuses a package for a dependency it - has not seen yet. + has not seen yet. It does register them in dependency order: the stored + packages, and the apps installed through `os package install`, are each sorted + so a package registers after the packages it declares. Two packages that + declare each other are a cycle: they register in stored order for that boot, + and the server logs an error naming them. - **at `PATCH /api/v1/packages/:id`.** Editing an authored package's `dependencies` after install is applied as given; an entry added there is judged when the package is next installed. @@ -134,12 +141,35 @@ The check runs at those install acts only. It does not run: ## Sharing a namespace with the app you depend on -Each package's namespace must still be its own across artifacts: a package that -uses the namespace of the app it depends on is refused at install with -`NAMESPACE_CONFLICT`, exactly as before. Inside one artifact, packages may share -a namespace ([ADR-0130](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0130-release-artifact-as-co-ownership-boundary.md)). -Dependency-declared namespace co-ownership across artifacts (ADR-0141 D5) is -not delivered yet. +A namespace is otherwise one package's: a package using a namespace another +installed package holds is refused at install with `NAMESPACE_CONFLICT`. Two +relations make packages co-owners of one namespace instead: + +- **the same artifact** delivers them ([ADR-0130](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0130-release-artifact-as-co-ownership-boundary.md)); or +- **a declared dependency**: your package declares an installed app that uses the + same namespace — or another installed package that declares that same app + uses it too (two modules of one app). + +So a module that ships as its own artifact, declares `app.objectstack.hotcrm` +and uses `crm` installs beside HotCRM, and a second such module installs beside +both. The relation is one-way — the app declares nothing — and it is read from +either side, so upgrading or reinstalling the app beside its modules is +admitted too. A package that shares the namespace without declaring the app is +still refused, and the fix the error names is the declaration. + +Sharing a namespace never means sharing an object name. A package defining an +object another co-owner already defines — your module redefining +`crm_account`, two modules both defining `crm_invoice`, or an app upgrade newly +defining a name one of its modules owns — is refused at install, before any +table is touched, with `422 DUPLICATE_ARTIFACT_OBJECT_NAME` naming both +packages and the object. Rename the object, or `extend` the other package's +object instead of owning a second one. + +Every package of the depended-on app's own artifact that uses the namespace +should declare the app too (inside the artifact it costs nothing: it is judged +for presence only). The platform does not record which artifact an installed +package came from, so an undeclared companion is a sitting owner your module +cannot co-own with. ## Build-time validation cannot see a dependency's objects diff --git a/content/docs/protocol/kernel/plugin-spec.mdx b/content/docs/protocol/kernel/plugin-spec.mdx index 83dbde93b14..26a71f2908b 100644 --- a/content/docs/protocol/kernel/plugin-spec.mdx +++ b/content/docs/protocol/kernel/plugin-spec.mdx @@ -469,12 +469,16 @@ Use for large dependencies (React, Vue) that should be shared across plugins. **The kernel does not check these range strings at plugin load — installing a package -does.** `manifest.dependencies` has three consumers. Two read `Object.keys(...)` only — the -ids, never the ranges: `resolveArtifactPackageOrder` (`packages/core/src/artifact-packages.ts`) -hands the ids to `resolvePluginOrder` as `optionalDependencies`, so an id naming a sibling -package in the same artifact orders ahead of it and an id naming anything else is skipped; -and `resolveWritePackageScope` (`packages/metadata-protocol/src/protocol.ts`) walks the ids -transitively for the write-scope gate. The third is the pre-install dependency check every +does.** `manifest.dependencies` has five consumers. Four read the ids only, never the ranges: +`resolveArtifactPackageOrder` (`packages/core/src/artifact-packages.ts`) hands the ids to +`resolvePluginOrder` as `optionalDependencies`, so an id naming a sibling package in the same +artifact orders ahead of it and an id naming anything else is skipped; +`resolveWritePackageScope` (`packages/metadata-protocol/src/protocol.ts`) walks the ids +transitively for the write-scope gate; the install gate (`SchemaRegistry.installPackage`, +ADR-0141 D5) admits a package that declares an installed app into that app's namespace, and +two packages declaring one such app into it beside each other; and both boot hydrates sort +their batch with the same optional edges (`orderHydrationBatch`, +`packages/core/src/hydration-order.ts`). The fifth is the pre-install dependency check every install door runs (ADR-0141 D2): each entry must name a package installed inside its range, or one the same artifact delivers, or the install is refused `422 DEPENDENCY_MISSING`. That check reads ranges with `rangeAdmitsVersion` (`packages/metadata-core/src/protocol-handshake.ts`), diff --git a/packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts b/packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts index 70f33af4582..62945bdff20 100644 --- a/packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts @@ -196,6 +196,18 @@ describe('POST install-local: a multi-package artifact judges each entry, presen expect(res.payload?.error?.code).not.toBe('DEPENDENCY_MISSING'); }); + it('does not count the artifact wrapper\'s own id as delivered when it names no package', async () => { + // `com.example.multi` is the wrapper's id, not a package the artifact + // carries: a module declaring it would pass here and be `absent` on a + // later standalone install, so it is judged like any other entry. + const h = await mount(dir); + expectDependencyRefusal( + await h.install({ manifest: artifact({ 'com.example.multi': '^1.0.0' }) }), + [{ packageId: 'com.example.multi.orders', dependency: 'com.example.multi', range: '^1.0.0', found: 'absent' }], + ); + expectNothingWritten(h); + }); + it('still judges an entry naming a package outside the artifact', async () => { const h = await mount(dir); expectDependencyRefusal( diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 72271f58175..fb2b90bff0e 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -150,6 +150,11 @@ import { // branded and quiet, every other rejection is the outage it is. classifyAdmissionTenancyPosture, type TenancyPostureSource, + // [ADR-0141 D5] The rehydrate's dependency order — the sorter, and the + // readings of a package's id and its declared dependencies it sorts by. + orderHydrationBatch, + artifactPackageId, + declaredDependencyIds, } from '@objectstack/core'; import { resolveTenancyPosture, @@ -913,7 +918,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { return; } - for (const entry of entries) { + for (const entry of this.inDependencyOrder(ctx, entries)) { try { // [#21762] ADR-0087 D1: the handshake runs "before loading a // package's metadata", and a rehydrate IS a load. An entry whose @@ -983,6 +988,39 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } }; + /** + * [ADR-0141 D5] The ledger's entries in `manifest.dependencies` order, not + * the order the directory lists them in: a standalone module read before + * the artifact carrying its parent met the namespace gate — and the + * `kernel:ready` seals — with that parent absent. Each entry is one node + * (an artifact may carry several packages), so a declared id resolves to + * the entry that carries that package and an id declared between packages + * of one entry is no edge. A cycle between entries keeps listed order for + * its members and is reported by name; it never stops the rehydrate. + */ + private inDependencyOrder(ctx: PluginContext, entries: InstalledManifestEntry[]): InstalledManifestEntry[] { + const { ordered, cycles } = orderHydrationBatch(entries.map((entry) => { + const bodies: unknown[] = Array.isArray(entry.manifest?.packages) + ? entry.manifest.packages.map((p: { manifest?: unknown } | null) => p?.manifest) + : [entry.manifest]; + return { + item: entry, + label: entry.manifestId, + packageIds: bodies.map((body) => artifactPackageId(body)).filter((id): id is string => id !== undefined), + declaredIds: bodies.flatMap((body) => declaredDependencyIds(body)), + }; + })); + for (const members of cycles) { + ctx.logger?.error?.( + `[MarketplaceInstallLocal] installed apps ${members.map((m) => `'${m}'`).join(', ')} declare each other in a ` + + 'dependency cycle (manifest.dependencies), so no order puts each after the packages it declares. They ' + + 'rehydrate in listed order for this boot (ADR-0141 D5); a namespace they share may be refused. Remove the ' + + 'declaration that closes the cycle and reinstall.', + ); + } + return ordered; + } + /** * Rehydrate-time sample-data healing (the "installed app, empty database" * repair). Runs the bundled seed datasets iff: @@ -1547,8 +1585,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // nothing. Every package this act delivers is judged on its OWN // `manifest.dependencies`: the entries of `packages[]` for a // multi-package artifact, the manifest itself otherwise. A - // dependency the same artifact delivers (an entry, or the - // artifact's own identity) is judged for presence only; any other + // dependency the same artifact delivers — one of those packages; + // never the wrapper's own id, which names no package unless one of + // them carries it — is judged for presence only; any other // must be installed in this runtime's registry at a version inside // its range. The refusal is the other install doors' answer, from // the one shared helper, 422 on both branches: a dependency this @@ -1568,7 +1607,6 @@ export class MarketplaceInstallLocalPlugin implements Plugin { try { registry = ctx.getService('objectql')?.registry; } catch { /* no data engine — nothing installed */ } const unmet = checkDeclaredDependencies({ delivered: entries, - deliveredIds: [manifestId], getInstalled: (id) => registry?.getPackage?.(id), }); if (unmet.length > 0) { diff --git a/packages/cloud-connection/src/marketplace-install-local-rehydrate-order.test.ts b/packages/cloud-connection/src/marketplace-install-local-rehydrate-order.test.ts new file mode 100644 index 00000000000..5efad971d7b --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-rehydrate-order.test.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0141 D5 at the install-local `kernel:ready` rehydrate: the ledger's + * entries register in `manifest.dependencies` order, not the order the + * directory lists them in. + * + * Each ledger entry is a node — an artifact that may carry several packages — + * so a module's declared parent resolves to the entry carrying it. A cycle + * between entries degrades to listed order, is logged naming them, and never + * stops the rehydrate. The listing is pinned (spied) so the module-first order + * the directory could return is the one under test, whatever `readdirSync` + * answers on this filesystem. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import '@objectstack/runtime'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; + +const entryOf = (manifestId: string, manifest: Record): InstalledManifestEntry => ({ + packageId: manifestId, versionId: '1.0.0', manifestId, version: '1.0.0', + manifest: { id: manifestId, name: manifestId, version: '1.0.0', ...manifest }, + installedAt: '2026-01-01T00:00:00.000Z', installedBy: 'admin', withSampleData: false, +}); + +/** HotCRM's artifact: the app and a companion, one ledger entry. */ +const HOTCRM = entryOf('app.objectstack.hotcrm', { + type: 'app', + packages: [ + { manifest: { id: 'app.objectstack.hotcrm', name: 'HotCRM', version: '6.0.0', type: 'app', namespace: 'crm' } }, + { manifest: { id: 'app.objectstack.hotcrm.service', name: 'Service', version: '6.0.0', type: 'module', namespace: 'crm', dependencies: { 'app.objectstack.hotcrm': '^6.0.0' } } }, + ], +}); +/** A standalone module declaring the app, its own entry. */ +const REVENUE = entryOf('app.objectstack.hotcrm.revenue', { + type: 'module', namespace: 'crm', dependencies: { 'app.objectstack.hotcrm': '^6.0.0' }, +}); + +async function rehydrate(dir: string, listed: InstalledManifestEntry[]) { + vi.spyOn(LocalManifestSource.prototype, 'list').mockReturnValue({ entries: listed, skipped: [] }); + const hooks = new Map(); + const order: string[] = []; + const register = vi.fn((m: any) => { order.push(m.id); }); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const services: Record = { + manifest: { register }, + auth: installerAuthService(), + objectql: withInstallerGrants({ syncSchemas: vi.fn(async () => undefined), registry: { getAllPackages: () => [], getPackage: () => undefined } }), + }; + const routes = new Map(); + const rawApp = { get: (p: string, h: unknown) => routes.set(p, h), post: (p: string, h: unknown) => routes.set(p, h), delete: (p: string, h: unknown) => routes.set(p, h) }; + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + logger, + }; + await new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }).start(ctx as any); + await hooks.get('kernel:ready')?.(); + // The plugin registers its own UI package too; only the ledger's entries are under test. + const ledgerIds = new Set(listed.map((e) => e.manifestId)); + return { order: order.filter((id) => ledgerIds.has(id)), errors: logger.error.mock.calls.map((c: unknown[]) => String(c[0])) }; +} + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-rehydrate-order-')); }); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe('kernel:ready rehydrate — ADR-0141 D5 dependency order', () => { + it('registers the entry carrying a declared parent before the module listed ahead of it', async () => { + const { order, errors } = await rehydrate(dir, [REVENUE, HOTCRM]); + expect(order).toEqual(['app.objectstack.hotcrm', 'app.objectstack.hotcrm.revenue']); + expect(errors.filter((e) => e.includes('cycle'))).toEqual([]); + }); + + it('degrades a cycle between entries to listed order, logs it naming them, and rehydrates every entry', async () => { + const a = entryOf('com.acme.a', { type: 'app', namespace: 'aa', dependencies: { 'com.acme.b': '1.x' } }); + const b = entryOf('com.acme.b', { type: 'app', namespace: 'bb', dependencies: { 'com.acme.a': '1.x' } }); + const { order, errors } = await rehydrate(dir, [a, b]); + expect(order).toEqual(['com.acme.a', 'com.acme.b']); + const cycle = errors.find((e) => e.includes('cycle')); + expect(cycle).toContain('com.acme.a'); + expect(cycle).toContain('com.acme.b'); + }); +}); diff --git a/packages/core/src/artifact-packages.ts b/packages/core/src/artifact-packages.ts index 8149e550f1c..0690833db0e 100644 --- a/packages/core/src/artifact-packages.ts +++ b/packages/core/src/artifact-packages.ts @@ -181,6 +181,24 @@ export function artifactPackageId(manifest: unknown): string | undefined { return typeof id === 'string' && id !== '' ? id : undefined; } +/** + * The package ids a manifest declares in `manifest.dependencies` — the record + * `{ [packageId]: versionRange }` (`ManifestSchema.dependencies`) — in + * declaration order. + * + * Read by the seams that act on the declaration without judging its ranges: + * the install gate's dependency-declared co-owners (ADR-0141 D5) and the + * hydrate order (D5). Anything that is not a plain record — absent, `null`, an + * array (the marketplace `PackageDependencySchema` shape) — declares nothing + * here; judging a malformed declaration is the install check's job (D2), not + * this reader's. + */ +export function declaredDependencyIds(manifest: unknown): string[] { + const declared = (manifest as { dependencies?: unknown } | null | undefined)?.dependencies; + if (declared === null || typeof declared !== 'object' || Array.isArray(declared)) return []; + return Object.keys(declared); +} + /** * One package to register, as {@link resolveArtifactPackageEntries} answers it: * the body, and the origin its artifact entry stated for it. diff --git a/packages/core/src/hydration-order.test.ts b/packages/core/src/hydration-order.test.ts new file mode 100644 index 00000000000..348adf50f1a --- /dev/null +++ b/packages/core/src/hydration-order.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0141 D5 — a hydrate batch is ordered by its declared dependencies, and a + * cycle never costs the batch. + */ + +import { describe, it, expect } from 'vitest'; +import { orderHydrationBatch, type HydrationNode } from './hydration-order.js'; +import { declaredDependencyIds } from './artifact-packages.js'; + +/** One package per node, the `sys_packages` shape. */ +const pkg = (id: string, ...declared: string[]): HydrationNode => + ({ item: id, label: id, packageIds: [id], declaredIds: declared }); + +describe('orderHydrationBatch', () => { + it('registers a parent before the modules that declare it, whatever order the source lists', () => { + const { ordered, cycles } = orderHydrationBatch([ + pkg('revenue', 'crm'), + pkg('marketing', 'crm'), + pkg('crm'), + ]); + expect(ordered).toEqual(['crm', 'revenue', 'marketing']); + expect(cycles).toEqual([]); + }); + + it('keeps source order where no edge decides, and treats a declared id outside the batch as no edge', () => { + const { ordered } = orderHydrationBatch([pkg('b', 'com.elsewhere'), pkg('a'), pkg('c', 'b')]); + expect(ordered).toEqual(['b', 'a', 'c']); + }); + + it('resolves a declared id to the node that carries it, and ignores ids declared between packages of one node', () => { + // An install-local ledger entry is an artifact: one node, several packages. + const hotcrm: HydrationNode = { + item: 'hotcrm-entry', label: 'hotcrm', packageIds: ['crm', 'crm.service'], declaredIds: ['crm'], + }; + const revenue: HydrationNode = { + item: 'revenue-entry', label: 'revenue', packageIds: ['crm.revenue'], declaredIds: ['crm'], + }; + const { ordered, cycles } = orderHydrationBatch([revenue, hotcrm]); + expect(ordered).toEqual(['hotcrm-entry', 'revenue-entry']); + expect(cycles).toEqual([]); + }); + + it('degrades a cycle to source order for its members, reports it, and keeps sorting the rest — never throws', () => { + const batch = [ + pkg('late', 'parent'), + pkg('x', 'y', 'parent'), + pkg('y', 'x'), + pkg('parent'), + ]; + let result: ReturnType> | undefined; + expect(() => { result = orderHydrationBatch(batch); }).not.toThrow(); + const { ordered, cycles } = result!; + expect(cycles).toEqual([['x', 'y']]); + // Every item still reaches the caller exactly once. + expect([...ordered].sort()).toEqual(['late', 'parent', 'x', 'y']); + // The acyclic edge still holds for the cycle's member and for the rest… + expect(ordered.indexOf('parent')).toBeLessThan(ordered.indexOf('x')); + expect(ordered.indexOf('parent')).toBeLessThan(ordered.indexOf('late')); + // …and the cycle's members keep source order among themselves. + expect(ordered.indexOf('x')).toBeLessThan(ordered.indexOf('y')); + }); + + it('keeps every row when two rows name one id', () => { + const { ordered } = orderHydrationBatch([pkg('dup'), pkg('dup'), pkg('m', 'dup')]); + expect(ordered).toEqual(['dup', 'dup', 'm']); + }); +}); + +describe('declaredDependencyIds', () => { + it('reads the record spelling only', () => { + expect(declaredDependencyIds({ dependencies: { a: '^1', b: '2.x' } })).toEqual(['a', 'b']); + expect(declaredDependencyIds({ dependencies: [{ packageId: 'a', versionRange: '^1' }] })).toEqual([]); + expect(declaredDependencyIds({ dependencies: null })).toEqual([]); + expect(declaredDependencyIds({})).toEqual([]); + expect(declaredDependencyIds(undefined)).toEqual([]); + }); +}); diff --git a/packages/core/src/hydration-order.ts b/packages/core/src/hydration-order.ts new file mode 100644 index 00000000000..8cfee3666d4 --- /dev/null +++ b/packages/core/src/hydration-order.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0141 D5 — the order a boot hydrates its installed packages in. + * + * A boot re-registers what earlier installs admitted, from two batches: the + * `sys_packages` rows (`@objectstack/service-package`) and the install-local + * ledger (`@objectstack/cloud-connection`, on `kernel:ready`). Each used to + * register in whatever order its source listed — `list()` rows, `readdirSync` + * files — and the install gate is order-sensitive: two modules hydrated before + * the parent whose namespace they share meet the gate with that namespace + * unreadable, and the second is refused and never recovered for the process + * lifetime (the recorded verdict AGENTS.md's *Startup registry reads* names as + * a defect). So both batches sort themselves by `manifest.dependencies` before + * registering any of it. + * + * ## The one sorter, the one reading of the key + * + * The sort is `resolvePluginOrder` (ADR-0116's single topological sorter), with + * declared ids as OPTIONAL edges — the reading + * `resolveArtifactPackageEntries` already gives the key inside one artifact: a + * declared id that names a package in the batch is a real edge, one that does + * not is simply not an edge here. ⛔ No second sort. + * + * A node is whatever the batch registers in one act — a `sys_packages` row is + * one package; an install-local ledger entry is an artifact that may carry + * several — so a node lists every package id it carries, a declared id resolves + * to the node that carries it, and an id declared between packages of one node + * is no edge between nodes. + * + * ## A cycle never costs the batch + * + * `resolvePluginOrder` throws on a cycle, and a cycle is reachable here — two + * authored packages whose `PATCH` edits declare each other, two marketplace + * artifacts that declare each other. Both hydrates are per-entry ("one stale + * package must not brick the environment"), so a sort that threw outside the + * loop would register nothing, worse than source order. So the edges INSIDE a + * cycle are dropped before sorting: its members keep source order among + * themselves while still following every acyclic dependency, the acyclic rest + * keeps its sorted order, nothing throws, and the cycle is handed back for the + * caller to log as a refusal naming its members. + */ + +import { resolvePluginOrder } from './plugin-order.js'; + +/** One unit a hydrate registers in one act. */ +export interface HydrationNode { + /** What the caller registers. */ + item: T; + /** The name a logged cycle uses for this node. */ + label: string; + /** Every package id this node delivers. */ + packageIds: readonly string[]; + /** Every package id this node's packages declare in `manifest.dependencies`. */ + declaredIds: readonly string[]; +} + +/** {@link orderHydrationBatch}'s answer. */ +export interface HydrationOrder { + /** The items, dependencies first; source order wherever no edge decides. */ + ordered: T[]; + /** Each dependency cycle found, as its members' labels in source order. Empty when there is none. */ + cycles: string[][]; +} + +/** + * Order a hydrate batch by its declared dependencies (ADR-0141 D5). Never + * throws on the batch's shape: a cycle degrades to source order for its members + * and is reported in {@link HydrationOrder.cycles}. + */ +export function orderHydrationBatch(nodes: readonly HydrationNode[]): HydrationOrder { + // Keyed by position, not by label: two rows naming one id must still both + // reach the caller, which judges each as it always has. + const carrierOf = new Map(); + nodes.forEach((node, index) => { + for (const id of node.packageIds) if (!carrierOf.has(id)) carrierOf.set(id, index); + }); + + const edges: number[][] = nodes.map((node, index) => { + const targets = new Set(); + for (const id of node.declaredIds) { + const carrier = carrierOf.get(id); + if (carrier !== undefined && carrier !== index) targets.add(carrier); + } + return [...targets]; + }); + + const components = stronglyConnectedComponents(edges); + const componentOf = new Array(nodes.length); + components.forEach((members, c) => { for (const m of members) componentOf[m] = c; }); + const cyclic = components.filter((members) => members.length > 1); + + const graph = new Map(nodes.map((_, index) => [String(index), { + name: String(index), + optionalDependencies: edges[index] + .filter((target) => !(components[componentOf[index]].length > 1 && componentOf[target] === componentOf[index])) + .map(String), + index, + }])); + + return { + ordered: resolvePluginOrder(graph).map(({ index }) => nodes[index].item), + cycles: cyclic.map((members) => [...members].sort((a, b) => a - b).map((m) => nodes[m].label)), + }; +} + +/** Tarjan's algorithm over node indices; iterative, so a long chain cannot overflow the stack. */ +function stronglyConnectedComponents(edges: readonly number[][]): number[][] { + const index = new Array(edges.length).fill(-1); + const low = new Array(edges.length).fill(0); + const onStack = new Array(edges.length).fill(false); + const stack: number[] = []; + const components: number[][] = []; + let counter = 0; + + for (let root = 0; root < edges.length; root++) { + if (index[root] !== -1) continue; + const work: Array<{ node: number; next: number }> = [{ node: root, next: 0 }]; + index[root] = low[root] = counter++; + stack.push(root); + onStack[root] = true; + + while (work.length > 0) { + const frame = work[work.length - 1]; + const { node } = frame; + if (frame.next < edges[node].length) { + const target = edges[node][frame.next++]; + if (index[target] === -1) { + index[target] = low[target] = counter++; + stack.push(target); + onStack[target] = true; + work.push({ node: target, next: 0 }); + } else if (onStack[target]) { + low[node] = Math.min(low[node], index[target]); + } + continue; + } + work.pop(); + if (work.length > 0) { + const parent = work[work.length - 1].node; + low[parent] = Math.min(low[parent], low[node]); + } + if (low[node] === index[node]) { + const members: number[] = []; + let member: number; + do { + member = stack.pop()!; + onStack[member] = false; + members.push(member); + } while (member !== node); + components.push(members); + } + } + } + return components; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c139b1055a0..fb80e43666c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,11 @@ export * from './plugin-order.js'; // because the ordering it performs is `resolvePluginOrder` directly above. // `@objectstack/objectql` re-exports it, so its published surface is unchanged. export * from './artifact-packages.js'; +// ADR-0141 D5 — the order a boot hydrates installed packages in. Two batches +// read it (`@objectstack/service-package`'s `sys_packages` hydrate and +// `@objectstack/cloud-connection`'s ledger rehydrate), and it sorts with +// `resolvePluginOrder` above. +export * from './hydration-order.js'; // [#22301] The two halves of resolving an app's `requires` that `os serve` // (`@objectstack/cli`) and the verification handle (`@objectstack/verify`) both // read — the package-owned collection reader (ADR-0130 D4) and the token → diff --git a/packages/metadata-core/src/protocol-handshake-dependencies.test.ts b/packages/metadata-core/src/protocol-handshake-dependencies.test.ts index b2ef58fe512..7ab30a781ae 100644 --- a/packages/metadata-core/src/protocol-handshake-dependencies.test.ts +++ b/packages/metadata-core/src/protocol-handshake-dependencies.test.ts @@ -157,14 +157,24 @@ describe('checkDeclaredDependencies — ADR-0141 D2\'s one predicate', () => { const core = { id: 'com.example.multi.core', version: '2.0.0' }; const orders = { id: 'com.example.multi.orders', dependencies: { 'com.example.multi.core': '^1.0.0' } }; expect(checkDeclaredDependencies({ delivered: [orders, core], getInstalled: installed({}) })).toEqual([]); - // An id delivered by the act without being judged itself (an artifact's own identity). - expect(checkDeclaredDependencies({ - delivered: [orders], deliveredIds: ['com.example.multi.core'], getInstalled: installed({}), - })).toEqual([]); // Control: delivered by nobody, the same entry is absent. expect(checkDeclaredDependencies({ delivered: [orders], getInstalled: installed({}) })).toHaveLength(1); }); + it('names the range, not a readable installed version, when an unreadable alternative is what left the union undecided', () => { + // `<5` reads and refuses 7.0.0; `latest` cannot be read — so the union is + // undecided because of the RANGE. 7.0.0 is a perfectly readable version. + const pkg = { id: 'com.acme.psa', dependencies: { 'com.hotcrm.sales': '<5 || latest' } }; + expect(checkDeclaredDependencies({ delivered: [pkg], getInstalled: installed({ 'com.hotcrm.sales': '7.0.0' }) })).toEqual([ + { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: '<5 || latest', found: 'unreadable-range', installedVersion: '7.0.0' }, + ]); + // Control: the same readable range against an unreadable version still names the version. + expect(checkDeclaredDependencies({ + delivered: [{ ...pkg, dependencies: { 'com.hotcrm.sales': '^6.0.0' } }], + getInstalled: installed({ 'com.hotcrm.sales': '6.1.0-beta.1' }), + })[0]?.found).toBe('unreadable-version'); + }); + it('reads a declaration that is not a record as unreadable, never as "no dependencies"', () => { const pkg = { id: 'com.acme.psa', dependencies: [{ packageId: 'com.hotcrm.sales', versionRange: '^6' }] }; expect(checkDeclaredDependencies({ delivered: [pkg], getInstalled: installed({}) })).toEqual([ diff --git a/packages/metadata-core/src/protocol-handshake.ts b/packages/metadata-core/src/protocol-handshake.ts index e822aa3ddc5..acc17b4fb90 100644 --- a/packages/metadata-core/src/protocol-handshake.ts +++ b/packages/metadata-core/src/protocol-handshake.ts @@ -735,11 +735,6 @@ export interface DeclaredDependencyCheck { * single-package door; every package of an artifact for an artifact door. */ delivered: readonly DependencyDeclaringManifest[]; - /** - * Further ids the same act delivers without being judged itself (an - * artifact's own identity). Every `delivered` id counts already. - */ - deliveredIds?: readonly string[]; /** The installed package record for an id (`SchemaRegistry.getPackage`), or `undefined`. */ getInstalled: (id: string) => InstalledDependencyRecord | undefined; } @@ -767,7 +762,10 @@ function deliveredId(manifest: DependencyDeclaringManifest): string | undefined * Pure: no throwing, no I/O. Doors call {@link assertDeclaredDependencies}. */ export function checkDeclaredDependencies(check: DeclaredDependencyCheck): UnmetDependency[] { - const delivered = new Set(check.deliveredIds ?? []); + // Packages only: an artifact wrapper's own id names no package unless one of + // `delivered` carries it, and a module declaring it would pass here and be + // `absent` on its first standalone install. + const delivered = new Set(); for (const manifest of check.delivered) { const id = deliveredId(manifest); if (id !== undefined) delivered.add(id); @@ -804,7 +802,13 @@ export function checkDeclaredDependencies(check: DeclaredDependencyCheck): Unmet } const admits = typeof stored === 'string' ? rangeAdmitsVersion(range, stored) : null; if (admits === null) { - unmet.push({ packageId, dependency, range, found: 'unreadable-version', ...withVersion }); + // Undecided. The `0.0.0` probe above only shows SOME alternative reads: + // a union whose readable alternatives refuse a readable version and + // whose other alternative cannot be read (`<5 || latest` against + // 7.0.0) is undecided because of the range, and naming the version + // would send the author to fix the wrong side. + const versionReadable = typeof stored === 'string' && readExactVersion(stored) !== null; + unmet.push({ packageId, dependency, range, found: versionReadable ? 'unreadable-range' : 'unreadable-version', ...withVersion }); } else if (!admits) { unmet.push({ packageId, dependency, range, found: 'out-of-range', ...withVersion }); } diff --git a/packages/objectql/src/registry-artifact-co-ownership.test.ts b/packages/objectql/src/registry-artifact-co-ownership.test.ts index 3e9a71dcd89..0841d1e5a39 100644 --- a/packages/objectql/src/registry-artifact-co-ownership.test.ts +++ b/packages/objectql/src/registry-artifact-co-ownership.test.ts @@ -352,3 +352,161 @@ describe('ADR-0130 D1 — the relaxation does not widen the same-id reinstall ex expect(caught?.status).toBe(422); }); }); + +describe('ADR-0141 D5 — a declared dependency is a second co-owner source, judged by both halves of the pair', () => { + /** + * The same pair as above, across artifacts. Each package below arrives as + * its OWN artifact through the real load path, so the artifact scope never + * names the other package: whatever admits it is the declaration. + * + * - `com.acme.crm` (X) owns namespace `crm` and `crm_account`; + * - `com.acme.crm.revenue` (P) declares X and uses `crm`; + * - `com.acme.crm.marketing` (S) declares X and uses `crm` — P's sibling + * under one declared parent, declaring nothing about P. + */ + const kernels: ObjectKernel[] = []; + + const freshManifestService = async () => { + const k = await bootKernel(); + kernels.push(k); + return { svc: k.getService('manifest') as ManifestService, registry: engineOf(k).registry }; + }; + + afterEach(async () => { + while (kernels.length) { + const k = kernels.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } + }); + + const module = (id: string, objectName: string, dependencies: Record = { 'com.acme.crm': '^1.0.0' }) => ({ + id, + name: id.replace(/\W/g, '_'), + version: '1.0.0', + type: 'module', + namespace: 'crm', + dependencies, + objects: [ + { name: objectName, label: objectName, fields: { total: { name: 'total', label: 'Total', type: 'number' } } }, + ], + }); + const revenue = (objectName = 'crm_invoice') => module('com.acme.crm.revenue', objectName); + const marketing = (objectName = 'crm_campaign') => module('com.acme.crm.marketing', objectName); + + it('THE PAIRING GATE ACROSS ARTIFACTS — given the gate admits a dependent sharing its parent\'s namespace, a dependent defining a name the parent owns is REFUSED', async () => { + // ── Half 1: the relaxation. Without the declaration this is the + // different-artifacts refusal pinned above. + const admitting = await freshManifestService(); + expect(await registerAndCatch(admitting.svc, crmCore())).toBeUndefined(); + expect(await registerAndCatch(admitting.svc, revenue())).toBeUndefined(); + expect(admitting.registry.getNamespaceOwners('crm').sort()).toEqual(['com.acme.crm', 'com.acme.crm.revenue']); + expect(admitting.registry.getObjectOwner('crm_account')?.packageId).toBe('com.acme.crm'); + expect(admitting.registry.getObjectOwner('crm_invoice')?.packageId).toBe('com.acme.crm.revenue'); + + // ── Half 2: the uniqueness check follows the widened set. + const refusing = await freshManifestService(); + expect(await registerAndCatch(refusing.svc, crmCore())).toBeUndefined(); + const refused = await registerAndCatch(refusing.svc, revenue('crm_account')); + expect(refused).toBeInstanceOf(ArtifactObjectNameConflictError); + expect(refused?.code).toBe('DUPLICATE_ARTIFACT_OBJECT_NAME'); + expect(refused?.status).toBe(422); + const err = refused as ArtifactObjectNameConflictError; + expect(err.coOwnership).toBe('dependency'); + expect(err.existingPackageId).toBe('com.acme.crm'); + expect(err.incomingPackageId).toBe('com.acme.crm.revenue'); + // Worded for its source: a cross-artifact refusal never claims one artifact. + expect(err.message).toContain('declared dependency'); + expect(err.message).not.toContain('same artifact'); + expect(err.message).not.toContain('one release artifact'); + expect(refusing.registry.getPackage('com.acme.crm.revenue')).toBeUndefined(); + }); + + it('admits a sibling under the same declared parent, and refuses a sibling defining a name the other sibling owns', async () => { + const admitting = await freshManifestService(); + expect(await registerAndCatch(admitting.svc, crmCore())).toBeUndefined(); + expect(await registerAndCatch(admitting.svc, revenue())).toBeUndefined(); + // The sitting owners of `crm` are now {crm, revenue}; marketing declares no + // revenue, and the sibling arm is what admits it. + expect(await registerAndCatch(admitting.svc, marketing())).toBeUndefined(); + expect(admitting.registry.getNamespaceOwners('crm').sort()) + .toEqual(['com.acme.crm', 'com.acme.crm.marketing', 'com.acme.crm.revenue']); + + const refusing = await freshManifestService(); + expect(await registerAndCatch(refusing.svc, crmCore())).toBeUndefined(); + expect(await registerAndCatch(refusing.svc, revenue())).toBeUndefined(); + const refused = await registerAndCatch(refusing.svc, marketing('crm_invoice')); + expect(refused?.code).toBe('DUPLICATE_ARTIFACT_OBJECT_NAME'); + expect(refused?.status).toBe(422); + const err = refused as ArtifactObjectNameConflictError; + expect(err.coOwnership).toBe('sibling'); + expect(err.existingPackageId).toBe('com.acme.crm.revenue'); + expect(err.incomingPackageId).toBe('com.acme.crm.marketing'); + expect(err.message).toContain('com.acme.crm'); + expect(err.message).not.toContain('one release artifact'); + expect(refusing.registry.getPackage('com.acme.crm.marketing')).toBeUndefined(); + }); + + it('reads the relation from either end: the parent registering second beside its dependent is admitted (a boot that hydrates P before X)', async () => { + const { svc, registry } = await freshManifestService(); + expect(await registerAndCatch(svc, revenue())).toBeUndefined(); + expect(await registerAndCatch(svc, crmCore())).toBeUndefined(); + expect(registry.getNamespaceOwners('crm').sort()).toEqual(['com.acme.crm', 'com.acme.crm.revenue']); + }); + + it('upgrade: the parent re-registered beside its module is admitted; a parent newly defining a name the module owns is refused naming the module', async () => { + const { svc, registry } = await freshManifestService(); + expect(await registerAndCatch(svc, crmCore())).toBeUndefined(); + expect(await registerAndCatch(svc, revenue())).toBeUndefined(); + + expect(await registerAndCatch(svc, { ...crmCore(), version: '1.1.0' })).toBeUndefined(); + expect(registry.getPackage('com.acme.crm')?.manifest?.version).toBe('1.1.0'); + + const grabbing = { + ...crmCore(), + version: '1.2.0', + objects: [ + ...crmCore().objects, + { name: 'crm_invoice', label: 'Invoice (core)', fields: { amount: { name: 'amount', label: 'Amount', type: 'number' } } }, + ], + }; + const refused = await registerAndCatch(svc, grabbing); + expect(refused?.code).toBe('DUPLICATE_ARTIFACT_OBJECT_NAME'); + expect(refused?.status).toBe(422); + const err = refused as ArtifactObjectNameConflictError; + expect(err.coOwnership).toBe('dependency'); + expect(err.existingPackageId).toBe('com.acme.crm.revenue'); + expect(err.incomingPackageId).toBe('com.acme.crm'); + expect(registry.getObjectOwner('crm_invoice')?.packageId).toBe('com.acme.crm.revenue'); + }); + + it('still refuses a sharer that is neither declared nor a sibling, and names the declaration as the fix', async () => { + const { svc } = await freshManifestService(); + expect(await registerAndCatch(svc, crmCore())).toBeUndefined(); + const refused = await registerAndCatch(svc, module('com.other.crm.addon', 'crm_lead', {})); + expect(refused).toBeInstanceOf(NamespaceConflictError); + expect(refused?.code).toBe('NAMESPACE_CONFLICT'); + expect(refused?.status).toBe(422); + expect(refused?.message).toContain('manifest.dependencies'); + }); + + it('never admits two siblings whose declared parent is absent, or whose shared parent uses another namespace', async () => { + // The sibling arm reads Q's namespace from Q's installed record: two + // modules sharing a declaration of a package that is not installed — or of a + // utility under its own namespace — co-own nothing. + const absentParent = await freshManifestService(); + expect(await registerAndCatch(absentParent.svc, revenue())).toBeUndefined(); + const refusedAbsent = await registerAndCatch(absentParent.svc, marketing()); + expect(refusedAbsent?.code).toBe('NAMESPACE_CONFLICT'); + expect(refusedAbsent?.status).toBe(422); + + const utility = await freshManifestService(); + expect(await registerAndCatch(utility.svc, { + id: 'com.acme.util', name: 'acme_util', version: '1.0.0', type: 'module', namespace: 'util', objects: [], + })).toBeUndefined(); + const onUtil = { 'com.acme.util': '^1.0.0' }; + expect(await registerAndCatch(utility.svc, module('com.acme.crm.revenue', 'crm_invoice', onUtil))).toBeUndefined(); + const refusedUtility = await registerAndCatch(utility.svc, module('com.acme.crm.marketing', 'crm_campaign', onUtil)); + expect(refusedUtility?.code).toBe('NAMESPACE_CONFLICT'); + expect(refusedUtility?.status).toBe(422); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index ca946b02a92..7653523e9b8 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -53,7 +53,7 @@ import { applyProtection } from '@objectstack/spec/shared'; // (`id || name`). The install gate's co-ownership set must name packages by the // same string the artifact loader ordered them by, or a co-owner would be // admitted — or refused — under a key nothing else in the path uses. -import { artifactPackageId, type SecurityCatalogType } from '@objectstack/core'; +import { artifactPackageId, declaredDependencyIds, type SecurityCatalogType } from '@objectstack/core'; // One name, one holder for positions, permission sets and capabilities — the // rule, the holders and the one place a declaration is read from (module doc). import { @@ -2015,7 +2015,10 @@ export class NamespaceConflictError extends Error { `of every object name (e.g. "${namespace}_account") and the container ` + `that scopes a package's UI metadata, so it must be unique per ` + `installation. Choose a different namespace for "${incomingPackageId}", ` + - `or uninstall "${existingPackageId}" first. If this is a deliberate ` + + `or uninstall "${existingPackageId}" first. If "${incomingPackageId}" ` + + `extends the app that holds this namespace, declare that app in its ` + + `manifest.dependencies: a declared dependency co-owns the namespace ` + + `(ADR-0141 D5). If this is a deliberate ` + `migration, set OS_METADATA_COLLISION=warn to downgrade to a warning. ` + `See ADR-0048.`, ); @@ -2320,6 +2323,15 @@ export interface ArtifactInstallScope { readonly packageIds: readonly string[]; } +/** + * Why two packages co-own one namespace at the install gate: + * + * - `artifact` — the same release artifact delivers both (ADR-0130 D1); + * - `dependency` — one declares the other in `manifest.dependencies` (ADR-0141 D5); + * - `sibling` — both declare one installed package that holds the namespace (ADR-0141 D5). + */ +export type CoOwnershipSource = 'artifact' | 'dependency' | 'sibling'; + /** * The object names one manifest CLAIMS OWNERSHIP of at install time — the names * `registerApp` is about to hand `registerObject` with `ownership: 'own'`. @@ -2372,6 +2384,29 @@ function declaredOwnedObjectNames(manifest: ObjectStackManifest): string[] { * Carries the ADR-0112 envelope (`code` + `status`) — the shape this * repository's rejection tests assert against, never a bare throw. */ +/** + * The words {@link ArtifactObjectNameConflictError} uses per co-ownership + * source, so a refusal between packages of different artifacts never claims + * they arrived in one (ADR-0141 D5). + */ +const CO_OWNERSHIP_WORDING: Record = { + artifact: { + conflict: 'inside one release artifact', + relation: 'delivered by the same artifact', + allowed: 'Sharing a namespace across packages in one artifact is allowed (ADR-0130 D1)', + }, + dependency: { + conflict: 'between packages that share a namespace through a declared dependency', + relation: 'which shares the namespace because one of the two declares the other in `manifest.dependencies`', + allowed: 'Sharing a namespace through a declared dependency is allowed (ADR-0141 D5)', + }, + sibling: { + conflict: 'between sibling packages that share a namespace under one declared parent', + relation: 'which shares the namespace because both declare the same installed package in `manifest.dependencies`', + allowed: 'Sharing a namespace between modules of one declared parent is allowed (ADR-0141 D5)', + }, +}; + export class ArtifactObjectNameConflictError extends Error { readonly code = DUPLICATE_ARTIFACT_OBJECT_NAME_CODE; readonly status = 422; @@ -2383,24 +2418,32 @@ export class ArtifactObjectNameConflictError extends Error { readonly existingPackageId: string; /** The package whose install this refusal stopped. */ readonly incomingPackageId: string; + /** Why the two packages co-own the namespace — the message is worded for it (ADR-0141 D5). */ + readonly coOwnership: CoOwnershipSource; - constructor(objectName: string, existingPackageId: string, incomingPackageId: string) { + constructor( + objectName: string, + existingPackageId: string, + incomingPackageId: string, + coOwnership: CoOwnershipSource = 'artifact', + ) { + const { conflict, relation, allowed } = CO_OWNERSHIP_WORDING[coOwnership]; super( - `Object name conflict inside one release artifact: object "${objectName}" is ` + + `Object name conflict ${conflict}: object "${objectName}" is ` + `already owned by package "${existingPackageId}", so package ` + - `"${incomingPackageId}" — delivered by the same artifact — cannot define it ` + + `"${incomingPackageId}" — ${relation} — cannot define it ` + `too. Two packages defining one object name map to one physical table: the ` + `install would either fail at the DB with a duplicate CREATE TABLE or, ` + `driver-dependent, let one definition silently overwrite the other. Rename ` + `the object in "${incomingPackageId}", or have it \`extend\` ` + - `"${existingPackageId}"'s object instead of owning a second one. Sharing a ` + - `namespace across packages in one artifact is allowed (ADR-0130 D1); ` + + `"${existingPackageId}"'s object instead of owning a second one. ${allowed}; ` + `sharing an object NAME is not (ADR-0130 D3).`, ); this.name = 'ArtifactObjectNameConflictError'; this.objectName = objectName; this.existingPackageId = existingPackageId; this.incomingPackageId = incomingPackageId; + this.coOwnership = coOwnership; } } @@ -6113,8 +6156,11 @@ export class SchemaRegistry { // ({@link artifactPackageId}), not by `manifest.id` alone: a manifest // carrying only `name` is installed under that name, and a co-owner set // that still contained it would let the package "co-own" with itself. + // + // [ADR-0141 D5] …and, beside the artifact, the packages a declared + // dependency makes co-owners of the namespace — see {@link coOwnersOf}. const selfId = artifactPackageId(manifest); - const coOwners = new Set((scope?.packageIds ?? []).filter((id) => id !== selfId)); + const coOwners = this.coOwnersOf(manifest, selfId, scope); // ADR-0048 Phase 1 — install-time namespace gate. Refuse a package whose // namespace is already owned by an installed package that is not a co-owner @@ -6255,6 +6301,60 @@ export class SchemaRegistry { return pkg; } + /** + * The co-owners of the incoming package's namespace, each with the source + * that makes it one. Both halves of the install-time pair read this one map, + * so the namespace relaxation and the object-name refusal cannot disagree + * about who counts. + * + * Two sources: + * + * - **`artifact`** (ADR-0130 D1) — the other packages the same release + * artifact is delivering, as the load path's scope names them. + * - **a declared dependency** (ADR-0141 D5) — for a non-shareable namespace + * N the incoming package P uses: + * - `dependency`: P declares an installed X whose namespace is N; or a + * sitting owner of N declares P. The relation is declared by the + * dependent alone and read from either end, so the parent registering + * second — an upgrade, or a boot that hydrated the module first — meets + * its module as a co-owner instead of `NAMESPACE_CONFLICT`. + * - `sibling`: a sitting owner S of N and P each directly declare one + * installed package Q whose namespace is N — two modules under one + * declared parent, neither declaring the other. + * + * Direct declarations only, never the closure the save door walks. Q's + * namespace is read from Q's installed record: ⛔ two packages that merely + * declare one common package co-own nothing unless that package is installed + * and holds N, or any two packages sharing a utility dependency could share a + * namespace. Nothing is persisted: the relation is the declarations of the + * packages the registry holds now. + */ + private coOwnersOf( + manifest: ObjectStackManifest, + selfId: string | undefined, + scope: ArtifactInstallScope | undefined, + ): Map { + const coOwners = new Map(); + for (const id of scope?.packageIds ?? []) if (id !== selfId) coOwners.set(id, 'artifact'); + + const namespace = manifest.namespace; + if (!namespace || isShareableNamespace(namespace) || selfId === undefined) return coOwners; + + const holdsNamespace = (id: string) => this.getPackage(id)?.manifest?.namespace === namespace; + // P's declared parents that hold N — the parent arm from P's side, and the + // Q set the sibling arm matches against. + const parents = new Set(declaredDependencyIds(manifest).filter((id) => id !== selfId && holdsNamespace(id))); + for (const id of parents) if (!coOwners.has(id)) coOwners.set(id, 'dependency'); + + for (const owner of this.getNamespaceOwners(namespace)) { + if (owner === selfId || coOwners.has(owner)) continue; + const declaredByOwner = declaredDependencyIds(this.getPackage(owner)?.manifest); + if (declaredByOwner.includes(selfId)) coOwners.set(owner, 'dependency'); + else if (declaredByOwner.some((id) => parents.has(id))) coOwners.set(owner, 'sibling'); + } + return coOwners; + } + /** * [ADR-0130 D3] Refuse an install in which a CO-OWNER of the incoming package * — a package delivered by the same release artifact — already owns an object @@ -6293,7 +6393,7 @@ export class SchemaRegistry { private refuseCoOwnedObjectNameCollision( manifest: ObjectStackManifest, selfId: string | undefined, - coOwners: ReadonlySet, + coOwners: ReadonlyMap, ): void { // No artifact scope, or an artifact carrying one package: nothing this // method can refuse. Stated as an early return rather than left to fall out @@ -6311,7 +6411,7 @@ export class SchemaRegistry { // configurations D1 never admitted, outside this card and outside D3. if (!coOwners.has(ownerId)) continue; if (isTenantAuthored(owner.definition)) continue; - throw new ArtifactObjectNameConflictError(fqn, ownerId, selfId ?? manifest.id); + throw new ArtifactObjectNameConflictError(fqn, ownerId, selfId ?? manifest.id, coOwners.get(ownerId)); } } diff --git a/packages/services/service-package/src/hydration-dependency-order.test.ts b/packages/services/service-package/src/hydration-dependency-order.test.ts new file mode 100644 index 00000000000..c7c2c1ab399 --- /dev/null +++ b/packages/services/service-package/src/hydration-dependency-order.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0141 D5 — the `sys_packages` hydrate registers its batch in + * `manifest.dependencies` order, through the REAL registry gate. + * + * Two modules that share their parent's namespace and come back from + * `sys_packages` before the parent used to meet the namespace gate with the + * parent absent: the second module was refused `NAMESPACE_CONFLICT`, logged as + * "NOT hydrated", and missing for the process lifetime. Sorted, the parent + * registers first and both modules are its co-owners. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { PackageServicePlugin } from './index.js'; + +const parent = { id: 'com.acme.crm', name: 'CRM', version: '6.0.0', type: 'app', namespace: 'crm' }; +const moduleOf = (id: string, dependencies: Record) => + ({ id, name: id, version: '1.0.0', type: 'module', namespace: 'crm', dependencies }); + +function row(manifest: Record) { + return { + id: manifest.id, version: manifest.version, manifest: JSON.stringify(manifest), + metadata: '{}', hash: 'h', created_at: 't', updated_at: 't', + }; +} + +async function hydrate(manifests: Array>) { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + (registry as unknown as { logLevel: string }).logLevel = 'silent'; + const execute = vi.fn(async ({ sql }: { sql: string }) => + (/SELECT \* FROM sys_packages/i.test(sql) ? { rows: manifests.map(row) } : { rows: [] })); + const services = new Map([['objectql', { execute, registry }]]); + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const ctx = { + logger, + getService: (n: string) => services.get(n), + registerService: (n: string, s: unknown) => services.set(n, s), + } as never; + await new PackageServicePlugin().start(ctx); + return { registry, errors: logger.error.mock.calls.map((c: unknown[]) => String(c[0])) }; +} + +describe('sys_packages hydrate — ADR-0141 D5 dependency order', () => { + it('hydrates two modules listed before their parent: all three registered, none refused', async () => { + const onCrm = { 'com.acme.crm': '^6.0.0' }; + const { registry, errors } = await hydrate([ + moduleOf('com.acme.crm.revenue', onCrm), + moduleOf('com.acme.crm.marketing', onCrm), + parent, + ]); + expect(errors).toEqual([]); + expect(registry.getNamespaceOwners('crm').sort()) + .toEqual(['com.acme.crm', 'com.acme.crm.marketing', 'com.acme.crm.revenue']); + }); + + it('a dependency cycle degrades to source order, is logged naming its members, and the batch still hydrates', async () => { + const a = { id: 'com.acme.a', name: 'A', version: '1.0.0', type: 'app', namespace: 'aa', dependencies: { 'com.acme.b': '1.x' } }; + const b = { id: 'com.acme.b', name: 'B', version: '1.0.0', type: 'app', namespace: 'bb', dependencies: { 'com.acme.a': '1.x' } }; + const { registry, errors } = await hydrate([a, b, parent]); + expect(registry.getPackage('com.acme.a')).toBeDefined(); + expect(registry.getPackage('com.acme.b')).toBeDefined(); + expect(registry.getPackage('com.acme.crm')).toBeDefined(); + const cycle = errors.find((e) => e.includes('cycle')); + expect(cycle).toContain('com.acme.a'); + expect(cycle).toContain('com.acme.b'); + }); +}); diff --git a/packages/services/service-package/src/index.ts b/packages/services/service-package/src/index.ts index b14842e9983..b103fe41ebb 100644 --- a/packages/services/service-package/src/index.ts +++ b/packages/services/service-package/src/index.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; +import { declaredDependencyIds, orderHydrationBatch } from '@objectstack/core'; import { createHash } from 'node:crypto'; import { checkProtocolCompat } from '@objectstack/metadata-core'; import type { ObjectStackManifest } from '@objectstack/spec/kernel'; @@ -698,7 +699,32 @@ export class PackageServicePlugin implements Plugin { // the handshake below refuses, not the ones already registered, and // not the ones the registry refuses (each of those is reported by name). let hydrated = 0; - for (const rec of await packageService.list()) { + // [ADR-0141 D5] The batch registers in `manifest.dependencies` order, + // not list order: a module sharing its parent's namespace that came + // back before the parent met the gate with that namespace unreadable, + // and a second such module was refused and absent for the process + // lifetime. A dependency cycle costs nothing but its own order — its + // members keep list order and the cycle is reported by name; nothing + // throws out of the hydrate. + const rows = await packageService.list(); + const { ordered, cycles } = orderHydrationBatch(rows.map((rec) => { + const id = rec?.manifest?.id; + return { + item: rec, + label: String(id), + packageIds: typeof id === 'string' ? [id] : [], + declaredIds: declaredDependencyIds(rec?.manifest), + }; + })); + for (const members of cycles) { + logger.error( + `Packages ${members.map((m) => `'${m}'`).join(', ')} in sys_packages declare each other in a dependency ` + + `cycle (manifest.dependencies), so no order puts each after the packages it declares. They hydrate ` + + `in stored order for this boot (ADR-0141 D5); a namespace they share may be refused below. Remove ` + + `the declaration that closes the cycle.`, + ); + } + for (const rec of ordered) { const id = rec?.manifest?.id; if (id && !registry.getPackage(id)) { // ADR-0087 D1 — protocol handshake on the boot-time rehydration From 10c01a418105fedab936a4b17a343fecf621fe04 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:11:24 +0000 Subject: [PATCH 2/2] test(service-package): declare the registry log level now that a suite builds a SchemaRegistry Claude-Session: https://claude.ai/code/session_01NndsH9Qm7k6AS2i2fWbjNA Co-authored-by: Claude --- packages/services/service-package/vitest.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/services/service-package/vitest.config.ts b/packages/services/service-package/vitest.config.ts index 7c9964fdb58..0d74c903d67 100644 --- a/packages/services/service-package/vitest.config.ts +++ b/packages/services/service-package/vitest.config.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// This config exists for two settings; everything else stays on vitest's +// This config exists for three settings; everything else stays on vitest's // defaults, deliberately — a key added here re-specifies behaviour for every // test file in the package (packages/cli/vitest.config.ts's header records the // incident that taught that). @@ -16,6 +16,10 @@ export default defineConfig({ // Mechanism + measured costs: examples/app-showcase/vitest.config.ts. // Enforced repo-wide by scripts/check-console-intercept-disarm.mjs. disableConsoleIntercept: true, + // #13517: quiet the registry's per-item registration chatter — the + // engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped + // default. Enforced by scripts/check-registry-log-declared.mjs. + env: { OS_REGISTRY_LOG: 'warn' }, }, resolve: { // `hydration-convert-forward.test.ts` boots the REAL engine, protocol and