Skip to content

Commit bbf5a12

Browse files
committed
fix(metadata-protocol): every promote-door text whose draft is bound to a package names it in its publish step
A publish naming no package resolves by the rows the deletes leave, and can then address another draft (packagelessDraftAddress). The step is now the request itself, POST .../publish?package=PKG, measured over every combination of stored rows, drafts and naming at the promote door. Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2e4ade0 commit bbf5a12

2 files changed

Lines changed: 61 additions & 6 deletions

File tree

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19696,7 +19696,12 @@ export class ObjectStackProtocolImplementation implements
1969619696
+ `Delete the '${q}' row first (REST: DELETE ${path}?package=${q}), then send this save again: it `
1969719697
+ 'then saves the package-less definition.' + tail;
1969819698
} else if (rows.length > 0) {
19699-
const finish = state.at === 'promote' ? 'publish this draft again' : `save it naming '${q}' in publish mode`;
19699+
// [#23514] A promotion's last step NAMES the draft's package: a
19700+
// publish naming none resolves by the rows the deletes leave, and
19701+
// can then address another draft (`packagelessDraftAddress`).
19702+
const finish = state.at === 'promote'
19703+
? `POST ${path}/publish?package=${q}`
19704+
: `save it naming '${q}' in publish mode`;
1970019705
// Every step is a request, in order, so the whole way out fits the
1970119706
// REST message bound with two packages' rows and long names.
1970219707
message = `Object '${name}' is served by its package-less definition; ${rowsServed}. To move it into `
@@ -19707,7 +19712,7 @@ export class ObjectStackProtocolImplementation implements
1970719712
// promotion is the save that moves the object (it registers).
1970819713
message = `Move object '${name}' into package '${q}' first: delete its package-less definition without `
1970919714
+ `dropStorage, which keeps its table and records (REST: DELETE ${path} with no package `
19710-
+ 'parameter), then publish this draft again: it then serves the object at once.' + tail;
19715+
+ `parameter), then POST ${path}/publish?package=${q}: the draft then serves the object at once.` + tail;
1971119716
} else if (state.mode === 'draft') {
1971219717
message = `Move object '${name}' into package '${q}' first: delete its package-less definition without `
1971319718
+ `dropStorage, which keeps its table and records (REST: DELETE ${path} with no package parameter), `

‎packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -646,9 +646,11 @@ describe('#23514 — a save naming a package beside an object its package-less d
646646
expect(promoteErr?.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH');
647647
expect(promoteErr?.status).toBe(422);
648648
expect(promoteErr?.message).toMatch(pRowDelete);
649-
expect(promoteErr?.message).toContain('then publish this draft again.');
649+
expect(promoteErr?.message).toContain(
650+
`then POST /api/v1/meta/object/${NAME}/publish?package=${Q}.`,
651+
);
650652

651-
// The walk the promote text names: P's row, the package-less definition, then the same publish.
653+
// The walk the promote text names: P's row, the package-less definition, then the publish naming Q.
652654
expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true);
653655
expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true);
654656
expect((await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true);
@@ -661,6 +663,53 @@ describe('#23514 — a save naming a package beside an object its package-less d
661663
expect(drops).toEqual([]);
662664
});
663665

666+
it('another package\'s row `[package-less, P]` with BOTH P\'s draft and a draft bound to no package, published naming no package: the text names P in its publish step, and that walk promotes P\'s draft', async () => {
667+
const stores: Stores = new Map();
668+
const drops: string[] = [];
669+
const k = await seedLegacyRows(stores, drops, [P]);
670+
const rows = stores.get('sys_metadata')!;
671+
const unbound = Array.from(rows.values())
672+
.find((r) => r.type === 'object' && r.name === NAME && r.package_id == null && r.state === 'active')!;
673+
const asDraft = (id: string, pkg: string | null, label: string) => {
674+
const body = typeof unbound.metadata === 'string'
675+
? JSON.parse(unbound.metadata as string)
676+
: { ...(unbound.metadata as Record<string, unknown>) };
677+
body.label = label;
678+
rows.set(id, {
679+
...unbound, id, package_id: pkg, state: 'draft',
680+
metadata: typeof unbound.metadata === 'string' ? JSON.stringify(body) : body,
681+
});
682+
};
683+
asDraft('unbound_draft', null, 'Gadget unbound draft');
684+
asDraft('p_draft', P, 'Gadget P draft');
685+
686+
// The address is P's draft (one active binding, and P's draft stored); D9.9 refuses it.
687+
const err = await k.protocol.publishMetaItem({ type: 'object', name: NAME }).then(() => null, (e: any) => e);
688+
expect(err?.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH');
689+
expect(err?.status).toBe(422);
690+
expect(err?.packageId).toBe(P);
691+
expect(err?.message).toMatch(new RegExp(`^Object '${NAME}' is served by its package-less definition; `));
692+
expect(err?.message).toContain(
693+
`then POST /api/v1/meta/object/${NAME}/publish?package=${P}.`,
694+
);
695+
696+
// The walk, in the named order: P's row, the package-less definition, then the publish NAMING P.
697+
expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true);
698+
expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true);
699+
expect((await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true);
700+
// P's draft is live in P; the unbound draft is left stored, unpublished.
701+
expect(await served(k)).toEqual({
702+
owner: P, dataPlaneLabel: 'Gadget P draft', metaReadLabel: 'Gadget P draft', records: 2,
703+
});
704+
705+
await k.kernel.shutdown();
706+
const k2 = await boot(stores, drops);
707+
expect(await served(k2)).toEqual({
708+
owner: P, dataPlaneLabel: 'Gadget P draft', metaReadLabel: 'Gadget P draft', records: 2,
709+
});
710+
expect(drops).toEqual([]);
711+
});
712+
664713
it('another package\'s row `[package-less, P]` and a draft bound to NO package, published naming no package: the text says the publish lands package-less, and names the move into P after it', async () => {
665714
const stores: Stores = new Map();
666715
const drops: string[] = [];
@@ -789,13 +838,14 @@ describe('#23514 — a save naming a package beside an object its package-less d
789838
expect(err.ownerPackageId).toBeNull();
790839
expect(err.message).toMatch(new RegExp(
791840
`^Move object '${NAME}' into package '${Q.replace(/\./g, '\\.')}' first: delete its package-less `
792-
+ 'definition without dropStorage, .*then publish this draft again',
841+
+ `definition without dropStorage, .*then POST /api/v1/meta/object/${NAME}/publish\\?package=`
842+
+ `${Q.replace(/\./g, '\\.')}: the draft then serves the object at once`,
793843
));
794844
expect(await served(k)).toEqual({
795845
owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2,
796846
});
797847

798-
// The order the refusal names: the delete, then the same publish.
848+
// The order the refusal names: the delete, then the publish naming Q.
799849
expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true);
800850
expect(k.engine.registry.getObjectOwner(NAME)).toBeUndefined();
801851
expect((await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true);

0 commit comments

Comments
 (0)