Skip to content

Commit b62260c

Browse files
fix(objectql): an item registered through the registry path is served with its package's _packageVersion (#22724)
Fixes #22689 Clause-②: no ## What was wrong `_packageVersion` is declared on the spec's protection envelope (`packages/spec/src/kernel/metadata-protection.zod.ts`: "Owning package version."). The metadata plugin's artifact loader stamps it through `applyProtection(item, { packageId, packageVersion })`. The registry load path, `ObjectQL.registerApp(manifest)` into `SchemaRegistry.registerItem` / `registerObject`, called `applyProtection(item, { packageId })` and stamped the id alone. The comment above that call said both paths "produce identical lock state". They did not: `GET /api/v1/meta/app` served a registry-registered app with `_packageId` and no `_packageVersion`, so a console waiting on an upgrade could not tell the new app from the old one a stale kernel still serves (the upgrade case behind objectstack-ai/objectui#12087). Premise checked on `origin/main` `61bea242`: both `applyProtection` sites in `registry.ts` passed `{ packageId }` only. Measured before the change: the served app had `_packageVersion` undefined (ablation A below is that reading). ## How the version reaches the registry The caller that holds a package's manifest stamps each item **before** handing it to the registry, with the artifact loader's own call, `applyProtection(item, { packageId, packageVersion })`. `applyProtection` writes a key only while it is unset, so the registry's own id-only stamp leaves the caller's envelope as stamped. **The registry's public signatures are unchanged** (`SchemaRegistry.registerItem`, `registerObject` and `registerApp`, signature and JSDoc as on `main`), and both of its `applyProtection` calls still pass `{ packageId }` alone. No version table, no second manifest list, no lookup in the registry's installed-package record (which would stamp a tenant row merely bound to a package with a version it was never shipped at), no implicit context and no new export. - `ObjectQL.registerApp(manifest)`, the private `registerPlugin` and the private `registerMetadataCollections` stamp through two module-local helpers in `engine.ts`: - `stampPackage` stamps apps, collection items, view items, seed data and extensions in place, as the registry already mutates them on `main`; - `stampObjectPackage` stamps a shallow **copy** of each authored object definition, so a payload registered twice (the ADR-0130 D7 pin) is never mutated. The test pins this: "the authored object definition is left unstamped". - Both do nothing when `manifest.version` is absent. A nested plugin's items carry the parent's version, as they carry its id. - `ObjectQLPlugin`'s residual registration (`registerUnclaimedTopLevelObjects`, landed from `main` in PR #22701 after dispatch) stamps a shallow copy of each residual object with `unclaimedTopLevel(stack)`'s `(ownerId, ownerVersion)` before `registerObject`, the pair the metadata door stamps the residual with. **`Clause-②: no`, by measurement:** the declaration closure that `@objectstack/objectql`'s `.` and `./core` entries reach is byte-identical to `origin/main` (`e5899a67d6`): 3 of 3 closure files and 6 of 6 dist declarations. A determinism control (head versus a rebuilt head) is identical. A positive control carrying round 0's trailing parameter differs in 12 places, so the instrument sees a widening. ## Callers that have no version, and stamp the id alone `applyProtection` never invents a version, so these are unchanged: - `SchemaRegistry.registerKind` and `registerItem('plugin', manifest, 'id')`: no package id at all. - `ObjectQLPlugin.loadMetadataFromService`, `ingestReloadedObjects` and the per-object refresh, and `MetadataFacade.register`: they pass the item's own `_packageId`. An item that came through the artifact loader already carries `_packageVersion`, and `applyProtection` keeps it. - The metadata-protocol stored-row paths (hydration, write-through, rollback): `registerItem` with no package id, or `registerObject` of a `_provenance: 'org'` row under its binding. These are tenant-authored, not shipped by a package version. - plugin-security's built-ins (`registerBuiltinPositions` / `registerBuiltinCapabilities`): the plugin's own id. - `ObjectQL.registerObject(schema, packageId, namespace)`: the public proxy, unchanged. The comment above `registerItem`'s `applyProtection` call now says this instead of "identical lock state". ## Files outside the claimed surface The claim named `registry.ts`, the registry's callers inside `packages/objectql/src/`, tests there, and one changeset. Two more files changed. Both are test pins that asserted the asymmetry this card closes, and both went red with the change: - `packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts`: "the registry copy carries no `scope` (and no `_packageVersion`)". It now expects the registry copy to carry `_packageVersion: '3.0.0'`, the version the door copy carries. - `packages/runtime/src/standalone-stack-security-registrar.test.ts` (the control case): `expect(got.capability[0]._packageVersion).toBeUndefined()`. It now expects `'2.0.0'`. The comment says `scope` is the one key that still tells the raw copy from the door's. ## Tests (round 1, head `73fc84cbed`) - New `packages/objectql/src/registry-package-version-stamp.test.ts`: - an app and an object registered through `ObjectQL.registerApp` are served with `_packageVersion` through the real list door; - an upgrade (1.0.0, then 1.1.0) is served with the new version; - a nested plugin's object and a collection item carry the owning version; - an id-only caller stamps the id alone; - the authored object definition is left unstamped; - CONTROL: the artifact loader door (`MetadataPlugin._parseAndRegisterArtifact`, unchanged) serves an identical envelope. - `manifest-service-residual-objects.test.ts`: the residual object carries the stack's version (`1.0.0`). - `engine.test.ts`: one manifest gained `version: '1.2.0'`, and its pin asserts `objectContaining({ name, _packageId, _packageVersion: '1.2.0' })`. The other call-shape pins are `main`'s. - `pnpm --filter @objectstack/objectql` vitest (`--project local`, two foreground shards): 397 files and 7,773 tests passed, 0 failed. Typecheck exit 0, with the test layer's pinned debt held. - `@objectstack/runtime`: typecheck exit 0. Vitest in two shards: 348 files, 0 failed. - **Round 0's CI red** on `7539a404` (`Test Core (6/6)`, `artifact-load-path.test.ts`'s ADR-0130 D7 pin) was caused by this card's first caller-side stamp mutating the authored object in place. It is fixed by stamping a copy, and the D7 test is green and unchanged. ## Reverse verification Both legs used `scripts/ablation-replace.mjs` from the committed head, with each restore proven (blob equal to HEAD, `git diff HEAD` empty): - **A:** `stampPackage`'s stamp removed. Predicted and observed: 5 red (the new file's app, upgrade, nested and control cases, plus the `engine.test.ts` version pin); the id-only, residual and D7 cases green. - **B:** the residual stamp removed. Predicted and observed: only the residual case red. ## Gates All 70 families that `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives for this diff ran at `73fc84cbed`, and each exited 0. `--ran`: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. `check:dual-build-cjs-loads` was first recorded as PREREQUISITE NOT MET in a recreated worktree and re-run after a full build, exit 0. A narrowed `eslint --no-inline-config` over the 8 changed `.ts` files reports 0 errors and 0 warnings. Repo-wide lint is CI's. CI on `73fc84cbed`: 31 success, 3 skipped. ## Line budget +213 / -34 across 9 files, against a suggested 150: - code: `engine.ts` +49/-18, `plugin.ts` +14/-4, `registry.ts` +10/-3 (comments only); - tests: the new file +112, and +15/-9 elsewhere; - changeset: +13. The new test file carries the cross-door control and the copy pin. ## Acceptance notes - `SchemaRegistry.updatePackageManifest` (the in-memory half of `PATCH /packages/:id`) can change a package record's `version` without restamping the items already registered. They keep the version they were installed with. This is a read-only inference; it was not exercised. - The objectui follow-up objectstack-ai/objectui#12098 consumes this field. The rebuild-pending read is cloud's (objectstack-ai/cloud#2760) and is not in this PR. --- _Generated by [Claude Code](https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5081090 commit b62260c

9 files changed

Lines changed: 213 additions & 34 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
An app, object or other item registered through `ObjectQL.registerApp` is now served with its package's `_packageVersion`, as the artifact loader already serves it
6+
7+
Clause-②: no
8+
9+
`_packageVersion` ("Owning package version") is declared on the protection envelope, and the metadata plugin's artifact loader stamps it. The registry load path stamped only `_packageId`: `ObjectQL.registerApp(manifest)` called `SchemaRegistry.registerItem` and `registerObject` with the package id alone. So `GET /api/v1/meta/app` served such an app with no version, and a console waiting for an upgrade could not tell the new app from the old one that a stale kernel still serves.
10+
11+
`registerApp` now stamps `manifest.version` on every item it registers: owned objects, object extensions, apps, every metadata collection, seed datasets, and the objects, app and collections of a nested plugin, which carry their owning package's version. A multi-package stack's residual top-level objects, which the engine registers under the stack's own id, now carry the stack's version too, as the metadata door stamps them. Each item is stamped before it reaches the registry, with the artifact loader's own call, `applyProtection(item, { packageId, packageVersion })`. The registry's stamp knows the id only, and it keeps a version that is already set. No signature changes: `SchemaRegistry.registerItem`, `registerObject` and `registerApp` keep theirs.
12+
13+
A caller that passes only a package id still stamps the id alone. No version is looked up or invented. Those callers are the metadata-service re-sync and the facade, which pass along the stamp the artifact loader already put on the item; the stored-row hydration, whose rows are tenant-authored; and the security catalog's built-ins.

‎packages/objectql/src/engine.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,17 @@ describe('ObjectQL Engine', () => {
129129
const manifest = {
130130
id: 'com.example.app',
131131
namespace: 'example',
132+
version: '1.2.0',
132133
objects: [
133134
{ name: 'task', fields: {} }
134135
]
135136
};
136137

137138
engine.registerApp(manifest);
139+
// The engine stamps the manifest's version before the registry sees
140+
// the object; the registry's own signature is unchanged.
138141
expect(SchemaRegistry.registerObject).toHaveBeenCalledWith(
139-
expect.objectContaining({ name: 'task' }),
142+
expect.objectContaining({ name: 'task', _packageId: 'com.example.app', _packageVersion: '1.2.0' }),
140143
'com.example.app',
141144
'example',
142145
'own'

‎packages/objectql/src/engine.ts‎

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ import {
250250
} from './secret-fields.js';
251251
import { assertGroupByNamesNoJsonStoredField } from './group-by-structured-json-door.js';
252252
import { assertAggregationFieldTypesAccepted } from './aggregate-field-type-door.js';
253-
import { pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared';
253+
import { applyProtection, pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared';
254254
import { SchemaRegistry, computeFQN, type ArtifactInstallScope } from './registry.js';
255255
import { expandSearchToFilter } from './search-filter.js';
256256
import { isSearchCompanionRequested, stripSearchCompanion } from './search-companion.js';
@@ -4069,6 +4069,32 @@ function cascadeSetAdd(index: Map<string, Set<string>>, object: string, id: unkn
40694069
return true;
40704070
}
40714071

4072+
/**
4073+
* [ADR-0010 §3.7] Stamp a manifest item with its owning package's
4074+
* `(packageId, packageVersion)` before the registry stores it: the call the
4075+
* artifact loader makes (`metadata/plugin.ts`), so both load paths serve one
4076+
* envelope. The registry's own `applyProtection` knows the id only, and
4077+
* `applyProtection` stamps a key only while it is unset, so the version
4078+
* stamped here is the one the item keeps. With no version this does nothing:
4079+
* the registry stamps the id, exactly as before.
4080+
*/
4081+
function stampPackage<T>(item: T, packageId: string, packageVersion: string | undefined): T {
4082+
if (packageVersion !== undefined) applyProtection(item as any, { packageId, packageVersion });
4083+
return item;
4084+
}
4085+
4086+
/**
4087+
* {@link stampPackage} for an authored OBJECT definition, on a shallow copy.
4088+
* The registry stamps an object schema on its own copy (`applySystemFields`
4089+
* and friends run first), so `registerApp` leaves the caller's definition
4090+
* unstamped, and a manifest registered twice, or recorded by a second install,
4091+
* shows the same objects both times. `applyProtection` writes top-level keys
4092+
* only, so a shallow copy holds every key it touches.
4093+
*/
4094+
function stampObjectPackage(def: ServiceObject, packageId: string, packageVersion: string | undefined): ServiceObject {
4095+
return packageVersion === undefined ? def : stampPackage({ ...def }, packageId, packageVersion);
4096+
}
4097+
40724098
export class ObjectQL implements IObjectQLEngine {
40734099
/**
40744100
* Ambient transaction store (ADR-0034). While a `transaction()` callback
@@ -7295,6 +7321,9 @@ export class ObjectQL implements IObjectQLEngine {
72957321
registerApp(manifest: any, scope?: ArtifactInstallScope) {
72967322
const id = manifest.id || manifest.name;
72977323
const namespace = manifest.namespace as string | undefined;
7324+
// Stamped beside `id` on every item below ({@link stampPackage}), so an
7325+
// item served from this path tells an upgrade's new item from its old one.
7326+
const version = manifest.version as string | undefined;
72987327
this.invalidateSummaryIndex(); // new objects may add/change summary fields
72997328
this.logger.debug('Registering package manifest', { id, namespace });
73007329

@@ -7329,7 +7358,7 @@ export class ObjectQL implements IObjectQLEngine {
73297358
if (Array.isArray(manifest.objects)) {
73307359
this.logger.debug('Registering objects from manifest (Array)', { id, objectCount: manifest.objects.length });
73317360
for (const objDef of manifest.objects) {
7332-
const fqn = this._registry.registerObject(objDef, id, namespace, 'own');
7361+
const fqn = this._registry.registerObject(stampObjectPackage(objDef, id, version), id, namespace, 'own');
73337362
this.logger.debug('Registered Object', { fqn, from: id });
73347363
}
73357364
} else {
@@ -7342,7 +7371,7 @@ export class ObjectQL implements IObjectQLEngine {
73427371
for (const [name, objDef] of Object.entries(manifest.objects) as [string, ServiceObject][]) {
73437372
// Ensure name in definition matches key
73447373
objDef.name = name;
7345-
const fqn = this._registry.registerObject(objDef, id, namespace, 'own');
7374+
const fqn = this._registry.registerObject(stampObjectPackage(objDef, id, version), id, namespace, 'own');
73467375
this.logger.debug('Registered Object', { fqn, from: id });
73477376
}
73487377
}
@@ -7365,7 +7394,7 @@ export class ObjectQL implements IObjectQLEngine {
73657394
indexes: ext.indexes,
73667395
};
73677396
// Register as extension (namespace is undefined since we're targeting by FQN)
7368-
this._registry.registerObject(extDef, id, undefined, 'extend', priority);
7397+
this._registry.registerObject(stampPackage(extDef, id, version), id, undefined, 'extend', priority);
73697398
this.logger.debug('Registered Object Extension', { target: targetFqn, priority, from: id });
73707399
}
73717400
}
@@ -7379,7 +7408,7 @@ export class ObjectQL implements IObjectQLEngine {
73797408
const appName = app.name || app.id;
73807409
if (appName) {
73817410
const resolved = namespace ? this.resolveNavObjectNames(app, namespace) : app;
7382-
this._registry.registerApp(resolved, id);
7411+
this._registry.registerApp(stampPackage(resolved, id, version), id);
73837412
this.logger.debug('Registered App', { app: appName, from: id });
73847413
}
73857414
}
@@ -7389,7 +7418,7 @@ export class ObjectQL implements IObjectQLEngine {
73897418
// This handles the case where the manifest IS the app definition (legacy/simple packages)
73907419
if (manifest.name && manifest.navigation && !manifest.apps?.length) {
73917420
const resolved = namespace ? this.resolveNavObjectNames(manifest, namespace) : manifest;
7392-
this._registry.registerApp(resolved, id);
7421+
this._registry.registerApp(stampPackage(resolved, id, version), id);
73937422
this.logger.debug('Registered manifest-as-app', { app: manifest.name, from: id });
73947423
}
73957424

@@ -7410,15 +7439,15 @@ export class ObjectQL implements IObjectQLEngine {
74107439
// 5. Register all other metadata types generically — the SAME seam the
74117440
// nested-plugin path uses (`registerMetadataCollections`), so the two
74127441
// entry points cannot answer differently for one collection (#7163).
7413-
this.registerMetadataCollections(manifest, id, 'manifest');
7442+
this.registerMetadataCollections(manifest, id, 'manifest', version);
74147443

74157444
// 6. Register seed data as metadata (keyed by target object name)
74167445
const seedData = (manifest as any).data;
74177446
if (Array.isArray(seedData) && seedData.length > 0) {
74187447
this.logger.debug('Registering seed data datasets', { id, count: seedData.length });
74197448
for (const dataset of seedData) {
74207449
if (dataset.object) {
7421-
this._registry.registerItem('data', dataset, 'object' as any, id);
7450+
this._registry.registerItem('data', stampPackage(dataset, id, version), 'object' as any, id);
74227451
}
74237452
}
74247453
}
@@ -7449,7 +7478,7 @@ export class ObjectQL implements IObjectQLEngine {
74497478
if (plugin && typeof plugin === 'object') {
74507479
const pluginName = plugin.name || plugin.id || 'unnamed-plugin';
74517480
this.logger.debug('Registering nested plugin', { pluginName, parentId: id });
7452-
this.registerPlugin(plugin, id, namespace);
7481+
this.registerPlugin(plugin, id, namespace, version);
74537482
}
74547483
}
74557484
}
@@ -7489,7 +7518,8 @@ export class ObjectQL implements IObjectQLEngine {
74897518
* @param parentId - The parent package ID (for ownership tracking)
74907519
* @param parentNamespace - The parent package's namespace (for FQN resolution)
74917520
*/
7492-
private registerPlugin(plugin: any, parentId: string, parentNamespace?: string) {
7521+
private registerPlugin(plugin: any, parentId: string, parentNamespace?: string, parentVersion?: string) {
7522+
// `parentVersion` is the owning package's version, stamped like `parentId`.
74937523
const pluginName = plugin.name || plugin.id || 'unnamed';
74947524
const pluginNamespace = plugin.namespace || parentNamespace;
74957525

@@ -7504,7 +7534,7 @@ export class ObjectQL implements IObjectQLEngine {
75047534
if (Array.isArray(plugin.objects)) {
75057535
this.logger.debug('Registering plugin objects (Array)', { pluginName, count: plugin.objects.length });
75067536
for (const objDef of plugin.objects) {
7507-
const fqn = this._registry.registerObject(objDef, ownerId, pluginNamespace, 'own');
7537+
const fqn = this._registry.registerObject(stampObjectPackage(objDef, ownerId, parentVersion), ownerId, pluginNamespace, 'own');
75087538
this.logger.debug('Registered Object', { fqn, from: pluginName });
75097539
}
75107540
} else {
@@ -7514,7 +7544,7 @@ export class ObjectQL implements IObjectQLEngine {
75147544
this.logger.debug('Registering plugin objects (Map)', { pluginName, count: entries.length });
75157545
for (const [name, objDef] of entries) {
75167546
objDef.name = name;
7517-
const fqn = this._registry.registerObject(objDef, ownerId, pluginNamespace, 'own');
7547+
const fqn = this._registry.registerObject(stampObjectPackage(objDef, ownerId, parentVersion), ownerId, pluginNamespace, 'own');
75187548
this.logger.debug('Registered Object', { fqn, from: pluginName });
75197549
}
75207550
}
@@ -7527,7 +7557,7 @@ export class ObjectQL implements IObjectQLEngine {
75277557
if (plugin.name && plugin.navigation) {
75287558
try {
75297559
const resolved = pluginNamespace ? this.resolveNavObjectNames(plugin, pluginNamespace) : plugin;
7530-
this._registry.registerApp(resolved, ownerId);
7560+
this._registry.registerApp(stampPackage(resolved, ownerId, parentVersion), ownerId);
75317561
this.logger.debug('Registered plugin-as-app', { app: plugin.name, from: pluginName });
75327562
} catch (err: any) {
75337563
this.logger.warn('Failed to register plugin as app', { pluginName, error: err.message });
@@ -7543,7 +7573,7 @@ export class ObjectQL implements IObjectQLEngine {
75437573
// list is no longer copied here to be patched one name at a time; the
75447574
// aggregated-view expansion, #7163, for why the loop BODY is no longer
75457575
// copied here either).
7546-
this.registerMetadataCollections(plugin, ownerId, 'nested plugin');
7576+
this.registerMetadataCollections(plugin, ownerId, 'nested plugin', parentVersion);
75477577
}
75487578

75497579
/**
@@ -7588,7 +7618,8 @@ export class ObjectQL implements IObjectQLEngine {
75887618
* @param ownerId The owning package id — stamped as ADR-0010 provenance.
75897619
* @param sourceLabel Human-readable source name for the `debug` line.
75907620
*/
7591-
private registerMetadataCollections(source: any, ownerId: string, sourceLabel: string) {
7621+
private registerMetadataCollections(source: any, ownerId: string, sourceLabel: string, ownerVersion?: string) {
7622+
// `ownerVersion` is the owning package's version, stamped like `ownerId`.
75927623
for (const key of METADATA_ARRAY_KEYS) {
75937624
const items = (source as any)?.[key];
75947625
if (!Array.isArray(items) || items.length === 0) continue;
@@ -7685,7 +7716,7 @@ export class ObjectQL implements IObjectQLEngine {
76857716
err.httpStatus = 422;
76867717
throw err;
76877718
}
7688-
this._registry.registerItem(pluralToSingular(key), toRegister, 'name' as any, ownerId);
7719+
this._registry.registerItem(pluralToSingular(key), stampPackage(toRegister, ownerId, ownerVersion), 'name' as any, ownerId);
76897720
// "Object has-many View" (ADR-0017): a `defineView` document
76907721
// aggregates an object's views. Register the container under the
76917722
// bare <object> key (above, back-compat) AND expand it into
@@ -7697,7 +7728,7 @@ export class ObjectQL implements IObjectQLEngine {
76977728
for (const w of vi._diagnostics?.warnings ?? []) {
76987729
this.logger.warn(`View expansion warning for '${vi.name}': ${w.message}`, { from: ownerId });
76997730
}
7700-
this._registry.registerItem('view', vi, 'name' as any, ownerId);
7731+
this._registry.registerItem('view', stampPackage(vi, ownerId, ownerVersion), 'name' as any, ownerId);
77017732
}
77027733
}
77037734
}
@@ -7740,7 +7771,7 @@ export class ObjectQL implements IObjectQLEngine {
77407771
continue;
77417772
}
77427773
const toRegister = body.name === itemName ? body : { ...body, name: itemName };
7743-
this._registry.registerItem('view', toRegister, 'name' as any, ownerId);
7774+
this._registry.registerItem('view', stampPackage(toRegister, ownerId, ownerVersion), 'name' as any, ownerId);
77447775
}
77457776
}
77467777
}

‎packages/objectql/src/manifest-service-residual-objects.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ describe('ADR-0130 D4 — the manifest service registers the residual\'s objects
160160
acme_note: RELEASE_ID,
161161
});
162162
expect((ql.registry.resolveObject('acme_note') as { _packageId?: string } | undefined)?._packageId).toBe(RELEASE_ID);
163+
// …with the stack's version beside it, as the metadata door stamps the residual (#22689).
164+
expect((ql.registry.resolveObject('acme_note') as { _packageVersion?: string } | undefined)?._packageVersion).toBe('1.0.0');
163165
expect(unservedLines(recorder)).toEqual([]);
164166
});
165167

‎packages/objectql/src/plugin.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { resolveArtifactPackageOrder, artifactPackageId, readDeploymentOrgScopin
1111
import { unclaimedTopLevel } from '@objectstack/metadata';
1212
import { applyConversionsToStoredItem } from '@objectstack/spec';
1313
import { StorageNameMapping } from '@objectstack/spec/system';
14+
import { applyProtection } from '@objectstack/spec/shared';
1415
// [#21777] The ONE "is this schema the remote's?" predicate, shared with `ObjectQL.syncSchemas`.
1516
import { isFederatedObject } from './federated-object.js';
1617
// [#22070] The builtin audit stamps are wrapped like a bound hook but registered in code.
@@ -104,13 +105,15 @@ function hasLoadMetaFromDb(service: unknown): service is ProtocolWithDbRestore {
104105
*/
105106
function unclaimedTopLevelObjects(
106107
stack: unknown,
107-
): { ownerId: string | undefined; objects: ServiceObject[] } | undefined {
108+
): { ownerId: string | undefined; ownerVersion: string | undefined; objects: ServiceObject[] } | undefined {
108109
const residual = unclaimedTopLevel(stack);
109110
if (!residual) return undefined;
110111
const objects = residual.items
111112
.filter((entry) => entry.type === 'object')
112113
.map((entry) => entry.item as ServiceObject);
113-
return objects.length > 0 ? { ownerId: residual.ownerId, objects } : undefined;
114+
return objects.length > 0
115+
? { ownerId: residual.ownerId, ownerVersion: residual.ownerVersion, objects }
116+
: undefined;
114117
}
115118

116119
/**
@@ -2307,7 +2310,7 @@ export class ObjectQLPlugin implements Plugin {
23072310
*/
23082311
private registerUnclaimedTopLevelObjects(
23092312
ctx: PluginContext,
2310-
residual: { ownerId: string | undefined; objects: ServiceObject[] },
2313+
residual: { ownerId: string | undefined; ownerVersion: string | undefined; objects: ServiceObject[] },
23112314
bodies: unknown[],
23122315
): void {
23132316
const ql = this.ql;
@@ -2325,7 +2328,14 @@ export class ObjectQLPlugin implements Plugin {
23252328
continue;
23262329
}
23272330
try {
2328-
ql.registry.registerObject(object, residual.ownerId, undefined, 'own');
2331+
// The metadata door stamps the residual with `(ownerId, ownerVersion)`. The
2332+
// registry's own stamp knows the id only and keeps a key already set, so
2333+
// the version is stamped here first — on a shallow copy, leaving the
2334+
// artifact's own object as the registry leaves it.
2335+
const stamped = residual.ownerVersion === undefined
2336+
? object
2337+
: applyProtection({ ...object } as any, { packageId: residual.ownerId, packageVersion: residual.ownerVersion }) as ServiceObject;
2338+
ql.registry.registerObject(stamped, residual.ownerId, undefined, 'own');
23292339
} catch (e) {
23302340
if (!(e instanceof ObjectOwnershipConflictError)) throw e;
23312341
unserved.push(`'${object.name}' (package '${e.existingPackageId}' already owns that name)`);

0 commit comments

Comments
 (0)