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

/**
* [#25467 cut 24] The live metadata-type listing of
* `ObjectStackProtocolImplementation` (`listLiveMetadataTypes`), moved out
* of `protocol.ts` whole: the types the engine's SchemaRegistry holds, plus
* the types the `metadata` service reports, de-duplicated — the one answer to
* "which types exist here" that `getMetaTypes` and every other caller share.
*
* A MOVE, not a redesign. The 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 two sources in the
* same order, the same tolerance of an absent `metadata` service, and the
* same de-duplication.
*
* ## How the moved body reaches 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 the method — the published `dist/*.d.ts` lists its name,
* docblock and declared signature — and its body is one literal of exactly
* the {@link LiveMetadataTypesHost} members it reads, plus a `.call`. Both
* are handed over as getters, so the body reads the protocol's own `engine`
* and its constructor-supplied `getServicesRegistry` field at call time, as
* `protocol-metadata-service-read.ts` hands that field over. Every other
* reader of the method keeps calling the class method.
*
* The body reads none of `protocol.ts`'s module-level declarations and
* imports nothing but the class's type. Nothing re-exports this module, so
* the published surface does not move.
*/

import type { ObjectStackProtocolImplementation } from './protocol.js';

/**
* The `ObjectStackProtocolImplementation` members the function below reads —
* every one of them, and no other; the 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 LiveMetadataTypesHost {
readonly engine: ObjectStackProtocolImplementation['engine'];
readonly getServicesRegistry: ObjectStackProtocolImplementation['getServicesRegistry'];
}

/** The body of `ObjectStackProtocolImplementation#listLiveMetadataTypes`; the method's docblock there is its contract. */
export async function listLiveMetadataTypes(
this: Pick<LiveMetadataTypesHost, 'engine' | 'getServicesRegistry'>,
): Promise<string[]> {
const schemaTypes = this.engine.registry.getRegisteredTypes();

// Also include types from MetadataService (runtime-registered: agent, tool, etc.)
let runtimeTypes: string[] = [];
try {
const services = this.getServicesRegistry?.();
const metadataService = services?.get('metadata');
if (metadataService && typeof metadataService.getRegisteredTypes === 'function') {
runtimeTypes = await metadataService.getRegisteredTypes();
}
} catch {
// MetadataService not available
}

return Array.from(new Set([...schemaTypes, ...runtimeTypes]));
}
68 changes: 68 additions & 0 deletions packages/metadata-protocol/src/protocol-save-conflict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25467 cut 24] The save door's answer to an optimistic-save conflict, from
* `ObjectStackProtocolImplementation` (`saveConflict`), moved out of
* `protocol.ts` whole: the 409 for a `ConflictError` — the repository's race,
* or a caller's token the keyed comparison refused — built by the one
* conflict builder, plus its decision-audit row (#21207, #23406).
*
* A MOVE, not a redesign. The 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 refusal text and
* first-write pin, the same audit row, in the same order.
*
* ## How the moved body reaches 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 the method — the published `dist/*.d.ts` lists its name,
* docblock and declared signature — and its body is one literal of exactly
* the {@link SaveConflictHost} members it reads, each handed over as a
* closure, plus a `.call`. Every other reader of the method keeps calling
* the class method.
*
* What the body reads from elsewhere, it imports from where `protocol.ts`
* imported it. Nothing re-exports this module, so the published surface does
* not move.
*/

import type { ConflictError } from '@objectstack/metadata-core';
import type { ObjectStackProtocolImplementation } from './protocol.js';

/**
* The `ObjectStackProtocolImplementation` members the function below reads —
* every one of them, and no other; the 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 SaveConflictHost {
readonly metadataConflictRefusal: ObjectStackProtocolImplementation['metadataConflictRefusal'];
readonly recordOptimisticConflictAudit: ObjectStackProtocolImplementation['recordOptimisticConflictAudit'];
}

/** The body of `ObjectStackProtocolImplementation#saveConflict`; the method's docblock there is its contract. */
export async function saveConflict(
this: Pick<SaveConflictHost, 'metadataConflictRefusal' | 'recordOptimisticConflictAudit'>,
err: ConflictError,
request: { type: string; name: string; actor?: string; parentVersion?: string | null; mode?: 'draft' | 'publish' },
writeSource: string,
): Promise<Error> {
const conflict = await this.metadataConflictRefusal(
err,
`${request.type}/${request.name}`,
`${request.type}/${request.name} has been modified since you loaded it.`,
request.parentVersion === null ? (request.mode === 'draft' ? 'draft' : 'active') : undefined,
);
await this.recordOptimisticConflictAudit({
type: request.type,
name: request.name,
operation: 'save',
...(request.actor ? { actor: request.actor } : {}),
source: writeSource,
expectedParent: err.expectedParent,
actualHead: err.actualHead,
});
return conflict;
}
49 changes: 18 additions & 31 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,10 @@ import { reportSealedOverlayRows } from './protocol-sealed-overlay-report.js';
import { expandUnderOwnName, resolveRowlessExpandedView } from './protocol-view-expansion-names.js';
// [#25467 cut 23] The read-side conversion of a stored item: the ADR-0087 chain with its notices, and the warned-once read with the page `type` default.
import { convertStoredItem, convertStoredItemDetailed } from './protocol-stored-item-conversion.js';
// [#25467 cut 24] The live metadata-type listing: the SchemaRegistry's types and the `metadata` service's, de-duplicated.
import { listLiveMetadataTypes } from './protocol-live-metadata-types.js';
// [#25467 cut 24] The save door's 409 for a `ConflictError`, with its decision-audit row.
import { saveConflict } from './protocol-save-conflict.js';

/**
* Canonical Zod schema per metadata type lives in
Expand Down Expand Up @@ -8492,21 +8496,14 @@ export class ObjectStackProtocolImplementation implements
* One accessor, no second vocabulary.
*/
private async listLiveMetadataTypes(): Promise<string[]> {
const schemaTypes = this.engine.registry.getRegisteredTypes();

// Also include types from MetadataService (runtime-registered: agent, tool, etc.)
let runtimeTypes: string[] = [];
try {
const services = this.getServicesRegistry?.();
const metadataService = services?.get('metadata');
if (metadataService && typeof metadataService.getRegisteredTypes === 'function') {
runtimeTypes = await metadataService.getRegisteredTypes();
}
} catch {
// MetadataService not available
}

return Array.from(new Set([...schemaTypes, ...runtimeTypes]));
// [#25467 cut 24] The body moved to `protocol-live-metadata-types.ts` (see `LiveMetadataTypesHost`).
const readEngine = () => this.engine;
const readGetServicesRegistry = () => this.getServicesRegistry;
const host: ThisParameterType<typeof listLiveMetadataTypes> = {
get engine() { return readEngine(); },
get getServicesRegistry() { return readGetServicesRegistry(); },
};
return listLiveMetadataTypes.call(host);
}

async getMetaTypes() {
Expand Down Expand Up @@ -21471,22 +21468,12 @@ export class ObjectStackProtocolImplementation implements
request: { type: string; name: string; actor?: string; parentVersion?: string | null; mode?: 'draft' | 'publish' },
writeSource: string,
): Promise<Error> {
const conflict = await this.metadataConflictRefusal(
err,
`${request.type}/${request.name}`,
`${request.type}/${request.name} has been modified since you loaded it.`,
request.parentVersion === null ? (request.mode === 'draft' ? 'draft' : 'active') : undefined,
);
await this.recordOptimisticConflictAudit({
type: request.type,
name: request.name,
operation: 'save',
...(request.actor ? { actor: request.actor } : {}),
source: writeSource,
expectedParent: err.expectedParent,
actualHead: err.actualHead,
});
return conflict;
// [#25467 cut 24] The body moved to `protocol-save-conflict.ts` (see `SaveConflictHost`).
const host: ThisParameterType<typeof saveConflict> = {
metadataConflictRefusal: (...a) => this.metadataConflictRefusal(...a),
recordOptimisticConflictAudit: (...a) => this.recordOptimisticConflictAudit(...a),
};
return saveConflict.call(host, err, request, writeSource);
}

/**
Expand Down
Loading