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
5 changes: 5 additions & 0 deletions .changeset/draft-package-inherit-11087.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/metadata-protocol': patch
---

A package-less `state='draft'` save inherits the overlaid active row's `package_id` (#11087) — so package-scoped consumers (`listDrafts({packageId})`, per-package publish, pending-changes surfaces) count the draft instead of orphaning it — with in-place adoption of pre-fix NULL-package orphan drafts (never forked into a second row). Explicit `packageId` is never overridden; a brand-new item drafted first keeps package-less semantics.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#11087] Draft-save package inheritance.
*
* A `state='draft'` save is a pending change OVER the published row, and every
* package-scoped consumer (`listDrafts({ packageId })`, the console's
* pending-changes surfaces, `publishPackageDrafts`) keys drafts by
* `package_id`. A caller that names no base — the console's plain
* `PUT …?mode=draft` — used to stamp NULL even when the overlaid active row is
* package-bound, producing an "orphan draft" no package view counts and no
* per-package publish can promote. Measured live on a cloud tenant
* (cloud#1593): `GET /meta/_drafts` listed the draft, `?packageId=` listed
* nothing, and the build surface's pending-changes bar stayed dark over a
* publishable change.
*
* Pinned here:
* 1. inheritance — a package-less draft save over a bound active row adopts
* the active row's binding, and the scoped listDrafts counts it;
* 2. an EXPLICIT packageId is never overridden (ADR-0048: callers state
* their scope);
* 3. a brand-new item drafted first (no active row) keeps package-less
* semantics;
* 4. orphan adoption — a pre-fix NULL-package draft for the same
* (org, type, name) is UPDATED and adopted, never forked into a second
* draft row.
*/

import { describe, it, expect } from 'vitest';
// The engine-double contract gate: a fake looser than ObjectQL's own verb
// dispatch is how #4434 shipped a dead route with its suite green.
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { SysMetadataRepository } from './sys-metadata-repository.js';

interface Row {
[k: string]: unknown;
}

function makeFakeEngine(seed: Row[] = []) {
let nextId = 1;
const rows: Row[] = seed.map((r) => ({ id: `seed_${nextId++}`, ...r }));
const history: Row[] = [];

const matches = (row: Row, where: Record<string, unknown>): boolean => {
for (const [k, v] of Object.entries(where)) {
if (k === '$or') {
const branches = v as Array<Record<string, unknown>>;
if (!branches.some((b) => matches(row, b))) return false;
continue;
}
if (k.startsWith('$')) {
// Refuse combinators this double does not implement — a silent
// field-name read is how a fake matcher lies (WHERE-matcher gate).
throw new Error(`fake matcher: unimplemented combinator ${k}`);
}
const rv = row[k] ?? null;
if ((v ?? null) !== rv) return false;
}
return true;
};

const tableOf = (name: string): Row[] => (name === 'sys_metadata' ? rows : history);

return {
rows,
history,
async findOne(table: string, q: { where: Record<string, unknown> }) {
return tableOf(table).find((r) => matches(r, q.where)) ?? null;
},
async find(table: string, q: { where: Record<string, unknown> }) {
return tableOf(table).filter((r) => matches(r, q.where));
},
async insert(table: string, data: Row) {
const row = { id: `row_${nextId++}`, ...data };
tableOf(table).push(row);
return row;
},
async update(table: string, data: Row, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
const row = tableOf(table).find((r) => matches(r, opts.where));
if (row) Object.assign(row, data);
return row;
},
async delete(_table: string, opts: { where?: Record<string, unknown> }) {
assertEngineDeleteDispatch(opts);
/* not exercised here */
},
};
}

const REF = { org: 'system', type: 'view' as const, name: 'k9qk_member.member_list' };

function makeRepo(engine: ReturnType<typeof makeFakeEngine>) {
return new SysMetadataRepository({
engine: engine as never,
organizationId: null,
orgLabel: 'env',
} as never);
}

describe('SysMetadataRepository draft-save package inheritance (#11087)', () => {
it('a package-less draft save over a bound active row inherits the binding, and scoped listDrafts counts it', async () => {
const engine = makeFakeEngine();
const repo = makeRepo(engine);
await repo.put(REF, { label: 'Member' }, { parentVersion: null, actor: 't', packageId: 'app.k9qk' });
const active = engine.rows.find((r) => r.state === 'active')!;
expect(active.package_id).toBe('app.k9qk');

await repo.put(REF, { label: 'Member', description: 'edited' }, { parentVersion: null, actor: 't', state: 'draft' as const });
const draft = engine.rows.find((r) => r.state === 'draft')!;
expect(draft.package_id).toBe('app.k9qk');

const scoped = await repo.listDrafts({ packageId: 'app.k9qk' });
expect(scoped.map((d) => d.name)).toEqual(['k9qk_member.member_list']);
});

it('an explicit packageId on the draft save is never overridden by inheritance', async () => {
const engine = makeFakeEngine();
const repo = makeRepo(engine);
await repo.put(REF, { label: 'Member' }, { parentVersion: null, actor: 't', packageId: 'app.k9qk' });
await repo.put(REF, { label: 'Member v2' }, { parentVersion: null, actor: 't', state: 'draft' as const, packageId: 'app.other' });
const draft = engine.rows.find((r) => r.state === 'draft')!;
expect(draft.package_id).toBe('app.other');
});

it('a brand-new item drafted first keeps package-less semantics (nothing to inherit)', async () => {
const engine = makeFakeEngine();
const repo = makeRepo(engine);
await repo.put(REF, { label: 'Member' }, { parentVersion: null, actor: 't', state: 'draft' as const });
const draft = engine.rows.find((r) => r.state === 'draft')!;
expect(draft.package_id ?? null).toBeNull();
});

it('adopts a pre-fix orphan draft (NULL package) instead of forking a second draft row', async () => {
const engine = makeFakeEngine([
{
type: REF.type, name: REF.name, organization_id: null, state: 'active',
package_id: 'app.k9qk', metadata: '{"label":"Member"}', checksum: 'sha-active', version: 1,
},
{
type: REF.type, name: REF.name, organization_id: null, state: 'draft',
package_id: null, metadata: '{"label":"Member","description":"orphan"}', checksum: 'sha-orphan', version: 2,
},
]);
const repo = makeRepo(engine);
await repo.put(
REF,
{ label: 'Member', description: 'orphan edited' },
{ parentVersion: 'sha-orphan', actor: 't', state: 'draft' as const },
);
const drafts = engine.rows.filter((r) => r.state === 'draft');
expect(drafts).toHaveLength(1); // updated in place, never forked
expect(drafts[0].package_id).toBe('app.k9qk'); // adopted into the package
});
});
35 changes: 33 additions & 2 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -426,18 +426,49 @@ export class SysMetadataRepository implements MetadataRepository {
// overlay. Callers state their scope; this line no longer decides it
// anywhere but for the documented `PutOptions.packageId` default
// (omitted/undefined = the env-local, unbound row).
const targetPackageId: string | null = opts.packageId ?? null;
let targetPackageId: string | null = opts.packageId ?? null;
// [#11087] Draft-save package inheritance. A `state='draft'` save is a
// pending change OVER the published row, and every package-scoped consumer
// — `listDrafts({ packageId })`, the console's pending-changes surfaces,
// `publishPackageDrafts` — keys drafts by `package_id`. A caller that names
// no base (the console's plain `?mode=draft` save) used to stamp NULL even
// when the row it overlays is package-bound, producing an "orphan draft"
// that no package view counts and no per-package publish can ever promote
// (measured live: `_drafts` lists it, `_drafts?packageId=` does not).
// Inherit the overlaid ACTIVE row's binding instead. Explicit
// `opts.packageId` is untouched (a caller that states its base keeps it,
// ADR-0048), and with no active row — a brand-new item drafted first —
// there is nothing to inherit and the package-less semantics stand.
if (state === 'draft' && opts.packageId == null) {
const activeRow = await this.engine.findOne('sys_metadata', {
where: this.whereFor(ref, 'active'),
});
const activePkg = (activeRow as { package_id?: string | null } | null)?.package_id ?? null;
if (activePkg) targetPackageId = activePkg;
}

// Run all reads + writes inside one transaction so the optimistic
// lock, the parent-row mutation, and the history append are atomic.
const result = await this.withTxn(async (ctx) => {
// ADR-0048 — scope the existing-row lookup to the requested package so a
// save for package B does not find (and overwrite) package A's same-name
// overlay. A package-less save (packageId null) targets the global row.
const existing = await this.engine.findOne('sys_metadata', {
let existing = await this.engine.findOne('sys_metadata', {
where: this.whereFor(ref, state, targetPackageId),
context: ctx,
});
// [#11087] Orphan-draft adoption: when the package binding above was
// INHERITED (caller named none), a pre-fix draft for the same
// (org, type, name) sits at `package_id NULL` and the scoped lookup
// misses it — creating a second draft row would fork the pending change.
// Re-read the package-less row and update THAT one; the stamp below
// (`existingPkg ?? targetPackageId`) then adopts it into the package.
if (!existing && state === 'draft' && opts.packageId == null && targetPackageId !== null) {
existing = await this.engine.findOne('sys_metadata', {
where: this.whereFor(ref, state, null),
context: ctx,
});
}
const existingHash: string | null = existing?.checksum ?? null;
if (opts.parentVersion !== existingHash) {
throw new ConflictError(this.fullRef(ref), opts.parentVersion, existingHash);
Expand Down
10 changes: 10 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -871,6 +871,16 @@
"verb": "update",
"pinned": 1
},
{
"file": "packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts",
"verb": "delete",
"pinned": 1
},
{
"file": "packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts",
"verb": "update",
"pinned": 1
},
{
"file": "packages/metadata-protocol/src/sys-metadata-repository.history-counters.test.ts",
"verb": "delete",
Expand Down
Loading