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
15 changes: 15 additions & 0 deletions .changeset/17676-package-registry-docblock-capability-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/metadata-protocol": patch
---

`installPackage`'s docblock no longer names `marketplace` as the capability that governs package persistence — after #17676 ruling A′ item 1 that half is `package-registry`, and `marketplace` names only the optional catalogue (#17676).

Clause-②: no

The shipped note read *"when the `package` service is absent (e.g. the `marketplace` capability is off)"*. That parenthetical was accurate when it was written and stopped being accurate when the carve-out landed: `packages/spec/src/kernel/platform-capabilities.ts` now carries `marketplace` and `package-registry` as two tokens, with the `sys_packages` container and its boot hydration under the second — a core capability mounted always — and browsing left under the first. A consumer reading this docblock in an editor, out of `dist/index.d.ts`, was being pointed at the wrong switch.

- **⛔ No behaviour moves, and this is not the card's defect being repaired.** The in-memory-only branches in `installPackage` and `updatePackage` are byte-identical. Ruling A′ item 2 keeps them deliberately, as the documented degraded path for reduced hosts — a host that mounts no provider must still be able to install a package for the life of its process — so the note now says that too, rather than leaving the branch reading like an oversight. #17676 stays open.
- **The note also records what is NOT true yet, measured rather than assumed.** `Serve.CAPABILITY_PROVIDERS` (`packages/cli/src/commands/serve.ts`) keys `marketplace` and does not key `package-registry`, so the always-on token is force-appended to every app's `requires` and then resolves to no provider — silently, because the resolver warns only for tokens outside the vocabulary. A stock boot still takes the absent-service branch. That half of the ruling belongs to the capability resolver and is not in this package.
- `updatePackage`'s docblock points at the same note, since the ruling names both primitives.

Published surface: doc comments only. `dist/index.js`, `dist/index.cjs`, `dist/index.d.ts` and `dist/index.d.cts` all carry the corrected text — tsup keeps JSDoc, which is why this ships at all — and no export, type, signature or runtime string moves.
221 changes: 212 additions & 9 deletions packages/metadata-protocol/src/durable-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,88 @@
// builder / Setup), which is exactly why those packages vanished on restart.
// These tests pin the fixed contract: version is defaulted (never skipped) and
// uninstall drops the durable row so packages don't resurrect at boot.
//
// #17676 ruling A' item 3 (decision batch #125 item 2, maintainer 「同意」
// 2026-09-13) — `makeImpl()` gains the service-ABSENT composition, so the pin
// can express the bug THAT card found: a writable package created through
// `POST /api/v1/packages` on a host with no `package` service is registered
// in-memory only, and after a restart the state splits three ways (Studio's
// writable list is empty, the data route 404s, the published metadata is still
// there). Before this, `makeImpl()` could only build the service-PRESENT host,
// so no pin here could fail the way the card failed.
//
// ⭐ The service-ABSENT branch is NOT a bug to delete — ruling item 2 keeps it
// as the documented degraded path for reduced hosts (`--preset minimal`, a host
// that mounts no `package-registry` provider). What the ruling changes is which
// hosts take it, and that half lives outside this package; these pins state
// what the primitive does on each composition, either side of it.

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackProtocolImplementation } from './index.js';

/**
* Compose a protocol implementation over a fake host.
*
* The fake `registry` mirrors the real `SchemaRegistry` package API by NAME
* (`installPackage` / `getPackage` / `getAllPackages` / `updatePackageManifest`,
* `packages/objectql/src/registry.ts`) so a pin written against it is a pin
* about the primitive's real collaborator, not about an invented one.
*/
function makeImpl(overrides?: {
publish?: (d: { manifest: unknown; metadata: unknown }) => Promise<unknown>;
del?: (id: string) => Promise<unknown>;
find?: (obj: string, q: unknown) => Promise<unknown[]>;
/**
* #17676 ruling A' item 3 — the service-ABSENT composition.
*
* `false` builds a services registry with NO `package` entry at all, which
* is what a host that mounts no `PackageServicePlugin` hands the protocol.
* `installPackage` / `updatePackage` then take their in-memory-only branch:
* the registry write lands, nothing reaches `sys_packages`, and the process
* is the only place the package exists.
*/
packageService?: false;
/**
* Manifests to replay into the fresh registry before the test runs — a
* stand-in for the boot hydration `PackageServicePlugin.start()` performs
* (`for (const rec of await packageService.list()) registry.installPackage(rec.manifest)`,
* `packages/services/service-package/src/index.ts`). A host composed with
* the rows a previous host persisted IS that process after a restart; a host
* composed with none is a restart that found `sys_packages` empty.
*/
hydrate?: readonly { id: string; [key: string]: unknown }[];
}) {
const registryCalls: Array<{ manifest: any; settings: any }> = [];
const engine = {
registry: {
installPackage: (manifest: any, settings: any) => {
registryCalls.push({ manifest, settings });
return { manifest, status: 'installed', enabled: true };
},
const installed = new Map<string, any>();
const registry = {
installPackage: (manifest: any, settings?: any) => {
registryCalls.push({ manifest, settings });
const pkg = { manifest, status: 'installed', enabled: true };
installed.set(manifest.id, pkg);
return pkg;
},
getPackage: (id: string) => installed.get(id),
getAllPackages: () => [...installed.values()],
updatePackageManifest: (id: string, patch: Record<string, unknown>) => {
const pkg = installed.get(id);
if (!pkg) return undefined;
Object.assign(pkg.manifest, patch);
return pkg;
},
find: overrides?.find ?? (async () => []),
};
const engine = { registry, find: overrides?.find ?? (async () => []) };
// Seeded through the registry's own verb, exactly as the boot hydration does.
for (const manifest of overrides?.hydrate ?? []) registry.installPackage(manifest);
registryCalls.length = 0; // hydration is the fixture, not an observation

const publish = vi.fn(overrides?.publish ?? (async () => ({ success: true })));
const del = vi.fn(overrides?.del ?? (async () => ({ success: true })));
const services = new Map<string, any>([['package', { publish, delete: del }]]);
const services = new Map<string, any>();
if (overrides?.packageService !== false) {
services.set('package', { publish, delete: del });
}
const impl = new ObjectStackProtocolImplementation(engine as any, () => services);
return { impl, registryCalls, publish, del };
return { impl, registryCalls, publish, del, registry, engine, services };
}

describe('installPackage — durable persistence (#2532)', () => {
Expand Down Expand Up @@ -130,3 +188,148 @@ describe('deletePackage — uninstall cleanups (#2747)', () => {
expect(res.cleanups[0].removed).toBe(2);
});
});

// ─────────────────────────────────────────────────────────────────────────────
// #17676 ruling A' item 3 — the service-ABSENT composition, and the restart
// split it exists to express.
//
// ⚠️ SCOPE, stated so a green run here is not over-read. Ruling item 5's
// acceptance is that THREE probes agree after a restart — Studio's writable
// list, `GET /api/v1/data/<ns>_<obj>` and
// `GET /api/v1/meta/object/<ns>_<obj>/published`. Those are HTTP surfaces over
// a booted composition; this is a unit tier over the protocol primitive, and a
// unit tier cannot restart a server. What it CAN do is pin the one fact the
// three probes disagree about — whether the package crosses the boundary at
// all — at the seam that decides it. The registry reads below stand for the
// first two probes (both are registry-backed: the dispatcher's
// `/api/v1/packages` list and `getMetaItems({type:'package'})` feed Studio's
// selector; the data route needs the object registered), and the `find` read
// stands for the third (published `sys_metadata` rows, which no package state
// gates). ⛔ Item 5 is NOT met by these pins.
// ─────────────────────────────────────────────────────────────────────────────

describe("service-ABSENT host — the degraded path (#17676 ruling A' items 2/3)", () => {
it('installPackage registers in memory, writes NOTHING durable, and says so', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const { impl, registryCalls, publish, registry } = makeImpl({ packageService: false });

const res: any = await (impl as any).installPackage({
manifest: { id: 'com.example.leave', name: '请假' },
});

// The door still answers 201 — which is exactly why the card calls the
// failure invisible until a restart.
expect(res.package.status).toBe('installed');
expect(registryCalls).toHaveLength(1);
expect(registry.getPackage('com.example.leave')).toBeTruthy();
// Durable half never ran: there was no service to run it.
expect(publish).not.toHaveBeenCalled();
// ⭐ Absence must be loud (AGENTS.md, Route & surface ownership §3): the
// branch is allowed to degrade, never to degrade SILENTLY.
const said = warn.mock.calls.map((c) => String(c[0]));
expect(said.some((m) => m.includes("no 'package' service"))).toBe(true);
expect(said.some((m) => m.includes('will not survive a restart'))).toBe(true);
} finally {
warn.mockRestore();
}
});

it('updatePackage degrades the same way — the ruling names BOTH primitives', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const { impl, publish, registry } = makeImpl({
packageService: false,
hydrate: [{ id: 'com.example.leave', name: '请假', version: '0.1.0' }],
});

const res: any = await (impl as any).updatePackage({
packageId: 'com.example.leave',
patch: { name: '请假 v2' },
});

expect(res.package.manifest.name).toBe('请假 v2');
expect(registry.getPackage('com.example.leave').manifest.name).toBe('请假 v2');
expect(publish).not.toHaveBeenCalled();
const said = warn.mock.calls.map((c) => String(c[0]));
expect(said.some((m) => m.includes("no 'package' service"))).toBe(true);
expect(said.some((m) => m.includes('will not survive a restart'))).toBe(true);
} finally {
warn.mockRestore();
}
});
});

describe("the restart split this card reported (#17676 ruling A' items 3/5)", () => {
/** The durable `sys_packages` table — the only package state a restart keeps. */
const makeSysPackages = () => {
const rows = new Map<string, { id: string; [key: string]: unknown }>();
return {
rows,
publish: async (d: any) => {
rows.set(d.manifest.id, d.manifest);
return { success: true };
},
};
};

/**
* The published object metadata, which lives in `sys_metadata` and is gated
* by nothing the package registry holds — that asymmetry IS the card's
* contradiction: "the published metadata outlives both the package and the
* runtime registration".
*/
const publishedMetadata = [{ name: 'leave_request', _packageId: 'com.example.leave' }];

it('service ABSENT ⇒ the package does not cross the boundary, its metadata does', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const sysPackages = makeSysPackages();

// ── process 1 ─────────────────────────────────────────────────────────
const before = makeImpl({ packageService: false, publish: sysPackages.publish });
await (before.impl as any).installPackage({
manifest: { id: 'com.example.leave', name: '请假' },
});
expect(before.registry.getPackage('com.example.leave')).toBeTruthy();
expect([...sysPackages.rows.keys()]).toEqual([]);

// ── restart ───────────────────────────────────────────────────────────
const after = makeImpl({
packageService: false,
hydrate: [...sysPackages.rows.values()],
find: async () => publishedMetadata,
});

// Probe 1 — Studio: `No writable packages yet`.
expect(after.registry.getAllPackages()).toEqual([]);
// Probe 2 — the data route: `Object '<ns>_<obj>' is not registered`.
expect(after.registry.getPackage('com.example.leave')).toBeUndefined();
// Probe 3 — published metadata: still 200. The three DISAGREE.
expect(await after.engine.find('sys_metadata', {})).toEqual(publishedMetadata);
} finally {
warn.mockRestore();
}
});

it('CONTROL — service PRESENT ⇒ all three agree after the same restart', async () => {
const sysPackages = makeSysPackages();

const before = makeImpl({ publish: sysPackages.publish });
await (before.impl as any).installPackage({
manifest: { id: 'com.example.leave', name: '请假' },
});
// The durable half ran, so the restart has something to replay.
expect([...sysPackages.rows.keys()]).toEqual(['com.example.leave']);

const after = makeImpl({
publish: sysPackages.publish,
hydrate: [...sysPackages.rows.values()],
find: async () => publishedMetadata,
});

expect(after.registry.getAllPackages()).toHaveLength(1);
expect(after.registry.getPackage('com.example.leave')).toBeTruthy();
expect(await after.engine.find('sys_metadata', {})).toEqual(publishedMetadata);
});
});
31 changes: 29 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22325,8 +22325,31 @@ export class ObjectStackProtocolImplementation implements
* rows back into the registry on boot).
*
* The DB write is best-effort and non-fatal: when the `package` service is
* absent (e.g. the `marketplace` capability is off) the package is still
* registered in-memory and visible for the lifetime of the process.
* absent the package is still registered in-memory and visible for the
* lifetime of the process — and that in-memory-only branch STAYS, as the
* documented degraded path for reduced hosts (#17676 ruling A' item 2,
* decision batch #125 item 2). ⛔ It is not a bug to delete: a host that
* mounts no provider (`objectstack serve --preset minimal`, a metadata-only
* embedding) must still be able to install a package for the life of its
* process, and the `warn` below is what keeps the degradation from being
* silent.
*
* Which capability OWNS the service is no longer `marketplace`: ruling A'
* item 1 split the persistence half — the `sys_packages` container and the
* boot hydration that replays it — out under its own always-on token
* `package-registry` (`PLATFORM_ALWAYS_ON_CAPABILITIES`,
* `packages/spec/src/kernel/platform-capabilities.ts`), leaving
* `marketplace` naming only the optional catalogue / browsing half. ⚠️ The
* runtime half of that split is NOT landed: measured on `origin/main` at
* c334ba0f3a, `Serve.CAPABILITY_PROVIDERS`
* (`packages/cli/src/commands/serve.ts`) keys `marketplace` and does not key
* `package-registry`, so the always-on token is force-appended to every
* app's `requires` and then resolves to no provider — silently, because the
* resolver only warns for tokens outside the vocabulary. ⇒ on a stock
* `objectstack dev` boot of an app that does not itself declare
* `requires: ['marketplace']`, this branch is still the one taken, which is
* the defect #17676 reports. Recorded here rather than worked around: the
* fix belongs to the capability resolver, not to this primitive.
*
* [#19277] `request.enableOnInstall` is HONOURED here, under the same rule
* the HTTP door implements — 「缺省 = 保持,有旗 = 设置」: `true` enables,
Expand Down Expand Up @@ -22469,6 +22492,10 @@ export class ObjectStackProtocolImplementation implements
* service so the edit survives a restart. Persistence is best-effort and
* non-fatal (matching `installPackage`): the registry write already
* succeeded, so a persist failure is logged, never thrown.
*
* The service-absent branch below is the same documented degraded path
* #17676 ruling A' item 2 keeps — see `installPackage`'s note for which
* capability owns the service and for the measured state of that split.
*/
async updatePackage(request: {
packageId: string;
Expand Down
Loading