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

/**
* [#25467 cut 23] The read-side conversion of a stored metadata item by
* `ObjectStackProtocolImplementation`, moved out of `protocol.ts` whole: the
* ADR-0087 conversion chain over a stored body with its notices and TODOs
* handed back (`convertStoredItemDetailed`, #4327/#17321/#25279), and the
* read every stored row goes through, which warns once per conversion, row
* and process and fills a page's declared `type` default
* (`convertStoredItem`, #20101).
*
* 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 `flow` exemption,
* the same chain call, the same warn-once key and text, and the same page
* default.
*
* ## 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 both methods — the published `dist/*.d.ts` lists their
* names, docblocks and declared signatures. `convertStoredItemDetailed`
* reads no member, so its stub calls the plain function directly.
* `convertStoredItem`'s stub is one literal of exactly the
* {@link StoredItemConversionHost} members it reads, plus a `.call`: the
* `convertStoredItemDetailed` method as a closure, and the
* `storedConversionWarned` field as a getter, so the warn-once set the body
* reads and adds to is the protocol's own. Every other reader of either
* method keeps calling the class method.
*
* What the bodies read of `protocol.ts`'s own module-level declarations they
* import from it; what they import from elsewhere, they import from where
* `protocol.ts` imported it. Nothing re-exports this module, so the published
* surface does not move.
*/

import { applyConversionsToStoredItem, type ConversionNotice, type ConversionTodoNotice } from '@objectstack/spec';
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
import type { ObjectStackProtocolImplementation } from './protocol.js';
import { withDeclaredPageTypeDefault } from './protocol.js';

/**
* The `ObjectStackProtocolImplementation` members the functions below read —
* every one of them, and no other; a 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 StoredItemConversionHost {
readonly convertStoredItemDetailed: ObjectStackProtocolImplementation['convertStoredItemDetailed'];
readonly storedConversionWarned: ObjectStackProtocolImplementation['storedConversionWarned'];
}

/** The body of `ObjectStackProtocolImplementation#convertStoredItem`; the method's docblock there is its contract. */
export function convertStoredItem(
this: Pick<StoredItemConversionHost, 'convertStoredItemDetailed' | 'storedConversionWarned'>,
type: string,
data: unknown,
): unknown {
const singular = PLURAL_TO_SINGULAR[type] ?? type;
const converted = this.convertStoredItemDetailed(type, data, (n) => {
const name = (data as { name?: unknown } | null | undefined)?.name;
const key = `${n.conversionId}|${singular}|${String(name ?? '')}`;
if (this.storedConversionWarned.has(key)) return;
this.storedConversionWarned.add(key);
console.warn(
`[Protocol] stored ${singular}/${String(name ?? '<unnamed>')} carries a pre-protocol shape; ` +
`${n.message} The row itself is unchanged — re-save it (Studio edit → save, or run ` +
`"os migrate meta --stored --apply") to persist the canonical shape.`,
);
}).item;
return withDeclaredPageTypeDefault(singular, converted);
}

/** The body of `ObjectStackProtocolImplementation#convertStoredItemDetailed`; the method's docblock there is its contract. */
export function convertStoredItemDetailed(
type: string,
data: unknown,
onNotice?: (notice: ConversionNotice) => void,
knownDatasources?: ReadonlyMap<string, { readonly schemaMode?: unknown }>,
): { item: unknown; notices: ConversionNotice[]; todos: ConversionTodoNotice[] } {
const singular = PLURAL_TO_SINGULAR[type] ?? type;
if (singular === 'flow') return { item: data, notices: [], todos: [] };
const notices: ConversionNotice[] = [];
const todos: ConversionTodoNotice[] = [];
const item = applyConversionsToStoredItem(singular, data, {
onNotice: (n) => {
notices.push(n);
onNotice?.(n);
},
onTodo: (t) => {
todos.push(t);
},
...(knownDatasources ? { knownDatasources } : {}),
});
return { item, notices, todos };
}
43 changes: 13 additions & 30 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSp
// live transport in through `attachMetadataMutationPubSub`.
import type { IFieldFileService, IPubSub } from '@objectstack/spec/contracts';
import { FIELD_FILE_SERVICE, RLS_MEMBERSHIP_RESOLVER_SERVICE } from '@objectstack/spec/contracts';
import { applyConversionsToFlow, applyConversionsToStoredItem, type ConversionNotice, type ConversionTodoNotice } from '@objectstack/spec';
import { applyConversionsToFlow, type ConversionNotice, type ConversionTodoNotice } from '@objectstack/spec';
import { type FormView, type I18nLabel, isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec/ui';
// [commit ece4dad31] Emitted-specifier pin. This module's inferred public declarations
// structurally mention `FormFieldInput` (FormView `sections[].fields`), and
Expand Down Expand Up @@ -411,6 +411,8 @@ import { readItemFromMetadataService } from './protocol-metadata-service-read.js
import { reportSealedOverlayRows } from './protocol-sealed-overlay-report.js';
// [#25467 cut 22] A row-less view name's expansion for the by-name read, and a container's expansion under its own name.
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';

/**
* Canonical Zod schema per metadata type lives in
Expand Down Expand Up @@ -2260,7 +2262,7 @@ export function projectStorableViewBody(
* `null`), and an explicit value is left as it is. Returns the input itself
* when nothing is filled.
*/
function withDeclaredPageTypeDefault(type: string, item: unknown): unknown {
export function withDeclaredPageTypeDefault(type: string, item: unknown): unknown {
if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'page') return item;
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
if ((item as Record<string, unknown>).type !== undefined) return item;
Expand Down Expand Up @@ -6414,19 +6416,13 @@ export class ObjectStackProtocolImplementation implements
* its bytes.
*/
private convertStoredItem(type: string, data: unknown): unknown {
const singular = PLURAL_TO_SINGULAR[type] ?? type;
const converted = this.convertStoredItemDetailed(type, data, (n) => {
const name = (data as { name?: unknown } | null | undefined)?.name;
const key = `${n.conversionId}|${singular}|${String(name ?? '')}`;
if (this.storedConversionWarned.has(key)) return;
this.storedConversionWarned.add(key);
console.warn(
`[Protocol] stored ${singular}/${String(name ?? '<unnamed>')} carries a pre-protocol shape; ` +
`${n.message} The row itself is unchanged — re-save it (Studio edit → save, or run ` +
`"os migrate meta --stored --apply") to persist the canonical shape.`,
);
}).item;
return withDeclaredPageTypeDefault(singular, converted);
// [#25467 cut 23] The body moved to `protocol-stored-item-conversion.ts` (see `StoredItemConversionHost`).
const readStoredConversionWarned = () => this.storedConversionWarned;
const host: ThisParameterType<typeof convertStoredItem> = {
convertStoredItemDetailed: (...a) => this.convertStoredItemDetailed(...a),
get storedConversionWarned() { return readStoredConversionWarned(); },
};
return convertStoredItem.call(host, type, data);
}

/**
Expand Down Expand Up @@ -6455,21 +6451,8 @@ export class ObjectStackProtocolImplementation implements
onNotice?: (notice: ConversionNotice) => void,
knownDatasources?: ReadonlyMap<string, { readonly schemaMode?: unknown }>,
): { item: unknown; notices: ConversionNotice[]; todos: ConversionTodoNotice[] } {
const singular = PLURAL_TO_SINGULAR[type] ?? type;
if (singular === 'flow') return { item: data, notices: [], todos: [] };
const notices: ConversionNotice[] = [];
const todos: ConversionTodoNotice[] = [];
const item = applyConversionsToStoredItem(singular, data, {
onNotice: (n) => {
notices.push(n);
onNotice?.(n);
},
onTodo: (t) => {
todos.push(t);
},
...(knownDatasources ? { knownDatasources } : {}),
});
return { item, notices, todos };
// [#25467 cut 23] The body moved to `protocol-stored-item-conversion.ts`.
return convertStoredItemDetailed(type, data, onNotice, knownDatasources);
}

/**
Expand Down
Loading