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
37 changes: 37 additions & 0 deletions .changeset/23495-draft-doors-one-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/spec': patch
---

fix(metadata-protocol)!: the per-item publish and the draft discard that name no package act on the draft a draft save that names no package writes, and are refused `409 RESOURCE_CONFLICT` where that save is; the draft save's receipt names the package it inherits (ADR-0048, ADR-0070)

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no authorable spelling, export or stored shape is removed, renamed or re-shaped, and no stored row is rewritten, converted or deleted by this change itself, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is two runtime write doors: a per-item publish or a draft discard through the metadata API that names no package is refused `409 RESOURCE_CONFLICT` for an item whose active rows two or more packages hold, and a publish naming no package answers `404 NO_DRAFT` where the only draft is bound to a package other than the item's own; the remedy is in the request a caller sends (name the package with `?package=`), never a rewrite of a package source or a stored row. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

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

A `sys_metadata` row is keyed by its package (ADR-0048), so one item can hold a draft per package. A draft save that names no package (`PUT /api/v1/meta/TYPE/NAME?mode=draft`) writes the draft of the package the item's active rows are bound to, and the strict draft read and the draft preview that name no package serve that draft. The other two draft doors did not take that address.

**FROM.** Over an item whose active rows are bound to package `P`:

- With a package-unbound draft stored beside `P`'s draft, `POST /api/v1/meta/TYPE/NAME/publish` promoted whichever draft the database returned first. On SQLite that is the package-unbound one, and it was promoted into a package-unbound active row that no read naming no package serves. The publish answered `200`, and the item read kept serving `P`'s row unchanged.
- With the only draft bound to another package `Q` (saved with `?mode=draft&package=Q`), the strict draft read answered `404 NO_DRAFT`, but the publish promoted `Q`'s draft into a second package's active row. After that, every save and delete of the item that names no package was refused `409`. `DELETE …?state=draft` removed `Q`'s draft.
- With only a package-unbound draft, the publish promoted it into a package-unbound active row beside `P`'s, which no read naming no package serves.
- With drafts bound to two or more packages, `DELETE …?state=draft` was refused `409 RESOURCE_CONFLICT`. The discard took its address from the draft rows, not from the active rows the draft save inherits from. An item never published into a package, with a package-unbound draft beside two or more packages' drafts, was refused the same way.

Over an item whose active rows two packages hold, the publish naming no package promoted the first draft it met, and the discard removed the draft of the one package the drafts were bound to, while the draft save naming no package was refused `409`. A draft save naming no package that inherited `P` answered "Saved … (env-wide, state=draft)", without the package.

**TO.** A per-item publish and a draft discard that name no package act on the draft the draft save naming no package writes:

- **Active rows bound to one package `P`.** They act on `P`'s draft, else the package-unbound draft. A publish promotes either one into `P`'s active row, the row the read naming no package serves next. A package-unbound draft is adopted into `P`, as the draft save adopts it. When neither exists, the publish answers `404 NO_DRAFT`, as the strict draft read does. Its message names the other packages' drafts and the remedy, `?package=`. The discard removes nothing (`reset: false`) and its receipt names the drafts that remain.
- **Active rows bound to two or more packages.** Both are refused `409 RESOURCE_CONFLICT` and change nothing, as the draft save naming no package is. The message names the packages and the remedy, `?package=`.
- **An item never published into a package.** Both act on the package-unbound draft, the one the draft save writes. If there is none, they act on the one package's draft, and are refused `409 RESOURCE_CONFLICT` when two or more packages hold drafts.

The draft save's receipt names the package the draft went into: "Saved view 'my_view' (env-wide, package=com.acme.app, state=draft)". A draft save into the package-unbound row reads as before.

**Unchanged.** A publish or a discard that names its package (`?package=`, `packageId` in process). A publish stating `packageId: null` in process, which still pins the package-unbound draft. The batch publish and discard of a package's drafts (`POST /packages/:id/publish-drafts`, the discard of a package's drafts). No stored row is rewritten or removed by the upgrade.

**Remedy.** For an item two or more packages hold, or to publish or discard a draft bound to a package other than the item's own, send the request with `?package=` naming that package (`packageId` in process). A client that compares draft-save receipt text should expect `, package=<id>` after `env-wide` when the draft inherits a package.

`@objectstack/spec`: the `PublishMetaItemRequestSchema.packageId` description now states what a publish naming no package promotes, where it said "match any package"; the `DeleteMetaItemRequestSchema.packageId` description states what a draft discard naming no package removes.
4 changes: 2 additions & 2 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
| **state** | `Enum<'active' \| 'draft'>` | optional | Which lifecycle row to discard: `draft` discards the pending draft overlay only (the still-active overlay, if any, keeps serving); `active` or absent resets the live row. Absent defaults to `active`. |
| **dropStorage** | `boolean` | optional | Destructive opt-in, default false: also drop the object's physical table after the metadata row is removed (`object` type + `active` state only; never `sys_` tables). Used by the "discard a previewed object" flow so a publish-to-preview leaves no orphan table. |
| **member** | `string` | optional | Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value (`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read answers; naming the canonical value is the same request. It is not the response translation locale, which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has no members, and for a blank value or one longer than a stored member can be (100 characters). A well-formed member with nothing stored or shipped answers as that door answers an absent item. |
| **packageId** | `string \| null` | optional | ADR-0048 — the software package whose row this delete removes (`sys_metadata.package_id`; `?package=<id>` on the REST door, where `all` and an empty value name no package), as a save names the row it writes. Named, the delete removes that package's row and no other; a package that holds no row removes nothing. Absent or `null` names no package: the delete then removes the row of the one package the item's rows at the deleted `state` are bound to, is refused `409 RESOURCE_CONFLICT` when they are bound to two or more, and with none removes the env-local row (no package stamp). That row is the one `parentVersion` is judged against. When another row of the item is still stored afterwards, the receipt `message` names it. |
| **packageId** | `string \| null` | optional | ADR-0048 — the software package whose row this delete removes (`sys_metadata.package_id`; `?package=<id>` on the REST door, where `all` and an empty value name no package), as a save names the row it writes. Named, the delete removes that package's row and no other; a package that holds no row removes nothing. Absent or `null` names no package: the delete of the `active` row then removes the row of the one package the item's active rows are bound to, is refused `409 RESOURCE_CONFLICT` when they are bound to two or more, and with none removes the env-local row (no package stamp); the discard of the `draft` removes the draft a draft save naming no package writes, the one the per-item publish naming none promotes, and is refused where that publish is (see `PublishMetaItemRequestSchema.packageId`). That row is the one `parentVersion` is judged against. When another row of the item is still stored afterwards, the receipt `message` names it. |


---
Expand Down Expand Up @@ -2442,7 +2442,7 @@ Installed package with runtime lifecycle state
| **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). The promotion door enforces the same grammar as `saveMetaItem`. |
| **actor** | `string` | optional | Identity recorded on the `op='publish'` history event. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. |
| **message** | `string` | optional | Optional human-readable note recorded with the publish history event. |
| **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=<id>` on the REST door). ⚠️ `null` is NOT the same as absent, and the difference is load-bearing: the implementation branches on the KEY BEING PRESENT, so an ABSENT key keeps the historical "match any package" resolution while `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft` on the untouched path. |
| **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=<id>` on the REST door). Named, the publish promotes that package's draft and no other. ABSENT names no package: the publish promotes the draft a draft save that names no package writes — the draft of the one package the item's active rows are bound to, else its package-unbound draft, promoted into that package — and answers `404 NO_DRAFT` when neither exists, as the strict draft read does. For an item never published into a package it is the package-unbound draft, else the one package's draft. It is refused `409 RESOURCE_CONFLICT` when the active rows (or, with none, the drafts) are bound to two or more packages. ⚠️ `null` is NOT the same as absent: the implementation branches on the KEY BEING PRESENT, and `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft`. |
| **member** | `string` | optional | Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value (`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read answers; naming the canonical value is the same request. It is not the response translation locale, which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has no members, and for a blank value or one longer than a stored member can be (100 characters). A well-formed member with nothing stored or shipped answers as that door answers an absent item. |


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,24 +373,49 @@ describe('publishMetaItem — the per-item door names a package too (#10350)', (
expect(labelOf(drafts[0])).toBe('FROM_OTHER');
});

it('keeps the historical match-any resolution when the caller states NO package', async () => {
// [#23495] Replaced, not respelled: this case pinned the absent key's
// "match any package" resolution, which promoted this fixture's
// first-scanned draft (`app.other`) — the storage-order coin toss #8907
// removed from the batch door, on the per-item door. Naming no package,
// the publish now promotes the draft a draft save naming no package
// writes, and this item (no active row, two packages' drafts, no
// package-less draft) has no single one. What the case also guarded stays
// pinned: the absent key is not a present `null`, which would pin the
// lookup to the unbound row and answer `no_draft` (the next case).
it('refuses 409 RESOURCE_CONFLICT when the caller states NO package and two packages hold drafts, promoting nothing', async () => {
const { engine, rows } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine);

await seedTwoPackageDrafts(protocol);

const refusal = await protocol.publishMetaItem({ type: 'object', name: 'shared_ticket' })
.then(() => undefined, (e: any) => e);

expect(refusal).toMatchObject({ code: 'RESOURCE_CONFLICT', status: 409 });
expect(refusal.message).toContain("'app.demo'");
expect(refusal.message).toContain("'app.other'");
expect(activeRowsOf(rows)).toEqual([]);
expect(draftRowsOf(rows).map((r) => r.package_id)).toEqual(['app.other', 'app.demo']);
});

it('promotes the one package\'s draft when the caller states NO package — the absent key is not a present `null`', async () => {
const { engine, rows } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine);

await protocol.saveMetaItem({
type: 'object',
name: 'shared_ticket',
item: objectBody('shared_ticket', 'FROM_OTHER'),
packageId: 'app.other',
mode: 'draft',
});

await protocol.publishMetaItem({ type: 'object', name: 'shared_ticket' });

// The contract `promoteDraftForPublish` spells as
// `...('packageId' in request ? ... : {})`: an ABSENT key means "match
// any package" (this fixture's first-scanned row), while a
// present-and-`undefined` key would coerce to `null` downstream and pin
// the lookup to UNBOUND rows — finding neither draft and answering
// `no_draft`. Declaring `packageId` optional must not turn the first
// spelling into the second, so the untouched path is pinned here.
const active = activeRowsOf(rows);
expect(active).toHaveLength(1);
expect(active[0].package_id).toBe('app.other');
expect(labelOf(active[0])).toBe('FROM_OTHER');
expect(draftRowsOf(rows)).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -523,9 +523,9 @@ describe('a publish promotes only the draft its gate judged', () => {

// The lock a publish consults is the one at the address it promotes: the
// package key the publish resolved (the caller's stated binding, else the
// draft row's own), not only the package the request stated. With two
// packages' env-wide rows of one view both locked, the refusal carries the
// lock of the package whose draft is being promoted.
// draft address it resolves), not only the package the request stated. With
// two packages' env-wide rows of one view both locked, the refusal of a
// publish naming its package carries that package's lock.
describe('a publish consults the lock of the package key it resolved', () => {
const locked = (pkg: string) => ({ ...VIEW, _lock: 'full', _lockReason: `${pkg} keeps this view as shipped` });

Expand All @@ -543,10 +543,20 @@ describe('a publish consults the lock of the package key it resolved', () => {
return { protocol, draftsOf };
}

it('a publish that states no package consults the lock of the draft row\'s own package', async () => {
// [#23495] Replaced, not respelled: this case pinned a publish naming no
// package consulting the lock of the draft row a package-agnostic read met
// (`pkg_b`), over an item whose active rows two packages hold. Naming no
// package, the publish now takes the draft save's address, and an item two
// packages hold has none: the publish is judged by the lock the draft save
// naming no package is judged by, and answers what that save answers.
it('a publish that states no package, of an item two packages hold, is judged by the lock the draft save naming none is', async () => {
const { protocol, draftsOf } = await seedLockedRowsAndDraft('pkg_b');
const saveRefusal = await protocol.saveMetaItem({
type: 'view', name: VIEW.name, item: { ...VIEW, label: 'Org grid (edited)' }, mode: 'draft',
}).then(() => undefined, (e: any) => e);
expect(saveRefusal).toMatchObject({ code: 'ITEM_LOCKED', status: 403, lock: 'full' });
await expect(protocol.publishMetaItem({ type: 'view', name: VIEW.name }))
.rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403, lock: 'full', lockReason: 'pkg_b keeps this view as shipped' });
.rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403, lock: 'full', lockReason: saveRefusal.lockReason });
expect(draftsOf()).toEqual(['pkg_b']);
});

Expand Down
Loading
Loading