Skip to content
Merged
18 changes: 18 additions & 0 deletions .changeset/24210-metadata-protocol-drafts-disabled-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol)!: `listDrafts` omits a disabled package's drafts, as the list read omits its items

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of the pending-drafts list for drafts whose owning package is disabled: no stored metadata changes shape, no spec key, export or error code is added or removed, and nothing must be rewritten; re-enabling the package restores the list -->

**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

`GET /meta/_drafts` (both transports) listed a disabled package's pending drafts, while the by-name draft read answers them `404 NO_DRAFT` and the list read omits the package's items.

- **Omitted now:** every draft whose `packageId` names a disabled package, with or without `?packageId=`. The predicate is the one the list and by-name reads ask. Drafts of the `package` type are never hidden.
- **Unchanged:** the package batch doors (`POST /packages/:id/publish-drafts`, `/discard-drafts`) still publish and discard a disabled package's drafts; they read the store directly. Other packages' drafts are listed as before.

What changes for you: re-enable the package to see its pending drafts in the list again.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/rest': minor
---

fix(rest)!: `/published` and `/meta/object/:name/state/:field` answer a disabled package's item as the by-name read does, and the event and diff doors' absence is the plain read's body

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of two runtime read doors for an item whose owning package is disabled, plus the body of a 404 that already carried this status and code: no stored metadata changes shape, no spec key, export or error code is added or removed, and nothing must be rewritten; re-enabling the package restores the reads -->

**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

- **`GET /meta/:type/:name/published`, every type but `object` and `package`:** a disabled package's item is answered as a name with nothing behind it (`404 NOT_FOUND`, byte for byte), on both exits: the stored published row and the code/package snapshot. The owner is the one the by-name read's protection envelope names (`packageId`). Objects already answered `404 OBJECT_PACKAGE_DISABLED` here.
- **`GET /meta/object/:name/state/:field`:** a disabled package's object is refused `404 OBJECT_PACKAGE_DISABLED`, what `GET /meta/object/:name` answers, as the runtime dispatcher's twin already does. The schema is not read.
- **`/history`, `/audit` and `/diff`:** the `404 RESOURCE_NOT_FOUND` a disabled package's gated item gets is now the plain read's nested body, `{ "error": { "code": "RESOURCE_NOT_FOUND", "message": "Metadata item not found or access denied." } }`, instead of the flat `{ "error": "…", "code": "RESOURCE_NOT_FOUND" }`. Status and code are unchanged; read `body.error.code`.
- **Unchanged:** items of enabled packages, a deleted item, and a name with nothing behind it.

What changes for you: re-enable the package to read its items' published versions and its objects' state machines again.
16 changes: 16 additions & 0 deletions .changeset/24210-runtime-published-disabled-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/runtime': minor
---

fix(runtime)!: the dispatcher's `/meta/:type/:name/published` answers a disabled package's non-object item as a missing name

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of one runtime read door for an item whose owning package is disabled: no stored metadata changes shape, no spec key, export or error code is added or removed, and nothing must be rewritten; re-enabling the package restores the read -->

**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

- **Absent now, `404 RESOURCE_NOT_FOUND` "Not found", byte for byte the missing name's answer:** `GET /meta/:type/:name/published` for an item of every type but `object` and `package` whose owning package is disabled, on the stored published row and the code/package snapshot alike. The owner is the one the by-name read's protection envelope names (`packageId`), as `RestServer`'s `/published` asks it. Objects already answered `404 OBJECT_PACKAGE_DISABLED` here.
- **Unchanged:** items of enabled packages, and a name with nothing behind it.

What changes for you: re-enable the package to read its items' published versions again.
12 changes: 11 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25531,6 +25531,14 @@ export class ObjectStackProtocolImplementation implements
* promote and discard by, with the canonical member spelled out, as the
* by-name doors' `member` selector takes it back
* ({@link bundleMemberDiscriminator}); absent for a type with no members.
*
* [#7557, #24210] A disabled package's drafts are omitted, as the list
* read omits its items and the by-name draft read answers them `404
* NO_DRAFT`: each row's `packageId`, asked of the same predicate ({@link
* isItemOfDisabledPackage}); `package` is never hidden. This method serves
* the `_drafts` doors only. The package batch doors (publish, discard, the
* batch closure) read the repository's `listDrafts` directly, so a
* disabled package's drafts still publish and discard.
*/
async listDrafts(request?: {
packageId?: string;
Expand Down Expand Up @@ -25558,8 +25566,10 @@ export class ObjectStackProtocolImplementation implements
// [#23073] The declared header (`ListDraftsResponseSchema`), with the
// row's stored member on every draft of a bundled type: absent there
// means the canonical member, which is spelled out here.
const served = drafts.filter((draft) => draft.type === 'package'
|| !isItemOfDisabledPackage(this.engine?.registry, { _packageId: draft.packageId }));
return {
drafts: drafts.map(({ member: stored, ...header }) => {
drafts: served.map(({ member: stored, ...header }) => {
const member = bundleMemberDiscriminator(header.type, stored);
return member === undefined ? header : { ...header, member };
}),
Expand Down
28 changes: 28 additions & 0 deletions packages/objectql/src/package-disable-enforcement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,29 @@ const READERS: Array<{
servesByName(() => protocol.getMetaItem({ type, name: shippedName(type, PKG) }), (r) => r?.item),
},
]),
{
reader: 'listDrafts() — GET /meta/_drafts',
enforces: true,
why: '[#24210] the pending-changes list omits a disabled package\'s drafts, as its by-name draft read answers NO_DRAFT',
serves: async ({ protocol }) =>
((await protocol.listDrafts())?.drafts ?? []).some((d: any) => d?.packageId === PKG),
},
{
reader: 'listDrafts({ packageId }) — GET /meta/_drafts?packageId=',
enforces: true,
why: '[#24210] naming the package does not bring its drafts back',
serves: async ({ protocol }) =>
((await protocol.listDrafts({ packageId: PKG }))?.drafts ?? []).length > 0,
},
{
reader: 'the repository\'s listDrafts — the package batch doors (publish-drafts, discard-drafts)',
enforces: false,
why: '[#24210] a disabled package\'s drafts still publish and discard: those doors define their batch here',
serves: async ({ protocol }) => {
await (protocol as any).ensureOverlayIndex();
return ((await (protocol as any).getOverlayRepo().listDrafts({ packageId: PKG })) ?? []).length > 0;
},
},
{
reader: 'assertObjectRegistered — the data plane (findData / getData funnel here)',
enforces: true,
Expand Down Expand Up @@ -587,6 +610,11 @@ describe('#7557 (A) — a disabled package stops serving, on every API reader at
expect(served).toEqual(allTrue(served));
const app: any = await h.protocol.getMetaItem({ type: 'app', name: 'healthy_app' });
expect(app?.item?._packageId).toBe(OTHER_PKG);
// [#24210] …and its pending drafts are still listed, both of them.
const drafts = (await h.protocol.listDrafts()).drafts;
expect(drafts.filter((d) => d.packageId === OTHER_PKG).map((d) => `${d.type}/${d.name}`).sort())
.toEqual(['app/healthy_app', 'object/healthy_widget']);
expect(drafts.filter((d) => d.packageId === PKG)).toEqual([]);
});

it('[#24210] the protection envelope stays: the write doors\' verdict on the address is not the item', async () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/rest/src/meta-alternate-door-read-gates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,8 @@ describe('[#20156] edges', () => {
const history = await drive(rest, '/history', 'doc', 'crm_admin_runbook');

expect(envelope(diff)).toEqual({ status: 404, code: 'RESOURCE_NOT_FOUND' });
// [#24210] Unchanged by the withheld-item answer: already the plain read's nested body.
expect(diff.body).toEqual({ error: { code: 'RESOURCE_NOT_FOUND', message: 'Metadata item not found or access denied.' } });
expect(text(diff)).not.toContain(DOC_SECRET);
expect(history.statusCode).toBe(200);
expect(Array.isArray(history.body?.events)).toBe(true);
Expand All @@ -1040,9 +1042,18 @@ describe('[#20156] edges', () => {
protocol.historyMetaItem.mockClear();
protocol.auditMetaItem.mockClear();
protocol.diffMetaItem.mockClear();
// [#24210] The plain read's own answer for this withheld item, the
// missing name's nested body (`sendMetaItemAbsent`), is the one
// these doors give, byte for byte: not the flat `{ error, code }`.
const plain = await drive(rest, '', type, name);
expect({ status: plain.statusCode, body: plain.body }).toEqual({
status: 404,
body: { error: { code: 'RESOURCE_NOT_FOUND', message: 'Metadata item not found or access denied.' } },
});
for (const suffix of ['/history', '/audit', '/diff']) {
const res = await drive(rest, suffix, type, name);
expect(envelope(res), `${type} ${suffix} withheld`).toEqual({ status: 404, code: 'RESOURCE_NOT_FOUND' });
expect(text(res), `${type} ${suffix} withheld body`).toBe(text(plain));
expect(text(res), `${type} ${suffix}`).not.toContain('w_open_matters');
expect(text(res), `${type} ${suffix}`).not.toContain('nav_payroll_runs');
}
Expand Down
Loading
Loading