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
30 changes: 30 additions & 0 deletions .changeset/23712-draft-save-d99-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol)!: a draft save naming no package of an object its package-less definition owns is refused when its draft address is a package's draft, with a way out to the package-less draft (ADR-0029 D9.9)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, no stored row is rewritten, converted or deleted by this change, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of the object save door for one shape: a draft save naming no package of an object whose registry owner is its package-less definition (the `sys_metadata` sentinel), when the draft address that save writes is a package's draft, is refused, where it wrote a draft no publish could serve. The remedy is in the requests a caller sends (a delete and draft discards through the same door, then the same save), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported 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.

The ADR-0029 D9.9 check of the object save door is now asked of the package the write lands in. A draft save naming no package (`PUT /api/v1/meta/object/NAME?mode=draft`, Studio's designer save) leaves the binding to the draft address, which is the item's one package `Q` when an active row is bound to `Q`, or `Q`'s draft when no active row is bound to a package and `Q`'s draft is the only one stored (ADR-0048). The check used to ask with the request's empty package, so an object whose owner is its package-less definition read as its own re-save, and the save wrote `Q`'s draft. It answered `200`, and the promote door then refused every publish of that draft.

What changes for you, where object `NAME` is owned by its package-less definition and `Q` is any package. Every refusal below is `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing written, carrying `packageId: Q` and `ownerPackageId: null`. Only rows written before the save door refused a package's row beside the package-less one, or drafts bound to a package stored before that row existed, reach these states.

- Over a row bound to `Q` stored beside the package-less one, with no draft, with a draft bound to no package, or with both that draft and `Q`'s: FROM `PUT /api/v1/meta/object/NAME?mode=draft` answering `200` with `Q`'s draft written (a new one, or the draft bound to no package adopted into `Q`), TO the refusal "A draft save naming no package of object 'NAME' writes a draft bound to 'Q', which no publish can serve: the object is served by its package-less definition. First DELETE /api/v1/meta/object/NAME?package=Q, then send this save again: it writes the package-less draft. Nothing was written."
- The same row with `Q`'s draft and no draft bound to no package: FROM `200` into `Q`'s draft, TO the refusal naming `Q`'s draft discard after the row's delete: "First DELETE /api/v1/meta/object/NAME?package=Q, then ?state=draft&package=Q (the draft discard), then send this save again: …". Deleting the row alone leaves `Q`'s draft, which the address then still names. Each package's draft is named the same way when several are stored.
- No row bound to a package, and `Q`'s draft the only draft stored: FROM `200` into `Q`'s draft, TO the refusal "First DELETE /api/v1/meta/object/NAME?state=draft&package=Q (the draft discard), then send this save again: it writes the package-less draft. …".

**Remedy, in one line.** Send the requests the refusal names, in its order, then the same draft save: it writes the package-less draft, and the table, its records and the served definition stay as they are. A discard removes only that package's draft, which no publish could serve; the save sent again carries the edit. To move the object into `Q` instead, send the draft save naming `Q`: its refusal names that move.

**Unchanged.**

- A draft save naming no package where no package's row and no package's draft is stored: it writes the draft bound to no package.
- A draft save naming no package where the draft bound to no package is the address (no package's row, that draft stored): it is written, as before.
- A draft save naming no package over rows bound to two or more packages, or over two packages' drafts with no package row and no draft bound to no package: `409 RESOURCE_CONFLICT`, as before.
- Every save naming a package, every publish-mode save, and the promote doors: same answers and texts.
- An object a code package ships, an object owned by a runtime row bound to a real package, and every type except `object`.
- Stored rows are not rewritten on upgrade.
56 changes: 54 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2158,6 +2158,14 @@ interface UnboundOwnerRefusalState {
* definition, and one naming `P` finds no `P` draft (`NO_DRAFT`).
*/
adoptedDraft?: boolean;
/**
* [#23712] A DRAFT save naming no package (with `namedNoPackage`): the
* packages whose drafts of the object are stored with no package-unbound
* draft beside them. Once the rows are deleted, the draft address is the
* one package's draft (`packagelessDraftAddress`), so each is discarded
* by naming its package before the save writes the package-less draft.
*/
strandedDrafts?: readonly string[];
/**
* Where the refusal is raised: the save door (default), a draft promotion
* (the per-item publish and `publish-drafts`), or a registration of a
Expand Down Expand Up @@ -19725,6 +19733,7 @@ export class ObjectStackProtocolImplementation implements
* | a DRAFT save naming `Q`, no package's row stored | the same delete, then a PUBLISH-mode save naming `Q` (it serves the object at once), then draft as before. A draft save registers nothing, so a draft sent right after the delete leaves the object unserved until it is published |
* | rows bound to packages (`Q` or any other) are already stored beside the package-less one (the rows the save door wrote before this refusal existed) | delete each of them by naming its package, then the package-less definition, then a publish-mode save naming `Q`. Each row is named because a delete naming no package resolves to the one binding present, or is refused `409` over two ({@link packagelessAddress}), never to the package-less definition |
* | one such row, a publish save naming NO package | it is addressed to that row ({@link packagelessAddress}); delete the row, then the same save edits the package-less definition. Over two bindings the save is refused `409` before this refusal |
* | [#23712] a DRAFT save naming NO package, whose draft address ({@link packagelessDraftAddress}) is a package `Q`'s: one row bound to `Q`, or no such row and a lone draft bound to `Q` | delete the row by naming its package, and with no package-unbound draft stored, discard each package's draft by naming its package (`?state=draft&package=`), then the same save writes the package-less draft. Deleting the row alone leaves `Q`'s draft, which the address then still names |
* | a promotion of `Q`'s draft (the per-item publish, `publish-drafts`), a draft stored before the package-less row or by a draft save the door addressed to `Q` | the deletes above for the state, then the same publish: the draft stays stored, and its promotion registers it |
* | a publish naming no package of a draft bound to NO package, which the address adopts into the one stored binding `P` | no publish moves that draft into `P` (measured: with `P`'s row gone it promotes into the package-less definition; one naming `P` answers `NO_DRAFT`). Delete `P`'s row, publish again (it updates the package-less definition), then the package-less DELETE and a publish-mode save naming `P` move the object |
*
Expand Down Expand Up @@ -19769,6 +19778,23 @@ export class ObjectStackProtocolImplementation implements
+ `addressed into '${q}', whose row is never served. DELETE ${path}?package=${q}, then publish again: `
+ `the draft lands in the package-less definition. To move it into '${q}' then: the same DELETE with no `
+ `package and no dropStorage (records stay), then save it naming '${q}' in publish mode.` + tail;
} else if (state.mode === 'draft' && state.namedNoPackage && state.at !== 'promote') {
// [#23712] A draft save naming no package writes the package's
// draft the address names. Each step names its package: a delete
// or discard naming none resolves by the rows the earlier steps
// leave. With the rows and the package drafts gone, the address is
// the package-unbound draft (`packagelessDraftAddress`).
// Every step is a request, in order, spelled as the row deletes
// above are, so one row and two drafts fit the REST message bound.
const drafts = [...new Set(state.strandedDrafts ?? [])].sort();
const steps = [
...rows.map((pkg) => `?package=${pkg}`),
...drafts.map((pkg, i) => `?state=draft&package=${pkg}${i === 0 ? ' (the draft discard)' : ''}`),
];
message = `A draft save naming no package of object '${name}' writes a draft bound to '${q}', which no `
+ 'publish can serve: the object is served by its package-less definition. First '
+ `DELETE ${path}${steps.join(', then ')}, then send this save again: it writes the package-less `
+ 'draft.' + tail;
} else if (rows.length > 0 && state.namedNoPackage) {
// A save naming no package is addressed to the one binding present.
message = `A save naming no package of object '${name}' is addressed to its stored definition bound to `
Expand Down Expand Up @@ -22579,8 +22605,28 @@ export class ObjectStackProtocolImplementation implements
// owns (the `'sys_metadata'` sentinel): a save naming a package beside
// it is refused here, with the move-into-a-package prescription
// ({@link unboundOwnerPackageRefusal}).
//
// [#23712] …asked of the package the write lands in. A draft save
// naming no package keeps `packageId` empty (the repository resolves
// its binding, #11087), but it writes the draft
// {@link packagelessDraftAddress} names, which over a row bound to
// `Q`, or a lone draft bound to `Q`, is `Q`'s: judged with the
// request's empty package, the sentinel owner read `own` and the save
// answered `200` for a draft no publish can serve. An ambiguous
// address is left to the repository's `409`, as before.
if (singularType === 'object') {
const layer = this.classifyObjectContribution(request.name, request.packageId);
const draftAddress = mode === 'draft'
&& request.packageId == null
&& request.source === undefined
&& request.writeFace !== 'package-duplicate'
? await this.packagelessDraftAddress(
singularType, request.name, itemDiscriminator(singularType, request.item),
)
: undefined;
const writtenPackage = draftAddress && draftAddress.kind !== 'ambiguous'
? draftAddress.into
: request.packageId;
const layer = this.classifyObjectContribution(request.name, writtenPackage);
if (layer.kind === 'mismatch') {
// [#23514] For a sentinel owner the way out depends on the
// state: the mode, whether the caller named a package, and
Expand All @@ -22589,9 +22635,15 @@ export class ObjectStackProtocolImplementation implements
const storedPackages = layer.ownerPackageId === 'sys_metadata'
? (await this.packagelessAddress(singularType, request.name)).candidates
: [];
// [#23712] With no package-unbound draft stored, the address
// is a package's draft once the rows are gone (`409` over
// two), so each such draft is discarded by its package too.
const strandedDrafts = draftAddress && draftAddress.kind !== 'ambiguous' && !draftAddress.drafts.unbound
? draftAddress.drafts.candidates
: [];
throw ObjectStackProtocolImplementation.overlayPackageMismatchError(
request.name, layer.packageId, layer.ownerPackageId,
{ mode, storedPackages, namedNoPackage },
{ mode, storedPackages, namedNoPackage, strandedDrafts },
);
}
}
Expand Down
Loading
Loading