diff --git a/.changeset/30429-seed-receipt-landed-on-throw.md b/.changeset/30429-seed-receipt-landed-on-throw.md new file mode 100644 index 00000000000..76c1d065f69 --- /dev/null +++ b/.changeset/30429-seed-receipt-landed-on-throw.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': minor +'@objectstack/runtime': patch +--- + +fix(metadata-protocol,runtime): a seed load that throws part-way still reports `seedApplied.landed` for the datasets that finished before the throw + +Clause-②: yes (widening: a thrown load's receipt carries `landed` for the datasets that finished, where it carried none) +Metadata-protocol: semantic + +- **`@objectstack/spec`: `seedApplied.landed`** (both publish responses) now says what it covers on a load that threw part-way (`success: false` with `error`): only the datasets that FINISHED before the throw. The dataset that threw is not listed (its rows are in `inserted` / `updated`, its object in `error`). Absent `landed` now means the apply failed before any load ran, or the producer reports no ids; a throw before any dataset finished reports `landed: []`. +- **`@objectstack/metadata-protocol`**: `SeedLoadPartialWrites` gains `finished`, the finished datasets' `object` / `landedIds` / `insertedIds` in load order, and `applySeedBodies`' catch folds it through the same `seedAppliedLanded` projection (same two lists, same 200 cap) the returned path uses. `seedLoadPartialWrites` and `SeedLoadPartialWrites` are exported from the package root. +- **`@objectstack/runtime`**: the package-publish door's fallback seed apply reads the same `seedLoadPartialWrites` off a thrown load and reports the same `landed`, plus the `inserted` / `updated` counts the response schema requires (it reported neither before) and the object whose seed stopped the load in `error`, as `metadata-protocol`'s apply does. +- Why the dataset that threw is left out: its ids are only partly known when it throws. A batched insert whose response was lost may have committed rows whose ids never came back, so a list of the ids the loader did see would read as complete while missing rows. + +Supersedes, in the same release: `.changeset/30348-seed-receipt-landed-ids.md` says absent `landed` means "not reported (a load that threw, or a producer that reports no ids)". After this change a load that threw part-way reports `landed` for its finished datasets; absent now means no load ran, or a producer that reports no ids. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 3c8ca94a3eb..d8ae6f2be17 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2546,7 +2546,7 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant' | **updated** | `integer` | ✅ | Rows updated by the externalId-keyed upsert. | | **error** | `string` | optional | Single failure message, present when the seed apply threw before the loader ran (including "no readable seed bodies"). | | **errors** | `any[]` | optional | Per-record failures reported by the seed loader. Present only when the loader ran and returned a non-empty error list. | -| **landed** | `{ object: string; inserted: object; matched: object }[]` | optional | Per object, the stored rows the seed landed on: `inserted` (created by this publish) and `matched` (pre-existing rows a seed key matched), each capped at `SEED_APPLIED_LANDED_IDS_CAP` (200) ids with `total` and `truncated` stating any cut. One entry per object the loader ran, empty lists included. ABSENT means "not reported" — the apply failed before or while loading (see `error`), or the producer reports no ids — never "no rows landed". | +| **landed** | `{ object: string; inserted: object; matched: object }[]` | optional | Per object, the stored rows the seed landed on: `inserted` (created by this publish) and `matched` (pre-existing rows a seed key matched), each capped at `SEED_APPLIED_LANDED_IDS_CAP` (200) ids with `total` and `truncated` stating any cut. One entry per object the loader ran, empty lists included. When the load threw part-way (`success: false` with `error`), it lists only the datasets that FINISHED before the throw, and is EMPTY when none had; the dataset that threw is not listed (its rows are in the counts, its object in `error`). ABSENT means "not reported" — the apply failed before any load ran (see `error`), or the producer reports no ids — never "no rows landed". | ### Nested Shape: `PublishMetaItemResponse.materializeApplied` @@ -2647,7 +2647,7 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant' | **error** | `string` | optional | Single failure message, present when the apply failed before the loader ran (including "no readable seed bodies"). When the failure is the seed bodies' own schema refusal, this is a one-sentence headline and the per-path detail rides `issues[]`. | | **errors** | `any[]` | optional | Per-record failures reported by the seed loader, plus any seed-body read failures. May be present and empty on a clean load. | | **issues** | `{ path: string; message: string; code?: string }[]` | optional | Structured spec-validation findings behind `error`, present when the apply was refused by the seed bodies' own schema — the declared 422 `seedRequestValidationError` mints. The per-path detail lives HERE, once; `error` stays a one-sentence headline. Absent on non-validation failures (driver faults, unreadable bodies), whose whole story is `error` / `errors[]`. | -| **landed** | `{ object: string; inserted: object; matched: object }[]` | optional | Per object, the stored rows the seed landed on: `inserted` (created by this publish) and `matched` (pre-existing rows a seed key matched), each capped at `SEED_APPLIED_LANDED_IDS_CAP` (200) ids with `total` and `truncated` stating any cut. One entry per object the loader ran, empty lists included. ABSENT means "not reported" — the apply failed before or while loading (see `error`), or the producer reports no ids — never "no rows landed". | +| **landed** | `{ object: string; inserted: object; matched: object }[]` | optional | Per object, the stored rows the seed landed on: `inserted` (created by this publish) and `matched` (pre-existing rows a seed key matched), each capped at `SEED_APPLIED_LANDED_IDS_CAP` (200) ids with `total` and `truncated` stating any cut. One entry per object the loader ran, empty lists included. When the load threw part-way (`success: false` with `error`), it lists only the datasets that FINISHED before the throw, and is EMPTY when none had; the dataset that threw is not listed (its rows are in the counts, its object in `error`). ABSENT means "not reported" — the apply failed before any load ran (see `error`), or the producer reports no ids — never "no rows landed". | ### Nested Shape: `PublishPackageDraftsResponse.materializeApplied` diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 2eef7938f07..be652187b29 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -281,7 +281,14 @@ export { } from './migrations/recorded-by-sentinel.js'; export type { SentinelHistoryRow } from './migrations/recorded-by-sentinel.js'; -export { SeedLoaderService, type SeedLoaderServiceOptions } from './seed-loader.js'; +export { + SeedLoaderService, + type SeedLoaderServiceOptions, + // [#30429] The runtime's fallback seed apply reads a thrown load's finished + // datasets through it, so its receipt reports `landed` as this package's does. + seedLoadPartialWrites, + type SeedLoadPartialWrites, +} from './seed-loader.js'; export { runBuildProbes } from './build-probes.js'; export type { RuntimeBuildIssue, diff --git a/packages/metadata-protocol/src/protocol-publish-batch.ts b/packages/metadata-protocol/src/protocol-publish-batch.ts index 7a2c8267655..769ccd7a457 100644 --- a/packages/metadata-protocol/src/protocol-publish-batch.ts +++ b/packages/metadata-protocol/src/protocol-publish-batch.ts @@ -134,9 +134,8 @@ export async function applySeedBodies( // [#30348] Which rows landed, per object: created by this publish // vs pre-existing rows a seed key matched — the loader's own ids, // capped and folded by the one projection both receipt producers - // share. Only on this path: a load that THREW (the catch below) - // reports counts but no ids, so `landed` stays absent there — - // "not reported", never "nothing landed". + // share. [#30429] A load that THREW reports the same key from the + // catch below, for the datasets that finished. landed: seedAppliedLanded(r.results), }; } catch (e: any) { @@ -156,9 +155,11 @@ export async function applySeedBodies( // The counters describe what was WRITTEN. The loader writes dataset // by dataset and stops at the one that throws; the rows of the // datasets ahead of it stay written (nothing rolls them back), so the - // receipt carries the loader's own count of them and names the seed - // that stopped it. No reading means the throw came before the loader - // wrote anything — the request parse, or the loader's import. + // receipt carries the loader's own count of them (plus any rows the + // dataset that threw wrote first) and names the seed that stopped + // it. No reading means no load ran — the request parse refused, or + // the loader's import failed — so nothing was written; a reading + // with zero counts is a load that threw before writing a row. const written = partialWritesOf?.(e); const failure = clientFacingFailureText(e, 'seed apply failed'); return { @@ -168,6 +169,15 @@ export async function applySeedBodies( error: written?.failedObject ? `seed apply stopped at the seed for object '${written.failedObject}': ${failure}` : failure, + // [#30429] The rows of the datasets that FINISHED before the + // throw, through the same projection as the returned path — the + // ids a reader clearing a partial publish's sample data needs. The + // dataset that threw is not listed (its ids are only partly known, + // see `SeedLoadPartialWrites.finished`); its rows are in the + // counts above and its object in `error`. Empty when the throw + // came before any dataset finished; absent when there is no + // reading (no load ran, see above): "not reported". + ...(written ? { landed: seedAppliedLanded(written.finished) } : {}), // [#10524] A DECLARED refusal's structured findings ride the // receipt beside the headline `error` — the message is a // one-sentence headline now, so this is where the per-key diff --git a/packages/metadata-protocol/src/seed-loader-deferred-failure.test.ts b/packages/metadata-protocol/src/seed-loader-deferred-failure.test.ts index 8283a5d02b7..6ca4b995d0d 100644 --- a/packages/metadata-protocol/src/seed-loader-deferred-failure.test.ts +++ b/packages/metadata-protocol/src/seed-loader-deferred-failure.test.ts @@ -1,7 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi } from 'vitest'; -import { SeedLoaderService } from './seed-loader.js'; +import { SeedLoaderService, seedLoadPartialWrites } from './seed-loader.js'; +import { seedAppliedLanded } from './seed-applied-landed.js'; import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; @@ -300,3 +301,58 @@ describe('seed deferred back-fill failure is reported, not swallowed (framework# expect(result.summary.totalErrored).toBe(0); }); }); + +/** + * [#30429] A load that throws in PASS 2 — after every dataset finished — is + * still a thrown load, and its partial-writes reading lists every dataset as + * finished: no `failedObject`, and the receipt's `landed` (the same + * `seedAppliedLanded` projection the publish catch uses) names every row the + * load wrote. + * + * Pass 2 guards its own driver traffic (a failed back-fill write is counted, + * a failed lookup probe is a miss), so the throw is staged at the one call in + * it that is not guarded: the `logger.error` its back-fill failure reports + * through — a log transport that fails while pass 2 reports. What is pinned is + * the loader's state at that point, not the transport. + */ +describe('a load that throws in pass 2 reports every dataset as finished (#30429)', () => { + it('finished lists both datasets with their store ids; no failed object; landed names every row', async () => { + const { engine, store } = createFaithfulEngine(); + const metadata = createMetadata(); + const realUpdate = (engine.update as any).getMockImplementation(); + (engine.update as any).mockImplementation(async (obj: string, data: any, opts: any) => { + if (obj === 'audit_department') throw new Error('fetch failed'); + return realUpdate(obj, data, opts); + }); + const logger = createLogger(); + logger.error.mockImplementation(() => { throw new Error('the log transport is down'); }); + + const err = await new SeedLoaderService(engine, metadata, logger) + .load({ seeds: SEEDS, config: CONFIG }) + .then(() => undefined, (e: unknown) => e); + + expect((err as Error)?.message).toBe('the log transport is down'); + // Positive control: the throw came from pass 2's back-fill report, after + // both datasets wrote their row. + expect((engine.update as any).mock.calls.some(([obj]: [string]) => obj === 'audit_department')).toBe(true); + const deptIds = (store.audit_department ?? []).map((r) => String(r.id)); + const workerIds = (store.audit_worker ?? []).map((r) => String(r.id)); + expect(deptIds).toHaveLength(1); + expect(workerIds).toHaveLength(1); + const partial = seedLoadPartialWrites(err)!; + expect(partial).toEqual({ + inserted: 2, + updated: 0, + finished: [ + { object: 'audit_department', landedIds: deptIds, insertedIds: deptIds }, + { object: 'audit_worker', landedIds: workerIds, insertedIds: workerIds }, + ], + }); + expect('failedObject' in partial).toBe(false); + const list = (ids: string[]) => ({ ids, total: ids.length, truncated: false }); + expect(seedAppliedLanded(partial.finished)).toEqual([ + { object: 'audit_department', inserted: list(deptIds), matched: list([]) }, + { object: 'audit_worker', inserted: list(workerIds), matched: list([]) }, + ]); + }); +}); diff --git a/packages/metadata-protocol/src/seed-loader-seed-files.test.ts b/packages/metadata-protocol/src/seed-loader-seed-files.test.ts index 56d8b0263a8..6d7c921ae1c 100644 --- a/packages/metadata-protocol/src/seed-loader-seed-files.test.ts +++ b/packages/metadata-protocol/src/seed-loader-seed-files.test.ts @@ -558,7 +558,15 @@ describe('seedLoadPartialWrites: the rows a thrown load had written', () => { expect((err as Error).message).toBe('the storage service is down'); expect((store.pw_tag ?? []).map((r) => r.name).sort()).toEqual(['T1', 'T2']); expect((store.pw_doc ?? []).map((r) => r.code)).toEqual(['D1']); - expect(seedLoadPartialWrites(err)).toEqual({ inserted: 3, updated: 0, failedObject: 'pw_doc' }); + // [#30429] The finished dataset (`pw_tag`) carries its landed ids — the + // store's own; the dataset that threw (`pw_doc`) is in the counts but not + // in `finished`, although its row D1 was written before the throw. + const tagIds = (store.pw_tag ?? []).map((r) => String(r.id)); + expect(tagIds).toHaveLength(2); + expect(seedLoadPartialWrites(err)).toEqual({ + inserted: 3, updated: 0, failedObject: 'pw_doc', + finished: [{ object: 'pw_tag', landedIds: tagIds, insertedIds: tagIds }], + }); }); it('a load that throws before any dataset wrote: zero, and no object named', async () => { @@ -569,7 +577,7 @@ describe('seedLoadPartialWrites: the rows a thrown load had written', () => { expect((err as Error).message).toBe('metadata store unavailable'); expect(store).toEqual({}); - expect(seedLoadPartialWrites(err)).toEqual({ inserted: 0, updated: 0 }); + expect(seedLoadPartialWrites(err)).toEqual({ inserted: 0, updated: 0, finished: [] }); }); it('an error that did not come out of a load carries no reading', () => { diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index 4140f681fe2..a7986c958ec 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -48,6 +48,7 @@ import { isMissingTableError } from '@objectstack/metadata/errors'; // [#25779] `seedFile(path)` values: stored, kept across replays, and owned by // the row that holds them; an id-shaped file value that names no file refused. import { SeedDatasetFiles, type SeedFileClaims } from './seed-files.js'; +import type { SeedLandingResult } from './seed-applied-landed.js'; interface Logger { info(message: string, meta?: Record): void; @@ -581,6 +582,21 @@ export interface SeedLoadPartialWrites { readonly updated: number; /** The object of the dataset that was loading when the load threw; absent when it threw outside any dataset. */ readonly failedObject?: string; + /** + * [#30429] The datasets that FINISHED before the throw, in load order, each + * with the row ids it landed on (`landedIds` / `insertedIds`, exactly as its + * returned result carries them) — what the publish receipt projects into + * `seedApplied.landed` with the same `seedAppliedLanded` a returned load + * uses. Empty when the throw came before any dataset finished. + * + * The dataset that threw is NOT here, although its rows are in the counts + * above: its ids are only partly known when it throws. A batched insert + * whose response was lost may have committed rows whose ids never came back + * (the loader re-reads by key on its retry for exactly that reason), so a + * list of the ids it did see would read as complete while missing rows. A + * finished dataset's list is complete; that is the line this field draws. + */ + readonly finished: readonly SeedLandingResult[]; } /** The writes a thrown load had made, keyed by the error it threw. */ @@ -815,6 +831,9 @@ export class SeedLoaderService implements ISeedLoaderService { inserted: finished.reduce((sum, r) => sum + r.inserted, 0) + (loading?.written.inserted ?? 0), updated: finished.reduce((sum, r) => sum + r.updated, 0) + (loading?.written.updated ?? 0), ...(loading ? { failedObject: loading.object } : {}), + // A snapshot of the ids alone: the results array is the live one pass 2 + // writes its counters into. + finished: finished.map((r) => ({ object: r.object, landedIds: r.landedIds, insertedIds: r.insertedIds })), }; const carrier = err !== null && typeof err === 'object' ? err : new Error(String(err)); partialWritesByError.set(carrier, partial); diff --git a/packages/objectql/src/publish-seed-receipt-landed-ids.test.ts b/packages/objectql/src/publish-seed-receipt-landed-ids.test.ts index c888047034a..ac8305d6244 100644 --- a/packages/objectql/src/publish-seed-receipt-landed-ids.test.ts +++ b/packages/objectql/src/publish-seed-receipt-landed-ids.test.ts @@ -284,20 +284,46 @@ describe('seedApplied.landed — the rows a published seed landed on (#30348)', expect('seedApplied' in res).toBe(false); }); - it('a load that threw reports no ids: `landed` absent means "not reported", never "nothing landed"', async () => { + // [#30429] A load that THREW part-way reports `landed` for the datasets + // that FINISHED before the throw — the same projection, the same two + // lists — and leaves the dataset that threw out: its ids are only partly + // known when it throws (`SeedLoadPartialWrites.finished`), while its rows + // stay in the counts and its object in `error`. + it('a load that threw: `landed` lists the seeds that finished, created vs matched, and not the one that threw', async () => { const { p, engine } = await makeProtocol(); + // A customer's own row sharing a seed key, so both lists are exercised. + const customer: any = await engine.insert(ITEM, { name: 'R1' }, { context: { isSystem: true } } as any); await stage(p, [ - { type: 'seed', name: 'a_good', item: seed(['R1']) }, + { type: 'seed', name: 'a_good', item: seed(['R1', 'R2', 'R3']) }, { type: 'seed', name: 'z_missing', item: { object: MISSING, mode: 'ignore', records: [{ name: 'Z' }] } }, ]); const res: any = await p.publishPackageDrafts({ packageId: PKG }); - // Positive control: the good seed's row DID land; only the ids are unreported. - expect((await rowsOf(engine)).map((r) => r.name)).toEqual(['R1']); + const rows = await rowsOf(engine); + // Positive control: the good seed's rows DID land before the throw. + expect(rows.map((r) => r.name)).toEqual(['R1', 'R2', 'R3']); expect(res.seedApplied.success).toBe(false); - expect(res.seedApplied.inserted).toBe(1); - expect('landed' in res.seedApplied).toBe(false); + expect(res.seedApplied.error).toContain(`'${MISSING}'`); + expect(res.seedApplied.inserted).toBe(2); + const created = rows.filter((r) => r.name !== 'R1').map((r) => r.id); + // One entry, for the seed that finished; none for the object that threw. + expect(res.seedApplied.landed).toEqual([{ object: ITEM, inserted: list(created), matched: list([customer.id]) }]); + // The wire contract keeps it on a failed apply too. + expect(PublishPackageDraftsResponseSchema.parse(res).seedApplied?.landed).toEqual(res.seedApplied.landed); + }); + + it('a load that threw before any seed finished: `landed` is reported and empty, not absent', async () => { + const { p, engine } = await makeProtocol(); + await stage(p, [{ type: 'seed', name: 'z_missing', item: { object: MISSING, mode: 'ignore', records: [{ name: 'Z' }] } }]); + + const res: any = await p.publishPackageDrafts({ packageId: PKG }); + + expect(await rowsOf(engine)).toEqual([]); + expect(res.seedApplied.success).toBe(false); + expect(res.seedApplied.inserted).toBe(0); + expect(res.seedApplied.error).toContain(`'${MISSING}'`); + expect(res.seedApplied.landed).toEqual([]); }); it('the single-item door (`publishMetaItem`) carries the same `landed`, and its schema keeps it', async () => { diff --git a/packages/runtime/src/domains/packages-seed-apply-landed-thrown.test.ts b/packages/runtime/src/domains/packages-seed-apply-landed-thrown.test.ts new file mode 100644 index 00000000000..fafd843eafb --- /dev/null +++ b/packages/runtime/src/domains/packages-seed-apply-landed-thrown.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30429] The runtime package-publish door's fallback seed apply reports + * `seedApplied.landed` on a load that THREW part-way, exactly as + * `metadata-protocol`'s in-protocol apply does: the datasets that FINISHED + * before the throw are listed (created vs matched, through the shared + * `seedAppliedLanded`), the dataset that threw is not, and a failure before + * any load ran leaves `landed` absent. + * + * The in-protocol half is pinned at a real engine in `@objectstack/objectql`'s + * `publish-seed-receipt-landed-ids.test.ts`. This file pins the second + * producer, which reads the same `seedLoadPartialWrites` off the error: a + * dispatcher whose protocol does NOT self-apply seeds, driving the REAL + * `SeedLoaderService` (harness shape copied from + * `packages-seed-apply-disclosure.test.ts`, so each file fails on its own). + */ +import { describe, expect, it, vi } from 'vitest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const PKG_ADMIN = () => ({ + request: {}, + executionContext: { + userId: 'u_pkg_admin', + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }, +}) as any; + +/** The object whose existing-row read fails — the throw inside the second dataset. */ +const BROKEN = 'broken_thing'; + +function makeDoor(opts: { brokenSeed?: boolean; failGetMetaItem?: boolean } = {}) { + const published = [ + { type: 'seed', name: 'project_seed', version: 'h' }, + ...(opts.brokenSeed ? [{ type: 'seed', name: 'broken_seed', version: 'h' }] : []), + ]; + const publishPackageDrafts = vi.fn().mockResolvedValue({ + success: true, publishedCount: published.length, failedCount: 0, published, failed: [], + }); + const bodies: Record = { + project_seed: { object: 'project', externalId: 'name', mode: 'ignore', records: [{ name: 'Apollo' }, { name: 'Gemini' }] }, + broken_seed: { object: BROKEN, externalId: 'name', mode: 'ignore', records: [{ name: 'X' }] }, + }; + const getMetaItem = vi.fn().mockImplementation(async ({ name }: { name: string }) => { + if (opts.failGetMetaItem) throw new Error('read-back failed'); + return { type: 'seed', name, lock: null, editable: true, item: bodies[name] }; + }); + // The rows the engine handed back ids for, in write order — the ids the + // receipt must name, read here rather than derived from the code under test. + const insertedIds: string[] = []; + const insert = vi.fn().mockImplementation(async (_object: string, rec: any) => { + const one = (r: any) => { const id = `id_${r.name}`; insertedIds.push(id); return { id }; }; + return Array.isArray(rec) ? rec.map(one) : one(rec); + }); + // A customer's own `Gemini` already exists, so `ignore` matches it. + const find = vi.fn().mockImplementation(async (object: string) => { + if (object === 'sys_organization') return [{ id: 'org_default' }]; + if (object === BROKEN) throw new Error('the broken_thing table is unreadable'); + if (object === 'project') return [{ id: 'cust_gemini', name: 'Gemini' }]; + return []; + }); + const getObject = vi.fn().mockImplementation(async (name: string) => ({ + name, fields: { name: { type: 'text' } }, + })); + const kernel: any = { + getService: (name: string) => { + if (name === 'protocol') return Promise.resolve({ publishPackageDrafts, getMetaItem }); + if (name === 'objectql') { + return Promise.resolve({ + insert, find, update: vi.fn(), + registry: { getAllPackages: vi.fn().mockReturnValue([]) }, + }); + } + if (name === 'metadata') return Promise.resolve({ getObject }); + return null; + }, + context: { getService: () => null }, + }; + return { dispatcher: new HttpDispatcher(kernel), insertedIds, find }; +} + +async function publishDrafts(opts: Parameters[0] = {}) { + const door = makeDoor(opts); + const result = await door.dispatcher.handlePackages('/com.workspace/publish-drafts', 'POST', {}, {}, PKG_ADMIN()); + expect(result.response?.status).toBe(200); + return { ...door, seedApplied: (result.response as any)?.body?.data?.seedApplied }; +} + +const list = (ids: string[]) => ({ ids, total: ids.length, truncated: false }); + +describe('#30429 · the runtime fallback reports a thrown load\'s finished datasets and counts', () => { + it('positive control: a returned load lists the rows, created vs matched', async () => { + const { seedApplied, insertedIds } = await publishDrafts(); + + expect(seedApplied?.success).toBe(true); + expect(insertedIds).toEqual(['id_Apollo']); + expect(seedApplied?.landed).toEqual([{ object: 'project', inserted: list(insertedIds), matched: list(['cust_gemini']) }]); + }); + + it('a load that threw on a later dataset still lists the dataset that finished, and not the one that threw', async () => { + const { seedApplied, insertedIds, find } = await publishDrafts({ brokenSeed: true }); + + // The throw is the second dataset's own existing-row read, after the + // first dataset wrote its row. + expect(find).toHaveBeenCalledWith(BROKEN, expect.anything()); + expect(insertedIds).toEqual(['id_Apollo']); + expect(seedApplied?.success).toBe(false); + expect(seedApplied?.landed).toEqual([{ object: 'project', inserted: list(insertedIds), matched: list(['cust_gemini']) }]); + // The counts the schema requires, read off the same partial-writes + // record: the finished dataset's one created row. The object that + // stopped the load is named; the driver's sentence is withheld. + expect(seedApplied?.inserted).toBe(1); + expect(seedApplied?.updated).toBe(0); + expect(seedApplied?.error).toBe(`seed apply stopped at the seed for object '${BROKEN}': seed apply failed`); + expect(JSON.stringify(seedApplied)).not.toContain('unreadable'); + }); + + it('a failure before any load ran leaves `landed` absent: "not reported"', async () => { + const { seedApplied } = await publishDrafts({ failGetMetaItem: true }); + + expect(seedApplied?.success).toBe(false); + expect(seedApplied && 'landed' in seedApplied).toBe(false); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index ac4353741fe..39d41e8df5c 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -97,7 +97,7 @@ import type { MetadataProtocol, PackageProtocol } from '@objectstack/spec/api'; // a second copy of `metadata-protocol`'s `applySeedBodies`, and a second // private restatement of "when may a caught sentence be quoted" is where the // two copies start answering differently. -import { clientFacingFailureText, seedAppliedLanded, seedRequestValidationError } from '@objectstack/metadata-protocol'; +import { clientFacingFailureText, seedAppliedLanded, seedLoadPartialWrites, seedRequestValidationError } from '@objectstack/metadata-protocol'; import type { SeedAppliedLandedParsed } from '@objectstack/spec/api'; // [#15591] The DECLARED removal of our OWN read-time annotations, imported // from the list that defines them (`METADATA_READ_DECORATIONS`) rather than @@ -2126,9 +2126,24 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin (deps.logger ?? console).warn( `[handlePackages] seed apply failed: ${e?.message ?? e}`, ); + // [#30429] A load that threw mid-batch still reports + // what it wrote, read off the error exactly as + // `metadata-protocol`'s catch reads it: the counts + // (finished datasets plus the one that threw), the + // object that stopped it, and `landed` for the + // datasets that finished, through the same + // projection. No reading (no load ran) means nothing + // was written: zero counts, `landed` absent. + const written = seedLoadPartialWrites(e); + const failure = clientFacingFailureText(e, 'seed apply failed'); (result as any).seedApplied = { success: false, - error: clientFacingFailureText(e, 'seed apply failed'), + inserted: written?.inserted ?? 0, + updated: written?.updated ?? 0, + error: written?.failedObject + ? `seed apply stopped at the seed for object '${written.failedObject}': ${failure}` + : failure, + ...(written ? { landed: seedAppliedLanded(written.finished) } : {}), // [#10524] The declared 422's structured // findings ride beside the headline `error` — // `seedRequestValidationError`'s message is a diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 65aab6040cc..7f7aa17e78e 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -1494,9 +1494,12 @@ const SEED_APPLIED_LANDED_DESCRIPTION = 'Per object, the stored rows the seed landed on: `inserted` (created by this publish) and ' + '`matched` (pre-existing rows a seed key matched), each capped at ' + '`SEED_APPLIED_LANDED_IDS_CAP` (200) ids with `total` and `truncated` stating any cut. One ' - + 'entry per object the loader ran, empty lists included. ABSENT means "not reported" — the ' - + 'apply failed before or while loading (see `error`), or the producer reports no ids — never ' - + '"no rows landed".'; + + 'entry per object the loader ran, empty lists included. When the load threw part-way ' + + '(`success: false` with `error`), it lists only the datasets that FINISHED before the throw, ' + + 'and is EMPTY when none had; the dataset that threw is not listed (its rows are in the counts, ' + + 'its object in `error`). ' + + 'ABSENT means "not reported" — the apply failed before any load ran (see `error`), or the ' + + 'producer reports no ids — never "no rows landed".'; /** * Publish Metadata Item Response @@ -1529,8 +1532,12 @@ const SEED_APPLIED_LANDED_DESCRIPTION = * created, the only ones a reader may treat as the seed's own — and * `matched` — pre-existing rows a seed key matched, possibly a customer's. * Each list carries at most {@link SEED_APPLIED_LANDED_IDS_CAP} (200) ids - * and declares a cut with `truncated: true` beside the uncut `total`; - * absent `landed` means "not reported", never "nothing landed". The same + * and declares a cut with `truncated: true` beside the uncut `total`. + * [#30429] A load that threw part-way still reports `landed` for the + * datasets that FINISHED before the throw, and an empty `landed` when the + * throw came before any had; the dataset that threw is not listed (its ids + * are only partly known), though its rows are in the counts. Absent `landed` means "not reported" (no load ran, or the + * producer reports no ids), never "nothing landed". The same * key, same schema, rides {@link PublishPackageDraftsResponseSchema}'s * `seedApplied`. Accepted cost (objectstack-ai/cloud#3598 ruling A): row * ids on the public publish responses, bounded by the cap.