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
155 changes: 155 additions & 0 deletions packages/metadata-protocol/src/protocol-lock-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25467 cut 10] The served lock state and the stored-row decline of
* `ObjectStackProtocolImplementation` — the read envelope's lock derivation
* (`servedLockState`), the shipped-flow-name predicate and its registry half
* (`isShippedFlowName`, `isStoredFlowEntryOfShippedName`), the one by-name
* decision the active reads take before they serve a stored row
* (`declinesStoredRow`), its sealed-overlay limb (`isUnservedSealedOverlay`)
* and its registry half (`isStoredEntryOfDeclinedName`) — moved out of
* `protocol.ts` whole.
*
* A MOVE, not a redesign. Each function below is the body of the
* `ObjectStackProtocolImplementation` method of the same name as it stood,
* re-indented one level and otherwise unedited: the same reads, in the same
* order, throwing exactly where they did with the same message.
*
* ## How the moved bodies reach the class
*
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
* The class keeps every method — the published `dist/*.d.ts` lists each one's
* name, docblock and declared signature — and each body is one literal of
* exactly the {@link LockStateHost} members that method reads, plus a
* `.call`. A method is handed over as a closure that calls it on the protocol
* at call time, so a test double or spy set on the instance is still the one
* the body reaches, and the moved bodies that call one another do so through
* the protocol, exactly as before. Every other reader of these six methods
* keeps calling the class method, the REST and runtime published doors among
* them.
*
* The bodies read no field, so the host carries no getter.
*
* The one module-level declaration of `protocol.ts` the bodies read,
* `canonicalMetaType`, stays there, where code that did not move reads it too,
* and is imported from there: the module cycle is evaluation-safe because it
* is read only inside a function body, never while the two modules are first
* evaluated. Everything else is imported from the module `protocol.ts`
* imports it from. Nothing re-exports this module, so the published surface
* does not move.
*/

import type { ObjectStackProtocolImplementation } from './protocol.js';
import { canonicalMetaType } from './protocol.js';
import type { ItemLock } from './item-lock.js';
import { isOriginGatedType } from './packaged-base-regime.js';
import { isCodeArtifactBody } from '@objectstack/metadata-core';
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
import {
extractProtection,
evaluateLockForWrite,
evaluateLockForDelete,
MetadataLockSchema,
} from '@objectstack/spec/kernel';
import type { resolveLockState } from '@objectstack/spec/kernel';

/**
* The `ObjectStackProtocolImplementation` members the functions below read —
* every one of them, and no other; each function's `this` is the `Pick` of the
* members IT reads. Each is typed AS the class member (an indexed access on
* the class), so a change to a member's signature on the class is a change
* here too, and naming a member the class does not have is a compile error.
*/
export interface LockStateHost {
declinesStoredRow: ObjectStackProtocolImplementation['declinesStoredRow'];
isDeclaredCodeDatasource: ObjectStackProtocolImplementation['isDeclaredCodeDatasource'];
isSealedManagedItem: ObjectStackProtocolImplementation['isSealedManagedItem'];
isShippedFlowName: ObjectStackProtocolImplementation['isShippedFlowName'];
isUnservedSealedOverlay: ObjectStackProtocolImplementation['isUnservedSealedOverlay'];
packagedArtifactOwner: ObjectStackProtocolImplementation['packagedArtifactOwner'];
packagedBaseRefusal: ObjectStackProtocolImplementation['packagedBaseRefusal'];
}

/** The body of `ObjectStackProtocolImplementation#servedLockState`; the method's docblock there is its contract. */
export function servedLockState(
this: Pick<LockStateHost, 'packagedBaseRefusal'>,
type: string,
name: string,
document: unknown,
artifactBacked: boolean,
itemLock: ItemLock,
storedRowServed = false,
): ReturnType<typeof resolveLockState> {
const { provenance, packageId, packageVersion } = extractProtection(document);
const editable = evaluateLockForWrite(itemLock.lock) === null
&& this.packagedBaseRefusal({ type, name, operation: 'save' }) === null;
const removalRefusal = this.packagedBaseRefusal({ type, name, operation: 'delete' });
const deletable = evaluateLockForDelete(itemLock.lock) === null
&& (removalRefusal === null || (storedRowServed && isOriginGatedType(type)));
const lock = MetadataLockSchema.options.find((state) =>
(evaluateLockForWrite(state) === null) === editable
&& (evaluateLockForDelete(state) === null) === deletable);
if (lock === undefined) {
// Unreachable while the lock algebra covers all four verdict pairs;
// a state added to it without a write/delete answer must fail here,
// loudly, not publish a guessed lock.
throw new Error(
`No ADR-0010 lock state answers editable=${editable}, deletable=${deletable}.`,
);
}
return {
lock,
lockReason: itemLock.lockReason,
lockSource: itemLock.lockSource,
lockDocsUrl: itemLock.lockDocsUrl,
provenance,
packageId,
packageVersion,
editable,
deletable,
resettable: artifactBacked,
};
}

/** The body of `ObjectStackProtocolImplementation#isShippedFlowName`; the method's docblock there is its contract. */
export function isShippedFlowName(
this: Pick<LockStateHost, 'packagedArtifactOwner'>,
type: string, name: unknown): boolean {
if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'flow') return false;
if (typeof name !== 'string' || name === '') return false;
return this.packagedArtifactOwner({ type: 'flow', name }) !== undefined;
}

/** The body of `ObjectStackProtocolImplementation#isStoredFlowEntryOfShippedName`; the method's docblock there is its contract. */
export function isStoredFlowEntryOfShippedName(
this: Pick<LockStateHost, 'isShippedFlowName'>,
type: string, item: unknown): boolean {
return this.isShippedFlowName(type, (item as { name?: unknown } | null | undefined)?.name)
&& !isCodeArtifactBody(item);
}

/** The body of `ObjectStackProtocolImplementation#declinesStoredRow`; the method's docblock there is its contract. */
export function declinesStoredRow(
this: Pick<LockStateHost, 'isDeclaredCodeDatasource' | 'isShippedFlowName' | 'isUnservedSealedOverlay'>,
type: string, name: unknown): boolean {
if (this.isShippedFlowName(type, name)) return true;
if (typeof name !== 'string' || name === '') return false;
return this.isDeclaredCodeDatasource(type, name) || this.isUnservedSealedOverlay(type, name);
}

/** The body of `ObjectStackProtocolImplementation#isUnservedSealedOverlay`; the method's docblock there is its contract. */
export function isUnservedSealedOverlay(
this: Pick<LockStateHost, 'isSealedManagedItem'>,
type: string, name: string): boolean {
if (canonicalMetaType(type) === 'permission') return false;
return this.isSealedManagedItem(type, name);
}

/** The body of `ObjectStackProtocolImplementation#isStoredEntryOfDeclinedName`; the method's docblock there is its contract. */
export function isStoredEntryOfDeclinedName(
this: Pick<LockStateHost, 'declinesStoredRow'>,
type: string, item: unknown): boolean {
return this.declinesStoredRow(type, (item as { name?: unknown } | null | undefined)?.name)
&& !isCodeArtifactBody(item);
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ const PRODUCERS: Readonly<Record<string, number>> = {
'protocol-save-door-refusals.ts': 5,
// Package install and update, moved out of `protocol.ts` whole on the same terms.
'protocol-package-install.ts': 3,
// The served lock state and the stored-row decline, moved out of `protocol.ts`
// whole on the same terms.
'protocol-lock-state.ts': 1,
'sys-metadata-repository.ts': 5,
'runtime-authoring-gate.ts': 1,
};
Expand Down
84 changes: 40 additions & 44 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,7 @@ export type { FormFieldInput };
import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system';
import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema, getMetadataTypeActions, getMetadataCreateSeed, PROTOCOL_VERSION } from '@objectstack/spec/kernel';
import {
extractProtection,
evaluateLockForWrite,
evaluateLockForDelete,
resolveLockState,
MetadataLockSchema,
type MetadataLock,
type MetadataLockSource,
type MetadataProvenance,
Expand Down Expand Up @@ -381,6 +377,15 @@ import {
restoredCredentialPathsFor,
storedBodyForCarryForward,
} from './protocol-credential-carry-forward.js';
// [#25467 cut 10] The served lock state and the stored-row decline.
import {
declinesStoredRow,
isShippedFlowName,
isStoredEntryOfDeclinedName,
isStoredFlowEntryOfShippedName,
isUnservedSealedOverlay,
servedLockState,
} from './protocol-lock-state.js';

/**
* Canonical Zod schema per metadata type lives in
Expand Down Expand Up @@ -15898,35 +15903,11 @@ export class ObjectStackProtocolImplementation implements
itemLock: ItemLock,
storedRowServed = false,
): ReturnType<typeof resolveLockState> {
const { provenance, packageId, packageVersion } = extractProtection(document);
const editable = evaluateLockForWrite(itemLock.lock) === null
&& this.packagedBaseRefusal({ type, name, operation: 'save' }) === null;
const removalRefusal = this.packagedBaseRefusal({ type, name, operation: 'delete' });
const deletable = evaluateLockForDelete(itemLock.lock) === null
&& (removalRefusal === null || (storedRowServed && isOriginGatedType(type)));
const lock = MetadataLockSchema.options.find((state) =>
(evaluateLockForWrite(state) === null) === editable
&& (evaluateLockForDelete(state) === null) === deletable);
if (lock === undefined) {
// Unreachable while the lock algebra covers all four verdict pairs;
// a state added to it without a write/delete answer must fail here,
// loudly, not publish a guessed lock.
throw new Error(
`No ADR-0010 lock state answers editable=${editable}, deletable=${deletable}.`,
);
}
return {
lock,
lockReason: itemLock.lockReason,
lockSource: itemLock.lockSource,
lockDocsUrl: itemLock.lockDocsUrl,
provenance,
packageId,
packageVersion,
editable,
deletable,
resettable: artifactBacked,
// [#25467 cut 10] The body moved to `protocol-lock-state.ts` (see `LockStateHost`).
const host: ThisParameterType<typeof servedLockState> = {
packagedBaseRefusal: (...a) => this.packagedBaseRefusal(...a),
};
return servedLockState.call(host, type, name, document, artifactBacked, itemLock, storedRowServed);
}

/**
Expand Down Expand Up @@ -15970,9 +15951,11 @@ export class ObjectStackProtocolImplementation implements
* the code definition too.
*/
isShippedFlowName(type: string, name: unknown): boolean {
if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'flow') return false;
if (typeof name !== 'string' || name === '') return false;
return this.packagedArtifactOwner({ type: 'flow', name }) !== undefined;
// [#25467 cut 10] The body moved to `protocol-lock-state.ts` (see `LockStateHost`).
const host: ThisParameterType<typeof isShippedFlowName> = {
packagedArtifactOwner: (...a) => this.packagedArtifactOwner(...a),
};
return isShippedFlowName.call(host, type, name);
}

/**
Expand All @@ -15985,8 +15968,11 @@ export class ObjectStackProtocolImplementation implements
* envelope ({@link hydrateOverlayIntoRegistry}), so it never passes it.
*/
private isStoredFlowEntryOfShippedName(type: string, item: unknown): boolean {
return this.isShippedFlowName(type, (item as { name?: unknown } | null | undefined)?.name)
&& !isCodeArtifactBody(item);
// [#25467 cut 10] The body moved to `protocol-lock-state.ts` (see `LockStateHost`).
const host: ThisParameterType<typeof isStoredFlowEntryOfShippedName> = {
isShippedFlowName: (...a) => this.isShippedFlowName(...a),
};
return isStoredFlowEntryOfShippedName.call(host, type, item);
}

/**
Expand Down Expand Up @@ -16046,9 +16032,13 @@ export class ObjectStackProtocolImplementation implements
* ask it; the writes keep their own refusals.
*/
declinesStoredRow(type: string, name: unknown): boolean {
if (this.isShippedFlowName(type, name)) return true;
if (typeof name !== 'string' || name === '') return false;
return this.isDeclaredCodeDatasource(type, name) || this.isUnservedSealedOverlay(type, name);
// [#25467 cut 10] The body moved to `protocol-lock-state.ts` (see `LockStateHost`).
const host: ThisParameterType<typeof declinesStoredRow> = {
isDeclaredCodeDatasource: (...a) => this.isDeclaredCodeDatasource(...a),
isShippedFlowName: (...a) => this.isShippedFlowName(...a),
isUnservedSealedOverlay: (...a) => this.isUnservedSealedOverlay(...a),
};
return declinesStoredRow.call(host, type, name);
}

/**
Expand All @@ -16068,8 +16058,11 @@ export class ObjectStackProtocolImplementation implements
* operator's act, so it keeps that ruling's disposition.
*/
private isUnservedSealedOverlay(type: string, name: string): boolean {
if (canonicalMetaType(type) === 'permission') return false;
return this.isSealedManagedItem(type, name);
// [#25467 cut 10] The body moved to `protocol-lock-state.ts` (see `LockStateHost`).
const host: ThisParameterType<typeof isUnservedSealedOverlay> = {
isSealedManagedItem: (...a) => this.isSealedManagedItem(...a),
};
return isUnservedSealedOverlay.call(host, type, name);
}

/**
Expand All @@ -16082,8 +16075,11 @@ export class ObjectStackProtocolImplementation implements
* MetadataService's), so every entry it matches is a hydrated row.
*/
private isStoredEntryOfDeclinedName(type: string, item: unknown): boolean {
return this.declinesStoredRow(type, (item as { name?: unknown } | null | undefined)?.name)
&& !isCodeArtifactBody(item);
// [#25467 cut 10] The body moved to `protocol-lock-state.ts` (see `LockStateHost`).
const host: ThisParameterType<typeof isStoredEntryOfDeclinedName> = {
declinesStoredRow: (...a) => this.declinesStoredRow(...a),
};
return isStoredEntryOfDeclinedName.call(host, type, item);
}

/**
Expand Down
Loading