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
34 changes: 34 additions & 0 deletions .changeset/25792-dependency-declared-co-ownership.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is added, removed, renamed or re-shaped: `manifest.dependencies` keeps its record shape and meaning. The one narrowing is an install-local acceptance detail of the not-yet-released ADR-0141 D2 check (an artifact wrapper's own id no longer counts as a delivered package), plus the matching removal of the unreleased `DeclaredDependencyCheck.deliveredIds` option; there is nothing for `objectstack migrate meta` to rewrite and no tombstone. -->

**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`.
46 changes: 38 additions & 8 deletions content/docs/deployment/app-dependencies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down
16 changes: 10 additions & 6 deletions content/docs/protocol/kernel/plugin-spec.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -469,12 +469,16 @@ Use for large dependencies (React, Vue) that should be shared across plugins.

<Callout type="warn">
**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`),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
46 changes: 42 additions & 4 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -1568,7 +1607,6 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
try { registry = ctx.getService<IObjectQLEngine>('objectql')?.registry; } catch { /* no data engine — nothing installed */ }
const unmet = checkDeclaredDependencies({
delivered: entries,
deliveredIds: [manifestId],
getInstalled: (id) => registry?.getPackage?.(id),
});
if (unmet.length > 0) {
Expand Down
Loading
Loading