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
11 changes: 11 additions & 0 deletions .changeset/30168-unshipped-locale-write-open-before-pair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol): an email template at a locale the base does not ship keeps its package-less write on a fresh install; the named-base refusal names `os migrate packageless-overlays`

Clause-②: no
Metadata-protocol: semantic

- **An added locale is writable before the pair exists.** Both overlay-write closes (the package-less write, and the write naming the read-only base with `?package=<base>`) stay open where the fix they would name cannot be taken: an `email_template` member at a locale the base does not ship is refused as a declared override, so its write stays open (ADR-0146 D11). The close asked the override write gate about the pair; on a fresh install the pair is not ensured yet, the gate's closure read answers nothing for a package that does not exist, and the close read that "not concerned" as "admitted". So `PUT /api/v1/meta/email_template/<name>` with a zh-CN body beside a base that ships en-US only answered `403 NOT_OVERRIDABLE`, naming a pair write that was refused too (`declared_package_ships_no_such_item`). The close now judges a pair that is not installed with the closure the ensure door gives it (the base and what the base reaches), so the write is accepted, as it is once the pair exists. A member the base ships is closed exactly as before.
- **The named-base refusal names the ceremony.** `os migrate packageless-overlays` moves a row stored bound to the read-only base into the pair with its declaration. The sentence for a save, publish or rollback naming the base now says such a row "is served until moved by `os migrate packageless-overlays`, overridden by the pair, or deleted naming the base". One spelling, every door; the code and status (`403 NOT_OVERRIDABLE`) are unchanged.
99 changes: 75 additions & 24 deletions packages/metadata-protocol/src/protocol-packaged-base-refusal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
import { SysMetadataRepository } from './sys-metadata-repository.js';
import { OBJECT_EXTENSION_TYPE } from './protocol-object-extension-item.js';
import { customizationPairEnsureRefusal, type PairRegistry } from './protocol-customization-package.js';
import { resolvePackageClosure } from '@objectstack/lint/runtime';
import { overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js';
import {
customizationBasePackageId,
Expand Down Expand Up @@ -254,12 +255,12 @@ export function packagelessOverlayWriteRefusal(
closed: { base: string; pair: string },
operation: PackagelessWriteOperation,
): Error {
const { item, shipped, pair, declared, writeInto } = closedOverlayWriteClauses(request, closed);
const rule = 'A write naming no package is closed where `os migrate packageless-overlays` can move its row '
const { item, shipped, pair, declared, writeInto, ceremony } = closedOverlayWriteClauses(request, closed);
const rule = `A write naming no package is closed where ${ceremony} can move its row `
+ `(ADR-0146 D11); a package-less row stored before it is served until that command moves it into '${closed.pair}'.`;
const message = operation === 'rollback'
? `${item} ${shipped}, and a rollback of its package-less row is closed (ADR-0146 D11): run `
+ `\`os migrate packageless-overlays\` to move the row into ${pair}; to restore a version from before `
+ `${ceremony} to move the row into ${pair}; to restore a version from before `
+ `the move, save that version's body there, declaring ${declared}.`
: operation === 'delete'
? `${item} ${shipped}: remove its customization from ${pair}. ${rule}`
Expand All @@ -274,6 +275,13 @@ export function packagelessOverlayWriteRefusal(
* the declaration, and the fix — write it into the pair, declaring the
* override. ONE spelling: a writer refused at either address reads the same
* fix, and only the rule clause that follows it says which address is closed.
*
* Two more clauses, one spelling each: `ceremony`, the command that moves a
* stored row into the pair (both sentences name it), and `boundRowServed`,
* what serves a row bound to the base stored before the close until one of
* the three open doors acts on it — [#29392] that command, which moves it
* into the pair with its declaration, the pair's declared override, or a
* delete naming the base.
*/
function closedOverlayWriteClauses(
request: { type: string; name: string },
Expand All @@ -286,7 +294,9 @@ function closedOverlayWriteClauses(
const declared = `overrides: { package: '${closed.base}', basedOn }`;
const writeInto = (operation: 'save' | 'publish') =>
`${item} ${shipped}: ${operation === 'publish' ? 'publish' : 'save'} it into ${pair}, declaring ${declared}.`;
return { item, shipped, pair, declared, writeInto };
const ceremony = '`os migrate packageless-overlays`';
const boundRowServed = `is served until moved by ${ceremony}, overridden by the pair, or deleted naming the base.`;
return { item, shipped, pair, declared, writeInto, ceremony, boundRowServed };
}

/** `403 NOT_OVERRIDABLE`, the code and status the sealed write already answers, so neither close gains a code. */
Expand All @@ -306,11 +316,11 @@ function closedOverlayWriteError(message: string): Error {
* a row bound to the base. `rollback` is every restore of a stored version:
* the per-item rollback and [#29390] a commit revert's restore limb (and the
* package rollback through it), one sentence for both. ⛔ Not `delete`:
* removing a row bound to the base writes nothing, and it is the one way to
* clear such a row stored before the close — `os migrate packageless-overlays`
* reads package-less rows only, so it never moves one. The removal serves the
* base's own document again, or the pair's declared override where one exists
* (it is served over the row).
* removing a row bound to the base writes nothing, and it is one way to clear
* such a row stored before the close; [#29392] `os migrate packageless-overlays`
* is the other, moving it into the pair with its declaration. The removal
* serves the base's own document again, or the pair's declared override where
* one exists (it is served over the row).
*/
export type NamedBaseWriteOperation = Exclude<PackagelessWriteOperation, 'delete'>;

Expand All @@ -322,12 +332,14 @@ export type NamedBaseWriteOperation = Exclude<PackagelessWriteOperation, 'delete
* package itself (`?package=<base>`) onto an item of one of the five overlay
* types that package ships stored the customization as a row bound to the
* base, with no declaration: served at the base's address in place of its
* document, outside the pair D1 puts every customization in, and not seen by
* `os migrate packageless-overlays`. It is refused with the fix the
* package-less close names ({@link closedOverlayWriteClauses}): write it into
* the pair, declaring the override. A row stored that way before the close is
* still served until the pair declares the override (the declared override is
* served over it) or a delete naming the base removes it — both open.
* document, outside the pair D1 puts every customization in. It is refused
* with the fix the package-less close names ({@link closedOverlayWriteClauses}):
* write it into the pair, declaring the override. A row stored that way before
* the close is still served until one of three open doors acts on it, all
* named in the sentence ({@link closedOverlayWriteClauses}' `boundRowServed`):
* [#29392] `os migrate packageless-overlays` moves it into the pair with its
* declaration, the pair declares the override (served over it), or a delete
* naming the base removes it.
*
* Fix first, under the REST door's 500-character bound (`CLIENT_MESSAGE_MAX`,
* `@objectstack/types`), as the package-less sentence.
Expand All @@ -337,13 +349,12 @@ export function namedBaseOverlayWriteRefusal(
closed: { base: string; pair: string },
operation: NamedBaseWriteOperation,
): Error {
const { item, shipped, pair, declared, writeInto } = closedOverlayWriteClauses(request, closed);
const served = 'is served until the pair declares the override, or a delete naming the base removes it.';
const { item, shipped, pair, declared, writeInto, boundRowServed } = closedOverlayWriteClauses(request, closed);
const message = operation === 'rollback'
? `${item} ${shipped}, and a rollback of its row bound to the read-only base is closed (ADR-0146 D1, D11): `
+ `save the version's body into ${pair}, declaring ${declared}. The row ${served}`
+ `save the version's body into ${pair}, declaring ${declared}. It ${boundRowServed}`
: `${writeInto(operation)} The read-only base itself takes no customization (ADR-0146 D1, D11): `
+ `a row bound to it from before ${served}`;
+ `a row bound to it ${boundRowServed}`;
return closedOverlayWriteError(message);
}

Expand All @@ -356,8 +367,31 @@ export interface OverlayPairFix {
readonly base: string;
readonly pair: string;
readonly basedOn: string;
/**
* The closure the pair WILL have, present only while the pair is not
* installed: the ensure door declares the pair's one dependency, the base
* (`manifest.dependencies: { [base]: range }`), so its closure is the base
* and what the base reaches. The gate's own read
* (`resolveWritePackageScope`) answers nothing for a package the registry
* cannot produce, and its `not_concerned` on a declared body is no verdict
* about the write the fix takes.
*/
readonly pendingPairScope?: WrittenPackageScope;
}

/** A written package and the closure of its declared dependencies, as `resolveWritePackageScope` answers it. */
export interface WrittenPackageScope {
readonly packageId: string;
readonly dependencies: readonly string[];
}

/**
* The override write gate as a close asks it: {@link overrideWriteVerdict},
* with the written package's closure when the caller holds it (a pair the
* ensure step would create, {@link OverlayPairFix.pendingPairScope}).
*/
export type OverrideWriteVerdictAsk = (request: OverrideWriteSubject, writtenScope?: WrittenPackageScope) => OverrideWriteVerdict;

/**
* [ADR-0146 D1, D11] The registry half of whether the fix both overlay-write
* closes name — write it into `base`'s pair, declaring the override — can be
Expand Down Expand Up @@ -390,7 +424,11 @@ export function overlayPairFixOf(
if (customizationPairEnsureRefusal(host.engine, registry, base, pair) !== undefined) return null; // the ensure door would refuse
const basedOn = registry.getPackage(base)?.manifest?.version;
if (typeof basedOn !== 'string' || basedOn === '') return null; // no `basedOn` to declare
return { base, pair, basedOn };
if (registry.getPackage(pair)) return { base, pair, basedOn };
// The pair the ensure door would create: one dependency, the base.
const baseClosure = resolvePackageClosure(base, (id) => registry.getPackage(id) as { manifest?: unknown } | undefined);
const dependencies = [base, ...(baseClosure?.dependencies ?? []).filter((id) => id !== pair)];
return { base, pair, basedOn, pendingPairScope: { packageId: pair, dependencies } };
}

/**
Expand All @@ -400,9 +438,14 @@ export function overlayPairFixOf(
* identity at `member`, declaring `overrides: { package: base, basedOn }`. An
* `email_template` member at a locale the base does not ship is refused
* there, so its write stays open.
*
* The gate is asked about the pair the fix writes into, installed or not: a
* pair not yet ensured (a fresh install) is judged with the closure the
* ensure door gives it ({@link OverlayPairFix.pendingPairScope}), so the
* answer is the one the write meets once the pair exists.
*/
export function overlayPairFixAdmits(
host: { readonly overrideWriteVerdict: (request: OverrideWriteSubject) => OverrideWriteVerdict },
host: { readonly overrideWriteVerdict: OverrideWriteVerdictAsk },
fix: OverlayPairFix,
subject: {
/** The singular type. */
Expand All @@ -419,7 +462,7 @@ export function overlayPairFixAdmits(
...(body ?? { name, ...(disc && member !== null ? { [disc]: member } : {}) }),
overrides: { package: fix.base, basedOn: fix.basedOn },
};
const verdict = host.overrideWriteVerdict({ type, name, packageId: fix.pair, item: declaredBody });
const verdict = host.overrideWriteVerdict({ type, name, packageId: fix.pair, item: declaredBody }, fix.pendingPairScope);
return verdict.verdict !== 'refuse';
}

Expand All @@ -429,7 +472,7 @@ export interface NamedBaseCloseHost {
/** The read-only code packages that ship a name of an overlay type ([] for any other type). */
readonly overlayBaseShippers: (type: string, name: string) => string[];
/** The override write gate, as the declared write into the pair meets it at the save door. */
readonly overrideWriteVerdict: (request: OverrideWriteSubject) => OverrideWriteVerdict;
readonly overrideWriteVerdict: OverrideWriteVerdictAsk;
}

/** The write naming a package that {@link namedBaseCloseSubject} judges. */
Expand Down Expand Up @@ -953,17 +996,25 @@ export function overrideWriteRefusalError(
* member ({@link overrideWriteMemberOf}), and an artifact of another member is
* no shipper: a registry that ignores `member` can answer one, as
* `mergeArtifactProtection` guards on the read side.
*
* `writtenScope`, when given for the written package, is its closure in
* place of the registry's read: a close asking about a pair the ensure step
* would create ({@link OverlayPairFix.pendingPairScope}). Every door's own
* write passes none.
*/
export function overrideWriteVerdict(
this: Pick<PackagedBaseRefusalHost, 'isWritablePackage' | 'resolveWritePackageScope' | 'shippedArtifactsOf'>,
request: OverrideWriteSubject,
writtenScope?: WrittenPackageScope,
): OverrideWriteVerdict {
const packageId = typeof request.packageId === 'string' && request.packageId.length > 0
? request.packageId
: undefined;
const declaration = declarationOf(request.item);
if (packageId === undefined && !declaration.present) return NOT_CONCERNED;
const scope = packageId === undefined ? undefined : this.resolveWritePackageScope(packageId);
const scope = packageId === undefined
? undefined
: writtenScope?.packageId === packageId ? writtenScope : this.resolveWritePackageScope(packageId);
// A named package the registry cannot produce is not this gate's to
// judge: both doors ask `uninstalledNamedBaseRefusal`, which refuses a
// write naming an uninstalled base (`WRITABLE_PACKAGE_REQUIRED`) — the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,7 @@ import {
overlayPairFixAdmits,
overlayPairFixOf,
overrideWriteMemberOf,
type OverrideWriteSubject,
type OverrideWriteVerdict,
type OverrideWriteVerdictAsk,
} from './protocol-packaged-base-refusal.js';
import { readOnlyShippingPackages, shippingPackageIds } from './protocol-environment-rows.js';
import { isServedOverrideRowOf } from './served-override.js';
Expand Down Expand Up @@ -689,8 +688,12 @@ export interface PackagelessCloseHost {
readonly rethrowUnlessMetadataStoreUnprovisioned: ObjectStackProtocolImplementation['rethrowUnlessMetadataStoreUnprovisioned'];
/** The read-only code packages that ship a name of an overlay type ([] for any other type). */
readonly overlayBaseShippers: (type: string, name: string) => string[];
/** The override write gate, as the ceremony's step 2 meets it through the save door. */
readonly overrideWriteVerdict: (request: OverrideWriteSubject) => OverrideWriteVerdict;
/**
* The override write gate, as the ceremony's step 2 meets it through the
* save door: on a pair step 1 has not ensured yet, with the closure the
* ensure door gives it (`OverlayPairFix.pendingPairScope`).
*/
readonly overrideWriteVerdict: OverrideWriteVerdictAsk;
}

/** The package-less write {@link packagelessCloseSubject} judges, and the row it would write. */
Expand Down
Loading
Loading