From 620a9966ea2a7178119ee6c8d6a21b9cdedb2535 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:14:28 +0000 Subject: [PATCH 1/5] fix(cloud-connection): the install-local listing reads withSampleData from the caller's own rows The listing answered each entry's withSampleData from the install-wide ledger record, so after a purge in organization A it told organization B 'no sample data' while B held all its seed rows. It now derives the flag per request: true when the purge's own identification (each dataset's externalId, read-only, scoped to the caller's active organization under a wall) finds at least one seed row. The identification pass is split out of the purge as matchSeedRows; the purge deletes what it returns, with its counts and log text unchanged. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- .../src/local-manifest-source.ts | 19 ++- .../src/marketplace-install-local-plugin.ts | 141 +++++++++++++++++- .../src/marketplace-install-local-purge.ts | 134 ++++++++++++++--- 3 files changed, 261 insertions(+), 33 deletions(-) diff --git a/packages/cloud-connection/src/local-manifest-source.ts b/packages/cloud-connection/src/local-manifest-source.ts index b1627863966..97f0846f498 100644 --- a/packages/cloud-connection/src/local-manifest-source.ts +++ b/packages/cloud-connection/src/local-manifest-source.ts @@ -42,15 +42,26 @@ export interface InstalledManifestEntry { manifest: any; installedAt: string; installedBy: string | null; - /** Whether the bundled seed datasets have been loaded into the kernel - * database. True after install (seedNow=true) or an explicit reseed; - * false after a purge. Persisted so the UI can show "Add" vs "Re-seed". */ + /** Install-time record: true after an install that seeded, a reseed or a + * rehydrate heal that landed rows; false after a purge. ONE value per + * install, NOT per organization — under an organization wall it records + * whichever organization seeded or purged last. + * + * ⛔ Not the answer to "does this caller's organization have the sample + * data?". The GET listing derives that per request from the caller's own + * rows (MarketplaceInstallLocalPlugin.sampleDataInScope, #21775) and does + * not read this field. */ withSampleData?: boolean; /** True only after an explicit purge-sample-data call. The rehydrate-time * sample-data healer (see MarketplaceInstallLocalPlugin.maybeHealSampleData) * must not resurrect demo rows the user deliberately removed — an empty * table after a purge is desired state, not data loss. Cleared again by - * install/reseed runs that land rows. */ + * install/reseed runs that land rows. + * + * Like `withSampleData`, ONE value per install, not per organization. + * The healer is its one reader, and under an organization wall the healer + * heals nothing at all, so this field decides something only without a + * wall, where the install is one tenant. */ sampleDataPurged?: boolean; /** * [ADR-0120 D5e] The installer's answer to the `isolated`-posture question diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 2ce1f2e4546..50a71c2f32a 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -44,7 +44,10 @@ * authenticated principal (anonymous → 401); `installedBy` and * `storageDir` are served only to a `manage_metadata` holder * (commit 01074e551). The four routes above require `manage_metadata` - * outright (#8976). + * outright (#8976). Each entry's `withSampleData` says whether the + * caller's own scope — its active organization under a wall — + * holds any of the package's seed rows, read from the rows, never + * from the install-wide ledger record (#21775). * * DELETE /api/v1/marketplace/install-local/:manifestId * → removes the cached manifest, withdraws the package from the @@ -114,7 +117,7 @@ import { } from './local-manifest-source.js'; import { ConnectionCredentialStore } from './connection-credential-store.js'; import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js'; -import { purgeSeedRows } from './marketplace-install-local-purge.js'; +import { matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js'; import type { IHttpServer, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; import type { DeletePackageRequest, UninstallCleanupOutcome } from '@objectstack/metadata-protocol'; @@ -299,6 +302,17 @@ function manifestIdOf(p: any): string | undefined { return p?.manifest?.id ?? p?.id ?? p?.manifest?.name ?? undefined; } +/** + * The seed datasets a cached manifest bundles: its `data[]` entries that name + * an object and carry records. One reading for the two doors that match seed + * rows — the purge, and the listing's `withSampleData` (#21775). + */ +function seedDatasetsOf(manifest: any): PurgeSeedDataset[] { + return Array.isArray(manifest?.data) + ? manifest.data.filter((d: any) => d && d.object && Array.isArray(d.records)) + : []; +} + /** * [ADR-0093 D4/D5, ADR-0105 D1 / #5262] Is an organization wall actually IN * FORCE for this boot? Both seeding decisions in this plugin key off it. @@ -1344,6 +1358,12 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * the four mutating doors use, not a second session read. Two auth * mechanisms in one file is how the next gap gets created, and this file has * already produced one. + * + * ## [#21775] `withSampleData` is the caller's own scope, read from its rows + * + * Each entry's `withSampleData` answers "does MY scope hold this package's + * sample data?" — derived per request by {@link sampleDataInScope}, never + * read from the ledger's install-wide record of the same name. */ private handleList = async (c: any, ctx: PluginContext): Promise => { // Before the ledger is touched, exactly as the mutating doors refuse @@ -1355,6 +1375,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const { entries, skipped } = this.readAll(); this.warnSkippedLedgerEntries(ctx, skipped, 'it is MISSING from the installed-apps list served to the console'); + const withSampleData = await this.sampleDataInScope(c, ctx, entries); return c.json({ success: true, data: { @@ -1364,7 +1385,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { manifestId: e.manifestId, version: e.version, installedAt: e.installedAt, - withSampleData: e.withSampleData ?? false, + withSampleData: withSampleData.has(e.manifestId), ...(operator ? { installedBy: e.installedBy } : {}), })), total: entries.length, @@ -1373,6 +1394,116 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, 200); }; + /** + * [#21775] The listed entries whose sample data is in the CALLER'S scope — + * the set the listing answers each entry's `withSampleData` from. + * + * ## The rows are the authority, not the ledger + * + * The ledger's `withSampleData` / `sampleDataPurged` are install-time + * records: one value per install, flipped by whichever organization seeded + * or purged last. Under an organization wall sample data is per + * organization — the install, the reseed and the purge each act in the + * caller's active organization, and the per-organization replay seeds every + * new one — so the listing that read the record told organization B "no + * sample data" after a purge in organization A, while B held every one of + * its seed rows. The ledger shape stays as it is; the listing stops reading + * it. + * + * ## Present = at least one seed row, identified the purge's way + * + * An entry is in the set when {@link matchSeedRows} — the purge's own + * identification (each dataset's `externalId`), read-only — finds at least + * ONE of the package's seed rows in scope. Not "all", and not a count: the + * console reads the flag to enable its purge item and to word its reseed + * item ("Add sample data" / "Reseed again"), and the purge has work to do + * exactly when one seed row is there. A seed record the purge could not + * identify either (no key value, a key two rows carry) is not evidence of + * presence; the purge reports those when it runs. + * + * ## Scope — the purge's + * + * Under a wall, the caller's active organization, from the same + * `organizationWallActive` + `resolveActiveOrgId` pair the purge scopes + * with. Without a wall the deployment is one logical tenant and the match is + * table-wide, as the purge's is, so the derived answer is the one the + * ledger records after an install that seeded, a purge and a reseed. Where + * a door's write did not land whole — a purge the engine refused for some + * rows, rows deleted by hand — the rows answer and the record does not. + * + * A walled session with NO active organization reads nothing: every entry + * answers `false`, HTTP 200, and no row is read (ADR-0123 D2 — a + * tenant-scoped read resolves to nothing, not an error). The doors that + * would act on that answer, reseed and purge, refuse the same session with + * D2's write refusal. + * + * ## When the rows cannot be read + * + * No metadata service, a dependency graph that does not build, an object + * whose rows the engine will not return: the entry answers `false`, and why + * is logged at `warn` — once per entry per request, the way this door + * reports a corrupt ledger entry — to the operator, with the wire shape + * unchanged. + */ + private sampleDataInScope = async (c: any, ctx: PluginContext, entries: InstalledEntry[]): Promise> => { + const present = new Set(); + const seeded = entries + .map((entry) => ({ entry, datasets: seedDatasetsOf(entry.manifest) })) + .filter(({ datasets }) => datasets.length > 0); + if (seeded.length === 0) return present; + + let organizationId: string | undefined; + if (organizationWallActive(ctx)) { + const resolved = await this.resolveActiveOrgId(c, ctx); + // [ADR-0123 D2] No organization, so no organization's rows to read. + if (!resolved) return present; + organizationId = resolved; + } + + const unread = (manifestId: string, why: string): void => { + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] ${manifestId}: the installed-apps listing could not read this package's ` + + `seed rows (${why}), so it answers withSampleData: false for it`, + ); + }; + let ql: IObjectQLEngine | undefined; + let metadata: IMetadataService | undefined; + try { ql = ctx.getService('objectql'); } catch { /* no data engine */ } + try { metadata = ctx.getService('metadata'); } catch { /* no metadata service */ } + if (!ql || !metadata) { + for (const { entry } of seeded) { + unread(entry.manifestId, 'the data engine (objectql) or the metadata service is not available on this runtime'); + } + return present; + } + + for (const { entry, datasets } of seeded) { + try { + // The graph the purge reads its order and its reference keys + // from: the loader's own, over the package's seeded objects. + const { SeedLoaderService } = await import('@objectstack/runtime'); + const loader = new (SeedLoaderService as any)(ql, metadata, ctx.logger); + const graph = await loader.buildDependencyGraph([...new Set(datasets.map((d) => String(d.object)))]); + const failedReads: string[] = []; + const match = await matchSeedRows({ + engine: ql, + datasets, + graph, + organizationId, + firstOnly: true, + report: (problem) => { + if (problem.kind === 'read') failedReads.push(`${problem.object}: ${problem.detail}`); + }, + }); + if (match.rows.length > 0) present.add(entry.manifestId); + else if (failedReads.length > 0) unread(entry.manifestId, failedReads.join('; ')); + } catch (err: any) { + unread(entry.manifestId, String(err?.message ?? err)); + } + } + return present; + }; + private handleUninstall = async (c: any, ctx: PluginContext): Promise => { const admission = await this.requireInstallCapability(c, ctx, 'Uninstalling a package'); if (!admission.ok) return admission.response; @@ -1844,9 +1975,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { return this.unreadableLedgerEntry(c, ctx, manifestId, failure, 'purge sample data'); } - const datasets = Array.isArray(entry.manifest?.data) - ? entry.manifest.data.filter((d: any) => d && d.object && Array.isArray(d.records)) - : []; + const datasets = seedDatasetsOf(entry.manifest); if (datasets.length === 0) { return c.json({ diff --git a/packages/cloud-connection/src/marketplace-install-local-purge.ts b/packages/cloud-connection/src/marketplace-install-local-purge.ts index a6856b9370b..069039a7c2e 100644 --- a/packages/cloud-connection/src/marketplace-install-local-purge.ts +++ b/packages/cloud-connection/src/marketplace-install-local-purge.ts @@ -22,6 +22,12 @@ * through the rows this purge matched for the parent dataset — the same * in-memory resolution the loader uses for targets seeded in the same load. * + * The identification is its own pass, {@link matchSeedRows}, and it only reads. + * The purge deletes what it identifies; the install-local listing asks it + * whether the caller's scope holds ANY seed row, and answers that as the + * entry's `withSampleData` (#21775). So "is this package's sample data here?" + * and "what would a purge delete?" are one answer, read one way. + * * ## Scope — the install's own * * `organizationId` is the scope the install and the reseed wrote under, chosen @@ -85,12 +91,57 @@ export interface SamplePurgeInput { /** A read under the posture the loader's upsert match reads with. */ const READ_CONTEXT = { isSystem: true } as const; -/** One planned delete, in parent-first (insert) order. */ -interface PlannedDelete { +/** One row the matcher identified as a seed record's. */ +export interface MatchedSeedRow { object: string; id: string; } +/** + * Why seed records could not be matched to a row, reported as data so each + * caller words it for its own door: + * - `externalId`: the dataset's `externalId` does not parse (every record of it); + * - `read`: the dataset's rows in scope could not be read (every record of it); + * - `unidentifiable`: the record names no usable key (`detail` says why); + * - `ambiguous`: `rows` rows in scope carry the key `detail`. + */ +export interface SeedMatchProblem { + object: string; + kind: 'externalId' | 'read' | 'unidentifiable' | 'ambiguous'; + /** The seed records the problem covers. */ + records: number; + detail: string; + /** `ambiguous` only: how many rows in scope carry the key. */ + rows?: number; +} + +/** What {@link matchSeedRows} found. */ +export interface SeedRowMatch { + /** The rows identified as the seed's, each once, in the loader's insert order (parents first). */ + rows: MatchedSeedRow[]; + /** Seed records no row in scope carries, or naming a row an earlier record already matched. */ + absent: number; + /** Seed records no row could be identified for, each reported through `report`. */ + unidentified: number; +} + +export interface SeedRowMatchInput { + engine: Pick; + datasets: ReadonlyArray; + /** The loader's dependency graph over the datasets' objects. */ + graph: Pick; + /** The scope: set under an organization wall, absent otherwise. */ + organizationId?: string; + /** Where a record that could not be matched is reported. */ + report: (problem: SeedMatchProblem) => void; + /** + * Stop at the first identified row: for a caller asking only whether ANY + * seed row is in scope. The counts are then partial; `rows` is empty + * exactly when no seed row is in scope (or none could be identified). + */ + firstOnly?: boolean; +} + /** `string | number | boolean` — the only key values a row can be matched on. */ function isLiteral(v: unknown): v is string | number | boolean { return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'; @@ -102,15 +153,17 @@ function keyOf(parts: readonly string[]): string { } /** - * Delete the rows `datasets` seeded, in scope, through `engine`. + * Identify, READ-ONLY, the rows in scope that `datasets` seeded: the identity + * rules in this module's header, and nothing deleted. The purge deletes what + * this returns; the install-local listing asks it whether the caller's scope + * holds any seed row at all (`firstOnly`). * * Never throws for a per-record or per-object failure — each is counted into - * `errors` and reported through `warn`, so one refused row does not strand the - * rest of the purge. + * `unidentified` and reported through `report`. */ -export async function purgeSeedRows(input: SamplePurgeInput): Promise { - const { engine, datasets, graph, organizationId, warn } = input; - const outcome: SamplePurgeOutcome = { deleted: 0, skipped: 0, errors: 0 }; +export async function matchSeedRows(input: SeedRowMatchInput): Promise { + const { engine, datasets, graph, organizationId, report, firstOnly } = input; + const match: SeedRowMatch = { rows: [], absent: 0, unidentified: 0 }; // Datasets per object, in the loader's insert order (parents first); an // object the graph did not order keeps its dataset order, after the rest. @@ -132,8 +185,7 @@ export async function purgeSeedRows(input: SamplePurgeInput): Promise>(); - const planned: PlannedDelete[] = []; - const plannedIds = new Map>(); // object → row ids already planned + const plannedIds = new Map>(); // object → row ids already matched for (const object of order) { const refs = referenceTargets.get(object) ?? new Map(); @@ -146,8 +198,8 @@ export async function purgeSeedRows(input: SamplePurgeInput): Promise 1) { - outcome.errors++; - warn(`purge ${object}: ${ids.length} rows in scope carry the seed key ${keyOf(parts)}; the seed's row cannot be told from a user-authored one, so none is deleted`); + match.unidentified++; + report({ object, kind: 'ambiguous', records: 1, detail: keyOf(parts), rows: ids.length }); continue; } const id = ids[0]; @@ -226,15 +278,51 @@ export async function purgeSeedRows(input: SamplePurgeInput): Promise(); plannedIds.set(object, seen); - if (seen.has(id)) { outcome.skipped++; continue; } + if (seen.has(id)) { match.absent++; continue; } seen.add(id); - planned.push({ object, id }); + match.rows.push({ object, id }); + if (firstOnly) return match; } } } + return match; +} + +/** A matcher problem in the purge's own words (its log lines predate the matcher). */ +function describePurgeProblem(p: SeedMatchProblem): string { + switch (p.kind) { + case 'externalId': + return `purge ${p.object}: the dataset's externalId does not parse (${p.detail}) — ${p.records} seed record(s) not purged`; + case 'read': + return `purge ${p.object}: reading its rows failed (${p.detail}) — ${p.records} seed record(s) not purged`; + case 'unidentifiable': + return `purge ${p.object}: ${p.detail}, so no row is identifiable as its — not purged`; + case 'ambiguous': + return `purge ${p.object}: ${p.rows} rows in scope carry the seed key ${p.detail}; the seed's row cannot be told from a user-authored one, so none is deleted`; + } +} + +/** + * Delete the rows `datasets` seeded, in scope, through `engine`: the rows + * {@link matchSeedRows} identifies, child before parent. + * + * Never throws for a per-record or per-object failure — each is counted into + * `errors` and reported through `warn`, so one refused row does not strand the + * rest of the purge. + */ +export async function purgeSeedRows(input: SamplePurgeInput): Promise { + const { engine, datasets, graph, organizationId, warn } = input; + const match = await matchSeedRows({ + engine, + datasets, + graph, + organizationId, + report: (problem) => warn(describePurgeProblem(problem)), + }); + const outcome: SamplePurgeOutcome = { deleted: 0, skipped: match.absent, errors: match.unidentified }; // Child before parent: the reverse of the loader's insert order. - for (const { object, id } of planned.reverse()) { + for (const { object, id } of [...match.rows].reverse()) { try { const result = await engine.delete(object, { where: { id }, context: SEED_WRITE_EXECUTION_CONTEXT }); if (result === false || result === 0) outcome.skipped++; From 1dcf61e34f6384d22c78bbebf8b921334dae17c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:21:29 +0000 Subject: [PATCH 2/5] test(cloud-connection): pin the listing's per-organization withSampleData and the read-only matcher Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- ...etplace-install-local-list-posture.test.ts | 6 +- .../marketplace-install-local-purge.test.ts | 141 +++++++++++++++++- .../marketplace-install-local-reseed.test.ts | 10 +- 3 files changed, 149 insertions(+), 8 deletions(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts b/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts index 4a6006b39e7..dd9a2edba1c 100644 --- a/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts @@ -269,7 +269,11 @@ describe('#9011 — an authenticated non-operator gets the inventory WITHOUT the manifestId: 'app.test.crm', version: '1.4.0', installedAt: INSTALLED.installedAt, - withSampleData: true, + // The ledger records `true`, but this manifest bundles no seed + // datasets, so no seed row can be in the caller's scope: the listing + // answers from the rows, not the record (#21775, pinned in + // `marketplace-install-local-purge.test.ts`). + withSampleData: false, }); // ② The half it NARROWS — absent keys, not null values. `null` would be diff --git a/packages/cloud-connection/src/marketplace-install-local-purge.test.ts b/packages/cloud-connection/src/marketplace-install-local-purge.test.ts index d46c6220109..9b377eba365 100644 --- a/packages/cloud-connection/src/marketplace-install-local-purge.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-purge.test.ts @@ -32,7 +32,7 @@ import { tmpdir } from 'node:os'; import { assertEngineDeleteDispatch } from '@objectstack/metadata-core'; import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; -import { purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js'; +import { matchSeedRows, purgeSeedRows, type PurgeSeedDataset, type SeedMatchProblem } from './marketplace-install-local-purge.js'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { LocalManifestSource } from './local-manifest-source.js'; import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; @@ -289,6 +289,59 @@ describe('purgeSeedRows — reference parts of a key', () => { }); }); +describe('matchSeedRows — the identification, read-only', () => { + it('identifies exactly the rows the purge deletes, and deletes nothing', async () => { + const tables: Record = { + acc: [{ id: 'a1', name: 'Acme' }, { id: 'a2', name: 'Globex' }, { id: 'u1', name: 'User Authored Co' }], + con: [{ id: 'c1', email: 'ada@acme.test', account: 'a1' }], + }; + const { engine, deletes } = makeEngine(tables); + const match = await matchSeedRows({ engine, datasets: DATASETS, graph: GRAPH, report: vi.fn() }); + expect(match).toEqual({ + rows: [{ object: 'acc', id: 'a1' }, { object: 'acc', id: 'a2' }, { object: 'con', id: 'c1' }], + absent: 0, + unidentified: 0, + }); + expect(deletes).toEqual([]); + expect(tables.acc).toHaveLength(3); + }); + + it('`firstOnly` stops at the first identified row: one object read when the first parent matches', async () => { + const tables: Record = { + acc: [{ id: 'a1', name: 'Acme' }, { id: 'a2', name: 'Globex' }], + con: [{ id: 'c1', email: 'ada@acme.test', account: 'a1' }], + }; + const { engine, reads } = makeEngine(tables); + const match = await matchSeedRows({ engine, datasets: DATASETS, graph: GRAPH, report: vi.fn(), firstOnly: true }); + expect(match.rows).toEqual([{ object: 'acc', id: 'a1' }]); + expect(reads.map((r) => r.object)).toEqual(['acc']); + }); + + it('`firstOnly` with no seed row in scope reads every seeded object and identifies nothing', async () => { + const tables: Record = { acc: [{ id: 'u1', name: 'User Authored Co' }], con: [] }; + const { engine, reads } = makeEngine(tables); + const match = await matchSeedRows({ engine, datasets: DATASETS, graph: GRAPH, report: vi.fn(), firstOnly: true }); + expect(match.rows).toEqual([]); + expect(reads.map((r) => r.object)).toEqual(['acc', 'con']); + }); + + it('reports a failed read as `read` data, counting every record of that dataset as unidentified', async () => { + const { engine } = makeEngine({ con: [] }); + const failing = { + ...engine, + find: async (object: string, query?: any) => { + if (object === 'acc') throw new Error('no such table: acc'); + return engine.find(object, query); + }, + }; + const problems: SeedMatchProblem[] = []; + const match = await matchSeedRows({ engine: failing, datasets: DATASETS, graph: GRAPH, report: (p) => problems.push(p) }); + expect(problems[0]).toEqual({ object: 'acc', kind: 'read', records: 2, detail: 'no such table: acc' }); + expect(match.unidentified).toBe(2); + expect(match.rows).toEqual([]); + }); +}); + // ── The route: scope chosen by the install's own rule, order from the loader ── type Handler = (c: any) => Promise; @@ -307,6 +360,7 @@ const MANIFEST = { }; const PURGE = 'POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data'; +const LIST = 'GET /api/v1/marketplace/install-local'; function makeC(manifestId: string) { return { @@ -346,7 +400,7 @@ async function bootWith(opts: { installedBy: 'admin', withSampleData: true, }); - const { engine, deletes } = makeEngine(opts.tables); + const { engine, deletes, reads } = makeEngine(opts.tables); const routes = new Map(); const rawApp = { get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), @@ -384,7 +438,7 @@ async function bootWith(opts: { const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); await plugin.start(ctx as any); await hooks.get('kernel:ready')?.(); - return { purge: routes.get(PURGE)!, deletes, logger }; + return { purge: routes.get(PURGE)!, list: routes.get(LIST)!, deletes, reads, logger }; } describe('POST …/purge-sample-data — through the engine, in the install\'s scope', () => { @@ -451,3 +505,84 @@ describe('POST …/purge-sample-data — through the engine, in the install\'s s expect(new LocalManifestSource(dir).read(MANIFEST.id).entry?.sampleDataPurged).toBeUndefined(); }); }); + +/** The listing's `withSampleData` for the one ledgered package. */ +async function listedSampleData(list: Handler): Promise<{ status: number; withSampleData: unknown; items: number }> { + const res = await list(makeC(MANIFEST.id)); + const items = res.payload?.data?.items ?? []; + return { status: res.status, withSampleData: items[0]?.withSampleData, items: items.length }; +} + +describe('GET … listing — `withSampleData` is the caller\'s own scope, read from its rows (#21775)', () => { + it('wall: after a purge in organization A, the listing read as B still answers true and read as A answers false', async () => { + const tables: Record = { + pg_account: [ + { id: 'a1', name: 'Acme', organization_id: 'org_a' }, + { id: 'u1', name: 'User Authored Co', organization_id: 'org_a' }, + { id: 'b1', name: 'Acme', organization_id: 'org_b' }, + ], + pg_contact: [ + { id: 'c1', email: 'ada@acme.test', account: 'a1', organization_id: 'org_a' }, + { id: 'd1', email: 'ada@acme.test', account: 'b1', organization_id: 'org_b' }, + ], + }; + const opts: { tables: Record; posture: 'isolated'; activeOrg?: string } = { tables, posture: 'isolated', activeOrg: 'org_a' }; + const { purge, list, reads } = await bootWith(opts); + + // Both organizations hold their seed rows before the purge. + expect(await listedSampleData(list)).toEqual({ status: 200, withSampleData: true, items: 1 }); + opts.activeOrg = 'org_b'; + expect(await listedSampleData(list)).toEqual({ status: 200, withSampleData: true, items: 1 }); + + opts.activeOrg = 'org_a'; + const purged = await purge(makeC(MANIFEST.id)); + expect(purged.payload.data).toMatchObject({ deleted: 2, skipped: 0, errors: 0 }); + // The install-wide record now says "no sample data" — for every organization. + expect(new LocalManifestSource(dir).read(MANIFEST.id).entry).toMatchObject({ withSampleData: false, sampleDataPurged: true }); + + reads.length = 0; + opts.activeOrg = 'org_b'; + expect(await listedSampleData(list)).toEqual({ status: 200, withSampleData: true, items: 1 }); + // B's answer came from B's rows, and from no other organization's. + expect(reads.length).toBeGreaterThan(0); + for (const r of reads) expect(r.query.where).toEqual({ organization_id: 'org_b' }); + + // A keeps a user-authored row in a seeded object; it is not a seed row. + opts.activeOrg = 'org_a'; + expect(await listedSampleData(list)).toEqual({ status: 200, withSampleData: false, items: 1 }); + }); + + it('wall, no active organization: every entry answers false, 200, and no seed row is read (ADR-0123 D2)', async () => { + const tables: Record = { pg_account: [{ id: 'a1', name: 'Acme', organization_id: 'org_a' }], pg_contact: [] }; + const { list, reads } = await bootWith({ tables, posture: 'isolated' }); + expect(await listedSampleData(list)).toEqual({ status: 200, withSampleData: false, items: 1 }); + expect(reads.filter((r) => r.object in tables)).toEqual([]); + }); + + it('no wall: the derived answer is the one the ledger records, before and after a purge', async () => { + const tables: Record = { + pg_account: [{ id: 'a1', name: 'Acme' }, { id: 'u1', name: 'User Authored Co' }], + pg_contact: [{ id: 'c1', email: 'ada@acme.test', account: 'a1' }], + }; + const { purge, list } = await bootWith({ tables, posture: 'single' }); + const recorded = () => new LocalManifestSource(dir).read(MANIFEST.id).entry?.withSampleData; + + expect(recorded()).toBe(true); + expect(await listedSampleData(list)).toEqual({ status: 200, withSampleData: recorded(), items: 1 }); + + expect((await purge(makeC(MANIFEST.id))).status).toBe(200); + expect(recorded()).toBe(false); + expect(await listedSampleData(list)).toEqual({ status: 200, withSampleData: recorded(), items: 1 }); + }); + + it('rows the engine will not return: the entry answers false, and the operator is told why', async () => { + const failing = withInstallerGrants({ + find: async (object: string) => { throw new Error(`no such table: ${object}`); }, + }); + const { list, logger } = await bootWith({ tables: {}, posture: 'single', services: { objectql: failing } }); + expect(await listedSampleData(list)).toEqual({ status: 200, withSampleData: false, items: 1 }); + const warned = logger.warn.mock.calls.map((c) => String(c[0])).join('\n'); + expect(warned).toContain(`${MANIFEST.id}: the installed-apps listing could not read this package's seed rows`); + expect(warned).toContain('no such table: pg_account'); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts b/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts index a110f32ea96..7b32928150e 100644 --- a/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts @@ -36,6 +36,7 @@ vi.mock('@objectstack/spec/data', async (importOriginal) => ({ })); import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { LocalManifestSource } from './local-manifest-source.js'; import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; type Handler = (c: any) => Promise; @@ -155,10 +156,11 @@ describe('reseed honest result', () => { expect(res.payload?.success).toBe(true); expect(res.payload?.data).toMatchObject({ inserted: 2, updated: 0, withSampleData: true }); - // The ledger now reflects that sample data is present. - const listRes = await rawApp.routes.get('GET /api/v1/marketplace/install-local')!(makeC({})); - const entry = listRes.payload.data.items.find((i: any) => i.manifestId === 'app.test.proj'); - expect(entry?.withSampleData).toBe(true); + // The ledger's install-time record flips. Read from the ledger itself: + // the GET listing no longer serves this record — it answers from the + // caller's own rows (#21775), and the seed loader here is a stub that + // writes none. + expect(new LocalManifestSource(dir).read('app.test.proj').entry?.withSampleData).toBe(true); }); it('partial success (some rows + some errors) still reports the error count', async () => { From 0dad7c7cc82f6343bbec1ac46bafb486ce8f88d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:22:43 +0000 Subject: [PATCH 3/5] test(dogfood): the install-local listing's withSampleData per organization across a restart, and off-wall beside the ledger Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- ...-local-listing-sample-data.dogfood.test.ts | 184 ++++++++++++++++++ ...ll-local-purge-sample-data.dogfood.test.ts | 30 ++- 2 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 packages/qa/dogfood/test/install-local-listing-sample-data.dogfood.test.ts diff --git a/packages/qa/dogfood/test/install-local-listing-sample-data.dogfood.test.ts b/packages/qa/dogfood/test/install-local-listing-sample-data.dogfood.test.ts new file mode 100644 index 00000000000..979094ae958 --- /dev/null +++ b/packages/qa/dogfood/test/install-local-listing-sample-data.dogfood.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// GOLDEN REGRESSION — under an organization wall, the install-local listing's +// `withSampleData` is the CALLER'S organization's answer, read from that +// organization's rows, and a restart keeps it (#21775). +// +// ## What was measured before the fix +// +// On a walled boot with a restart (`databaseFile`), the CRM package installed +// in organization A and reseeded in organization B (28 seed rows each), then a +// purge in A. `GET /api/v1/marketplace/install-local` read as B answered +// `withSampleData: false` for the package while B held all 28 of its seed +// rows; after the restart A held 0 and B held 28, and B's answer stayed false. +// The listing read the ledger's `withSampleData`, ONE value per install, which +// the purge in A had flipped for every organization. +// +// ## What this file pins, on two real boots over one database file and one ledger +// +// 1. PRECONDITIONS: the wall is in force; after A's purge, A holds 0 seed +// rows and B holds 28; the ledger's install-wide record says false. +// 2. The listing read as B answers true; read as A, false. +// 3. After the restart, the rows are where they were and both answers hold. +// +// Boots a walled fixture stack of its own, twice, so it stays out of +// `SHARED_SHOWCASE`. + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import crmStack from '@objectstack/example-crm'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { LocalManifestSource, MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { buildShapedArtifact } from './build-shaped-artifact.js'; + +const SYS = { isSystem: true } as ExecutionContext; +const CRM = 'com.example.crm'; +const BASE = '/marketplace/install-local'; +const ORG_A = 'org_21775_a'; +const ORG_B = 'org_21775_b'; +/** The CRM example's seeded objects and their seed-row counts (3 + 3 + 12 + 5 + 5). */ +const SEEDED: Record = { + crm_account: 3, crm_contact: 3, crm_opportunity: 12, crm_lead: 5, crm_activity: 5, +}; +const SEED_TOTAL = Object.values(SEEDED).reduce((a, b) => a + b, 0); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); + +/** The install body `os package install .json` sends. */ +function installBody(): { manifest: Record } { + const { artifact } = buildShapedArtifact(crmStack as unknown as Record); + const manifest = artifact.manifest as { id?: string; version?: string }; + return { manifest: { ...artifact, id: manifest.id, version: manifest.version } }; +} + +/** Seed-object rows in one organization, read as the system. */ +async function countRows(ql: IObjectQLEngine, organizationId: string): Promise { + let total = 0; + for (const object of Object.keys(SEEDED)) { + total += rowsOf(await ql.find(object, { where: { organization_id: organizationId }, context: SYS })).length; + } + return total; +} + +async function json(res: Response): Promise<{ status: number; body: any }> { // eslint-disable-line @typescript-eslint/no-explicit-any + return { status: res.status, body: await res.json().catch(() => null) }; +} + +/** One organization's view: the listing's answer for the CRM package, and the rows it holds. */ +type View = { status: number; listed: number; withSampleData: unknown; rows: number }; + +describe('dogfood: the install-local listing answers withSampleData per organization, across a restart', () => { + let stack: VerifyStack | undefined; + let storageDir: string; + let dbDir: string; + let tenancy: { posture?: string; isolationActive?: boolean } | undefined; + let installSeeded: unknown; + let reseedB: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any + let purgeA: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any + let recordAfterPurge: { withSampleData?: boolean; sampleDataPurged?: boolean } | undefined; + const beforeRestart: Record<'A' | 'B', View> = {} as never; + const afterRestart: Record<'A' | 'B', View> = {} as never; + + const boot = (databaseFile: string) => bootStack(showcaseStack, { + // `posture-only` requests the `isolated` posture — the wall is ACTIVE — + // without the organizations runtime; the memberships are written by hand. + multiTenant: 'posture-only', + databaseFile, + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); + + async function setActive(s: VerifyStack, token: string, organizationId: string): Promise { + const res = await s.apiAs(token, 'POST', '/auth/organization/set-active', { organizationId }); + expect(res.status, `set-active ${organizationId}: ${await res.clone().text()}`).toBe(200); + } + + /** The listing read as `organizationId`, beside that organization's seed rows. */ + async function viewAs(s: VerifyStack, token: string, organizationId: string): Promise { + await setActive(s, token, organizationId); + const listing = await json(await s.apiAs(token, 'GET', BASE)); + const items: Array<{ manifestId?: string; withSampleData?: unknown }> = listing.body?.data?.items ?? []; + const crm = items.find((i) => i.manifestId === CRM); + const ql = s.kernel.getService('objectql'); + return { status: listing.status, listed: items.length, withSampleData: crm?.withSampleData, rows: await countRows(ql, organizationId) }; + } + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-listing-ledger-')); + dbDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-listing-db-')); + const databaseFile = join(dbDir, 'verify.db'); + + // ── boot 1: install in A, reseed in B, purge in A ────────────────────── + stack = await boot(databaseFile); + const ql = stack.kernel.getService('objectql'); + tenancy = await stack.kernel.getServiceAsync<{ posture: string; isolationActive: boolean }>('tenancy'); + await stack.signIn(); + const [admin] = rowsOf(await ql.find('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS })); + for (const org of [ORG_A, ORG_B]) { + await ql.insert('sys_organization', { id: org, name: org, slug: org }, { context: SYS }); + await ql.insert('sys_member', { id: `mem_${org}`, organization_id: org, user_id: admin.id, role: 'owner' }, { context: SYS }); + } + // A fresh session, so it is minted with the memberships above. + let token = await stack.signIn(); + + await setActive(stack, token, ORG_A); + const install = await json(await stack.apiAs(token, 'POST', BASE, installBody())); + expect(install.status, JSON.stringify(install.body)).toBe(200); + installSeeded = install.body?.data?.seeded; + await setActive(stack, token, ORG_B); + reseedB = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/reseed-sample-data`, {})); + await setActive(stack, token, ORG_A); + purgeA = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/purge-sample-data`, {})); + recordAfterPurge = new LocalManifestSource(storageDir).read(CRM).entry; + + beforeRestart.B = await viewAs(stack, token, ORG_B); + beforeRestart.A = await viewAs(stack, token, ORG_A); + await stack.stop(); + stack = undefined; + + // ── boot 2: the same database file and the same ledger, nothing written ─ + stack = await boot(databaseFile); + token = await stack.signIn(); + afterRestart.B = await viewAs(stack, token, ORG_B); + afterRestart.A = await viewAs(stack, token, ORG_A); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + if (dbDir) rmSync(dbDir, { recursive: true, force: true }); + }); + + it('PRECONDITION: the organization wall is in force on this boot', () => { + expect(tenancy?.posture, 'tenancy posture in force').toBe('isolated'); + expect(tenancy?.isolationActive, 'the wall is enforced').toBe(true); + }); + + it('PRECONDITION: A and B each held the 28 seed rows, the purge in A removed A\'s, and the install-wide record says false', () => { + expect(installSeeded).toMatchObject({ mode: 'inline', inserted: SEED_TOTAL }); + expect(reseedB.status, JSON.stringify(reseedB.body)).toBe(200); + expect(reseedB.body?.data).toMatchObject({ inserted: SEED_TOTAL, errors: 0 }); + expect(purgeA.status, JSON.stringify(purgeA.body)).toBe(200); + expect(purgeA.body?.data).toMatchObject({ deleted: SEED_TOTAL, skipped: 0, errors: 0 }); + expect(beforeRestart.A.rows).toBe(0); + expect(beforeRestart.B.rows).toBe(SEED_TOTAL); + // The record the listing used to serve to every organization. + expect(recordAfterPurge).toMatchObject({ withSampleData: false, sampleDataPurged: true }); + }); + + it('after the purge in A, the listing read as B answers withSampleData: true and read as A answers false', () => { + expect(beforeRestart.B).toEqual({ status: 200, listed: 1, withSampleData: true, rows: SEED_TOTAL }); + expect(beforeRestart.A).toEqual({ status: 200, listed: 1, withSampleData: false, rows: 0 }); + }); + + it('a restart keeps both answers, over the same rows', () => { + expect(afterRestart.B).toEqual({ status: 200, listed: 1, withSampleData: true, rows: SEED_TOTAL }); + expect(afterRestart.A).toEqual({ status: 200, listed: 1, withSampleData: false, rows: 0 }); + }); +}); diff --git a/packages/qa/dogfood/test/install-local-purge-sample-data.dogfood.test.ts b/packages/qa/dogfood/test/install-local-purge-sample-data.dogfood.test.ts index 09e04503d86..8da5fd2f6ea 100644 --- a/packages/qa/dogfood/test/install-local-purge-sample-data.dogfood.test.ts +++ b/packages/qa/dogfood/test/install-local-purge-sample-data.dogfood.test.ts @@ -33,7 +33,11 @@ // fired once per seed row, and the audit plugin wrote one `delete` row each; // 4. the reseed then succeeds (28 inserted); // 5. under an organization wall, a purge in one organization leaves another -// organization's 28 seed rows exactly where they were. +// organization's 28 seed rows exactly where they were; +// 6. without a wall, the install-local listing's `withSampleData` — derived +// from the rows since #21775 — answers what the ledger records after the +// install, the purge and the reseed (the walled half, per organization +// and across a restart, is `install-local-listing-sample-data.dogfood.test.ts`). // // Boots fixture stacks of its own (custom plugins, a walled posture), so it // stays out of `SHARED_SHOWCASE`. @@ -47,7 +51,7 @@ import showcaseStack from '@objectstack/example-showcase'; import crmStack from '@objectstack/example-crm'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { AuditPlugin } from '@objectstack/plugin-audit'; -import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; +import { LocalManifestSource, MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { buildShapedArtifact } from './build-shaped-artifact.js'; @@ -117,6 +121,16 @@ async function json(res: Response): Promise<{ status: number; body: any }> { // return { status: res.status, body: await res.json().catch(() => null) }; } +/** The listing's `withSampleData` for the CRM package, beside the ledger's install-wide record of it. */ +async function listedBesideRecord(stack: VerifyStack, token: string, storageDir: string): Promise<{ listed: unknown; recorded: unknown }> { + const listing = await json(await stack.apiAs(token, 'GET', BASE)); + const items: Array<{ manifestId?: string; withSampleData?: unknown }> = listing.body?.data?.items ?? []; + return { + listed: items.find((i) => i.manifestId === CRM)?.withSampleData, + recorded: new LocalManifestSource(storageDir).read(CRM).entry?.withSampleData, + }; +} + describe('dogfood: install-local purge on the single-tenant posture — the card\'s own boot', () => { let stack: VerifyStack; let storageDir: string; @@ -131,6 +145,7 @@ describe('dogfood: install-local purge on the single-tenant posture — the card let survivors: string[]; let seen: { before: string[]; after: string[] }; let auditedDeletes: string[]; + const listing: Record<'afterInstall' | 'afterPurge' | 'afterReseed', { listed: unknown; recorded: unknown }> = {} as never; beforeAll(async () => { storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-purge-')); @@ -143,6 +158,7 @@ describe('dogfood: install-local purge on the single-tenant posture — the card install = await json(await stack.apiAs(token, 'POST', BASE, installBody())); afterInstall = await countSeeded(ql); seedRowKeys = await rowKeys(ql); + listing.afterInstall = await listedBesideRecord(stack, token, storageDir); // A user-authored row in a seeded object, written through the REST door. const user = await stack.apiAs(token, 'POST', '/data/crm_account', { name: 'User Authored Co', industry: 'technology' }); @@ -157,9 +173,11 @@ describe('dogfood: install-local purge on the single-tenant posture — the card .filter((r) => r.object_name in SEEDED) .map((r) => `${r.object_name}#${r.record_id}`) .sort(); + listing.afterPurge = await listedBesideRecord(stack, token, storageDir); reseed = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/reseed-sample-data`, {})); afterReseed = await countSeeded(ql); + listing.afterReseed = await listedBesideRecord(stack, token, storageDir); }, 300_000); afterAll(async () => { @@ -195,6 +213,14 @@ describe('dogfood: install-local purge on the single-tenant posture — the card expect(reseed.body?.data).toMatchObject({ inserted: SEED_TOTAL, errors: 0, withSampleData: true }); expect(afterReseed).toEqual({ ...SEEDED, crm_account: SEEDED.crm_account + 1 }); }); + + it('without a wall, the listing derived from the rows answers what the ledger records: true, false, true', () => { + expect(listing).toEqual({ + afterInstall: { listed: true, recorded: true }, + afterPurge: { listed: false, recorded: false }, + afterReseed: { listed: true, recorded: true }, + }); + }); }); describe('dogfood: install-local purge under an organization wall — one organization at a time', () => { From d46b1a3d78b7a456fb05dbd5553194e46a2662a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:38:43 +0000 Subject: [PATCH 4/5] chore(changeset): cloud-connection patch for the per-organization listing withSampleData Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- ...775-install-local-listing-per-org-sample-data.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/21775-install-local-listing-per-org-sample-data.md diff --git a/.changeset/21775-install-local-listing-per-org-sample-data.md b/.changeset/21775-install-local-listing-per-org-sample-data.md new file mode 100644 index 00000000000..92b5ff0de9d --- /dev/null +++ b/.changeset/21775-install-local-listing-per-org-sample-data.md @@ -0,0 +1,13 @@ +--- +"@objectstack/cloud-connection": patch +--- + +`GET /api/v1/marketplace/install-local` now answers each entry's `withSampleData` for the caller's own organization. Before, after a purge in organization A, the listing read as organization B answered `withSampleData: false` while B still held every one of its seed rows. + +Clause-②: no + +- **What was wrong.** The listing served the install ledger's `withSampleData`, one value per install. Under an organization wall, sample data is per organization: the install, the reseed and the purge each act in the caller's active organization. A purge in A flipped the one value for every organization, and a restart kept it. +- **What it does now.** The listing reads the rows. An entry answers `true` when at least one of the package's seed rows is in the caller's scope. Rows are matched the way the purge matches them, by each dataset's `externalId`. "At least one" is exactly when the purge has something to delete, and it decides whether the console labels its reseed action "Add sample data" or "Reseed again". The purge's matching is now a separate read-only step, and the purge deletes what it returns, with the same counts and log lines as before. +- **Scope.** Under a wall, the scope is the caller's active organization. A session with no active organization reads nothing, so every entry answers `false` with `200`, and no row is read. Without a wall, the match covers the whole table, and the answer is the one the ledger records after an install, a purge and a reseed. +- **When the rows cannot be read** (for example, a package the runtime did not load), the entry answers `false`, and the server log says why at `warn`, once per entry per request. +- **The response keeps its shape.** The ledger keeps its shape too. Its `withSampleData` and `sampleDataPurged` stay as install-time records, and their docs now say they are not per organization. From c668f551be27c05c70026fc90f2cf733f2c507cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:54:54 +0000 Subject: [PATCH 5/5] test(dogfood): type the ledger read as the reader returns it Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- .../test/install-local-listing-sample-data.dogfood.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/qa/dogfood/test/install-local-listing-sample-data.dogfood.test.ts b/packages/qa/dogfood/test/install-local-listing-sample-data.dogfood.test.ts index 979094ae958..1baf929ef98 100644 --- a/packages/qa/dogfood/test/install-local-listing-sample-data.dogfood.test.ts +++ b/packages/qa/dogfood/test/install-local-listing-sample-data.dogfood.test.ts @@ -82,7 +82,7 @@ describe('dogfood: the install-local listing answers withSampleData per organiza let installSeeded: unknown; let reseedB: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any let purgeA: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any - let recordAfterPurge: { withSampleData?: boolean; sampleDataPurged?: boolean } | undefined; + let recordAfterPurge: { withSampleData?: boolean; sampleDataPurged?: boolean } | null | undefined; const beforeRestart: Record<'A' | 'B', View> = {} as never; const afterRestart: Record<'A' | 'B', View> = {} as never;