|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// GOLDEN REGRESSION — install-local `purge-sample-data` removes exactly the |
| 4 | +// seed rows an installed package put in the database, THROUGH THE ENGINE, in |
| 5 | +// the install's own scope (#21728). |
| 6 | +// |
| 7 | +// ## What was measured before the fix |
| 8 | +// |
| 9 | +// The showcase booted with `OS_CLOUD_URL=off`, the CRM example installed from |
| 10 | +// its built artifact (`os package install examples/app-crm/dist/objectstack.json`, |
| 11 | +// 28 seed rows landed), then, as the admin: |
| 12 | +// |
| 13 | +// POST …/install-local/com.example.crm/purge-sample-data {} |
| 14 | +// -> 500 {"code":"DRIVER_UNAVAILABLE","message":"driver service unavailable — cannot purge."} |
| 15 | +// the 28 rows still there |
| 16 | +// POST …/install-local/com.example.crm/reseed-sample-data {} |
| 17 | +// -> 422 RESEED_NO_ROWS (nothing to write: the rows never left) |
| 18 | +// |
| 19 | +// Three defects stacked: the purge asked for a bare `driver` service no kernel |
| 20 | +// registers (drivers register as `driver.<name>`); behind it, it matched seed |
| 21 | +// records by `rec.id`, which none of the CRM's 28 records carries (they key by |
| 22 | +// `name` / `email` / `subject`), so fixing the lookup alone would have answered |
| 23 | +// 28 skipped / 0 deleted; and it deleted through the driver, past every engine |
| 24 | +// hook. The unit suites stayed green because they mocked a bare `driver`. |
| 25 | +// |
| 26 | +// ## What this file pins, on a real boot |
| 27 | +// |
| 28 | +// 1. the install lands the 28 rows (precondition — measured, not assumed); |
| 29 | +// 2. the purge answers `{ deleted: 28, skipped: 0, errors: 0 }`, a |
| 30 | +// user-authored row in a seeded object survives, and every seeded object |
| 31 | +// is otherwise empty; |
| 32 | +// 3. every delete passed through the engine: `beforeDelete` and `afterDelete` |
| 33 | +// fired once per seed row, and the audit plugin wrote one `delete` row each; |
| 34 | +// 4. the reseed then succeeds (28 inserted); |
| 35 | +// 5. under an organization wall, a purge in one organization leaves another |
| 36 | +// organization's 28 seed rows exactly where they were. |
| 37 | +// |
| 38 | +// Boots fixture stacks of its own (custom plugins, a walled posture), so it |
| 39 | +// stays out of `SHARED_SHOWCASE`. |
| 40 | + |
| 41 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 42 | +import { tmpdir } from 'node:os'; |
| 43 | +import { join } from 'node:path'; |
| 44 | + |
| 45 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 46 | +import showcaseStack from '@objectstack/example-showcase'; |
| 47 | +import crmStack from '@objectstack/example-crm'; |
| 48 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 49 | +import { AuditPlugin } from '@objectstack/plugin-audit'; |
| 50 | +import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; |
| 51 | +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; |
| 52 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 53 | +import { buildShapedArtifact } from './build-shaped-artifact.js'; |
| 54 | + |
| 55 | +const SYS = { isSystem: true } as ExecutionContext; |
| 56 | +const CRM = 'com.example.crm'; |
| 57 | +const BASE = '/marketplace/install-local'; |
| 58 | +/** The CRM example's seeded objects and their seed-row counts (3 + 3 + 12 + 5 + 5). */ |
| 59 | +const SEEDED: Record<string, number> = { |
| 60 | + crm_account: 3, crm_contact: 3, crm_opportunity: 12, crm_lead: 5, crm_activity: 5, |
| 61 | +}; |
| 62 | +const SEED_TOTAL = Object.values(SEEDED).reduce((a, b) => a + b, 0); |
| 63 | +const OBSERVER = 'dogfood.install-local-purge-observer'; |
| 64 | + |
| 65 | +// eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 66 | +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); |
| 67 | + |
| 68 | +/** |
| 69 | + * The install body `os package install <artifact>.json` sends: the whole |
| 70 | + * artifact, with the manifest's id and version lifted to the top level |
| 71 | + * (`packages/cli/src/commands/package/install.ts`). |
| 72 | + */ |
| 73 | +function installBody(): { manifest: Record<string, unknown> } { |
| 74 | + const { artifact } = buildShapedArtifact(crmStack as unknown as Record<string, unknown>); |
| 75 | + const manifest = artifact.manifest as { id?: string; version?: string }; |
| 76 | + return { manifest: { ...artifact, id: manifest.id, version: manifest.version } }; |
| 77 | +} |
| 78 | + |
| 79 | +/** Seed-row counts per CRM object, read as the system, optionally in one organization. */ |
| 80 | +async function countSeeded(ql: IObjectQLEngine, organizationId?: string): Promise<Record<string, number>> { |
| 81 | + const out: Record<string, number> = {}; |
| 82 | + for (const object of Object.keys(SEEDED)) { |
| 83 | + const rows = rowsOf(await ql.find(object, { |
| 84 | + ...(organizationId ? { where: { organization_id: organizationId } } : {}), |
| 85 | + context: SYS, |
| 86 | + })); |
| 87 | + out[object] = rows.length; |
| 88 | + } |
| 89 | + return out; |
| 90 | +} |
| 91 | + |
| 92 | +/** Records every engine delete on a CRM object, by phase, from the engine's own hook bus. */ |
| 93 | +function observeDeletes(ql: IObjectQLEngine): { before: string[]; after: string[] } { |
| 94 | + const seen = { before: [] as string[], after: [] as string[] }; |
| 95 | + const objects = Object.keys(SEEDED); |
| 96 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 97 | + const idOf = (ctx: any) => String(ctx?.input?.id ?? ctx?.previous?.id ?? ''); |
| 98 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 99 | + ql.registerHook('beforeDelete', (ctx: any) => { seen.before.push(`${ctx.object}#${idOf(ctx)}`); }, { object: objects, packageId: OBSERVER }); |
| 100 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 101 | + ql.registerHook('afterDelete', (ctx: any) => { seen.after.push(`${ctx.object}#${idOf(ctx)}`); }, { object: objects, packageId: OBSERVER }); |
| 102 | + return seen; |
| 103 | +} |
| 104 | + |
| 105 | +/** `object#id` for every current seed-object row matching `where`. */ |
| 106 | +async function rowKeys(ql: IObjectQLEngine, where?: Record<string, unknown>): Promise<string[]> { |
| 107 | + const keys: string[] = []; |
| 108 | + for (const object of Object.keys(SEEDED)) { |
| 109 | + for (const row of rowsOf(await ql.find(object, { ...(where ? { where } : {}), context: SYS }))) { |
| 110 | + keys.push(`${object}#${row.id}`); |
| 111 | + } |
| 112 | + } |
| 113 | + return keys.sort(); |
| 114 | +} |
| 115 | + |
| 116 | +async function json(res: Response): Promise<{ status: number; body: any }> { // eslint-disable-line @typescript-eslint/no-explicit-any |
| 117 | + return { status: res.status, body: await res.json().catch(() => null) }; |
| 118 | +} |
| 119 | + |
| 120 | +describe('dogfood: install-local purge on the single-tenant posture — the card\'s own boot', () => { |
| 121 | + let stack: VerifyStack; |
| 122 | + let storageDir: string; |
| 123 | + let ql: IObjectQLEngine; |
| 124 | + let install: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any |
| 125 | + let purge: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any |
| 126 | + let reseed: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any |
| 127 | + let afterInstall: Record<string, number>; |
| 128 | + let afterPurge: Record<string, number>; |
| 129 | + let afterReseed: Record<string, number>; |
| 130 | + let seedRowKeys: string[]; |
| 131 | + let survivors: string[]; |
| 132 | + let seen: { before: string[]; after: string[] }; |
| 133 | + let auditedDeletes: string[]; |
| 134 | + |
| 135 | + beforeAll(async () => { |
| 136 | + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-purge-')); |
| 137 | + stack = await bootStack(showcaseStack, { |
| 138 | + extraPlugins: [new AuditPlugin(), new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], |
| 139 | + }); |
| 140 | + ql = stack.kernel.getService<IObjectQLEngine>('objectql'); |
| 141 | + const token = await stack.signIn(); |
| 142 | + |
| 143 | + install = await json(await stack.apiAs(token, 'POST', BASE, installBody())); |
| 144 | + afterInstall = await countSeeded(ql); |
| 145 | + seedRowKeys = await rowKeys(ql); |
| 146 | + |
| 147 | + // A user-authored row in a seeded object, written through the REST door. |
| 148 | + const user = await stack.apiAs(token, 'POST', '/data/crm_account', { name: 'User Authored Co', industry: 'technology' }); |
| 149 | + expect(user.status, await user.clone().text()).toBeLessThan(300); |
| 150 | + |
| 151 | + seen = observeDeletes(ql); |
| 152 | + purge = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/purge-sample-data`, {})); |
| 153 | + ql.unregisterHooksByPackage(OBSERVER); |
| 154 | + afterPurge = await countSeeded(ql); |
| 155 | + survivors = rowsOf(await ql.find('crm_account', { context: SYS })).map((r) => r.name); |
| 156 | + auditedDeletes = rowsOf(await ql.find('sys_audit_log', { where: { action: 'delete' }, context: SYS })) |
| 157 | + .filter((r) => r.object_name in SEEDED) |
| 158 | + .map((r) => `${r.object_name}#${r.record_id}`) |
| 159 | + .sort(); |
| 160 | + |
| 161 | + reseed = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/reseed-sample-data`, {})); |
| 162 | + afterReseed = await countSeeded(ql); |
| 163 | + }, 300_000); |
| 164 | + |
| 165 | + afterAll(async () => { |
| 166 | + await stack?.stop?.(); |
| 167 | + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); |
| 168 | + }); |
| 169 | + |
| 170 | + it('PRECONDITION: the install landed all 28 seed rows', () => { |
| 171 | + expect(install.status, JSON.stringify(install.body)).toBe(200); |
| 172 | + expect(install.body?.data?.seeded).toMatchObject({ mode: 'inline', inserted: SEED_TOTAL }); |
| 173 | + expect(afterInstall).toEqual(SEEDED); |
| 174 | + expect(seedRowKeys).toHaveLength(SEED_TOTAL); |
| 175 | + }); |
| 176 | + |
| 177 | + it('the purge answers { deleted: 28, skipped: 0, errors: 0 } and keeps the user-authored row', () => { |
| 178 | + expect(purge.status, JSON.stringify(purge.body)).toBe(200); |
| 179 | + expect(purge.body).toEqual({ |
| 180 | + success: true, |
| 181 | + data: { manifestId: CRM, deleted: SEED_TOTAL, skipped: 0, errors: 0, withSampleData: false }, |
| 182 | + }); |
| 183 | + expect(afterPurge).toEqual({ ...Object.fromEntries(Object.keys(SEEDED).map((o) => [o, 0])), crm_account: 1 }); |
| 184 | + expect(survivors).toEqual(['User Authored Co']); |
| 185 | + }); |
| 186 | + |
| 187 | + it('every delete passed through the engine: both hook phases and the audit trail saw each seed row once', () => { |
| 188 | + expect([...seen.before].sort()).toEqual(seedRowKeys); |
| 189 | + expect([...seen.after].sort()).toEqual(seedRowKeys); |
| 190 | + expect(auditedDeletes).toEqual(seedRowKeys); |
| 191 | + }); |
| 192 | + |
| 193 | + it('the reseed then succeeds', () => { |
| 194 | + expect(reseed.status, JSON.stringify(reseed.body)).toBe(200); |
| 195 | + expect(reseed.body?.data).toMatchObject({ inserted: SEED_TOTAL, errors: 0, withSampleData: true }); |
| 196 | + expect(afterReseed).toEqual({ ...SEEDED, crm_account: SEEDED.crm_account + 1 }); |
| 197 | + }); |
| 198 | +}); |
| 199 | + |
| 200 | +describe('dogfood: install-local purge under an organization wall — one organization at a time', () => { |
| 201 | + const ORG_A = 'org_21728_a'; |
| 202 | + const ORG_B = 'org_21728_b'; |
| 203 | + let stack: VerifyStack; |
| 204 | + let storageDir: string; |
| 205 | + let ql: IObjectQLEngine; |
| 206 | + let token: string; |
| 207 | + let purge: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any |
| 208 | + let reseedA: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any |
| 209 | + let orgARowsBefore: Record<string, number>; |
| 210 | + let orgBKeysBefore: string[]; |
| 211 | + let orgBKeysAfter: string[]; |
| 212 | + let orgAAfter: Record<string, number>; |
| 213 | + let orgASurvivors: string[]; |
| 214 | + let orgAKeys: string[]; |
| 215 | + let seen: { before: string[]; after: string[] }; |
| 216 | + |
| 217 | + async function setActive(organizationId: string): Promise<void> { |
| 218 | + const res = await stack.apiAs(token, 'POST', '/auth/organization/set-active', { organizationId }); |
| 219 | + expect(res.status, `set-active ${organizationId}: ${await res.clone().text()}`).toBe(200); |
| 220 | + } |
| 221 | + |
| 222 | + beforeAll(async () => { |
| 223 | + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-purge-walled-')); |
| 224 | + // `posture-only` requests the `isolated` posture — the wall is ACTIVE — |
| 225 | + // without the organizations runtime; the memberships are written by hand |
| 226 | + // (the shape `no-active-organization-write-refusal.dogfood.test.ts` uses). |
| 227 | + stack = await bootStack(showcaseStack, { |
| 228 | + multiTenant: 'posture-only', |
| 229 | + extraPlugins: [new AuditPlugin(), new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], |
| 230 | + }); |
| 231 | + ql = stack.kernel.getService<IObjectQLEngine>('objectql'); |
| 232 | + await stack.signIn(); |
| 233 | + const [admin] = rowsOf(await ql.find('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS })); |
| 234 | + for (const org of [ORG_A, ORG_B]) { |
| 235 | + await ql.insert('sys_organization', { id: org, name: org, slug: org }, { context: SYS }); |
| 236 | + await ql.insert('sys_member', { id: `mem_${org}`, organization_id: org, user_id: admin.id, role: 'owner' }, { context: SYS }); |
| 237 | + } |
| 238 | + // A fresh session, so it is minted with the memberships above. |
| 239 | + token = await stack.signIn(); |
| 240 | + |
| 241 | + // Install in A, then the same package's seed in B through the reseed door. |
| 242 | + await setActive(ORG_A); |
| 243 | + const install = await json(await stack.apiAs(token, 'POST', BASE, installBody())); |
| 244 | + expect(install.status, JSON.stringify(install.body)).toBe(200); |
| 245 | + expect(install.body?.data?.seeded).toMatchObject({ mode: 'inline', inserted: SEED_TOTAL }); |
| 246 | + await setActive(ORG_B); |
| 247 | + const reseedB = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/reseed-sample-data`, {})); |
| 248 | + expect(reseedB.status, JSON.stringify(reseedB.body)).toBe(200); |
| 249 | + expect(reseedB.body?.data).toMatchObject({ inserted: SEED_TOTAL }); |
| 250 | + |
| 251 | + await setActive(ORG_A); |
| 252 | + const user = await stack.apiAs(token, 'POST', '/data/crm_account', { name: 'User Authored Co', industry: 'technology' }); |
| 253 | + expect(user.status, await user.clone().text()).toBeLessThan(300); |
| 254 | + |
| 255 | + orgARowsBefore = await countSeeded(ql, ORG_A); |
| 256 | + orgAKeys = await rowKeys(ql, { organization_id: ORG_A }); |
| 257 | + orgBKeysBefore = await rowKeys(ql, { organization_id: ORG_B }); |
| 258 | + |
| 259 | + seen = observeDeletes(ql); |
| 260 | + purge = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/purge-sample-data`, {})); |
| 261 | + ql.unregisterHooksByPackage(OBSERVER); |
| 262 | + |
| 263 | + orgAAfter = await countSeeded(ql, ORG_A); |
| 264 | + orgASurvivors = rowsOf(await ql.find('crm_account', { where: { organization_id: ORG_A }, context: SYS })).map((r) => r.name); |
| 265 | + orgBKeysAfter = await rowKeys(ql, { organization_id: ORG_B }); |
| 266 | + |
| 267 | + reseedA = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/reseed-sample-data`, {})); |
| 268 | + }, 300_000); |
| 269 | + |
| 270 | + afterAll(async () => { |
| 271 | + await stack?.stop?.(); |
| 272 | + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); |
| 273 | + }); |
| 274 | + |
| 275 | + it('PRECONDITION: both organizations hold the 28 seed rows, and A also holds a user row', () => { |
| 276 | + expect(orgARowsBefore).toEqual({ ...SEEDED, crm_account: SEEDED.crm_account + 1 }); |
| 277 | + expect(orgBKeysBefore).toHaveLength(SEED_TOTAL); |
| 278 | + }); |
| 279 | + |
| 280 | + it('a purge in organization A deletes A\'s 28 seed rows, each through both hook phases, and keeps A\'s user row', () => { |
| 281 | + expect(purge.status, JSON.stringify(purge.body)).toBe(200); |
| 282 | + expect(purge.body?.data).toEqual({ manifestId: CRM, deleted: SEED_TOTAL, skipped: 0, errors: 0, withSampleData: false }); |
| 283 | + expect(orgAAfter).toEqual({ ...Object.fromEntries(Object.keys(SEEDED).map((o) => [o, 0])), crm_account: 1 }); |
| 284 | + expect(orgASurvivors).toEqual(['User Authored Co']); |
| 285 | + // A's seed rows are A's rows minus the one user row: exactly what the hooks saw. |
| 286 | + const seedKeysA = orgAKeys.filter((k) => seen.after.includes(k)); |
| 287 | + expect(seedKeysA).toHaveLength(SEED_TOTAL); |
| 288 | + expect([...seen.after].sort()).toEqual(seedKeysA); |
| 289 | + expect([...seen.before].sort()).toEqual(seedKeysA); |
| 290 | + }); |
| 291 | + |
| 292 | + it('organization B\'s seed rows are untouched — and no hook ever saw one of them', () => { |
| 293 | + expect(orgBKeysAfter).toEqual(orgBKeysBefore); |
| 294 | + expect(orgBKeysAfter).toHaveLength(SEED_TOTAL); |
| 295 | + for (const k of orgBKeysBefore) { |
| 296 | + expect(seen.before).not.toContain(k); |
| 297 | + expect(seen.after).not.toContain(k); |
| 298 | + } |
| 299 | + }); |
| 300 | + |
| 301 | + it('the reseed in A then succeeds', () => { |
| 302 | + expect(reseedA.status, JSON.stringify(reseedA.body)).toBe(200); |
| 303 | + expect(reseedA.body?.data).toMatchObject({ inserted: SEED_TOTAL, errors: 0 }); |
| 304 | + }); |
| 305 | +}); |
0 commit comments