From 30c2efb699ff2b84c43fe0f54eb6e5ad9925ca17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:01:19 +0000 Subject: [PATCH 01/13] wip(metadata-protocol): sys_metadata overlay rows keyed by bundle member Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../src/objects/sys-metadata.object.ts | 42 +++- .../src/migrations/overlay-index.ts | 223 +++++++++++++++++- packages/metadata-protocol/src/protocol.ts | 79 ++++++- .../src/sys-metadata-repository.ts | 168 ++++++++++++- 4 files changed, 471 insertions(+), 41 deletions(-) diff --git a/packages/metadata-core/src/objects/sys-metadata.object.ts b/packages/metadata-core/src/objects/sys-metadata.object.ts index 4e156560669..83812b2ab4d 100644 --- a/packages/metadata-core/src/objects/sys-metadata.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata.object.ts @@ -93,6 +93,27 @@ export const SysMetadataObject = ObjectSchema.create({ defaultValue: 'platform', }), + /** + * The bundle member this row overlays, for a metadata type whose identity + * is a pair (`ITEM_KEY_DISCRIMINATORS` in `../item-key-discriminators.ts`, + * today `email_template`'s `(name, locale)`). + * + * NULL is the type's CANONICAL member, which is also every row of a type + * whose identity is `name` alone — so those rows never carry a value and + * stay byte-identical. A non-canonical member stores the value the body + * declares (`itemDiscriminator`), so two locales of one template are two + * rows. Written by the overlay repository from the body, never authored; + * part of the overlay uniqueness key below and of the runtime index + * (`metadata-protocol`'s `migrations/overlay-index.ts`). + */ + bundle_member: Field.text({ + label: 'Bundle Member', + required: false, + maxLength: 100, + description: + 'Bundle member (e.g. the locale of an email template) this overlay row customizes. Null = the canonical member.', + }), + /** JSON payload — the actual metadata configuration */ metadata: Field.textarea({ label: 'Metadata', @@ -200,11 +221,13 @@ export const SysMetadataObject = ObjectSchema.create({ indexes: [ // ADR-0005 (revised 2026-05) + ADR-0048: overlay uniqueness is scoped by - // (type, name, organization_id, package_id). `package_id` is part of the - // discriminator so two installed packages shipping the same `type`/`name` - // each get their OWN customization row (a package-less / global overlay - // uses NULL). environment_id is deprecated and not part of the - // discriminator. + // (type, name, organization_id, package_id, bundle_member). `package_id` is + // part of the discriminator so two installed packages shipping the same + // `type`/`name` each get their OWN customization row (a package-less / + // global overlay uses NULL). `bundle_member` is the bundled type's member + // (#22877): two locales of one email template are two overlay rows; NULL + // is the canonical member and every undiscriminated type's row. + // environment_id is deprecated and not part of the discriminator. // // ⚠️ The active-row restriction is NOT declared here, and never was in any // effective sense: this entry carried `partial: "state = 'active'"` until @@ -217,15 +240,16 @@ export const SysMetadataObject = ObjectSchema.create({ // What actually delivers active-row scoping is the runtime layer: // `protocol.ts ensureOverlayIndex` issues a DROP-then-CREATE migration for // `CREATE UNIQUE INDEX … ON sys_metadata (type, name, organization_id, - // COALESCE(package_id,'')) WHERE state = 'active'` — which additionally - // fixes NULL-distinctness for package-less rows, something this - // declaration could not express either. This entry is therefore the + // COALESCE(package_id,''), COALESCE(bundle_member,'')) WHERE state = + // 'active'` — which additionally fixes NULL-distinctness for package-less + // rows and canonical members, something this declaration could not + // express either. This entry is therefore the // coarser fallback that a driver without that runtime migration gets: it // still prevents duplicate ACTIVE overlays, at the cost of also colliding // with archived/reset rows. { name: 'idx_sys_metadata_overlay_active', - fields: ['type', 'name', 'organization_id', 'package_id'], + fields: ['type', 'name', 'organization_id', 'package_id', 'bundle_member'], unique: 'global', }, { name: 'idx_sys_metadata_org_type', fields: ['organization_id', 'type'] }, diff --git a/packages/metadata-protocol/src/migrations/overlay-index.ts b/packages/metadata-protocol/src/migrations/overlay-index.ts index c7617d2f1ed..b789d65ed35 100644 --- a/packages/metadata-protocol/src/migrations/overlay-index.ts +++ b/packages/metadata-protocol/src/migrations/overlay-index.ts @@ -90,15 +90,40 @@ * ever add an index and never replace a stronger one, and the report says * plainly that uniqueness is not enforced on this dialect. * - * ## The KEY spelling is untouched + * ## The KEY, and its one re-keying (#22877) * - * `(type, name, organization_id, COALESCE(package_id, ''))`, byte-identical to - * what shipped before. #6418 is an ORDER and REPORTING fix; re-keying this - * index (notably the bare, NULL-distinct `organization_id` part) is a separate - * question with its own ADR-0120 D4 ceremony and is deliberately not decided - * here. + * `(type, name, organization_id, COALESCE(package_id, ''), + * COALESCE(bundle_member, ''))`. #6418 was an ORDER and REPORTING fix and kept + * the key byte-identical; the member part is the one re-keying since, ruled + * through this ceremony. + * + * `bundle_member` is the member of a metadata type whose identity is a pair + * (`ITEM_KEY_DISCRIMINATORS`, today `email_template`'s `(name, locale)`): two + * locales of one template are two overlay rows, where the four-part key kept + * one and the second locale's save replaced the first at the next boot. NULL is + * the canonical member and every row of an undiscriminated type, folded to the + * `''` bucket so those rows are constrained exactly as before. + * + * The probe-then-replace order needs nothing new to carry a CHANGED key: it + * drops and rebuilds the real name on every first write, so the next boot after + * the upgrade replaces the four-part index with this one. Adding a key part only + * RELAXES uniqueness — rows the old key admitted are distinct under the new one + * — so no store that built the old index can be refused by this one; the + * `conflict` arm below still reports a store that was already blocked. Rows + * written before the column existed carry NULL whatever member their body + * declares, and {@link backfillOverlayBundleMembers} gives each its body's + * member before a read can take it for the canonical one. + * + * The bare, NULL-distinct `organization_id` part is still a separate question + * with its own ADR-0120 D4 ceremony and is deliberately not decided here. */ +import { ITEM_KEY_DISCRIMINATORS, itemDiscriminator } from '@objectstack/metadata-core'; +import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { operatorFacingErrorText } from '@objectstack/types'; +// The shared "which driver read errors are benign" vocabulary (#4867). +import { isMissingTableError } from '@objectstack/metadata/errors'; + import { logProblem, probeThenReplaceIndex, @@ -133,24 +158,38 @@ export const OVERLAY_PROBE_INDEX_NAMES: Readonly> = { package_id: '', + [OVERLAY_MEMBER_COLUMN]: '', }; /** @@ -202,10 +241,11 @@ export function buildOverlayFallbackIndexSql(indexName: string): string { * * It GROUPs by exactly the index's own key parts, so what it reports and what * the index rejects cannot diverge. The nullable column is projected through - * the SAME `COALESCE` it is grouped by (aliased `package_key`) rather than + * the SAME `COALESCE` it is grouped by (aliased `_key`) rather than * bare: a bare projection of a column that appears in `GROUP BY` only inside an * expression is rejected by PostgreSQL, and this query has to run on both - * dialects that can build the index it is explaining. + * dialects that can build the index it is explaining. Both NULL-folded parts + * project that way: `package_id_key` and `bundle_member_key`. */ export function buildOverlayDuplicateProbeSql(state: OverlayIndexState): string { const projected = OVERLAY_INDEX_COLUMNS.map((column) => { @@ -354,7 +394,8 @@ function reportDegradation( `[metadata-protocol] this database cannot build the overlay-uniqueness index — ` + `'${indexName}' on "${OVERLAY_TABLE}" cannot be scoped to state='${state}' rows over ` + `(${keyParts}), so ADR-0005 overlay uniqueness is NOT enforced as specified on this ` + - `dialect: package-less ${state} rows (package_id NULL) stay NULL-distinct and can duplicate ` + + `dialect: package-less ${state} rows (package_id NULL) and canonical bundle members ` + + `(${OVERLAY_MEMBER_COLUMN} NULL) stay NULL-distinct and can duplicate ` + `— getMetaItem then has no defined answer for which row wins — and any unrestricted UNIQUE ` + `that remains additionally rejects the legal ACTIVE+DRAFT pair. The system keeps looking ` + `healthy either way. Whatever index already carried this name is left exactly as it was, ` + @@ -394,3 +435,159 @@ function reportDegradation( detail, ); } + +// ── The member part (#22877) ──────────────────────────────────────────────── + +/** + * The discriminator `type` declares, under either spelling of the type. + * `undefined` for every type whose identity is `name` alone. + */ +function overlayDiscriminatorOf(type: string): { field: string; canonical: string } | undefined { + return ITEM_KEY_DISCRIMINATORS[PLURAL_TO_SINGULAR[type] ?? type]; +} + +/** + * The value {@link OVERLAY_MEMBER_COLUMN} holds for the member a caller names. + * + * - `undefined` — `type` declares no discriminator. The load-bearing answer: + * every caller adds the column to a key, a row or a `where` ONLY when it is + * defined, so an undiscriminated type's writes and reads stay byte-identical + * to what they were before the column existed (the `itemDiscriminator` + * contract, one layer down). + * - `null` — the canonical member: no member named, an empty one, or the + * canonical value itself. ONE spelling for it, never the canonical value, + * so a row written before the column existed and a row written after it + * cannot hold the same member under two keys. + * - the trimmed value — any other member. + */ +export function overlayMemberValue(type: string, member?: string | null): string | null | undefined { + const disc = overlayDiscriminatorOf(type); + if (!disc) return undefined; + const value = typeof member === 'string' ? member.trim() : ''; + return value === '' || value === disc.canonical ? null : value; +} + +/** + * The member a metadata BODY belongs to, as {@link OVERLAY_MEMBER_COLUMN} + * stores it — read through `itemDiscriminator`, the one place a type declares + * what else is part of its identity. Same three answers as + * {@link overlayMemberValue}. + */ +export function overlayMemberOfBody(type: string, body: unknown): string | null | undefined { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + const declared = itemDiscriminator(singular, body); + if (declared === undefined) return undefined; + return overlayMemberValue(singular, declared); +} + +/** + * The `where` fragment that addresses one member of `type`: `{}` for an + * undiscriminated type (no predicate at all), else the column pinned to the + * member — `null` (IS NULL) for the canonical one. Spread into a row read. + */ +export function overlayMemberWhere(type: string, member?: string | null): Record { + const value = overlayMemberValue(type, member); + return value === undefined ? {} : { [OVERLAY_MEMBER_COLUMN]: value }; +} + +/** The engine surface {@link backfillOverlayBundleMembers} reads and writes through. */ +export interface OverlayMemberBackfillEngine { + find(table: string, options: { where: Record; context?: any }): Promise; + update(table: string, data: Record, options: { where: Record; context?: any }): Promise; +} + +export interface OverlayMemberBackfillResult { + /** `'failed'` — a read or a write did not happen; `detail` says why. */ + status: 'done' | 'failed'; + /** Rows that now carry their body's member. */ + backfilled: number; + /** Rows of a discriminated type whose stored body does not parse — left NULL. */ + unparseable: number; + detail?: string; +} + +/** + * Give every row written before {@link OVERLAY_MEMBER_COLUMN} existed the + * member its body declares. + * + * Such a row carries NULL — the canonical member — whatever its body says, so + * a `zh-CN` overlay stored by the four-part key reads as the `en-US` one to a + * bare-name read, and the next `zh-CN` save would mint a second row beside it + * holding the same member. Only rows of a discriminated type whose body names + * a NON-canonical member are written; a canonical body's NULL is already + * right, and every other type's rows are never read here at all. + * + * Through the ENGINE rather than raw SQL: the member is read out of the JSON + * body by `itemDiscriminator`, which no portable SQL expression can reproduce, + * and the engine reaches every driver this table lives on (memory hosts + * included, where there is no raw seam). Idempotent — a row that carries a + * member is skipped — and safe in any order relative to the index rebuild, + * because the new key only relaxes the old one: rows unique under the + * four-part key stay unique when each gains its member. + * + * Never throws: a boot must not fail over a backfill. A failure is reported at + * `error` (AGENTS.md "Degradation log levels": the rows it did not reach keep + * answering bare-name reads as the canonical member while everything looks + * healthy) with the fix, and the next boot retries. + */ +export async function backfillOverlayBundleMembers( + engine: OverlayMemberBackfillEngine | undefined, + logger?: IndexMigrationLogger, +): Promise { + let backfilled = 0; + let unparseable = 0; + if (!engine || typeof engine.find !== 'function' || typeof engine.update !== 'function') { + return { status: 'done', backfilled, unparseable }; + } + const context = { isSystem: true }; + try { + for (const type of Object.keys(ITEM_KEY_DISCRIMINATORS)) { + const rows = (await engine.find(OVERLAY_TABLE, { where: { type }, context })) ?? []; + for (const row of rows as Array>) { + if (row?.[OVERLAY_MEMBER_COLUMN] != null) continue; + let body: unknown; + try { + body = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; + } catch { + // Every read of this row already fails on the same bytes; it + // stays the canonical member until a save repairs it. + unparseable++; + continue; + } + const member = overlayMemberOfBody(type, body); + if (member === null || member === undefined) continue; + await engine.update( + OVERLAY_TABLE, + { [OVERLAY_MEMBER_COLUMN]: member }, + { where: { id: row.id }, context }, + ); + backfilled++; + } + } + } catch (error) { + // Benign — and only benign: no table, therefore no row to backfill. + if (isMissingTableError(error, OVERLAY_TABLE)) return { status: 'done', backfilled, unparseable }; + // The dialect's own words, read out of the `cause` a raw seam attaches + // (see `partial-index-probe.ts`), so the report names the real refusal. + const detail = operatorFacingErrorText(error); + logProblem( + logger, + `[metadata-protocol] could not backfill "${OVERLAY_TABLE}".${OVERLAY_MEMBER_COLUMN} from the ` + + `stored bodies (${backfilled} row(s) done before the failure). Overlay rows of a bundled type ` + + `(${Object.keys(ITEM_KEY_DISCRIMINATORS).join(', ')}) stored before this column existed keep ` + + `NULL, so a non-canonical member (an email template's second locale) is answered as the ` + + `canonical one by a bare-name read, and the next save of that member stores a second row ` + + `beside it — while everything looks healthy. Fix the cause below (the column is added by the ` + + `schema sync of "${OVERLAY_TABLE}") and restart; the backfill runs again on every boot.`, + detail, + ); + return { status: 'failed', backfilled, unparseable, detail }; + } + if (backfilled > 0) { + logger?.info?.( + `[metadata-protocol] backfilled ${OVERLAY_MEMBER_COLUMN} on ${backfilled} "${OVERLAY_TABLE}" ` + + `row(s) from the member their body declares.`, + ); + } + return { status: 'done', backfilled, unparseable }; +} diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 432f1d78418..674a3bf7ba6 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -41,12 +41,17 @@ import { isWritablePackage as isWritablePackageShared } from './package-writabil import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js'; // [#6418] `sys_metadata`'s overlay-uniqueness indexes: probe-first DDL plus the // ADR-0120 D4 reporting that replaced this file's empty `catch` blocks. -import { ensureMetadataOverlayIndexes } from './migrations/overlay-index.js'; +import { + backfillOverlayBundleMembers, + ensureMetadataOverlayIndexes, + overlayMemberWhere, +} from './migrations/overlay-index.js'; import { driverCanRunSql, resolveDriverExec } from './migrations/driver-exec.js'; import { DraftConflictError, SysMetadataRepository, envWritableMetadataTypes, + historyRowsOfMember, packageScopedRowWhere, resetEnvWritableMetadataTypes, type SysMetadataEngine, @@ -106,6 +111,9 @@ import { // the anonymous form doors read it — the runtime authoring gate's input for // its public-form intake advisory (see `tenancyPostureInForce()`). anonymousFormIntakePosture, + // [#22877] A bundled type's member as its body declares it — the value the + // save door addresses the write's stored head by. + itemDiscriminator, } from '@objectstack/metadata-core'; // [#5532] One vocabulary of "which driver read errors are benign", shared with // `sys-metadata-repository.ts` in this package and with `DatabaseLoader` in @@ -7186,6 +7194,9 @@ export class ObjectStackProtocolImplementation implements private async ensureOverlayIndex(): Promise { if (this.overlayIndexEnsured) return; this.overlayIndexEnsured = true; + // [#22877] Rows stored before the member column existed get their + // body's member before the first write can address them. + await this.ensureOverlayMembersBackfilled(); try { const engineAny = this.engine as any; let driver: any = engineAny?.driver ?? engineAny?.getDriver?.(); @@ -7220,6 +7231,27 @@ export class ObjectStackProtocolImplementation implements } } + /** + * [#22877] One run per protocol instance of the bundle-member backfill + * (`./migrations/overlay-index.ts` {@link backfillOverlayBundleMembers}): + * a row of a bundled type stored before `sys_metadata.bundle_member` + * existed is given the member its body declares, so a bare-name read does + * not take a second locale's overlay for the canonical one and that + * locale's next save updates the row instead of minting a second. + * + * Asked by boot hydration ({@link loadMetaFromDb}) once its own read of + * the table succeeded, and by the first write ({@link ensureOverlayIndex}) + * on a host that hydrates nothing. Never throws — the migration reports its + * own failure at `error`, and the guard stays down so the next instance + * (the next boot) retries. + */ + private overlayMembersBackfilled = false; + private async ensureOverlayMembersBackfilled(): Promise { + if (this.overlayMembersBackfilled) return; + this.overlayMembersBackfilled = true; + await backfillOverlayBundleMembers(this.engine as any, console); + } + /** * Exposes the environment scope the protocol is bound to. Consumers like * the HTTP dispatcher use this to decide whether to trust the process- @@ -8543,6 +8575,8 @@ export class ObjectStackProtocolImplementation implements state: 'draft' | 'active'; packageId: string | null; item: any; + /** [#22877] The bundle member the save addresses (its body's). */ + member?: string; }): Promise { if (!hasMetadataRedactor(args.type)) return args.item; const body = await this.storedBodyForCarryForward(args); @@ -8565,6 +8599,7 @@ export class ObjectStackProtocolImplementation implements ref: Parameters[0]; state: 'draft' | 'active'; packageId: string | null; + member?: string; }): Promise { // [#22128] The row this save overwrites, and with no draft row yet the // active row the draft overlays: the one in the package the draft is @@ -8572,12 +8607,14 @@ export class ObjectStackProtocolImplementation implements const target = await args.repo.headAt(args.ref, { state: args.state, packageId: args.packageId, + member: args.member, }); let stored = target.head; if (!stored && args.state === 'draft') { stored = (await args.repo.headAt(args.ref, { state: 'active', packageId: target.packageId, + member: args.member, })).head; } // [#20552] NO overlay row at either state: the body the read served is @@ -9806,6 +9843,10 @@ export class ObjectStackProtocolImplementation implements if (type === undefined) continue; const where: Record = { type, name: args.name, state: args.state, organization_id: candidate.organizationId, + // [#22877] A by-name read of a bundled type answers its + // CANONICAL member, as the registry's bare-name read does; + // nothing for every other type. + ...overlayMemberWhere(type), }; if (candidate.packageId !== undefined) where.package_id = candidate.packageId; // [#21911, ADR-0096] The explicit system opt-in: a platform store @@ -17867,8 +17908,13 @@ export class ObjectStackProtocolImplementation implements ref: Parameters[0], state: 'active' | 'draft', packageId: string | null, + /** + * [#22877] The bundle member the save addresses (its body's); omitted, + * the canonical member — what a by-name read addresses today. + */ + member?: string, ): Promise { - return (await repo.headAt(ref, { state, packageId })).head?.hash ?? null; + return (await repo.headAt(ref, { state, packageId, member })).head?.hash ?? null; } /** @@ -20801,6 +20847,9 @@ export class ObjectStackProtocolImplementation implements // against, the inherited draft binding included. const currentStored = await this.storedHeadAt( repo, ref, mode === 'draft' ? 'draft' : 'active', request.packageId ?? null, + // [#22877] The write's bundle member, from the body it writes — + // the member the repository's `put` keys this save by. + itemDiscriminator(singularTypeForRepo, request.item), ); if (request.parentVersion === undefined) { parentVersion = currentStored; @@ -20862,6 +20911,7 @@ export class ObjectStackProtocolImplementation implements state: mode === 'draft' ? 'draft' : 'active', packageId: request.packageId ?? null, item: request.item, + member: itemDiscriminator(singularTypeForRepo, request.item), }); // [#20790] …and then OUT of the body: a type with a write-only // credential channel (`flow`, registered by the automation plugin) @@ -22045,7 +22095,12 @@ export class ObjectStackProtocolImplementation implements // exactly as its sibling store reads do. Principal-less, the // engine now refuses it. draftRow = await this.engine.findOne('sys_metadata', { - where: { type: singularType, name: request.name, organization_id: null, state: 'draft' }, + where: { + type: singularType, name: request.name, organization_id: null, state: 'draft', + // [#22877] The canonical member's draft — the one + // `repo.get` / `repo.promoteDraft` below address. + ...overlayMemberWhere(singularType), + }, context: { isSystem: true }, }); } catch (error) { @@ -26044,14 +26099,17 @@ export class ObjectStackProtocolImplementation implements // the protocol scopes it by organization itself. Principal-less, // the engine refuses it, and the `catch` below would have answered // an empty diff for an item that has history. - const rows = await engineAny.find('sys_metadata_history', { + // [#22877] The canonical member's lineage only — a by-name diff of a + // bundled type compares the member a by-name read serves, never a + // second locale's snapshot as its "earlier version". + const rows = historyRowsOfMember(singularType, await engineAny.find('sys_metadata_history', { where: { organization_id: orgId, type: singularType, name: request.name, }, context: { isSystem: true }, - }); + })); rows.sort((a: any, b: any) => (a.version ?? 0) - (b.version ?? 0)); for (const r of rows) { const body = r.metadata == null @@ -26140,7 +26198,11 @@ export class ObjectStackProtocolImplementation implements // [#21908, ADR-0096 D5] The explicit system opt-in, as the history // read above. const current = (await this.engine.findOne('sys_metadata', { - where: { organization_id: orgId, type: singularType, name: request.name, state: 'active' }, + where: { + organization_id: orgId, type: singularType, name: request.name, state: 'active', + // [#22877] The canonical member, as the by-name read serves. + ...overlayMemberWhere(singularType), + }, context: { isSystem: true }, })) as { metadata?: unknown; version?: unknown } | null; toBody = current?.metadata == null @@ -26861,6 +26923,11 @@ export class ObjectStackProtocolImplementation implements organization_id: null, }; const records = await this.engine.find('sys_metadata', { where }); + // [#22877] The table answered, so it is provisioned: give rows stored + // before `bundle_member` existed their body's member now, before any + // by-name read or write can address them. Hydration below reads the + // bodies, not the column, so the order between the two is free. + await this.ensureOverlayMembersBackfilled(); // [#20312] ADR-0080 §5 — a page's `requires` is validated at save // AND at load. The deployment's manifest, read once for this load // through the key the save door reads per publish (one channel, diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 0d13161d099..314afe10d89 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -89,6 +89,10 @@ import type { IObjectQLEngine } from '@objectstack/core'; // door too (that shared-rule argument is the module's whole reason to exist). import { isWritablePackage } from './package-writability.js'; import { isOriginGatedType, managedItemSealedSentence, packagedBaseRegimePrescription } from './packaged-base-regime.js'; +// [#22877] The bundled type's member — the column, and the one derivation of +// its value from a selector or a body — owned by the migration that keys the +// overlay index on it, so the key and the write head cannot disagree. +import { OVERLAY_MEMBER_COLUMN, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; /** * Canonicalise a driver-materialised timestamp into the ISO-8601 string the @@ -231,6 +235,50 @@ function draftBodyLabel(row: any): I18nLabel | null { return parsed.success ? parsed.data : null; } +/** + * [#22877] The `sys_metadata_history` rows of ONE bundle member of `type`, in + * their order — every row for an undiscriminated type, which has no members. + * + * Read off each row's BODY through the member derivation the write head uses + * ({@link overlayMemberOfBody}), the identity's one declaration: the history + * table carries no member column, and needs none, because its rows already + * carry the body that names it. A delete tombstone has no body; it belongs to + * the member of the row it removed, whose stamp is its `previous_checksum` and + * whose write is in this same log. A tombstone whose parent is not in the log + * (a row stored before it was stamped), and a body that does not parse, read + * as the canonical member. + * + * `member` is a selector value (`'zh-CN'`); omitted means the canonical member, + * as on every read of this repository. + */ +export function historyRowsOfMember>( + type: string, + rows: readonly T[], + member?: string | null, +): T[] { + const wanted = overlayMemberValue(type, member); + if (wanted === undefined) return [...rows]; + const bodyMember = (row: T): string | null => { + try { + return overlayMemberOfBody(type, storedRowBody(row)) ?? null; + } catch { + return null; + } + }; + const memberByChecksum = new Map(); + for (const row of rows) { + if (row.metadata !== null && row.metadata !== undefined && typeof row.checksum === 'string') { + memberByChecksum.set(row.checksum, bodyMember(row)); + } + } + return rows.filter((row) => { + const own = row.metadata === null || row.metadata === undefined + ? (typeof row.previous_checksum === 'string' ? memberByChecksum.get(row.previous_checksum) ?? null : null) + : bodyMember(row); + return own === wanted; + }); +} + /** * Overlay-row lifecycle state. * @@ -565,7 +613,13 @@ export class SysMetadataRepository implements MetadataRepository { */ async get( ref: MetaRef, - opts?: { state?: OverlayState; packageId?: string | null }, + /** + * [#22877] `member` — the bundle member to read, for a type whose + * identity is a pair (`'zh-CN'` of an `email_template`); omitted, the + * canonical member, which is what a bare-name read answers. Ignored for + * every other type. A LOCAL extension, as `state` and `packageId` are. + */ + opts?: { state?: OverlayState; packageId?: string | null; member?: string | null }, ): Promise { this.assertOpen(); const state = opts?.state ?? 'active'; @@ -582,7 +636,12 @@ export class SysMetadataRepository implements MetadataRepository { // it scopes its own rows by organization. None of them reaches the data // engine as a principal-less context. const row = await this.engine.findOne('sys_metadata', { - where: this.whereFor(ref, state, opts && 'packageId' in opts ? (opts.packageId ?? null) : undefined), + where: this.whereFor( + ref, + state, + opts && 'packageId' in opts ? (opts.packageId ?? null) : undefined, + overlayMemberValue(ref.type, opts?.member), + ), context: { isSystem: true }, }); if (!row) return null; @@ -612,10 +671,17 @@ export class SysMetadataRepository implements MetadataRepository { */ async headAt( ref: MetaRef, - opts: { state?: OverlayState; packageId?: string | null }, + /** + * [#22877] `member` — the bundle member the write addresses: a save takes + * it from the body it writes, so a caller asking for that save's head + * passes the body's member (see {@link get} for the rest). + */ + opts: { state?: OverlayState; packageId?: string | null; member?: string | null }, ): Promise<{ head: MetadataItem | null; packageId: string | null }> { this.assertOpen(); - const { row, targetPackageId } = await this.resolveWriteHead(ref, opts.state ?? 'active', opts.packageId); + const { row, targetPackageId } = await this.resolveWriteHead( + ref, opts.state ?? 'active', opts.packageId, undefined, overlayMemberValue(ref.type, opts.member), + ); return { head: row ? this.rowToItem(ref, row) : null, packageId: targetPackageId }; } @@ -650,6 +716,12 @@ export class SysMetadataRepository implements MetadataRepository { * ADR-0048), and with no active row — a brand-new item drafted first — * there is nothing to inherit and the package-less semantics stand. * + * [#22877] A write addresses ONE bundle member of a type whose identity is + * a pair: `member` is that member as {@link OVERLAY_MEMBER_COLUMN} stores + * it (`null` the canonical one), and every lookup below — the inherited + * base included — is narrowed to it. `undefined` for every other type, whose + * lookups carry no member predicate at all. + * * @param ctx - The transaction handle when the caller holds one. */ private async resolveWriteHead( @@ -657,6 +729,7 @@ export class SysMetadataRepository implements MetadataRepository { state: OverlayState, packageId: string | null | undefined, ctx?: Record, + member?: string | null, ): Promise<{ row: any | null; targetPackageId: string | null }> { const context = { ...ctx, isSystem: true }; let targetPackageId: string | null = packageId ?? null; @@ -673,6 +746,7 @@ export class SysMetadataRepository implements MetadataRepository { name: ref.name, state: 'active', }; + if (member !== undefined) activeWhere[OVERLAY_MEMBER_COLUMN] = member; if (this.organizationId != null) { activeWhere.$or = [ { organization_id: this.organizationId }, @@ -686,7 +760,7 @@ export class SysMetadataRepository implements MetadataRepository { if (activePkg) targetPackageId = activePkg; } let row = await this.engine.findOne('sys_metadata', { - where: this.whereFor(ref, state, targetPackageId), + where: this.whereFor(ref, state, targetPackageId, member), context, }); // [#11087] Orphan-draft adoption: when the package binding above was @@ -697,7 +771,7 @@ export class SysMetadataRepository implements MetadataRepository { // (`existingPkg ?? targetPackageId`) then adopts it into the package. if (!row && state === 'draft' && packageId == null && targetPackageId !== null) { row = await this.engine.findOne('sys_metadata', { - where: this.whereFor(ref, state, null), + where: this.whereFor(ref, state, null, member), context, }); } @@ -769,6 +843,12 @@ export class SysMetadataRepository implements MetadataRepository { // [#21790] Hashed AS ITS TYPE: a map the spec declares ordered (an // object's `fields`) keeps its key order, so a pure reorder is a change. const hash = hashSpec(body, ref.type); + // [#22877] The bundle member this write addresses, from the BODY it + // writes (`itemDiscriminator`, the identity's one declaration) — never a + // caller's say-so, so a row's member and its body cannot disagree. Two + // locales of one email template are two rows; `undefined` for every type + // whose identity is `name` alone, whose rows never carry the column. + const member = overlayMemberOfBody(ref.type, body); // Run all reads + writes inside one transaction so the optimistic // lock, the parent-row mutation, and the history append are atomic. @@ -777,7 +857,7 @@ export class SysMetadataRepository implements MetadataRepository { // create, from THE write-address resolution ({@link resolveWriteHead}): // the one {@link headAt} answers too, so the head a caller was served // for this address is the head this lock judges. - const { row: existing, targetPackageId } = await this.resolveWriteHead(ref, state, opts.packageId, ctx); + const { row: existing, targetPackageId } = await this.resolveWriteHead(ref, state, opts.packageId, ctx, member); // The row's stored stamp, as written: the parent link this write records // (`previous_checksum`, the event's `parentHash`) and the no-op check's // input. `null` for a row stored without one. @@ -825,6 +905,9 @@ export class SysMetadataRepository implements MetadataRepository { version, updated_at: now, }; + // [#22877] Only for a type whose identity is a pair: every other type's + // row payload stays byte-identical to what it was before the column. + if (member !== undefined) parentRowData[OVERLAY_MEMBER_COLUMN] = member; // Software-package binding (Studio package authoring workspace). // Create: stamp with the requested package (or null). Update: preserve // an existing non-null binding so an edit made with a different package @@ -943,7 +1026,12 @@ export class SysMetadataRepository implements MetadataRepository { * should state it too is the open question this card deliberately did not * answer — see {@link whereFor} and #6215. */ - opts: DeleteOptions & { state?: OverlayState; packageId?: string | null }, + opts: DeleteOptions & { + state?: OverlayState; + packageId?: string | null; + /** [#22877] The bundle member to delete; omitted, the canonical one (see {@link get}). */ + member?: string | null; + }, ): Promise { this.assertOpen(); // [#6960] The DELETE verb's own gate — see {@link assertDeleteAllowed}. @@ -958,6 +1046,7 @@ export class SysMetadataRepository implements MetadataRepository { ref, state, 'packageId' in opts ? (opts.packageId ?? null) : undefined, + overlayMemberValue(ref.type, opts.member), ), context: { ...ctx, isSystem: true }, }); @@ -1120,6 +1209,13 @@ export class SysMetadataRepository implements MetadataRepository { * before. */ expectedDraftHash?: string | null; + /** + * [#22877] The bundle member whose draft is promoted; omitted, the + * canonical one (see {@link get}). The draft, the active parent and the + * drain are all read under it, and the put writes the member its body + * declares — the same one, since the draft row was keyed by its body. + */ + member?: string | null; }, ): Promise<{ version: string; @@ -1149,6 +1245,7 @@ export class SysMetadataRepository implements MetadataRepository { ref, 'draft', 'packageId' in opts ? (opts.packageId ?? null) : undefined, + overlayMemberValue(ref.type, opts.member), ), context: { isSystem: true }, }); @@ -1171,7 +1268,7 @@ export class SysMetadataRepository implements MetadataRepository { // Read the active row through the SAME package scope we will write, so the // optimistic-lock `parentVersion` matches the exact row `put` upserts. // (Package-less drafts → packageId null → identical to the prior behaviour.) - const currentActive = await this.get(ref, { state: 'active', packageId: draftPackageId }); + const currentActive = await this.get(ref, { state: 'active', packageId: draftPackageId, member: opts.member }); const activeBody = opts.deriveActiveBody ? opts.deriveActiveBody(draft.body) : draft.body; const result = await this.put(ref, activeBody, { parentVersion: currentActive?.hash ?? null, @@ -1245,6 +1342,14 @@ export class SysMetadataRepository implements MetadataRepository { * Omitted → the history body is restored byte for byte, as before. */ deriveRestoredBody?: (historyBody: unknown) => unknown; + /** + * [#22877] The bundle member whose lineage `targetVersion` is taken + * from; omitted, the canonical one (see {@link get}). The lineage + * counter stays per `(org, type, name)` — the history table's declared + * unique key — so a version names exactly one snapshot, and a snapshot + * of ANOTHER member of the bundle is not this member's version. + */ + member?: string | null; }, ): Promise<{ version: string; seq: number; item: MetadataItem }> { this.assertOpen(); @@ -1276,6 +1381,20 @@ export class SysMetadataRepository implements MetadataRepository { throw err; } const historyBody = typeof raw === 'string' ? JSON.parse(raw) : (raw as Record); + // [#22877] The snapshot must be one of the addressed member's: restoring + // another member's body here would be written to THAT member's row (a + // put keys by its body), under a parent read from this one's. + const member = overlayMemberValue(ref.type, opts.member); + if (member !== undefined && (overlayMemberOfBody(ref.type, historyBody) ?? null) !== member) { + const err: any = new Error( + `No history row at version ${targetVersion} for ${ref.type}/${ref.name} ` + + `(${member === null ? 'its canonical member' : `member '${member}'`}) — that version belongs ` + + `to another member of the bundle.`, + ); + err.code = 'VERSION_NOT_FOUND'; + err.status = 404; + throw err; + } const body = opts.deriveRestoredBody ? opts.deriveRestoredBody(historyBody) : historyBody; // ADR-0048 / #6215 — read the RAW active row, not just its body, and carry // its `package_id` into the write. `put` upserts exactly ONE row and scopes @@ -1296,7 +1415,7 @@ export class SysMetadataRepository implements MetadataRepository { // unbound row, which is the only defined answer: `sys_metadata_history` // carries no `package_id` column, so a vanished binding is not recoverable. const activeRow = await this.engine.findOne('sys_metadata', { - where: this.whereFor(ref, 'active'), + where: this.whereFor(ref, 'active', undefined, member), context: { isSystem: true }, }); const activePackageId = @@ -1413,7 +1532,14 @@ export class SysMetadataRepository implements MetadataRepository { * (type,name) bucket, then by `recorded_at` across buckets (we sort * client-side because the test engine doesn't honor `orderBy`). */ - async *history(ref: MetaRef, opts?: HistoryOptions): AsyncIterable { + async *history( + ref: MetaRef, + /** + * [#22877] `member` — the bundle member whose lineage to yield; omitted, + * the canonical one (see {@link get}). Ignored for every other type. + */ + opts?: HistoryOptions & { member?: string | null }, + ): AsyncIterable { this.assertOpen(); const full = this.fullRef(ref); const where: Record = { @@ -1422,7 +1548,12 @@ export class SysMetadataRepository implements MetadataRepository { name: full.name, }; // [#21908] The explicit system opt-in, as `get` carries — see its note. - const rows = await this.engine.find(this.historyTable, { where, context: { isSystem: true } }); + // [#22877] One member's rows only — {@link historyRowsOfMember}. + const rows = historyRowsOfMember( + full.type, + await this.engine.find(this.historyTable, { where, context: { isSystem: true } }), + opts?.member, + ); rows.sort((a: any, b: any) => { const va = typeof a.event_seq === 'number' ? a.event_seq : 0; const vb = typeof b.event_seq === 'number' ? b.event_seq : 0; @@ -2093,6 +2224,14 @@ export class SysMetadataRepository implements MetadataRepository { ref: Pick, state: OverlayState = 'active', packageId?: string | null, + /** + * [#22877] The bundle member, as {@link OVERLAY_MEMBER_COLUMN} stores it + * (`null` = the canonical member, IS NULL) — from {@link overlayMemberValue} + * or {@link overlayMemberOfBody}. `undefined` adds no predicate: the answer + * both give for a type whose identity is `name` alone, so its `where` is + * byte-identical to what it was before the column existed. + */ + member?: string | null, ): Record { const where: Record = { type: ref.type, @@ -2123,6 +2262,7 @@ export class SysMetadataRepository implements MetadataRepository { // `delete` and `restoreVersion` resolving reads (#6215); they are // deliberately unchanged here. if (packageId !== undefined) where.package_id = packageId; // string → eq; null → IS NULL + if (member !== undefined) where[OVERLAY_MEMBER_COLUMN] = member; // string → eq; null → IS NULL return where; } @@ -2450,7 +2590,7 @@ export class SysMetadataRepository implements MetadataRepository { private async dropPromotedDraftRow( ref: MetaRef, draftHash: string, - opts: { actor: string | null; source?: string; intent?: MetadataWriteIntent }, + opts: { actor: string | null; source?: string; intent?: MetadataWriteIntent; member?: string | null }, /** * [#8907] ADR-0048 binding of the row that was promoted. Threaded so the * drain deletes THAT row rather than re-resolving `(org, type, name)` @@ -2466,6 +2606,8 @@ export class SysMetadataRepository implements MetadataRepository { intent: opts.intent ?? 'override-artifact', state: 'draft', packageId, + // [#22877] …and under the member it was promoted from. + member: opts.member, }); } From 441a6415eb025672be670fa040ec4a006fec9fc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:13:58 +0000 Subject: [PATCH 02/13] wip: member key tests Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../src/migrations/overlay-index.test.ts | 262 ++++++++++++++++-- ...-metadata-repository.bundle-member.test.ts | 259 +++++++++++++++++ 2 files changed, 500 insertions(+), 21 deletions(-) create mode 100644 packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts diff --git a/packages/metadata-protocol/src/migrations/overlay-index.test.ts b/packages/metadata-protocol/src/migrations/overlay-index.test.ts index 033b45a4a44..50e79b82c3c 100644 --- a/packages/metadata-protocol/src/migrations/overlay-index.test.ts +++ b/packages/metadata-protocol/src/migrations/overlay-index.test.ts @@ -4,17 +4,26 @@ import { DatabaseSync } from 'node:sqlite'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +// The engine-double contract gate: the backfill's double reaches the real +// engine's update dispatch predicate rather than a looser copy of it. +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; + import { + backfillOverlayBundleMembers, buildOverlayDuplicateProbeSql, buildOverlayFallbackIndexSql, buildOverlayIndexSql, ensureMetadataOverlayIndexes, ensureOverlayStateIndex, overlayIndexKeyParts, + overlayMemberOfBody, + overlayMemberValue, + overlayMemberWhere, OVERLAY_INDEX_COLUMNS, OVERLAY_INDEX_NAMES, OVERLAY_NULL_SENTINELS, OVERLAY_PROBE_INDEX_NAMES, + type OverlayMemberBackfillEngine, } from './overlay-index.js'; import type { IndexExec } from './partial-index-probe.js'; @@ -37,12 +46,15 @@ describe('sys_metadata overlay uniqueness (#6418)', () => { let exec: IndexExec; /** - * What `SqlDriver.syncDeclaredIndexes` materializes for - * `sys-metadata.object.ts`'s declaration + * What `SqlDriver.syncDeclaredIndexes` materialized for + * `sys-metadata.object.ts`'s declaration before the member part * (`['type','name','organization_id','package_id']`, `unique: true`) — knex's * form, mirrored from the sibling test's measured fixture. It is * UNRESTRICTED (no `WHERE`) and NULL-distinct, which is exactly why the - * runtime migration exists and exactly what it must not destroy. + * runtime migration exists and exactly what it must not destroy. Kept in + * its four-part form on purpose (#22877): it is the index every deployed + * store carries under this name before the next boot re-keys it, and + * `syncDeclaredIndexes` skips by name, so it is the one the migration meets. */ const DECLARED_ACTIVE_INDEX_DDL = 'CREATE UNIQUE INDEX `idx_sys_metadata_overlay_active` on `sys_metadata` ' + @@ -70,12 +82,13 @@ describe('sys_metadata overlay uniqueness (#6418)', () => { org: string | null, packageId: string | null, state: string, + member: string | null = null, ): { ok: boolean; error?: string } => { try { db.prepare( - 'INSERT INTO sys_metadata (id, type, name, organization_id, package_id, state) ' - + 'VALUES (?,?,?,?,?,?)', - ).run(id, type, name, org, packageId, state); + 'INSERT INTO sys_metadata (id, type, name, organization_id, package_id, state, bundle_member) ' + + 'VALUES (?,?,?,?,?,?,?)', + ).run(id, type, name, org, packageId, state, member); return { ok: true }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; @@ -99,7 +112,7 @@ describe('sys_metadata overlay uniqueness (#6418)', () => { db = new DatabaseSync(':memory:'); db.exec(`CREATE TABLE sys_metadata ( id TEXT PRIMARY KEY, type TEXT, name TEXT, - organization_id TEXT, package_id TEXT, state TEXT + organization_id TEXT, package_id TEXT, state TEXT, bundle_member TEXT );`); db.exec(DECLARED_ACTIVE_INDEX_DDL); exec = async (sql: string) => db.exec(sql); @@ -508,28 +521,30 @@ describe('sys_metadata overlay uniqueness (#6418)', () => { // ── Seams ───────────────────────────────────────────────────────────── /** - * #6418 is an ORDER and REPORTING fix. The key spelling it inherited is - * pinned as a literal — byte-identical to what `protocol.ts` issued before - * — so a later "while we are in here" re-keying cannot ride in unnoticed: - * re-partitioning a live unique index needs its own ADR-0120 D4 ceremony. + * The key spelling is pinned as a literal, so a later "while we are in + * here" re-keying cannot ride in unnoticed: re-partitioning a live unique + * index needs its own ADR-0120 D4 ceremony. The member part is the one + * re-keying since #6418 (#22877, ruled), and both NULL-folded parts use the + * spelling that shipped for `package_id`. */ - it('pins the key spelling, unchanged by this fix', () => { - expect(OVERLAY_INDEX_COLUMNS).toEqual(['type', 'name', 'organization_id', 'package_id']); - expect(OVERLAY_NULL_SENTINELS).toEqual({ package_id: '' }); + it('pins the key spelling — the member part folded the way package_id is', () => { + expect(OVERLAY_INDEX_COLUMNS).toEqual(['type', 'name', 'organization_id', 'package_id', 'bundle_member']); + expect(OVERLAY_NULL_SENTINELS).toEqual({ package_id: '', bundle_member: '' }); expect(overlayIndexKeyParts()).toEqual([ 'type', 'name', 'organization_id', "COALESCE(package_id, '')", + "COALESCE(bundle_member, '')", ]); expect(buildOverlayIndexSql(OVERLAY_INDEX_NAMES.active, 'active')).toEqual( 'CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active ' - + "ON sys_metadata (type, name, organization_id, COALESCE(package_id, '')) " + + "ON sys_metadata (type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, '')) " + "WHERE state = 'active'", ); expect(buildOverlayIndexSql(OVERLAY_INDEX_NAMES.draft, 'draft')).toEqual( 'CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_metadata_overlay_draft ' - + "ON sys_metadata (type, name, organization_id, COALESCE(package_id, '')) " + + "ON sys_metadata (type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, '')) " + "WHERE state = 'draft'", ); }); @@ -542,7 +557,7 @@ describe('sys_metadata overlay uniqueness (#6418)', () => { const sql = buildOverlayFallbackIndexSql(OVERLAY_INDEX_NAMES.active); expect(sql).toEqual( 'CREATE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active ' - + 'ON sys_metadata (type, name, organization_id, package_id)', + + 'ON sys_metadata (type, name, organization_id, package_id, bundle_member)', ); expect(sql.toUpperCase()).not.toContain('UNIQUE'); expect(sql).toContain('IF NOT EXISTS'); @@ -558,12 +573,217 @@ describe('sys_metadata overlay uniqueness (#6418)', () => { const sql = buildOverlayDuplicateProbeSql('active'); expect(sql).toEqual( 'SELECT type, name, organization_id, ' - + "COALESCE(package_id, '') AS package_id_key, COUNT(*) AS duplicate_rows " + + "COALESCE(package_id, '') AS package_id_key, " + + "COALESCE(bundle_member, '') AS bundle_member_key, COUNT(*) AS duplicate_rows " + "FROM sys_metadata WHERE state = 'active' " - + "GROUP BY type, name, organization_id, COALESCE(package_id, '') HAVING COUNT(*) > 1", + + "GROUP BY type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, '') " + + 'HAVING COUNT(*) > 1', ); - // Every bare projection is a bare GROUP BY term; the folded one is - // projected only through its own expression. + // Every bare projection is a bare GROUP BY term; the folded ones are + // projected only through their own expressions. expect(sql).not.toMatch(/SELECT [^,]*, package_id,/); + expect(sql).not.toMatch(/, bundle_member,/); + }); +}); + +/** + * The member part of the overlay key (#22877, ruling A): two locales of one + * `email_template` are two overlay rows. + * + * Before it, the key was `(type, name, organization_id, package_id)`, so the + * second locale's save replaced the first locale's row and the first locale + * served the package wording again at the next boot. These cases run on the + * same REAL SQLite as the rest of this file, starting from the four-part index + * a deployed store carries, so the re-keying itself is what is measured. + */ +describe('sys_metadata overlay key — the bundle member part (#22877)', () => { + let db: DatabaseSync; + let exec: IndexExec; + + const FOUR_PART_INDEX_DDL = + 'CREATE UNIQUE INDEX `idx_sys_metadata_overlay_active` on `sys_metadata` ' + + '(`type`, `name`, `organization_id`, `package_id`)'; + + const insert = ( + id: string, + type: string, + name: string, + packageId: string | null, + state: string, + member: string | null, + body: Record = {}, + ): { ok: boolean; error?: string } => { + try { + db.prepare( + 'INSERT INTO sys_metadata (id, type, name, organization_id, package_id, state, bundle_member, metadata) ' + + 'VALUES (?,?,?,?,?,?,?,?)', + ).run(id, type, name, 'org1', packageId, state, member, JSON.stringify(body)); + return { ok: true }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } + }; + + /** + * The backfill's engine, over this database: `find` by `type`, `update` by + * `id` — the two calls it makes. Anything else is refused, so a backfill + * that started asking for more could not pass by being ignored. + */ + const sqliteEngine = (): OverlayMemberBackfillEngine & { updates: Array> } => { + const updates: Array> = []; + return { + updates, + async find(table, options) { + const keys = Object.keys(options.where); + if (keys.length !== 1 || keys[0] !== 'type') throw new Error(`unexpected where: ${keys.join(',')}`); + return db.prepare(`SELECT * FROM ${table} WHERE type = ?`).all(options.where.type as string) as any[]; + }, + async update(table, data, options) { + assertEngineUpdateDispatch(data, options); + const id = options.where.id; + if (typeof id !== 'string') throw new Error('update by id only'); + const columns = Object.keys(data); + db.prepare(`UPDATE ${table} SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`) + .run(...columns.map((c) => data[c] as string | null), id); + updates.push({ id, ...data }); + return { id }; + }, + }; + }; + + const allRows = (where = '1 = 1') => + db.prepare(`SELECT * FROM sys_metadata WHERE ${where} ORDER BY id`).all() as Array>; + + beforeEach(() => { + db = new DatabaseSync(':memory:'); + db.exec(`CREATE TABLE sys_metadata ( + id TEXT PRIMARY KEY, type TEXT, name TEXT, + organization_id TEXT, package_id TEXT, state TEXT, bundle_member TEXT, metadata TEXT + );`); + db.exec(FOUR_PART_INDEX_DDL); + exec = async (sql: string) => db.exec(sql); + }); + + afterEach(() => { + db.close(); + }); + + it('BEFORE: the four-part key refuses a second locale of one template — the store keeps one row per (type, name)', () => { + expect(insert('e1', 'email_template', 'acme.welcome', 'pkg1', 'active', null).ok).toBe(true); + const second = insert('z1', 'email_template', 'acme.welcome', 'pkg1', 'active', 'zh-CN'); + expect(second.ok).toBe(false); + expect(second.error).toContain('UNIQUE constraint failed'); + }); + + it('AFTER the next run: the key is replaced, two members coexist, and a duplicate member is refused loudly', async () => { + const result = await ensureMetadataOverlayIndexes(exec); + expect(result.active.status).toBe('created'); + expect(result.draft.status).toBe('created'); + // The CHANGED key replaced the four-part index under the same name. + const ddl = String((db.prepare("SELECT sql FROM sqlite_master WHERE type='index' AND name=?") + .get(OVERLAY_INDEX_NAMES.active) as { sql: string }).sql); + expect(ddl).toContain("COALESCE(bundle_member, '')"); + + // Two locales, package-bound and package-less alike. + expect(insert('e1', 'email_template', 'acme.welcome', 'pkg1', 'active', null).ok).toBe(true); + expect(insert('z1', 'email_template', 'acme.welcome', 'pkg1', 'active', 'zh-CN').ok).toBe(true); + expect(insert('e2', 'email_template', 'acme.welcome', null, 'active', null).ok).toBe(true); + expect(insert('z2', 'email_template', 'acme.welcome', null, 'active', 'zh-CN').ok).toBe(true); + + // A duplicate (type, name, member) is refused by the index — the store + // answers it, no caller has to remember to check. + const dupMember = insert('z3', 'email_template', 'acme.welcome', 'pkg1', 'active', 'zh-CN'); + expect(dupMember.ok).toBe(false); + expect(dupMember.error).toContain('UNIQUE constraint failed'); + // The canonical member's NULL is one bucket, as package_id's is. + const dupCanonical = insert('e3', 'email_template', 'acme.welcome', null, 'active', null); + expect(dupCanonical.ok).toBe(false); + expect(dupCanonical.error).toContain('UNIQUE constraint failed'); + // A member's draft still coexists with its active row. + expect(insert('zd', 'email_template', 'acme.welcome', 'pkg1', 'draft', 'zh-CN').ok).toBe(true); + + // The shipped duplicate query sees members as distinct keys. + expect(db.prepare(buildOverlayDuplicateProbeSql('active')).all()).toEqual([]); + }); + + it('the member helpers: undefined for a name-only type, NULL for the canonical member, the value otherwise', () => { + expect(overlayMemberValue('view', 'zh-CN')).toBeUndefined(); + expect(overlayMemberValue('email_template')).toBeNull(); + expect(overlayMemberValue('email_template', 'en-US')).toBeNull(); + expect(overlayMemberValue('email_template', ' ')).toBeNull(); + expect(overlayMemberValue('email_template', ' zh-CN ')).toBe('zh-CN'); + // The plural spelling folds — the registry key, not the URL segment. + expect(overlayMemberValue('email_templates', 'zh-CN')).toBe('zh-CN'); + + expect(overlayMemberOfBody('email_template', { locale: 'zh-CN' })).toBe('zh-CN'); + expect(overlayMemberOfBody('email_template', { locale: 'en-US' })).toBeNull(); + expect(overlayMemberOfBody('email_template', {})).toBeNull(); + expect(overlayMemberOfBody('view', { locale: 'zh-CN' })).toBeUndefined(); + + expect(overlayMemberWhere('view')).toEqual({}); + expect(overlayMemberWhere('email_template')).toEqual({ bundle_member: null }); + expect(overlayMemberWhere('email_template', 'ja-JP')).toEqual({ bundle_member: 'ja-JP' }); + }); + + it('backfills a pre-fix row from its body, and leaves every other row byte-identical', async () => { + // Rows as the four-part key stored them: no member anywhere. + insert('t_zh', 'email_template', 'acme.welcome', null, 'active', null, { name: 'acme.welcome', locale: 'zh-CN', subject: 'zh' }); + insert('t_en', 'email_template', 'acme.reset', null, 'active', null, { name: 'acme.reset', locale: 'en-US', subject: 'en' }); + insert('t_bare', 'email_template', 'acme.bare', null, 'active', null, { name: 'acme.bare', subject: 'no locale' }); + insert('t_draft', 'email_template', 'acme.draft', null, 'draft', null, { name: 'acme.draft', locale: 'ja-JP' }); + // A row of a type whose identity is `name` alone, carrying a locale key. + insert('v1', 'view', 'acme.list', 'pkg1', 'active', null, { name: 'acme.list', locale: 'zh-CN' }); + insert('tr', 'translation', 'zh-CN', null, 'active', null, { name: 'zh-CN', locale: 'zh-CN' }); + db.prepare("UPDATE sys_metadata SET metadata = '{not json' WHERE id = 't_bare'").run(); + const othersBefore = allRows("type <> 'email_template'"); + + const engine = sqliteEngine(); + const result = await backfillOverlayBundleMembers(engine); + + expect(result).toEqual({ status: 'done', backfilled: 2, unparseable: 1 }); + expect(engine.updates).toEqual([ + { id: 't_zh', bundle_member: 'zh-CN' }, + { id: 't_draft', bundle_member: 'ja-JP' }, + ]); + const members = Object.fromEntries(allRows("type = 'email_template'").map((r) => [r.id, r.bundle_member])); + expect(members).toEqual({ t_zh: 'zh-CN', t_en: null, t_bare: null, t_draft: 'ja-JP' }); + // Every other type's rows: byte-identical, every column. + expect(allRows("type <> 'email_template'")).toEqual(othersBefore); + + // Idempotent: a backfilled row is skipped. + const again = sqliteEngine(); + expect(await backfillOverlayBundleMembers(again)).toEqual({ status: 'done', backfilled: 0, unparseable: 1 }); + expect(again.updates).toEqual([]); + + // …and the re-keyed index builds over the backfilled rows, every + // other type's rows still untouched. + const indexes = await ensureMetadataOverlayIndexes(exec); + expect(indexes.active.status).toBe('created'); + expect(insert('t_en2', 'email_template', 'acme.welcome', null, 'active', null).ok).toBe(true); + expect(allRows("type <> 'email_template'")).toEqual(othersBefore); + }); + + it('a store with no table is nothing to backfill; any other failure is reported at error with its consequence', async () => { + const missing: OverlayMemberBackfillEngine = { + async find() { throw new Error('no such table: sys_metadata'); }, + async update() { throw new Error('unreachable'); }, + }; + const quiet = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + expect(await backfillOverlayBundleMembers(missing, quiet)).toEqual({ status: 'done', backfilled: 0, unparseable: 0 }); + expect(quiet.error).not.toHaveBeenCalled(); + + const broken: OverlayMemberBackfillEngine = { + async find() { throw new Error('no such column: bundle_member'); }, + async update() { throw new Error('unreachable'); }, + }; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const result = await backfillOverlayBundleMembers(broken, logger); + expect(result.status).toBe('failed'); + expect(result.detail).toContain('no such column: bundle_member'); + expect(logger.error).toHaveBeenCalledTimes(1); + const msg = String(logger.error.mock.calls[0]![0]); + expect(msg).toContain('bundle_member'); + expect(msg).toContain('answered as the canonical one'); + expect(msg).toContain('restart'); }); }); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts new file mode 100644 index 00000000000..0b25e49fc9a --- /dev/null +++ b/packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts @@ -0,0 +1,259 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22877] The overlay repository keys a bundled type's rows by MEMBER. + * + * `email_template` declares its identity as `(name, locale)` + * (`ITEM_KEY_DISCRIMINATORS`). The repository used to resolve every write by + * `(org, type, name, package_id)`, so the second locale's save found the first + * locale's row and updated it in place: one row per template, the first + * locale's wording lost at the next boot. Ruled A: the member is a key column, + * taken from the body; a by-name read, delete, publish, history or revert with + * no member addresses the CANONICAL member. + * + * Pinned here, on the repository alone: + * 1. two locales are two rows, each re-save updates its own row; + * 2. a read, a delete, a history, a promotion with no member addresses the + * canonical member; a named member addresses that one; + * 3. the lineage counter stays per `(org, type, name)` — the history table's + * declared unique key — so a version names one snapshot, and a revert to + * another member's version is refused; + * 4. CONTROL: a type whose identity is `name` alone writes and reads exactly + * as before — no member column in any row payload or `where`. + */ + +import { describe, it, expect } from 'vitest'; +// The engine-double contract gate: a fake looser than ObjectQL's own verb +// dispatch is how #4434 shipped a dead route with its suite green. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { SysMetadataRepository } from './sys-metadata-repository.js'; + +interface Row { + [k: string]: unknown; +} + +function makeFakeEngine() { + let nextId = 1; + const rows: Row[] = []; + const history: Row[] = []; + const wheres: Array> = []; + const payloads: Row[] = []; + + const matches = (row: Row, where: Record): boolean => { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + const branches = v as Array>; + if (!branches.some((b) => matches(row, b))) return false; + continue; + } + if (k.startsWith('$')) { + // Refuse combinators this double does not implement — a silent + // field-name read is how a fake matcher lies (WHERE-matcher gate). + throw new Error(`fake matcher: unimplemented combinator ${k}`); + } + const rv = row[k] ?? null; + if ((v ?? null) !== rv) return false; + } + return true; + }; + + const tableOf = (name: string): Row[] => (name === 'sys_metadata' ? rows : history); + + return { + rows, + history, + wheres, + payloads, + async findOne(table: string, q: { where: Record }) { + assertEngineFindOnePredicate(table, q); + if (table === 'sys_metadata') wheres.push(q.where); + return tableOf(table).find((r) => matches(r, q.where)) ?? null; + }, + async find(table: string, q: { where: Record }) { + if (table === 'sys_metadata') wheres.push(q.where); + return tableOf(table).filter((r) => matches(r, q.where)); + }, + async insert(table: string, data: Row) { + if (table === 'sys_metadata') payloads.push({ ...data }); + const row = { id: `row_${nextId++}`, ...data }; + tableOf(table).push(row); + return row; + }, + async update(table: string, data: Row, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + if (table === 'sys_metadata') payloads.push({ ...data }); + const row = tableOf(table).find((r) => matches(r, opts.where)); + if (row) Object.assign(row, data); + return row; + }, + async delete(table: string, opts: { where?: Record }) { + assertEngineDeleteDispatch(opts); + const t = tableOf(table); + const i = t.findIndex((r) => matches(r, opts.where ?? {})); + if (i >= 0) t.splice(i, 1); + return { deleted: i >= 0 ? 1 : 0 }; + }, + }; +} + +const TEMPLATE = { org: 'env', type: 'email_template' as const, name: 'acme.welcome' }; +const body = (locale: string | undefined, subject: string): Record => ({ + name: 'acme.welcome', + ...(locale ? { locale } : {}), + subject, + bodyHtml: `

${subject}

`, +}); + +function makeRepo(engine: ReturnType) { + return new SysMetadataRepository({ engine: engine as never, organizationId: null, orgLabel: 'env' } as never); +} + +/** Last-write-wins, the way the save door's unpinned save resolves its parent. */ +async function save(repo: SysMetadataRepository, spec: Record, state: 'active' | 'draft' = 'active') { + const locale = typeof spec.locale === 'string' ? spec.locale : undefined; + const head = (await repo.headAt(TEMPLATE, { state, member: locale })).head?.hash ?? null; + return repo.put(TEMPLATE, spec, { parentVersion: head, actor: 'admin', state }); +} + +const activeRows = (engine: ReturnType) => + engine.rows.filter((r) => r.type === 'email_template' && r.state === 'active'); + +describe('SysMetadataRepository — a bundled type is keyed by member (#22877)', () => { + it('two locales of one template are two rows; each re-save updates its own row', async () => { + const engine = makeFakeEngine(); + const repo = makeRepo(engine); + + await save(repo, body('en-US', 'EN 1')); + await save(repo, body('zh-CN', 'ZH 1')); + expect(activeRows(engine)).toHaveLength(2); + + await save(repo, body('en-US', 'EN 2')); + await save(repo, body('zh-CN', 'ZH 2')); + const rows = activeRows(engine); + expect(rows).toHaveLength(2); + // NULL is the canonical member; the other member carries its value. + const byMember = Object.fromEntries(rows.map((r) => [String(r.bundle_member), JSON.parse(String(r.metadata)).subject])); + expect(byMember).toEqual({ null: 'EN 2', 'zh-CN': 'ZH 2' }); + }); + + it('a body that declares no locale is the canonical member, the same row as en-US', async () => { + const engine = makeFakeEngine(); + const repo = makeRepo(engine); + await save(repo, body(undefined, 'no locale')); + await save(repo, body('en-US', 'EN')); + const rows = activeRows(engine); + expect(rows).toHaveLength(1); + expect(rows[0]!.bundle_member).toBeNull(); + expect(JSON.parse(String(rows[0]!.metadata)).subject).toBe('EN'); + }); + + it('a read with no member answers the canonical member; a named member answers its own', async () => { + const engine = makeFakeEngine(); + const repo = makeRepo(engine); + await save(repo, body('zh-CN', 'ZH')); + // Only a non-canonical member stored: a bare read finds no canonical row. + expect(await repo.get(TEMPLATE)).toBeNull(); + await save(repo, body('en-US', 'EN')); + + expect((await repo.get(TEMPLATE))?.body.subject).toBe('EN'); + expect((await repo.get(TEMPLATE, { member: 'en-US' }))?.body.subject).toBe('EN'); + expect((await repo.get(TEMPLATE, { member: 'zh-CN' }))?.body.subject).toBe('ZH'); + expect(await repo.get(TEMPLATE, { member: 'ja-JP' })).toBeNull(); + }); + + it('a delete with no member removes the canonical member only; a named member removes its own', async () => { + const engine = makeFakeEngine(); + const repo = makeRepo(engine); + await save(repo, body('en-US', 'EN')); + await save(repo, body('zh-CN', 'ZH')); + + const en = await repo.get(TEMPLATE); + await repo.delete(TEMPLATE, { parentVersion: en!.hash, actor: 'admin' }); + expect(activeRows(engine).map((r) => r.bundle_member)).toEqual(['zh-CN']); + + const zh = await repo.get(TEMPLATE, { member: 'zh-CN' }); + await repo.delete(TEMPLATE, { parentVersion: zh!.hash, actor: 'admin', member: 'zh-CN' }); + expect(activeRows(engine)).toEqual([]); + }); + + it('history yields one member\'s lineage — its delete tombstone included — on a counter shared by the bundle', async () => { + const engine = makeFakeEngine(); + const repo = makeRepo(engine); + await save(repo, body('en-US', 'EN 1')); // v1 + await save(repo, body('zh-CN', 'ZH 1')); // v2 + await save(repo, body('en-US', 'EN 2')); // v3 + const zh = await repo.get(TEMPLATE, { member: 'zh-CN' }); + await repo.delete(TEMPLATE, { parentVersion: zh!.hash, actor: 'admin', member: 'zh-CN' }); // v4 tombstone + + const canonical: Array<{ op: string; version?: number }> = []; + for await (const ev of repo.history(TEMPLATE)) canonical.push({ op: ev.op, version: ev.version }); + expect(canonical).toEqual([{ op: 'create', version: 1 }, { op: 'update', version: 3 }]); + + const zhEvents: Array<{ op: string; version?: number }> = []; + for await (const ev of repo.history(TEMPLATE, { member: 'zh-CN' })) zhEvents.push({ op: ev.op, version: ev.version }); + expect(zhEvents).toEqual([{ op: 'create', version: 2 }, { op: 'delete', version: 4 }]); + + // One counter for the bundle: every version names exactly one snapshot. + const versions = engine.history.map((h) => h.version); + expect(new Set(versions).size).toBe(versions.length); + }); + + it('a revert restores within its member, and refuses another member\'s version', async () => { + const engine = makeFakeEngine(); + const repo = makeRepo(engine); + await save(repo, body('en-US', 'EN 1')); // v1 + await save(repo, body('zh-CN', 'ZH 1')); // v2 + await save(repo, body('zh-CN', 'ZH 2')); // v3 + + // v2 is the zh-CN member's: not the canonical member's version. + await expect(repo.restoreVersion(TEMPLATE, 2, { actor: 'admin' })).rejects.toMatchObject({ + code: 'VERSION_NOT_FOUND', + status: 404, + }); + expect((await repo.get(TEMPLATE))?.body.subject).toBe('EN 1'); + + await repo.restoreVersion(TEMPLATE, 2, { actor: 'admin', member: 'zh-CN' }); + expect((await repo.get(TEMPLATE, { member: 'zh-CN' }))?.body.subject).toBe('ZH 1'); + // Restored IN PLACE: still one row per member. + expect(activeRows(engine)).toHaveLength(2); + expect((await repo.get(TEMPLATE))?.body.subject).toBe('EN 1'); + }); + + it('a member\'s draft is promoted onto that member\'s active row, and drained under it', async () => { + const engine = makeFakeEngine(); + const repo = makeRepo(engine); + await save(repo, body('en-US', 'EN')); + await save(repo, body('zh-CN', 'ZH')); + await save(repo, body('zh-CN', 'ZH draft'), 'draft'); + await save(repo, body('en-US', 'EN draft'), 'draft'); + + await repo.promoteDraft(TEMPLATE, { actor: 'admin', member: 'zh-CN' }); + expect((await repo.get(TEMPLATE, { member: 'zh-CN' }))?.body.subject).toBe('ZH draft'); + expect((await repo.get(TEMPLATE))?.body.subject).toBe('EN'); + const drafts = engine.rows.filter((r) => r.state === 'draft'); + expect(drafts.map((r) => r.bundle_member)).toEqual([null]); + + // No member: the canonical member's draft. + await repo.promoteDraft(TEMPLATE, { actor: 'admin' }); + expect((await repo.get(TEMPLATE))?.body.subject).toBe('EN draft'); + expect(engine.rows.filter((r) => r.state === 'draft')).toEqual([]); + expect(activeRows(engine)).toHaveLength(2); + }); + + it('CONTROL: a type whose identity is name alone never sees the member column', async () => { + const engine = makeFakeEngine(); + const repo = makeRepo(engine); + const VIEW = { org: 'env', type: 'view' as const, name: 'lead.list' }; + // A body carrying a `locale` key: only a declared discriminator keys a type. + await repo.put(VIEW, { name: 'lead.list', label: 'One', locale: 'zh-CN' }, { parentVersion: null, actor: 'admin' }); + const head = (await repo.headAt(VIEW, { state: 'active', member: 'zh-CN' })).head; + await repo.put(VIEW, { name: 'lead.list', label: 'Two', locale: 'ja-JP' }, { parentVersion: head!.hash, actor: 'admin' }); + await repo.get(VIEW, { member: 'zh-CN' }); + await repo.delete(VIEW, { parentVersion: (await repo.get(VIEW))!.hash, actor: 'admin', member: 'ja-JP' }); + + expect(engine.payloads.length).toBeGreaterThan(0); + for (const payload of engine.payloads) expect(payload).not.toHaveProperty('bundle_member'); + for (const where of engine.wheres) expect(where).not.toHaveProperty('bundle_member'); + expect(engine.rows).toEqual([]); + }); +}); From 9963b961cbccc44bd7b7cc931110d02eb27353ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:29:38 +0000 Subject: [PATCH 03/13] wip: objectql restart pins Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../src/migrations/overlay-index.test.ts | 9 +- .../runtime-index-preflight.test.ts | 27 +- packages/metadata-protocol/src/protocol.ts | 5 +- ...nment-overlay-email-locale-restart.test.ts | 273 ++++++++++++++++++ 4 files changed, 303 insertions(+), 11 deletions(-) create mode 100644 packages/objectql/src/environment-overlay-email-locale-restart.test.ts diff --git a/packages/metadata-protocol/src/migrations/overlay-index.test.ts b/packages/metadata-protocol/src/migrations/overlay-index.test.ts index 50e79b82c3c..69101db3471 100644 --- a/packages/metadata-protocol/src/migrations/overlay-index.test.ts +++ b/packages/metadata-protocol/src/migrations/overlay-index.test.ts @@ -712,8 +712,8 @@ describe('sys_metadata overlay key — the bundle member part (#22877)', () => { expect(overlayMemberValue('email_template', 'en-US')).toBeNull(); expect(overlayMemberValue('email_template', ' ')).toBeNull(); expect(overlayMemberValue('email_template', ' zh-CN ')).toBe('zh-CN'); - // The plural spelling folds — the registry key, not the URL segment. - expect(overlayMemberValue('email_templates', 'zh-CN')).toBe('zh-CN'); + // The type's other spelling folds to the registry key. + expect(overlayMemberValue('emailTemplates', 'zh-CN')).toBe('zh-CN'); expect(overlayMemberOfBody('email_template', { locale: 'zh-CN' })).toBe('zh-CN'); expect(overlayMemberOfBody('email_template', { locale: 'en-US' })).toBeNull(); @@ -741,9 +741,10 @@ describe('sys_metadata overlay key — the bundle member part (#22877)', () => { const result = await backfillOverlayBundleMembers(engine); expect(result).toEqual({ status: 'done', backfilled: 2, unparseable: 1 }); - expect(engine.updates).toEqual([ - { id: 't_zh', bundle_member: 'zh-CN' }, + // Exactly the two non-canonical bodies are written, each by id. + expect([...engine.updates].sort((a, b) => String(a.id).localeCompare(String(b.id)))).toEqual([ { id: 't_draft', bundle_member: 'ja-JP' }, + { id: 't_zh', bundle_member: 'zh-CN' }, ]); const members = Object.fromEntries(allRows("type = 'email_template'").map((r) => [r.id, r.bundle_member])); expect(members).toEqual({ t_zh: 'zh-CN', t_en: null, t_bare: null, t_draft: 'ja-JP' }); diff --git a/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts b/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts index e9fda8dafe9..aec37b38d99 100644 --- a/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts +++ b/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts @@ -81,7 +81,7 @@ describe('kernel:ready index pre-flight (#8725)', () => { // ── sys_metadata: two ACTIVE package-less overlays for one key ───── db.exec(`CREATE TABLE sys_metadata ( id TEXT PRIMARY KEY, type TEXT, name TEXT, organization_id TEXT, - package_id TEXT, state TEXT + package_id TEXT, state TEXT, bundle_member TEXT );`); const meta = db.prepare( 'INSERT INTO sys_metadata (id, type, name, organization_id, package_id, state) VALUES (?,?,?,?,?,?)', @@ -132,6 +132,8 @@ describe('kernel:ready index pre-flight (#8725)', () => { name: 'crm_case.board', organization_id: null, package_id_key: '', + // [#22877] A row of a name-only type is the canonical member. + bundle_member_key: '', }, rowCount: 2, }, @@ -160,13 +162,13 @@ describe('kernel:ready index pre-flight (#8725)', () => { migration: 'ensureMetadataOverlayIndexes', table: 'sys_metadata', rowScope: "state = 'active'", - keyParts: ['type', 'name', 'organization_id', "COALESCE(package_id, '')"], + keyParts: ['type', 'name', 'organization_id', "COALESCE(package_id, '')", "COALESCE(bundle_member, '')"], }); expect(by('idx_sys_metadata_overlay_draft', results)).toMatchObject({ migration: 'ensureMetadataOverlayIndexes', table: 'sys_metadata', rowScope: "state = 'draft'", - keyParts: ['type', 'name', 'organization_id', "COALESCE(package_id, '')"], + keyParts: ['type', 'name', 'organization_id', "COALESCE(package_id, '')", "COALESCE(bundle_member, '')"], }); // `sys_setting` has no lifecycle column: the declaration means every row. expect(by('uniq_sys_setting_organization_id_namespace_key_scope_user_id', results).rowScope).toBeNull(); @@ -256,4 +258,23 @@ describe('kernel:ready index pre-flight (#8725)', () => { // …and one unreadable index never aborts the rest of the inventory. expect(by('idx_sys_metadata_overlay_active', results).status).toBe('blocked'); }); + + it('a store this version has not booted yet (no member column) is unreadable for the overlay key, never clear', async () => { + // [#22877] The member part is added by the next SERVING boot's schema + // sync, and this command boots read-only. Against the table as an + // earlier version left it, the overlay probes cannot run — said so, + // with the dialect's own words, rather than reported as nothing found. + db.exec('DROP TABLE sys_metadata'); + db.exec(`CREATE TABLE sys_metadata ( + id TEXT PRIMARY KEY, type TEXT, name TEXT, organization_id TEXT, + package_id TEXT, state TEXT + );`); + const results = await collectRuntimeIndexPreflight(exec); + + for (const index of ['idx_sys_metadata_overlay_active', 'idx_sys_metadata_overlay_draft']) { + const entry = by(index, results); + expect(entry.status).toBe('unreadable'); + expect(entry.detail).toContain('bundle_member'); + } + }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 674a3bf7ba6..94a8d992f92 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -111,9 +111,6 @@ import { // the anonymous form doors read it — the runtime authoring gate's input for // its public-form intake advisory (see `tenancyPostureInForce()`). anonymousFormIntakePosture, - // [#22877] A bundled type's member as its body declares it — the value the - // save door addresses the write's stored head by. - itemDiscriminator, } from '@objectstack/metadata-core'; // [#5532] One vocabulary of "which driver read errors are benign", shared with // `sys-metadata-repository.ts` in this package and with `DatabaseLoader` in @@ -26102,7 +26099,7 @@ export class ObjectStackProtocolImplementation implements // [#22877] The canonical member's lineage only — a by-name diff of a // bundled type compares the member a by-name read serves, never a // second locale's snapshot as its "earlier version". - const rows = historyRowsOfMember(singularType, await engineAny.find('sys_metadata_history', { + const rows: any[] = historyRowsOfMember(singularType, await engineAny.find('sys_metadata_history', { where: { organization_id: orgId, type: singularType, diff --git a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts new file mode 100644 index 00000000000..049d3375d93 --- /dev/null +++ b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts @@ -0,0 +1,273 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Two locales of one email template, saved through the metadata door, both + * survive a restart (#22877, ruling A). + * + * ## The defect + * + * `email_template` declares its identity as `(name, locale)`: rows sharing one + * `name` form an i18n bundle, one row per locale tag. The registry (#7730) and + * the `/meta` list merge (#7774) already keyed it that way; the overlay store + * did not. `sys_metadata` kept one overlay row per `(type, name)`, so an + * administrator who saved `acme.welcome` in `en-US` and then in `zh-CN` had + * the second save update the first locale's row in place. Both read back + * correctly until the next boot, when `en-US` served the package wording + * again. And a bare-name read served the `zh-CN` overlay, not the canonical + * `en-US` member the registry's bare-name read resolves to. + * + * ## The pins (the triage's, kept by the ruling, plus the ruling's own) + * + * - two locales saved through the metadata door, then a restart → each + * serves its own wording; + * - CONTROL: a single-locale save is unchanged; + * - CONTROL: a package-shipped bundle is unchanged; + * - a bare-name read answers the canonical member; + * - a row stored before the member column existed (a second locale's body, + * no member) is backfilled at boot, so it is answered as its own member. + * + * ## The harness + * + * `environment-overlay-five-types.test.ts`'s: the real `ObjectQL` engine and + * `SchemaRegistry`, the real `ObjectStackProtocolImplementation`, and an + * in-memory driver. `sys_metadata` is registered from `@objectstack/metadata-core`'s + * own declaration, so the member column a row carries is the one the + * platform declares. A RESTART is a new engine and a new protocol over the + * SAME driver stores, booted the way `ObjectQLPlugin.start()` hydrates: + * the package registered, then `loadMetaFromDb()`. + */ + +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol'; +import { + SysMetadataAuditObject, + SysMetadataCommitObject, + SysMetadataHistoryObject, + SysMetadataObject, +} from '@objectstack/metadata-core'; +import { ObjectQL } from './engine.js'; + +const PKG = 'com.acme.mail'; +const NAME = 'acme.welcome'; + +const template = (locale: string, subject: string): Record => ({ + name: NAME, + label: 'Welcome', + locale, + subject, + bodyHtml: `

${subject}

`, +}); + +/** The package: one template, shipped in two locales — an i18n bundle. */ +function mailManifest(): Record { + return { + id: PKG, + name: 'acme_mail', + emailTemplates: [template('en-US', 'PKG en'), template('zh-CN', 'PKG zh')], + }; +} + +/** In-memory driver, copied from `environment-overlay-five-types.test.ts`; equality-only WHERE. */ +function makeMemoryDriver(stores: Map>>) { + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + // Unique across boots: a restart is a new driver over the same stores. + const id = (data.id as string) ?? `r_${randomUUID()}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return driver; +} + +/** + * One boot over `stores`: engine, package, protocol, then the hydration + * `ObjectQLPlugin.start()` runs. Called twice over the same stores = a restart. + */ +async function boot(stores: Map>>) { + const engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(stores), true); + await engine.init(); + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + engine.registry.registerObject(o as any, 'test-package'); + } + engine.registerApp(mailManifest()); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_prod'); + const load = await protocol.loadMetaFromDb(); + return { engine, protocol, load }; +} + +const overlayRows = (stores: Map>>) => + Array.from(stores.get('sys_metadata')?.values() ?? []) + .filter((r) => r.type === 'email_template' && r.name === NAME && r.state === 'active'); + +/** + * The wording each locale serves, as the send path reads it: the layered list + * `plugin-email`'s registry loader resolves a send from by `(name, locale)`. + */ +async function servedSubjects(protocol: ObjectStackProtocolImplementation): Promise> { + const res: any = await protocol.getMetaItemsForExecution({ type: 'email_template' }); + const out: Record = {}; + for (const item of (res?.items ?? []) as any[]) { + if (item?.name === NAME) out[String(item.locale)] = String(item.subject); + } + return out; +} + +const bareNameSubject = async (protocol: ObjectStackProtocolImplementation) => + String(((await protocol.getMetaItem({ type: 'email_template', name: NAME })) as any)?.item?.subject); + +const savedWritable = process.env.OS_METADATA_WRITABLE; +beforeEach(() => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); +afterEach(() => { + if (savedWritable === undefined) delete process.env.OS_METADATA_WRITABLE; + else process.env.OS_METADATA_WRITABLE = savedWritable; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); + +describe('two locales of one email template survive a restart (#22877)', () => { + it('two locales saved through the metadata door, then a restart → each serves its own wording', async () => { + const stores = new Map>>(); + const first = await boot(stores); + expect(await first.protocol.saveMetaItem({ type: 'email_template', name: NAME, item: template('en-US', 'ADMIN en') })) + .toMatchObject({ success: true }); + expect(await first.protocol.saveMetaItem({ type: 'email_template', name: NAME, item: template('zh-CN', 'ADMIN zh') })) + .toMatchObject({ success: true }); + // Two rows: the canonical member's NULL, and the second locale's value. + expect(overlayRows(stores).map((r) => r.bundle_member ?? null).sort()).toEqual([null, 'zh-CN'].sort()); + expect(await servedSubjects(first.protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'ADMIN zh' }); + + const second = await boot(stores); + expect(second.load).toMatchObject({ loaded: 2, errors: 0 }); + expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'ADMIN zh' }); + expect(await bareNameSubject(second.protocol)).toBe('ADMIN en'); + + // A re-save of either locale after the restart updates its own row. + expect(await second.protocol.saveMetaItem({ type: 'email_template', name: NAME, item: template('zh-CN', 'ADMIN zh 2') })) + .toMatchObject({ success: true }); + expect(overlayRows(stores)).toHaveLength(2); + expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'ADMIN zh 2' }); + }); + + it('CONTROL: a single-locale save is unchanged — one row, the other locale keeps the package wording', async () => { + const stores = new Map>>(); + const first = await boot(stores); + await first.protocol.saveMetaItem({ type: 'email_template', name: NAME, item: template('en-US', 'ADMIN en') }); + expect(overlayRows(stores)).toHaveLength(1); + expect(overlayRows(stores)[0]!.bundle_member ?? null).toBeNull(); + + const second = await boot(stores); + expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'PKG zh' }); + expect(await bareNameSubject(second.protocol)).toBe('ADMIN en'); + }); + + it('CONTROL: a package-shipped bundle is unchanged across a restart', async () => { + const stores = new Map>>(); + const first = await boot(stores); + expect(await servedSubjects(first.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'PKG zh' }); + + const second = await boot(stores); + expect(overlayRows(stores)).toEqual([]); + expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'PKG zh' }); + expect(await bareNameSubject(second.protocol)).toBe('PKG en'); + }); + + it('a bare-name read answers the canonical member, not a second locale\'s overlay', async () => { + const stores = new Map>>(); + const first = await boot(stores); + await first.protocol.saveMetaItem({ type: 'email_template', name: NAME, item: template('zh-CN', 'ADMIN zh') }); + + // Before the fix this answered 'ADMIN zh': the one row for the name. + expect(await bareNameSubject(first.protocol)).toBe('PKG en'); + const second = await boot(stores); + expect(await bareNameSubject(second.protocol)).toBe('PKG en'); + expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'ADMIN zh' }); + }); + + it('a row stored before the member column existed is backfilled at boot from its body', async () => { + const stores = new Map>>(); + await boot(stores); + // The four-part key's row: a second locale's body, no member. + stores.get('sys_metadata')!.set('legacy_zh', { + id: 'legacy_zh', type: 'email_template', name: NAME, organization_id: null, package_id: null, + metadata: JSON.stringify(template('zh-CN', 'LEGACY zh')), state: 'active', version: 1, + }); + + const second = await boot(stores); + expect(stores.get('sys_metadata')!.get('legacy_zh')!.bundle_member).toBe('zh-CN'); + expect(await bareNameSubject(second.protocol)).toBe('PKG en'); + expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'LEGACY zh' }); + + // That locale's next save updates the backfilled row — no second row. + await second.protocol.saveMetaItem({ type: 'email_template', name: NAME, item: template('zh-CN', 'ADMIN zh') }); + expect(overlayRows(stores).map((r) => r.id)).toEqual(['legacy_zh']); + expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'ADMIN zh' }); + }); +}); From c7f399ba7fb38a9ca991c543440f5e17854d590e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:34:17 +0000 Subject: [PATCH 04/13] wip: bare-name read answers the canonical member; harness columns Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 15 +++++++++++++++ .../engine-security-catalog-package-door.test.ts | 3 +++ .../src/environment-overlay-five-types.test.ts | 3 +++ 3 files changed, 21 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 94a8d992f92..a707bbad8e9 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -10171,6 +10171,21 @@ export class ObjectStackProtocolImplementation implements // necessary. Without this, project-kernel callers of // `GET /api/v1/meta/object/` 404 even though the object is // registered and visible via the list endpoint. + // [#22877] A bundled type's bare name addresses its CANONICAL member + // (ruling A, Q2 i: no member named means the canonical one). Step 1 + // read only the canonical member's stored row; when there is none, the + // canonical member is the code layer's. The registry's bare-name read + // below answers its overlay tier first, which would hand back a + // non-canonical member's hydrated overlay (a template customized in + // `zh-CN` only) for a read that named no member. It still answers a + // bundle that ships no canonical member at all. + if (item === undefined) { + const disc = ITEM_KEY_DISCRIMINATORS[request.type]; + const artifact = disc ? this.lookupArtifactItem(request.type, request.name, request.packageId) : undefined; + if (disc && artifact !== undefined && itemDiscriminator(request.type, artifact) === disc.canonical) { + item = artifact; + } + } if (item === undefined) { item = this.engine.registry.getItem(request.type, request.name, request.packageId); if (item === undefined) { diff --git a/packages/objectql/src/engine-security-catalog-package-door.test.ts b/packages/objectql/src/engine-security-catalog-package-door.test.ts index 005bc3a9bef..c42a376d4ef 100644 --- a/packages/objectql/src/engine-security-catalog-package-door.test.ts +++ b/packages/objectql/src/engine-security-catalog-package-door.test.ts @@ -87,6 +87,9 @@ const sysMetadataObject: ServiceObject = { name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, + // The bundled type's member (#22877) — `sys-metadata.object.ts` declares + // it, and an `email_template` write keys its row by it. + bundle_member: { name: 'bundle_member', label: 'Bundle Member', type: 'text' as const, maxLength: 100 }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, diff --git a/packages/objectql/src/environment-overlay-five-types.test.ts b/packages/objectql/src/environment-overlay-five-types.test.ts index e585fbccd13..f702f649d0b 100644 --- a/packages/objectql/src/environment-overlay-five-types.test.ts +++ b/packages/objectql/src/environment-overlay-five-types.test.ts @@ -166,6 +166,9 @@ const sysMetadataObject: ServiceObject = { name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, + // The bundled type's member (#22877) — `sys-metadata.object.ts` declares + // it, and an `email_template` write keys its row by it. + bundle_member: { name: 'bundle_member', label: 'Bundle Member', type: 'text' as const, maxLength: 100 }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, From 095eae9b6ffb3306aaaf6469ef5e16bbe3d40404 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:37:27 +0000 Subject: [PATCH 05/13] wip: dogfood case and changeset Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/22877-overlay-locale-member.md | 26 +++ ...e-two-locales-survive-boot.dogfood.test.ts | 166 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 .changeset/22877-overlay-locale-member.md create mode 100644 packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts diff --git a/.changeset/22877-overlay-locale-member.md b/.changeset/22877-overlay-locale-member.md new file mode 100644 index 00000000000..e9654da84f2 --- /dev/null +++ b/.changeset/22877-overlay-locale-member.md @@ -0,0 +1,26 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/metadata-core': patch +--- + +fix(metadata-protocol): two locales of one email template saved through the metadata door both survive a restart — an overlay row is keyed by its bundle member (#22877) + +Clause-②: no + +**What an administrator sees.** Saving the same email template in two languages through the metadata door (`PUT /api/v1/meta/email_template/:name`, the Studio editor's save) keeps both. Before, the store kept one overlay row per template name, so the second language's save replaced the first language's row: both read back until the next restart, and then the first language mailed the package wording again. Each language now keeps its own wording across restarts, and each send in that language uses it. + +**What changed in the store.** + +- `sys_metadata` gains one nullable column, `bundle_member`. It holds the member of a metadata type whose identity is a pair — today `email_template`, identified by `(name, locale)`. NULL is the canonical member (`en-US`, the locale a send falls back to). Every other row stays NULL, so rows of every other type, and an `en-US` template row, are byte-identical to before. +- The value is taken from the saved body's `locale`; it is never authored. A body that declares no `locale` is the `en-US` member. +- The overlay uniqueness index becomes `(type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, ''))`, for active rows and for draft rows, and the declared index in `metadata-core` carries the column too. A second row for the same template, language, package and state is refused by the index. + +**What a read or a door addresses.** A save addresses the language its body names. A read, delete, publish, history or revert by name that names no language addresses the `en-US` member: `GET /meta/email_template/:name` serves the `en-US` wording (the stored one when it was customized, else the package's), where before it served whichever row the name had. The layered list and the send path serve every language as before. A non-`en-US` language's own customization is reached through the list, and is replaced by a save that names its `locale`; a by-name reset (`DELETE`) removes the `en-US` customization only. The lineage counter stays one per template name, so a version number names one saved snapshot, and a revert by name restores only a version of the `en-US` member. + +**What happens on the first boot after upgrading.** Nothing to configure. + +1. The schema sync adds the `bundle_member` column. +2. Boot hydration gives every `email_template` row stored before the column existed the member its body declares, so a row an earlier save left holding a second language's wording becomes that language's row, not the `en-US` one. Rows of every other type are not read or written. +3. The first metadata write rebuilds the overlay index with the new key, through the existing probe-then-replace order. Adding a key part only relaxes uniqueness, so no rows a store already holds can block the rebuild; a store that was already blocked is reported as before, with the duplicate-listing query and `os migrate duplicates`. + +`os migrate duplicates` boots read-only and adds no column: run against a store this version has not booted yet, it reports the two overlay indexes `unreadable` (the column does not exist yet) rather than `clear`. Boot the server once, then re-run it. diff --git a/packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts b/packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts new file mode 100644 index 00000000000..02b684bb2a8 --- /dev/null +++ b/packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22877] Two locales of one email template, each saved through the METADATA +// door, both keep the admin's wording across a cold boot on one database file +// — over the real showcase composition and a real SQLite store. +// +// ## What was broken +// +// `email_template` is identified by `(name, locale)`: rows sharing a name form +// an i18n bundle. The overlay store kept ONE `sys_metadata` row per +// `(type, name)`, so `PUT /meta/email_template/:name` in `en-US` and then in +// `zh-CN` left one row — the second save had updated the first locale's row in +// place. Both locales read back until the next boot, when `en-US` mailed the +// package wording again. Measured on the showcase before the fix. +// +// ## What changed (ruling A) +// +// `sys_metadata.bundle_member` carries the member, taken from the body; the +// overlay unique index carries it too, so the two saves are two rows. A +// by-name read with no member answers the canonical member (`en-US`). +// +// ## What each case pins +// +// - two locales saved through the metadata door are two rows (the canonical +// member's NULL and `zh-CN`), and each sends its own wording at once; +// - after a cold boot each locale still sends its own wording, and the +// by-name read serves the canonical member's; +// - CONTROL: a locale nobody saved keeps the package wording, and a +// single-locale re-save is still one row per member. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; +import { EmailServicePlugin } from '@objectstack/plugin-email'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); +const SYS = { isSystem: true, positions: [], permissions: [] }; + +/** The template the showcase package declares (`com.example.showcase`), in `en-US`. */ +const NAME = 'showcase_task_done_email'; +const PACKAGE_SUBJECT = '✅ Task done: {{title}}'; +const ADMIN_EN = 'Done (admin, English): {{title}}'; +const ADMIN_ZH = '已完成(管理员中文):{{title}}'; + +/** What reached the wire — `sendTemplate` returns a delivery result, not the rendered message. */ +const sent: Array<{ subject?: string }> = []; +const emailPlugin = () => new EmailServicePlugin({ + transport: { async send(message: { subject?: string }) { sent.push({ subject: message.subject }); return { messageId: `captured-${sent.length}` }; } } as never, + defaultFrom: { address: 'no-reply@example.test', name: 'Locale Fixture' }, +}); + +const boot = (databaseFile: string) => + bootShowcase({ databaseFile, orgContext: true, extraPlugins: [emailPlugin()] }); + +describe('[#22877] two locales of one email template survive a cold boot (showcase)', () => { + let prevCwd: string; + let dir: string; + let dbFile: string; + let stack: VerifyStack | undefined; + let token: string; + + const call = async (method: string, path: string, body?: unknown) => { + const res = await stack!.apiAs(token, method, path, body); + return { status: res.status, json: (await res.json().catch(() => ({}))) as any }; + }; + /** A real send in one locale; the subject is read off the wire. */ + const sendSubject = async (locale: string) => { + const email: any = await stack!.kernel.getServiceAsync('email'); + const result = await email.sendTemplate({ + to: 'someone@example.com', + template: NAME, + locale, + data: { title: 'Ship it', project: 'Apollo' }, + }); + expect(result.status, `sendTemplate(${locale}) failed: ${result.error ?? ''}`).not.toBe('failed'); + return sent.at(-1)?.subject; + }; + /** `GET /meta/email_template/:name` — the served document, unwrapped, `_`-decorations dropped. */ + const metaBody = async (): Promise<{ status: number; body: Record }> => { + const read = await call('GET', `/meta/email_template/${NAME}`); + const doc = read.json?.data ?? read.json?.item ?? read.json; + const body: Record = {}; + for (const [k, v] of Object.entries(((doc?.item ?? doc) ?? {}) as Record)) { + if (!k.startsWith('_')) body[k] = v; + } + return { status: read.status, body }; + }; + /** The overlay rows of the template, as the store holds them. */ + const overlayRows = async () => { + const ql: any = await stack!.kernel.getServiceAsync('objectql'); + const rows: any[] = await ql.find('sys_metadata', { + where: { type: 'email_template', name: NAME, state: 'active' }, + context: SYS, + }); + return rows + .map((r) => ({ member: r.bundle_member ?? null, subject: JSON.parse(String(r.metadata)).subject })) + .sort((a, b) => String(a.member).localeCompare(String(b.member))); + }; + const restart = async () => { + await stack!.stop(); + stack = await boot(dbFile); + token = await stack.signIn(); + }; + + beforeAll(async () => { + prevCwd = process.cwd(); + process.chdir(SHOWCASE_DIR); + dir = mkdtempSync(join(tmpdir(), 'dogfood-22877-')); + dbFile = join(dir, 'showcase.db'); + stack = await boot(dbFile); + token = await stack.signIn(); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + if (prevCwd) process.chdir(prevCwd); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('two locales saved through the metadata door are two rows, and each sends its own wording at once', async () => { + const served = await metaBody(); + expect(served.status).toBe(200); + expect(served.body.subject).toBe(PACKAGE_SUBJECT); + + const en = await call('PUT', `/meta/email_template/${NAME}`, { ...served.body, locale: 'en-US', subject: ADMIN_EN }); + expect(en.status, JSON.stringify(en.json)).toBe(200); + const zh = await call('PUT', `/meta/email_template/${NAME}`, { ...served.body, locale: 'zh-CN', subject: ADMIN_ZH }); + expect(zh.status, JSON.stringify(zh.json)).toBe(200); + + expect(await overlayRows()).toEqual([ + { member: null, subject: ADMIN_EN }, + { member: 'zh-CN', subject: ADMIN_ZH }, + ]); + expect(await sendSubject('en-US')).toBe('Done (admin, English): Ship it'); + expect(await sendSubject('zh-CN')).toBe('已完成(管理员中文):Ship it'); + }); + + it('after a cold boot each locale still sends its own wording, and the by-name read serves the canonical member', async () => { + await restart(); + + expect(await sendSubject('en-US')).toBe('Done (admin, English): Ship it'); + expect(await sendSubject('zh-CN')).toBe('已完成(管理员中文):Ship it'); + const served = await metaBody(); + expect(served).toMatchObject({ status: 200, body: { subject: ADMIN_EN } }); + expect(await overlayRows()).toHaveLength(2); + }, 180_000); + + it('control: a locale nobody saved keeps the package ladder, and a re-save of one locale is still one row per member', async () => { + // `ja-JP` is not in the bundle: the ladder falls to `en-US`, the admin's. + expect(await sendSubject('ja-JP')).toBe('Done (admin, English): Ship it'); + + const served = await metaBody(); + const again = await call('PUT', `/meta/email_template/${NAME}`, { ...served.body, locale: 'zh-CN', subject: ADMIN_ZH.replace(':', '(二):') }); + expect(again.status, JSON.stringify(again.json)).toBe(200); + expect((await overlayRows()).map((r) => r.member)).toEqual([null, 'zh-CN']); + + await restart(); + expect(await sendSubject('en-US')).toBe('Done (admin, English): Ship it'); + expect(await sendSubject('zh-CN')).toBe('已完成(管理员中文)(二):Ship it'); + }, 180_000); +}); From 59841e1570c2acc5a3a7b676d26817599a58bf2f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:45:39 +0000 Subject: [PATCH 06/13] wip: record the bare-name canonical lookup site Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../src/protocol.lookup-artifact-item-call-sites.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts b/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts index a8513b50198..90cc62d81c2 100644 --- a/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts @@ -76,6 +76,8 @@ const SITES: ReadonlyArray Date: Mon, 12 Oct 2026 04:00:54 +0000 Subject: [PATCH 07/13] wip: by-name reset pin Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- ...ronment-overlay-email-locale-restart.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts index 049d3375d93..7e863bd5faf 100644 --- a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts +++ b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts @@ -251,6 +251,23 @@ describe('two locales of one email template survive a restart (#22877)', () => { expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'ADMIN zh' }); }); + it('a by-name reset with no member removes the canonical member\'s overlay only', async () => { + const stores = new Map>>(); + const first = await boot(stores); + await first.protocol.saveMetaItem({ type: 'email_template', name: NAME, item: template('en-US', 'ADMIN en') }); + await first.protocol.saveMetaItem({ type: 'email_template', name: NAME, item: template('zh-CN', 'ADMIN zh') }); + + expect(await first.protocol.deleteMetaItem({ type: 'email_template', name: NAME })).toMatchObject({ success: true, reset: true }); + expect(overlayRows(stores).map((r) => r.bundle_member)).toEqual(['zh-CN']); + // At once, and after a restart: the canonical member is back on the + // package wording, the other member keeps its own. + expect(await servedSubjects(first.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'ADMIN zh' }); + expect(await bareNameSubject(first.protocol)).toBe('PKG en'); + const second = await boot(stores); + expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'ADMIN zh' }); + expect(await bareNameSubject(second.protocol)).toBe('PKG en'); + }); + it('a row stored before the member column existed is backfilled at boot from its body', async () => { const stores = new Map>>(); await boot(stores); From 139a2cbc9dfcc74b0fedfdfb6dc17f88c2c1a4ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 04:12:42 +0000 Subject: [PATCH 08/13] test(metadata-protocol): bound the find double; record the new pinned doubles Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../sys-metadata-repository.bundle-member.test.ts | 6 ++++-- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts index 0b25e49fc9a..a1a5608b9ce 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts @@ -69,9 +69,11 @@ function makeFakeEngine() { if (table === 'sys_metadata') wheres.push(q.where); return tableOf(table).find((r) => matches(r, q.where)) ?? null; }, - async find(table: string, q: { where: Record }) { + async find(table: string, q: { where: Record; limit?: number }) { if (table === 'sys_metadata') wheres.push(q.where); - return tableOf(table).filter((r) => matches(r, q.where)); + const rows = tableOf(table).filter((r) => matches(r, q.where)); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, async insert(table: string, data: Row) { if (table === 'sys_metadata') payloads.push({ ...data }); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 8ff5b60bf3a..a70b517c6e4 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1911,6 +1911,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts", "verb": "delete", From a9f79a360cc51858acba7f86f88ef9c54839213d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 05:22:26 +0000 Subject: [PATCH 09/13] test(metadata-protocol): pin that a bound sentinel reaches no carrier of the bundle-member backfill; enumerate its operatorFacingErrorText call Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../raw-exec-operator-detail-16657.test.ts | 92 +++++++++++++++++++ ...river-error-classification.callers.test.ts | 4 + 2 files changed, 96 insertions(+) diff --git a/packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts b/packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts index 11966339803..3455ee14385 100644 --- a/packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts +++ b/packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts @@ -71,7 +71,11 @@ import { describe, it, expect } from 'vitest'; import { inspect } from 'node:util'; import { redactStatementFromMessage } from '@objectstack/types'; +// The engine-double contract gate: the backfill's double reaches the real +// engine's update dispatch predicate rather than a looser copy of it. +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { backfillOverlayBundleMembers } from './overlay-index.js'; import { collectRuntimeIndexPreflight } from './runtime-index-preflight.js'; import { probeThenReplaceIndex, type IndexExec } from './partial-index-probe.js'; import { @@ -542,6 +546,94 @@ describe('[#21418] a bound sentinel reaches no carrier at any site in this packa } }); + /** + * [#22877] `overlay-index`'s bundle-member backfill writes the helper's + * answer to two carriers: the result's `detail`, and the `error`-level + * report (`logProblem`: the message, and the `Error` it is handed). It + * reads and writes through the ENGINE, so the fault arrives from the + * engine's verbs; both are refused with the raw path's envelope here, the + * shape whose `cause` the helper walks. The `find` leg binds identifiers + * only and is handed the synthetic bound statement; the `update` leg binds + * the row's id itself, and the id IS the sentinel. + */ + describe('overlay-index bundle-member backfill — the read leg and the write leg', () => { + function recordingLogger() { + const lines: string[] = []; + return { + lines, + logger: { + info: (message: string) => { lines.push(message); }, + warn: (message: string, meta?: Record) => { + lines.push(`${message} ${inspect(meta, { depth: 8 })}`); + }, + error: (message: string, error?: Error) => { + lines.push(`${message} ${inspect(error, { depth: 8 })} ${String(error?.message)}`); + }, + }, + }; + } + + it('the read leg (`find`) → result `detail` and the error report', async () => { + const seen: string[] = []; + const engine = { + async find() { + seen.push(BOUND_DIALECT_TEXT); + throw rawStatementFault(BOUND_DIALECT_TEXT); + }, + async update(_table: string, data: Record, options: { where: Record }) { + assertEngineUpdateDispatch(data, options); + throw new Error('unreachable: the read leg failed first'); + }, + }; + const log = recordingLogger(); + + const result = await backfillOverlayBundleMembers(engine, log.logger); + + expect(seen.length, 'the site was never refused').toBeGreaterThan(0); + expect(seen.every((m) => m.includes(SENTINEL))).toBe(true); + expect(result.status).toBe('failed'); + expect(result.detail).toContain('no such column: v'); + expect(inspect(result, { depth: 8 })).not.toContain(SENTINEL); + expect(log.lines.length).toBe(1); + expect(log.lines.join('\n')).toContain('no such column: v'); + expect(log.lines.join('\n')).not.toContain(SENTINEL); + }); + + it('the write leg (`update`, binding the row id) → result `detail` and the error report', async () => { + const seen: string[] = []; + const engine = { + async find() { + return [{ + id: SENTINEL, type: 'email_template', name: 'acme.welcome', bundle_member: null, + metadata: JSON.stringify({ name: 'acme.welcome', locale: 'zh-CN' }), + }]; + }, + async update(_table: string, data: Record, options: { where: Record }) { + assertEngineUpdateDispatch(data, options); + const dump = knexDump( + 'update "sys_metadata" set "bundle_member" = ? where "id" = ?', + [data.bundle_member, options.where.id], + 'database table is locked', + ); + seen.push(dump); + throw rawStatementFault(dump); + }, + }; + const log = recordingLogger(); + + const result = await backfillOverlayBundleMembers(engine, log.logger); + + expect(seen.length, 'the site was never refused').toBeGreaterThan(0); + expect(seen.every((m) => m.includes(SENTINEL))).toBe(true); + expect(result).toMatchObject({ status: 'failed', backfilled: 0 }); + expect(result.detail).toContain('database table is locked'); + expect(inspect(result, { depth: 8 })).not.toContain(SENTINEL); + expect(log.lines.length).toBe(1); + expect(log.lines.join('\n')).toContain('database table is locked'); + expect(log.lines.join('\n')).not.toContain(SENTINEL); + }); + }); + describe('seed-tenancy-backfill — all five sites', () => { /** * The backfill's own fixture shape (see the `[#16657]` block above), diff --git a/packages/types/src/driver-error-classification.callers.test.ts b/packages/types/src/driver-error-classification.callers.test.ts index f0262b60cbf..6433b268fc6 100644 --- a/packages/types/src/driver-error-classification.callers.test.ts +++ b/packages/types/src/driver-error-classification.callers.test.ts @@ -418,6 +418,10 @@ const OPERATOR_TEXT_CALLERS: Readonly Date: Mon, 12 Oct 2026 05:30:13 +0000 Subject: [PATCH 10/13] chore(platform-objects): regenerate the i18n bundles for sys_metadata.bundle_member (node scripts/check-i18n-bundles.mjs --write) Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../src/apps/translations/en.objects.generated.ts | 4 ++++ .../src/apps/translations/es-ES.objects.generated.ts | 4 ++++ .../src/apps/translations/es-ES.source-hashes.generated.ts | 2 ++ .../src/apps/translations/ja-JP.objects.generated.ts | 4 ++++ .../src/apps/translations/ja-JP.source-hashes.generated.ts | 2 ++ .../src/apps/translations/zh-CN.objects.generated.ts | 4 ++++ .../src/apps/translations/zh-CN.source-hashes.generated.ts | 2 ++ 7 files changed, 22 insertions(+) diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 504478d2b77..c31c78cafda 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -2948,6 +2948,10 @@ export const enObjects: NonNullable = { user: "user" } }, + bundle_member: { + label: "Bundle Member", + help: "Bundle member (e.g. the locale of an email template) this overlay row customizes. Null = the canonical member." + }, metadata: { label: "Metadata", help: "JSON-serialized metadata payload" diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index fcc04fce567..e23bb888a9b 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -2948,6 +2948,10 @@ export const esESObjects: NonNullable = { user: "Usuario" } }, + bundle_member: { + label: "Bundle Member", + help: "Bundle member (e.g. the locale of an email template) this overlay row customizes. Null = the canonical member." + }, metadata: { label: "Metadatos", help: "Payload de metadatos serializado en JSON." diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index 73f2c7be6b3..b7a742d87c7 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -24,6 +24,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_email_template.fields.id.label": "00b0385c9c152888", "objects.sys_email_template.fields.variables_json.label": "c2cfff50e8151e3d", "objects.sys_job_run.fields.error.label": "786fed84bd8d5a32", + "objects.sys_metadata.fields.bundle_member.help": "6912852b7523f266", + "objects.sys_metadata.fields.bundle_member.label": "49b49e6b77397e92", "objects.sys_metadata.fields.checksum.label": "4fe7cc0967282ec9", "objects.sys_metadata.fields.id.label": "00b0385c9c152888", "objects.sys_metadata_audit.fields.actor.label": "b155813f8a7f06e3", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index e8e7156bef7..b08af1a8416 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -2948,6 +2948,10 @@ export const jaJPObjects: NonNullable = { user: "ユーザー" } }, + bundle_member: { + label: "Bundle Member", + help: "Bundle member (e.g. the locale of an email template) this overlay row customizes. Null = the canonical member." + }, metadata: { label: "メタデータ", help: "JSON シリアライズされたメタデータペイロード" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index e1553f4ae95..3de8b1983d8 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -22,6 +22,8 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", "objects.sys_email_template.fields.id.label": "00b0385c9c152888", + "objects.sys_metadata.fields.bundle_member.help": "6912852b7523f266", + "objects.sys_metadata.fields.bundle_member.label": "49b49e6b77397e92", "objects.sys_metadata.fields.id.label": "00b0385c9c152888", "objects.sys_metadata_audit.fields.id.label": "00b0385c9c152888", "objects.sys_metadata_history.fields.id.label": "00b0385c9c152888", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 538306d975c..dafbd498dbd 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -2948,6 +2948,10 @@ export const zhCNObjects: NonNullable = { user: "用户" } }, + bundle_member: { + label: "Bundle Member", + help: "Bundle member (e.g. the locale of an email template) this overlay row customizes. Null = the canonical member." + }, metadata: { label: "元数据", help: "元数据负载的 JSON 序列化内容" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index 01317fb3fb6..0212e9df053 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -20,6 +20,8 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", "objects.sys_email_template.fields.id.label": "00b0385c9c152888", + "objects.sys_metadata.fields.bundle_member.help": "6912852b7523f266", + "objects.sys_metadata.fields.bundle_member.label": "49b49e6b77397e92", "objects.sys_metadata.fields.id.label": "00b0385c9c152888", "objects.sys_metadata_audit.fields.id.label": "00b0385c9c152888", "objects.sys_metadata_history.fields.id.label": "00b0385c9c152888", From f21b6f9d24483ad9ff10b1b8bef00008347758ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 06:03:17 +0000 Subject: [PATCH 11/13] fix(metadata-protocol): the overlay pre-flight lists a table that predates the member column, every row the empty member Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/22877-overlay-locale-member.md | 2 +- .../src/migrations/overlay-index.ts | 43 ++++++++++++++++ .../runtime-index-preflight.test.ts | 51 +++++++++++++++---- .../src/migrations/runtime-index-preflight.ts | 47 +++++++++++++++-- 4 files changed, 129 insertions(+), 14 deletions(-) diff --git a/.changeset/22877-overlay-locale-member.md b/.changeset/22877-overlay-locale-member.md index e9654da84f2..ea954eb859f 100644 --- a/.changeset/22877-overlay-locale-member.md +++ b/.changeset/22877-overlay-locale-member.md @@ -23,4 +23,4 @@ Clause-②: no 2. Boot hydration gives every `email_template` row stored before the column existed the member its body declares, so a row an earlier save left holding a second language's wording becomes that language's row, not the `en-US` one. Rows of every other type are not read or written. 3. The first metadata write rebuilds the overlay index with the new key, through the existing probe-then-replace order. Adding a key part only relaxes uniqueness, so no rows a store already holds can block the rebuild; a store that was already blocked is reported as before, with the duplicate-listing query and `os migrate duplicates`. -`os migrate duplicates` boots read-only and adds no column: run against a store this version has not booted yet, it reports the two overlay indexes `unreadable` (the column does not exist yet) rather than `clear`. Boot the server once, then re-run it. +`os migrate duplicates` boots read-only and adds no column. Run against a store this version has not booted yet, it lists the two overlay indexes with every row read as the empty member — the value the column will hold — so a duplicate that will block the rebuild is still reported, and the entry's `duplicateSql` is the member-less statement that ran there. diff --git a/packages/metadata-protocol/src/migrations/overlay-index.ts b/packages/metadata-protocol/src/migrations/overlay-index.ts index b789d65ed35..f869ff3cac7 100644 --- a/packages/metadata-protocol/src/migrations/overlay-index.ts +++ b/packages/metadata-protocol/src/migrations/overlay-index.ts @@ -259,6 +259,49 @@ export function buildOverlayDuplicateProbeSql(state: OverlayIndexState): string ); } +/** + * [#22877] Does this store's `sys_metadata` carry {@link OVERLAY_MEMBER_COLUMN} + * yet? `SELECT FROM sys_metadata WHERE 1 = 0` — reads no row, writes + * nothing, and is refused on a table an earlier version created. + */ +export function buildOverlayMemberPresenceSql(): string { + return `SELECT ${OVERLAY_MEMBER_COLUMN} FROM ${OVERLAY_TABLE} WHERE 1 = 0`; +} + +/** + * [#22877] {@link buildOverlayDuplicateProbeSql} for a store whose table + * predates {@link OVERLAY_MEMBER_COLUMN} — the shape `os migrate duplicates` + * meets, because it boots read-only and the column arrives with the next + * SERVING boot's schema sync. + * + * Every row there reads as the EMPTY member: the column is added NULL, and + * the member part folds NULL to the `''` bucket, so the key the rebuild will + * judge those rows by is the four columns plus `''`. The listing therefore + * groups by the other key parts and projects the member as the constant + * `bundle_member_key = ''`, the same projection names as the full listing, so + * a group reads the same whichever statement found it. A row of a bundled type + * whose body names a non-canonical member is backfilled to that member on the + * serving boot, so such a pair can be reported here and then not block — the + * strict direction, and the population the backfill measures at boot. + */ +export function buildOverlayDuplicateProbeSqlWithoutMember(state: OverlayIndexState): string { + const columns = OVERLAY_INDEX_COLUMNS.filter((column) => column !== OVERLAY_MEMBER_COLUMN); + const projected = columns.map((column) => { + const sentinel = OVERLAY_NULL_SENTINELS[column]; + return sentinel === undefined ? column : `COALESCE(${column}, '${sentinel}') AS ${column}_key`; + }); + const grouped = columns.map((column) => { + const sentinel = OVERLAY_NULL_SENTINELS[column]; + return sentinel === undefined ? column : `COALESCE(${column}, '${sentinel}')`; + }); + return ( + `SELECT ${projected.join(', ')}, '${OVERLAY_NULL_SENTINELS[OVERLAY_MEMBER_COLUMN]}' AS ${OVERLAY_MEMBER_COLUMN}_key, ` + + `COUNT(*) AS duplicate_rows ` + + `FROM ${OVERLAY_TABLE} WHERE state = '${state}' ` + + `GROUP BY ${grouped.join(', ')} HAVING COUNT(*) > 1` + ); +} + /** * What became of the non-UNIQUE composite fallback, when one was attempted. * diff --git a/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts b/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts index aec37b38d99..f92c3c69b30 100644 --- a/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts +++ b/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts @@ -9,7 +9,7 @@ import { runtimeIndexProbes, type RuntimeIndexPreflight, } from './runtime-index-preflight.js'; -import { buildOverlayDuplicateProbeSql } from './overlay-index.js'; +import { buildOverlayDuplicateProbeSql, buildOverlayDuplicateProbeSqlWithoutMember } from './overlay-index.js'; import { buildSysSettingDuplicateProbeSql, buildSysSettingDuplicateProbeSqlMysql, @@ -259,22 +259,55 @@ describe('kernel:ready index pre-flight (#8725)', () => { expect(by('idx_sys_metadata_overlay_active', results).status).toBe('blocked'); }); - it('a store this version has not booted yet (no member column) is unreadable for the overlay key, never clear', async () => { + it('a store this version has not booted yet (no member column) is still listed — every row the empty member', async () => { // [#22877] The member part is added by the next SERVING boot's schema // sync, and this command boots read-only. Against the table as an - // earlier version left it, the overlay probes cannot run — said so, - // with the dialect's own words, rather than reported as nothing found. + // earlier version left it, the full listing cannot run; the member-less + // one does, reading every row as the empty member the column will hold, + // so the duplicate that will block the rebuild is still reported — and + // the entry carries the statement that ran. db.exec('DROP TABLE sys_metadata'); db.exec(`CREATE TABLE sys_metadata ( id TEXT PRIMARY KEY, type TEXT, name TEXT, organization_id TEXT, package_id TEXT, state TEXT );`); + const meta = db.prepare( + 'INSERT INTO sys_metadata (id, type, name, organization_id, package_id, state) VALUES (?,?,?,?,?,?)', + ); + meta.run('m1', 'view', 'crm_case.board', null, null, 'active'); + meta.run('m2', 'view', 'crm_case.board', null, null, 'active'); + meta.run('m4', 'view', 'crm_case.retired', null, null, 'archived'); + meta.run('m5', 'view', 'crm_case.retired', null, null, 'archived'); + // Control: the full listing really cannot run on this table. + expect(() => db.prepare(buildOverlayDuplicateProbeSql('active')).all()).toThrow(/bundle_member/); + const results = await collectRuntimeIndexPreflight(exec); - for (const index of ['idx_sys_metadata_overlay_active', 'idx_sys_metadata_overlay_draft']) { - const entry = by(index, results); - expect(entry.status).toBe('unreadable'); - expect(entry.detail).toContain('bundle_member'); - } + const active = by('idx_sys_metadata_overlay_active', results); + expect(active).toMatchObject({ + status: 'blocked', + duplicateSql: buildOverlayDuplicateProbeSqlWithoutMember('active'), + groups: [ + { + key: { + type: 'view', + name: 'crm_case.board', + organization_id: null, + package_id_key: '', + bundle_member_key: '', + }, + rowCount: 2, + }, + ], + }); + const draft = by('idx_sys_metadata_overlay_draft', results); + expect(draft).toMatchObject({ + status: 'clear', + groups: [], + duplicateSql: buildOverlayDuplicateProbeSqlWithoutMember('draft'), + }); + expect(JSON.stringify(results)).not.toContain('crm_case.retired'); + // The descriptor fields other than the statement are the migration's own. + expect(active.keyParts).toEqual(runtimeIndexProbes()[0]!.keyParts); }); }); diff --git a/packages/metadata-protocol/src/migrations/runtime-index-preflight.ts b/packages/metadata-protocol/src/migrations/runtime-index-preflight.ts index ec89dcc64c1..fa5c4bd95c3 100644 --- a/packages/metadata-protocol/src/migrations/runtime-index-preflight.ts +++ b/packages/metadata-protocol/src/migrations/runtime-index-preflight.ts @@ -79,6 +79,8 @@ import { OVERLAY_INDEX_NAMES, OVERLAY_TABLE, buildOverlayDuplicateProbeSql, + buildOverlayDuplicateProbeSqlWithoutMember, + buildOverlayMemberPresenceSql, overlayIndexKeyParts, type OverlayIndexState, } from './overlay-index.js'; @@ -252,6 +254,31 @@ const SEAM_LIVENESS_SQL = 'SELECT 1 AS os_preflight_probe'; const SEAM_NO_ANSWER_DETAIL = 'the raw-SQL seam returned no result set — a seam that cannot answer is not a seam that answered "no rows"'; +/** + * [#22877] The overlay indexes' listing on a store whose `sys_metadata` + * predates the member column — the migration's own two statements + * (`overlay-index.ts`), keyed by the index they describe. + * + * This command boots read-only, so against an install the new version has not + * served yet the column does not exist and the full listing cannot run. The + * duplicates that will block the rebuild are still there: every such row is + * the empty member, so the migration's member-less listing finds them. The + * entry then carries the statement that actually ran as its `duplicateSql`, so + * the query an operator copies out of the report is one their database + * answers. Held here rather than on the descriptor, so the report document's + * shape is unchanged. + */ +const BEFORE_MEMBER_COLUMN: Readonly> = { + [OVERLAY_INDEX_NAMES.active]: { + columnPresenceSql: buildOverlayMemberPresenceSql(), + duplicateSql: buildOverlayDuplicateProbeSqlWithoutMember('active'), + }, + [OVERLAY_INDEX_NAMES.draft]: { + columnPresenceSql: buildOverlayMemberPresenceSql(), + duplicateSql: buildOverlayDuplicateProbeSqlWithoutMember('draft'), + }, +}; + /** * Run one probe. * @@ -278,13 +305,25 @@ async function runProbe(exec: IndexExec, probe: RuntimeIndexProbe): Promise>; try { - const result = await exec(probe.duplicateSql); - if (!isResultSet(result)) return unreadable(SEAM_NO_ANSWER_DETAIL); + const result = await exec(duplicateSql); + if (!isResultSet(result)) return { ...unreadable(SEAM_NO_ANSWER_DETAIL), duplicateSql }; rows = normalizeRows(result); } catch (error) { - return unreadable(operatorFacingErrorText(error)); + return { ...unreadable(operatorFacingErrorText(error)), duplicateSql }; } const groups: RuntimeIndexDuplicateGroup[] = rows.map((row) => { @@ -298,7 +337,7 @@ async function runProbe(exec: IndexExec, probe: RuntimeIndexProbe): Promise groupSortKey(a).localeCompare(groupSortKey(b))); - return { ...probe, status: groups.length > 0 ? 'blocked' : 'clear', groups }; + return { ...probe, duplicateSql, status: groups.length > 0 ? 'blocked' : 'clear', groups }; } /** From 8a092fa7e8c8c279c47676296970b4174c009f61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 07:03:23 +0000 Subject: [PATCH 12/13] docs(changeset): the fallback-dialect index and the stage-2 by-name member selector Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/22877-overlay-locale-member.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/22877-overlay-locale-member.md b/.changeset/22877-overlay-locale-member.md index ea954eb859f..fea60a1cdb4 100644 --- a/.changeset/22877-overlay-locale-member.md +++ b/.changeset/22877-overlay-locale-member.md @@ -15,7 +15,7 @@ Clause-②: no - The value is taken from the saved body's `locale`; it is never authored. A body that declares no `locale` is the `en-US` member. - The overlay uniqueness index becomes `(type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, ''))`, for active rows and for draft rows, and the declared index in `metadata-core` carries the column too. A second row for the same template, language, package and state is refused by the index. -**What a read or a door addresses.** A save addresses the language its body names. A read, delete, publish, history or revert by name that names no language addresses the `en-US` member: `GET /meta/email_template/:name` serves the `en-US` wording (the stored one when it was customized, else the package's), where before it served whichever row the name had. The layered list and the send path serve every language as before. A non-`en-US` language's own customization is reached through the list, and is replaced by a save that names its `locale`; a by-name reset (`DELETE`) removes the `en-US` customization only. The lineage counter stays one per template name, so a version number names one saved snapshot, and a revert by name restores only a version of the `en-US` member. +**What a read or a door addresses.** A save addresses the language its body names. A read, delete, publish, history or revert by name that names no language addresses the `en-US` member: `GET /meta/email_template/:name` serves the `en-US` wording (the stored one when it was customized, else the package's), where before it served whichever row the name had. The layered list and the send path serve every language as before. A non-`en-US` language's own customization is reached through the list, and is replaced by a save that names its `locale`; a by-name reset (`DELETE`) removes the `en-US` customization only. The lineage counter stays one per template name, so a version number names one saved snapshot, and a revert by name restores only a version of the `en-US` member. Resetting, publishing or reading a non-`en-US` language by name needs the by-name member selector, which arrives with stage 2 of #22877; until then those doors address the `en-US` member. **What happens on the first boot after upgrading.** Nothing to configure. @@ -23,4 +23,6 @@ Clause-②: no 2. Boot hydration gives every `email_template` row stored before the column existed the member its body declares, so a row an earlier save left holding a second language's wording becomes that language's row, not the `en-US` one. Rows of every other type are not read or written. 3. The first metadata write rebuilds the overlay index with the new key, through the existing probe-then-replace order. Adding a key part only relaxes uniqueness, so no rows a store already holds can block the rebuild; a store that was already blocked is reported as before, with the duplicate-listing query and `os migrate duplicates`. +**On a database without partial indexes (MySQL).** The rebuild above is refused there, as before, and reported at boot. A store that booted an earlier version keeps the four-column declared index under `idx_sys_metadata_overlay_active`, because the schema sync skips an index whose name already exists. That index has never constrained an environment-wide overlay row: those rows store no organization, and a UNIQUE key does not collide NULLs. So two languages of one template coexist there too, and no save is refused. `os migrate plan` lists the declaration change on that index as a destructive `recreate_index`. To bring the index to the five-column declaration, run `os migrate apply --allow-destructive`. A fresh store gets the five-column declaration. + `os migrate duplicates` boots read-only and adds no column. Run against a store this version has not booted yet, it lists the two overlay indexes with every row read as the empty member — the value the column will hold — so a duplicate that will block the rebuild is still reported, and the entry's `duplicateSql` is the member-less statement that ran there. From c3a7db05955c18f78126a6a2933cdaf080c04e9d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 07:03:36 +0000 Subject: [PATCH 13/13] docs(changeset): name when the fallback-dialect refusal is reported Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/22877-overlay-locale-member.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22877-overlay-locale-member.md b/.changeset/22877-overlay-locale-member.md index fea60a1cdb4..24d8960af22 100644 --- a/.changeset/22877-overlay-locale-member.md +++ b/.changeset/22877-overlay-locale-member.md @@ -23,6 +23,6 @@ Clause-②: no 2. Boot hydration gives every `email_template` row stored before the column existed the member its body declares, so a row an earlier save left holding a second language's wording becomes that language's row, not the `en-US` one. Rows of every other type are not read or written. 3. The first metadata write rebuilds the overlay index with the new key, through the existing probe-then-replace order. Adding a key part only relaxes uniqueness, so no rows a store already holds can block the rebuild; a store that was already blocked is reported as before, with the duplicate-listing query and `os migrate duplicates`. -**On a database without partial indexes (MySQL).** The rebuild above is refused there, as before, and reported at boot. A store that booted an earlier version keeps the four-column declared index under `idx_sys_metadata_overlay_active`, because the schema sync skips an index whose name already exists. That index has never constrained an environment-wide overlay row: those rows store no organization, and a UNIQUE key does not collide NULLs. So two languages of one template coexist there too, and no save is refused. `os migrate plan` lists the declaration change on that index as a destructive `recreate_index`. To bring the index to the five-column declaration, run `os migrate apply --allow-destructive`. A fresh store gets the five-column declaration. +**On a database without partial indexes (MySQL).** The rebuild above is refused there, as before, and reported in the server log at the first metadata write. A store that booted an earlier version keeps the four-column declared index under `idx_sys_metadata_overlay_active`, because the schema sync skips an index whose name already exists. That index has never constrained an environment-wide overlay row: those rows store no organization, and a UNIQUE key does not collide NULLs. So two languages of one template coexist there too, and no save is refused. `os migrate plan` lists the declaration change on that index as a destructive `recreate_index`. To bring the index to the five-column declaration, run `os migrate apply --allow-destructive`. A fresh store gets the five-column declaration. `os migrate duplicates` boots read-only and adds no column. Run against a store this version has not booted yet, it lists the two overlay indexes with every row read as the empty member — the value the column will hold — so a duplicate that will block the rebuild is still reported, and the entry's `duplicateSql` is the member-less statement that ran there.