diff --git a/packages/metadata-protocol/src/protocol-drafts.ts b/packages/metadata-protocol/src/protocol-drafts.ts new file mode 100644 index 00000000000..af8cee4e914 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-drafts.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25467 cut 12] The draft pair of `ObjectStackProtocolImplementation` — the + * environment's pending drafts as the `_drafts` doors list them + * (`listDrafts`) and the per-draft discard step the package batch doors share + * (`discardDraftInItsScope`) — moved out of `protocol.ts` whole. + * + * A MOVE, not a redesign. Each function below is the body of the + * `ObjectStackProtocolImplementation` method of the same name as it stood, + * re-indented one level and otherwise unedited: the same reads, the same + * disabled-package filter and member projection, the same delete request. + * + * ## How the moved bodies reach the class + * + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` + * header carries the full reasoning), as `protocol-batch-data.ts` applies it. + * The class keeps every method — the published `dist/*.d.ts` lists each one's + * name, docblock and declared signature, `listDrafts`' declared return type + * included — and each body is one literal of exactly the {@link DraftsHost} + * members that method reads, plus a `.call`. A method is handed over as a + * closure that calls it on the protocol at call time, so a test double or spy + * set on the instance is still the one the body reaches. Every other reader of + * these two methods keeps calling the class method. + * + * The field the bodies read, `engine`, goes as a GETTER, so each read happens + * when the body makes it: tests replace it on a constructed protocol. + * + * The module-level declarations of `protocol.ts` the bodies read stay there, + * where code that did not move reads them too, and are imported from there: + * `isItemOfDisabledPackage` and `bundleMemberDiscriminator`. The module cycle + * is evaluation-safe because each is read only inside a function body, never + * while the two modules are first evaluated. `index.ts` names what it + * publishes from `protocol.ts` one by one, so the `export` they gained for + * this keeps them off the published surface, and nothing re-exports this + * module, so the published surface does not move. + */ + +import type { ObjectStackProtocolImplementation } from './protocol.js'; +import { bundleMemberDiscriminator, isItemOfDisabledPackage } from './protocol.js'; +import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import type { I18nLabel } from '@objectstack/spec/ui'; + +/** + * The `ObjectStackProtocolImplementation` members the functions below read — + * every one of them, and no other; each function's `this` is the `Pick` of the + * members IT reads. Each is typed AS the class member (an indexed access on + * the class), so a change to a member's signature on the class is a change + * here too, and naming a member the class does not have is a compile error. + * See the module header for which members are getters. + */ +export interface DraftsHost { + readonly engine: ObjectStackProtocolImplementation['engine']; + ensureOverlayIndex: ObjectStackProtocolImplementation['ensureOverlayIndex']; + getOverlayRepo: ObjectStackProtocolImplementation['getOverlayRepo']; + deleteMetaItem: ObjectStackProtocolImplementation['deleteMetaItem']; +} + +/** The body of `ObjectStackProtocolImplementation#listDrafts`; the method's docblock there is its contract. */ +export async function listDrafts( + this: Pick, + request?: { + packageId?: string; + type?: string; + } +): Promise<{ + drafts: Array<{ + type: string; + name: string; + /** [#23073] The bundle member the draft is (`'zh-CN'`, the canonical `'en-US'`); absent for a type with no members. */ + member?: string; + /** The draft body's own top-level `label`, as authored; `null` when it declares none. */ + label: I18nLabel | null; + packageId: string | null; + updatedAt: string | null; + updatedBy: string | null; + }>; +}> { + await this.ensureOverlayIndex(); + // [ADR-0131 D6] The environment's pending drafts. + const repo = this.getOverlayRepo(); + const drafts = await repo.listDrafts({ + ...(request?.type ? { type: PLURAL_TO_SINGULAR[request.type] ?? request.type } : {}), + ...(request?.packageId ? { packageId: request.packageId } : {}), + }); + // [#23073] The declared header (`ListDraftsResponseSchema`), with the + // row's stored member on every draft of a bundled type: absent there + // means the canonical member, which is spelled out here. + const served = drafts.filter((draft) => draft.type === 'package' + || !isItemOfDisabledPackage(this.engine?.registry, { _packageId: draft.packageId })); + return { + drafts: served.map(({ member: stored, ...header }) => { + const member = bundleMemberDiscriminator(header.type, stored); + return member === undefined ? header : { ...header, member }; + }), + }; +} + +/** The body of `ObjectStackProtocolImplementation#discardDraftInItsScope`; the method's docblock there is its contract. */ +export async function discardDraftInItsScope( + this: Pick, + /** + * [#22877] `member` — the draft's bundle member, as `listDrafts` + * surfaced it: the discard removes THAT member's draft. Without it the + * canonical member's was addressed for every draft of the name, so a + * second locale's draft answered "no pending draft" and stayed. + */ + draft: { type: string; name: string; member?: string; packageId?: string | null }, + actor: string | undefined, +): Promise { + await this.deleteMetaItem({ + type: draft.type, + name: draft.name, + state: 'draft', + ...(actor ? { actor } : {}), + ...(draft.member !== undefined ? { member: draft.member } : {}), + // [#23165, ADR-0048] The package the draft was listed under: the + // discard removes THAT package's draft, never another package's + // draft of the same name. + ...(draft.packageId !== undefined ? { packageId: draft.packageId } : {}), + }); +} diff --git a/packages/metadata-protocol/src/protocol-publish-backfill.ts b/packages/metadata-protocol/src/protocol-publish-backfill.ts new file mode 100644 index 00000000000..c5099b554b6 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-publish-backfill.ts @@ -0,0 +1,239 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25467 cut 12] The publish backfill pair of + * `ObjectStackProtocolImplementation` — the judgement half a + * `publishPackageDrafts` backfill runs before anything is promoted + * (`planPublishBackfill`) and the write half it runs after the publish has + * committed (`applyPublishBackfill`) — moved out of `protocol.ts` whole. + * + * A MOVE, not a redesign. Each function below is the body of the + * `ObjectStackProtocolImplementation` method of the same name as it stood, + * re-indented one level and otherwise unedited: the same reads and writes, in + * the same order, refusing and stopping exactly where they did with the same + * statuses and messages. + * + * ## How the moved bodies reach the class + * + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` + * header carries the full reasoning), as `protocol-batch-data.ts` applies it. + * The class keeps every method — the published `dist/*.d.ts` lists each one's + * name, docblock and declared signature — and each body is one literal of + * exactly the {@link PublishBackfillHost} members that method reads, plus a + * `.call`. A method is handed over as a closure that calls it on the + * protocol at call time, so a test double or spy set on the instance is still + * the one the body reaches. Every other reader of these two methods keeps + * calling the class method. + * + * The field the bodies read, `engine`, goes as a GETTER, so each read happens + * when the body makes it: tests replace it on a constructed protocol. + * + * The module-level declarations of `protocol.ts` the bodies read stay there + * and are imported from there: `canonicalMetaType`, `clientFacingFailureText` + * and `clientFacingFailureCode`, which code that did not move reads too; the + * types `MetadataIssueEntry`, `PublishBackfillEntry` and + * `PublishBackfillReceipt`, which the class's own signatures name; the refusal + * `publishBackfillRefusal`, which the backfill request's shape judgement + * (`readPublishBackfillRequest`) throws too; and `PUBLISH_BACKFILL_PAGE_SIZE`, + * which sits with that family's declarations. The module cycle is + * evaluation-safe because every runtime import is read only inside a function + * body, never while the two modules are first evaluated. `index.ts` names what + * it publishes from `protocol.ts` one by one, so the `export` some of them + * gained for this keeps them off the published surface, and nothing + * re-exports this module, so the published surface does not move. + */ + +import type { + MetadataIssueEntry, + ObjectStackProtocolImplementation, + PublishBackfillEntry, + PublishBackfillReceipt, +} from './protocol.js'; +import { + PUBLISH_BACKFILL_PAGE_SIZE, + canonicalMetaType, + clientFacingFailureCode, + clientFacingFailureText, + publishBackfillRefusal, +} from './protocol.js'; +import type { UpdateManyDataRequest } from '@objectstack/spec/api'; +import { type DroppedFieldsEvent, discriminateDefaultValueShape } from '@objectstack/spec/data'; + +/** + * The `ObjectStackProtocolImplementation` members the functions below read — + * every one of them, and no other; each function's `this` is the `Pick` of the + * members IT reads. Each is typed AS the class member (an indexed access on + * the class), so a change to a member's signature on the class is a change + * here too, and naming a member the class does not have is a compile error. + * See the module header for which members are getters. + */ +export interface PublishBackfillHost { + readonly engine: ObjectStackProtocolImplementation['engine']; + getOverlayRepo: ObjectStackProtocolImplementation['getOverlayRepo']; + updateManyData: ObjectStackProtocolImplementation['updateManyData']; +} + +/** The body of `ObjectStackProtocolImplementation#planPublishBackfill`; the method's docblock there is its contract. */ +export async function planPublishBackfill( + this: Pick, + packageId: string, + entries: ReadonlyArray<{ object: string; field: string }>, + drafts: ReadonlyArray<{ type: string; name: string; member?: string }>, +): Promise { + const repo = this.getOverlayRepo(); + const issues: MetadataIssueEntry[] = []; + const plan: PublishBackfillEntry[] = []; + const draftFields = new Map | undefined>(); + for (const [i, e] of entries.entries()) { + const path = `backfill.${i}`; + const draft = drafts.find((d) => canonicalMetaType(d.type) === 'object' && d.name === e.object); + if (!draft) { + issues.push({ + path, + message: `Package '${packageId}' has no pending draft of object '${e.object}': a backfill fills ` + + 'fields this publish adds, so it names an object drafted in this package.', + code: 'custom', + }); + continue; + } + if (!draftFields.has(e.object)) { + const ref = { type: draft.type, name: draft.name, org: 'env' } as unknown as Parameters[0]; + const body = typeof repo.get === 'function' + ? (await repo.get(ref, { state: 'draft' }))?.body as { fields?: unknown } | undefined + : undefined; + const fields = body?.fields; + draftFields.set( + e.object, + fields && typeof fields === 'object' && !Array.isArray(fields) ? fields as Record : undefined, + ); + } + const def = draftFields.get(e.object)?.[e.field]; + if (!def || typeof def !== 'object') { + issues.push({ + path, + message: `The draft of object '${e.object}' declares no field '${e.field}'.`, + code: 'custom', + }); + continue; + } + const live = this.engine?.registry?.getObject?.(e.object) as { fields?: Record } | undefined; + if (live?.fields && Object.prototype.hasOwnProperty.call(live.fields, e.field)) { + issues.push({ + path, + message: `Field '${e.object}.${e.field}' already exists on the published object, so this publish does ` + + 'not add it: a backfill fills only fields a publish adds. Change existing rows through the data API.', + code: 'custom', + }); + continue; + } + const dv = (def as { defaultValue?: unknown }).defaultValue; + if (dv === undefined || dv === null) { + issues.push({ + path, + message: `Field '${e.object}.${e.field}' declares no defaultValue, so there is no value to write: ` + + 'declare one on the field, or drop it from the backfill.', + code: 'custom', + }); + continue; + } + const shape = discriminateDefaultValueShape(dv); + if (shape !== 'literal') { + issues.push({ + path, + message: `Field '${e.object}.${e.field}' declares a ${shape === 'token' ? 'runtime token' : 'expression'} ` + + 'defaultValue, resolved for each new record as it is created; a backfill writes a literal value only.', + code: 'custom', + }); + continue; + } + plan.push({ object: e.object, field: e.field, value: dv }); + } + if (issues.length > 0) throw publishBackfillRefusal(packageId, issues); + return plan; +} + +/** The body of `ObjectStackProtocolImplementation#applyPublishBackfill`; the method's docblock there is its contract. */ +export async function applyPublishBackfill( + this: Pick, + packageId: string, + plan: ReadonlyArray, + context: unknown, +): Promise { + const fields: PublishBackfillReceipt['fields'] = []; + for (const e of plan) { + let updated = 0; + const written = new Set(); + try { + for (;;) { + const rows = (await this.engine.find(e.object, { + where: { [e.field]: { $null: true } }, + limit: PUBLISH_BACKFILL_PAGE_SIZE, + context, + } as never)) as Array<{ id?: unknown }> | null | undefined; + const ids = (rows ?? []).map((r) => r?.id).filter((id): id is string | number => + typeof id === 'string' || typeof id === 'number').map(String); + if (ids.length === 0) break; + const stuck = ids.find((id) => written.has(id)); + if (stuck !== undefined) { + throw Object.assign(new Error( + `Record '${stuck}' of '${e.object}' still has no '${e.field}' after it was written, so a hook ` + + 'or rule on the object cleared it; the backfill stopped there.', + ), { status: 422 }); + } + const res = await this.updateManyData({ + object: e.object, + records: ids.map((id) => ({ id, data: { [e.field]: e.value } })), + options: { continueOnError: false } as UpdateManyDataRequest['options'], + context, + }); + for (const r of res.results ?? []) { + if (!r.success) continue; + const dropped = (r as { droppedFields?: DroppedFieldsEvent[] }).droppedFields + ?.find((ev) => ev.fields.includes(e.field)); + if (dropped) { + throw Object.assign(new Error( + `The write of '${e.field}' to record '${String(r.id)}' of '${e.object}' was dropped ` + + `(${dropped.reason}): this caller cannot set the field; the backfill stopped there.`, + ), { status: 403 }); + } + const kept = (r as { data?: Record }).data; + if (kept && Object.prototype.hasOwnProperty.call(kept, e.field) && kept[e.field] == null) { + throw Object.assign(new Error( + `Record '${String(r.id)}' of '${e.object}' was written with '${e.field}' left empty, so a ` + + 'hook or rule on the object cleared it; the backfill stopped there.', + ), { status: 422 }); + } + written.add(String(r.id)); + updated++; + } + if (!res.success) { + const failedRow = (res.results ?? []).find((r) => !r.success); + const rowError = (failedRow as { errors?: Array<{ code?: string; message?: string }> } | undefined)?.errors?.[0]; + const failure = { + error: `Record '${String(failedRow?.id)}' of '${e.object}' was not written: ` + + `${rowError?.message ?? 'the write failed'}; the backfill stopped there.`, + ...(rowError?.code ? { code: rowError.code } : {}), + }; + fields.push({ object: e.object, field: e.field, updated, ...failure }); + console.warn(`[Protocol] publishPackageDrafts backfill of '${e.object}.${e.field}' (package '${packageId}'): ${failure.error}`); + break; + } + } + if (!fields.some((f) => f.object === e.object && f.field === e.field)) { + fields.push({ object: e.object, field: e.field, updated }); + } + } catch (err: unknown) { + const code = clientFacingFailureCode(err); + console.warn( + `[Protocol] publishPackageDrafts backfill of '${e.object}.${e.field}' (package '${packageId}') ` + + `stopped after ${updated} row(s): ${(err as Error)?.message ?? String(err)}`, + ); + fields.push({ + object: e.object, field: e.field, updated, + error: clientFacingFailureText(err, 'the backfill failed; the reason is in the server log'), + ...(code ? { code } : {}), + }); + } + } + return { success: fields.every((f) => f.error === undefined), fields }; +} diff --git a/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts b/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts index f4a673b0e26..d76f3c98cf4 100644 --- a/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts +++ b/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts @@ -87,6 +87,9 @@ const PRODUCERS: Readonly> = { // The served lock state and the stored-row decline, moved out of `protocol.ts` // whole on the same terms. 'protocol-lock-state.ts': 1, + // The publish backfill pair, moved out of `protocol.ts` whole on the same + // terms. + 'protocol-publish-backfill.ts': 3, 'sys-metadata-repository.ts': 5, 'runtime-authoring-gate.ts': 1, }; diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 684c73ccc80..d7c13c04c0a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -164,7 +164,6 @@ import { type QueryAliasConflict, type DroppedFieldsEvent, type QueryAST, type EngineQueryOptionsParsed, type ValidationAdvisoryEvent, - discriminateDefaultValueShape, externalBindingDatasourceRefusal, } from '@objectstack/spec/data'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSpellingRefusal, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared'; @@ -393,6 +392,10 @@ import { stripMaterializedFromRegistry, stripServedObjectColumns, } from './protocol-registry-materialize.js'; +// [#25467 cut 12] The publish backfill pair: the judgement and the write half. +import { applyPublishBackfill, planPublishBackfill } from './protocol-publish-backfill.js'; +// [#25467 cut 12] The `_drafts` list read and the one-draft discard. +import { discardDraftInItsScope, listDrafts } from './protocol-drafts.js'; /** * Canonical Zod schema per metadata type lives in @@ -634,7 +637,7 @@ export function isObjectOfDisabledPackage(registry: any, name: string): boolean * isObjectOfDisabledPackage} is: a host whose registry cannot answer has no * item hidden. */ -function isItemOfDisabledPackage(registry: any, item: unknown): boolean { +export function isItemOfDisabledPackage(registry: any, item: unknown): boolean { return typeof registry?.isPackageDisabled === 'function' && registry.isPackageDisabled((item as { _packageId?: unknown } | null | undefined)?._packageId) === true; } @@ -1328,7 +1331,7 @@ export interface MetadataIssueEntry { * the request named it, plus the literal its draft declares. Built only by * `planPublishBackfill`, so every value here was judged before the publish. */ -interface PublishBackfillEntry { +export interface PublishBackfillEntry { object: string; field: string; value: unknown; @@ -1340,20 +1343,20 @@ interface PublishBackfillEntry { * `updated` counts the rows this publish wrote, so a row that already held a * value (a database column DEFAULT fills one on the SQL driver) is not counted. */ -type PublishBackfillReceipt = { +export type PublishBackfillReceipt = { success: boolean; fields: Array<{ object: string; field: string; updated: number; error?: string; code?: string }>; }; /** [#24253] Rows read and written per round of one field's backfill. */ -const PUBLISH_BACKFILL_PAGE_SIZE = 200; +export const PUBLISH_BACKFILL_PAGE_SIZE = 200; /** * [#24253] The 400 a `publishPackageDrafts` backfill request is refused with: * every entry that cannot be honoured, by path, in one answer, before anything * is promoted or written. The headline names the first; `issues[]` names all. */ -function publishBackfillRefusal( +export function publishBackfillRefusal( packageId: string, issues: MetadataIssueEntry[], ): Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] } { @@ -2689,7 +2692,7 @@ export function resolveBundleMemberSelector(type: string, member: unknown): stri * registry call or a body comparison: the selector itself, or the canonical * member when none was named. `undefined` for a type with no members. */ -function bundleMemberDiscriminator(type: string, member: string | undefined): string | undefined { +export function bundleMemberDiscriminator(type: string, member: string | undefined): string | undefined { const disc = ITEM_KEY_DISCRIMINATORS[canonicalMetaType(type)]; if (!disc) return undefined; return member?.trim() || disc.canonical; @@ -3919,7 +3922,7 @@ function clientFacingRowFailureText(err: unknown, fallback: string): string { * tolerance over a producer that does not need it (Prime Directive #12) — * the same call #8136 recorded at those sites for the message. */ -function clientFacingFailureCode(err: unknown): string | undefined { +export function clientFacingFailureCode(err: unknown): string | undefined { const code = (err as { code?: unknown } | null | undefined)?.code; if (!code) return undefined; if (typeof code === 'string' && ErrorCode.safeParse(code).success) return code; @@ -23453,24 +23456,14 @@ export class ObjectStackProtocolImplementation implements updatedBy: string | null; }>; }> { - await this.ensureOverlayIndex(); - // [ADR-0131 D6] The environment's pending drafts. - const repo = this.getOverlayRepo(); - const drafts = await repo.listDrafts({ - ...(request?.type ? { type: PLURAL_TO_SINGULAR[request.type] ?? request.type } : {}), - ...(request?.packageId ? { packageId: request.packageId } : {}), - }); - // [#23073] The declared header (`ListDraftsResponseSchema`), with the - // row's stored member on every draft of a bundled type: absent there - // means the canonical member, which is spelled out here. - const served = drafts.filter((draft) => draft.type === 'package' - || !isItemOfDisabledPackage(this.engine?.registry, { _packageId: draft.packageId })); - return { - drafts: served.map(({ member: stored, ...header }) => { - const member = bundleMemberDiscriminator(header.type, stored); - return member === undefined ? header : { ...header, member }; - }), + // [#25467 cut 12] The body moved to `protocol-drafts.ts` (see `DraftsHost`). + const readEngine = () => this.engine; + const host: ThisParameterType = { + get engine() { return readEngine(); }, + ensureOverlayIndex: (...a) => this.ensureOverlayIndex(...a), + getOverlayRepo: (...a) => this.getOverlayRepo(...a), }; + return listDrafts.call(host, request); } /** @@ -23653,76 +23646,13 @@ export class ObjectStackProtocolImplementation implements entries: ReadonlyArray<{ object: string; field: string }>, drafts: ReadonlyArray<{ type: string; name: string; member?: string }>, ): Promise { - const repo = this.getOverlayRepo(); - const issues: MetadataIssueEntry[] = []; - const plan: PublishBackfillEntry[] = []; - const draftFields = new Map | undefined>(); - for (const [i, e] of entries.entries()) { - const path = `backfill.${i}`; - const draft = drafts.find((d) => canonicalMetaType(d.type) === 'object' && d.name === e.object); - if (!draft) { - issues.push({ - path, - message: `Package '${packageId}' has no pending draft of object '${e.object}': a backfill fills ` - + 'fields this publish adds, so it names an object drafted in this package.', - code: 'custom', - }); - continue; - } - if (!draftFields.has(e.object)) { - const ref = { type: draft.type, name: draft.name, org: 'env' } as unknown as Parameters[0]; - const body = typeof repo.get === 'function' - ? (await repo.get(ref, { state: 'draft' }))?.body as { fields?: unknown } | undefined - : undefined; - const fields = body?.fields; - draftFields.set( - e.object, - fields && typeof fields === 'object' && !Array.isArray(fields) ? fields as Record : undefined, - ); - } - const def = draftFields.get(e.object)?.[e.field]; - if (!def || typeof def !== 'object') { - issues.push({ - path, - message: `The draft of object '${e.object}' declares no field '${e.field}'.`, - code: 'custom', - }); - continue; - } - const live = this.engine?.registry?.getObject?.(e.object) as { fields?: Record } | undefined; - if (live?.fields && Object.prototype.hasOwnProperty.call(live.fields, e.field)) { - issues.push({ - path, - message: `Field '${e.object}.${e.field}' already exists on the published object, so this publish does ` - + 'not add it: a backfill fills only fields a publish adds. Change existing rows through the data API.', - code: 'custom', - }); - continue; - } - const dv = (def as { defaultValue?: unknown }).defaultValue; - if (dv === undefined || dv === null) { - issues.push({ - path, - message: `Field '${e.object}.${e.field}' declares no defaultValue, so there is no value to write: ` - + 'declare one on the field, or drop it from the backfill.', - code: 'custom', - }); - continue; - } - const shape = discriminateDefaultValueShape(dv); - if (shape !== 'literal') { - issues.push({ - path, - message: `Field '${e.object}.${e.field}' declares a ${shape === 'token' ? 'runtime token' : 'expression'} ` - + 'defaultValue, resolved for each new record as it is created; a backfill writes a literal value only.', - code: 'custom', - }); - continue; - } - plan.push({ object: e.object, field: e.field, value: dv }); - } - if (issues.length > 0) throw publishBackfillRefusal(packageId, issues); - return plan; + // [#25467 cut 12] The body moved to `protocol-publish-backfill.ts` (see `PublishBackfillHost`). + const readEngine = () => this.engine; + const host: ThisParameterType = { + get engine() { return readEngine(); }, + getOverlayRepo: (...a) => this.getOverlayRepo(...a), + }; + return planPublishBackfill.call(host, packageId, entries, drafts); } /** @@ -23745,83 +23675,13 @@ export class ObjectStackProtocolImplementation implements plan: ReadonlyArray, context: unknown, ): Promise { - const fields: PublishBackfillReceipt['fields'] = []; - for (const e of plan) { - let updated = 0; - const written = new Set(); - try { - for (;;) { - const rows = (await this.engine.find(e.object, { - where: { [e.field]: { $null: true } }, - limit: PUBLISH_BACKFILL_PAGE_SIZE, - context, - } as never)) as Array<{ id?: unknown }> | null | undefined; - const ids = (rows ?? []).map((r) => r?.id).filter((id): id is string | number => - typeof id === 'string' || typeof id === 'number').map(String); - if (ids.length === 0) break; - const stuck = ids.find((id) => written.has(id)); - if (stuck !== undefined) { - throw Object.assign(new Error( - `Record '${stuck}' of '${e.object}' still has no '${e.field}' after it was written, so a hook ` - + 'or rule on the object cleared it; the backfill stopped there.', - ), { status: 422 }); - } - const res = await this.updateManyData({ - object: e.object, - records: ids.map((id) => ({ id, data: { [e.field]: e.value } })), - options: { continueOnError: false } as UpdateManyDataRequest['options'], - context, - }); - for (const r of res.results ?? []) { - if (!r.success) continue; - const dropped = (r as { droppedFields?: DroppedFieldsEvent[] }).droppedFields - ?.find((ev) => ev.fields.includes(e.field)); - if (dropped) { - throw Object.assign(new Error( - `The write of '${e.field}' to record '${String(r.id)}' of '${e.object}' was dropped ` - + `(${dropped.reason}): this caller cannot set the field; the backfill stopped there.`, - ), { status: 403 }); - } - const kept = (r as { data?: Record }).data; - if (kept && Object.prototype.hasOwnProperty.call(kept, e.field) && kept[e.field] == null) { - throw Object.assign(new Error( - `Record '${String(r.id)}' of '${e.object}' was written with '${e.field}' left empty, so a ` - + 'hook or rule on the object cleared it; the backfill stopped there.', - ), { status: 422 }); - } - written.add(String(r.id)); - updated++; - } - if (!res.success) { - const failedRow = (res.results ?? []).find((r) => !r.success); - const rowError = (failedRow as { errors?: Array<{ code?: string; message?: string }> } | undefined)?.errors?.[0]; - const failure = { - error: `Record '${String(failedRow?.id)}' of '${e.object}' was not written: ` - + `${rowError?.message ?? 'the write failed'}; the backfill stopped there.`, - ...(rowError?.code ? { code: rowError.code } : {}), - }; - fields.push({ object: e.object, field: e.field, updated, ...failure }); - console.warn(`[Protocol] publishPackageDrafts backfill of '${e.object}.${e.field}' (package '${packageId}'): ${failure.error}`); - break; - } - } - if (!fields.some((f) => f.object === e.object && f.field === e.field)) { - fields.push({ object: e.object, field: e.field, updated }); - } - } catch (err: unknown) { - const code = clientFacingFailureCode(err); - console.warn( - `[Protocol] publishPackageDrafts backfill of '${e.object}.${e.field}' (package '${packageId}') ` - + `stopped after ${updated} row(s): ${(err as Error)?.message ?? String(err)}`, - ); - fields.push({ - object: e.object, field: e.field, updated, - error: clientFacingFailureText(err, 'the backfill failed; the reason is in the server log'), - ...(code ? { code } : {}), - }); - } - } - return { success: fields.every((f) => f.error === undefined), fields }; + // [#25467 cut 12] The body moved to `protocol-publish-backfill.ts` (see `PublishBackfillHost`). + const readEngine = () => this.engine; + const host: ThisParameterType = { + get engine() { return readEngine(); }, + updateManyData: (...a) => this.updateManyData(...a), + }; + return applyPublishBackfill.call(host, packageId, plan, context); } /** @@ -24882,17 +24742,11 @@ export class ObjectStackProtocolImplementation implements draft: { type: string; name: string; member?: string; packageId?: string | null }, actor: string | undefined, ): Promise { - await this.deleteMetaItem({ - type: draft.type, - name: draft.name, - state: 'draft', - ...(actor ? { actor } : {}), - ...(draft.member !== undefined ? { member: draft.member } : {}), - // [#23165, ADR-0048] The package the draft was listed under: the - // discard removes THAT package's draft, never another package's - // draft of the same name. - ...(draft.packageId !== undefined ? { packageId: draft.packageId } : {}), - }); + // [#25467 cut 12] The body moved to `protocol-drafts.ts` (see `DraftsHost`). + const host: ThisParameterType = { + deleteMetaItem: (...a) => this.deleteMetaItem(...a), + }; + return discardDraftInItsScope.call(host, draft, actor); } /**