|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { PlatformObjectsPlugin } from './plugin.js'; |
| 5 | +import { SysMigration } from './system/index.js'; |
| 6 | + |
| 7 | +/** |
| 8 | + * Hand-rolled fake PluginContext (mirrors the service-plugin tests) — this |
| 9 | + * package has no kernel dependency and the plugin is structurally typed, so |
| 10 | + * booting a real kernel here would buy nothing. |
| 11 | + */ |
| 12 | +function makeCtx() { |
| 13 | + const services = new Map<string, any>(); |
| 14 | + const hooks: Array<() => Promise<void> | void> = []; |
| 15 | + const logs: { info: string[]; warn: string[] } = { info: [], warn: [] }; |
| 16 | + const ctx: any = { |
| 17 | + logger: { |
| 18 | + info: (m: string) => { logs.info.push(String(m)); }, |
| 19 | + warn: (m: string) => { logs.warn.push(String(m)); }, |
| 20 | + }, |
| 21 | + _logs: logs, |
| 22 | + registerService: (name: string, svc: any) => { services.set(name, svc); }, |
| 23 | + getService: <T>(name: string): T => { |
| 24 | + const s = services.get(name); |
| 25 | + if (!s) throw new Error(`service '${name}' not registered`); |
| 26 | + return s as T; |
| 27 | + }, |
| 28 | + hook: (event: string, fn: () => Promise<void> | void) => { |
| 29 | + if (event === 'kernel:ready') hooks.push(fn); |
| 30 | + }, |
| 31 | + _flushReady: async () => { for (const h of hooks) await h(); }, |
| 32 | + }; |
| 33 | + return ctx; |
| 34 | +} |
| 35 | + |
| 36 | +describe('PlatformObjectsPlugin: sys_migration ledger registration (#4243)', () => { |
| 37 | + it('registers SysMigration through the manifest service', async () => { |
| 38 | + const ctx = makeCtx(); |
| 39 | + const manifests: any[] = []; |
| 40 | + ctx.registerService('manifest', { register: (m: any) => manifests.push(m) }); |
| 41 | + |
| 42 | + await new PlatformObjectsPlugin().init(ctx); |
| 43 | + |
| 44 | + expect(manifests).toHaveLength(1); |
| 45 | + expect(manifests[0].id).toBe('com.objectstack.platform-objects'); |
| 46 | + expect(manifests[0].scope).toBe('system'); |
| 47 | + expect(manifests[0].objects).toEqual([SysMigration]); |
| 48 | + expect(manifests[0].objects[0].name).toBe('sys_migration'); |
| 49 | + }); |
| 50 | + |
| 51 | + it('degrades silently without a manifest service (lean/i18n-only kernels)', async () => { |
| 52 | + const plugin = new PlatformObjectsPlugin(); |
| 53 | + const ctx = makeCtx(); |
| 54 | + |
| 55 | + await expect(plugin.init(ctx)).resolves.toBeUndefined(); |
| 56 | + await plugin.start(ctx); |
| 57 | + await expect(ctx._flushReady()).resolves.toBeUndefined(); |
| 58 | + }); |
| 59 | +}); |
| 60 | + |
| 61 | +describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)', () => { |
| 62 | + /** Engine fake with a `sys_migration` table and a settable newness verdict. */ |
| 63 | + function engineWith(createdFromEmpty: boolean | undefined) { |
| 64 | + const rows: Array<Record<string, unknown>> = []; |
| 65 | + const engine: any = { |
| 66 | + getObject: (name: string) => (name === 'sys_migration' ? { name } : undefined), |
| 67 | + find: async (_object: string, options: any) => |
| 68 | + rows.filter((r) => options?.where?.id === undefined || r.id === options.where.id), |
| 69 | + insert: async (_object: string, data: any) => { |
| 70 | + rows.push({ ...data }); |
| 71 | + return data; |
| 72 | + }, |
| 73 | + update: async () => ({}), |
| 74 | + rows, |
| 75 | + }; |
| 76 | + if (createdFromEmpty !== undefined) { |
| 77 | + engine.wasDatastoreCreatedFromEmpty = () => createdFromEmpty; |
| 78 | + } |
| 79 | + return engine; |
| 80 | + } |
| 81 | + |
| 82 | + async function boot(engine: unknown) { |
| 83 | + const plugin = new PlatformObjectsPlugin(); |
| 84 | + const ctx = makeCtx(); |
| 85 | + ctx.registerService('objectql', engine); |
| 86 | + await plugin.init(ctx); |
| 87 | + await plugin.start(ctx); |
| 88 | + await ctx._flushReady(); |
| 89 | + } |
| 90 | + |
| 91 | + it('attests a store this boot created from empty', async () => { |
| 92 | + const engine = engineWith(true); |
| 93 | + |
| 94 | + await boot(engine); |
| 95 | + |
| 96 | + expect(engine.rows.map((r: any) => r.id).sort()).toEqual([ |
| 97 | + 'adr-0104-file-references', |
| 98 | + 'adr-0104-value-shapes', |
| 99 | + ]); |
| 100 | + for (const row of engine.rows) { |
| 101 | + expect(row.verified_at).toBeTruthy(); |
| 102 | + expect(row.blocking).toBe(0); |
| 103 | + } |
| 104 | + }); |
| 105 | + |
| 106 | + /** |
| 107 | + * The property the whole design rests on: a store that was FOUND — an |
| 108 | + * upgrade, a restart, anything with history — attests nothing and keeps |
| 109 | + * producing its evidence by scan. |
| 110 | + */ |
| 111 | + it('attests nothing on a store that already existed', async () => { |
| 112 | + const engine = engineWith(false); |
| 113 | + |
| 114 | + await boot(engine); |
| 115 | + |
| 116 | + expect(engine.rows).toHaveLength(0); |
| 117 | + }); |
| 118 | + |
| 119 | + it('attests nothing when the engine cannot say (older engine)', async () => { |
| 120 | + const engine = engineWith(undefined); |
| 121 | + |
| 122 | + await boot(engine); |
| 123 | + |
| 124 | + expect(engine.rows).toHaveLength(0); |
| 125 | + }); |
| 126 | + |
| 127 | + it('a failing attestation never breaks the boot', async () => { |
| 128 | + const engine = engineWith(true); |
| 129 | + engine.insert = async () => { |
| 130 | + throw new Error('disk full'); |
| 131 | + }; |
| 132 | + |
| 133 | + await expect(boot(engine)).resolves.toBeUndefined(); |
| 134 | + }); |
| 135 | + |
| 136 | + it('invalidates the engine memo after attesting (fast-boot race)', async () => { |
| 137 | + const engine = engineWith(true); |
| 138 | + let invalidated = 0; |
| 139 | + engine.invalidateDataMigrationFlags = () => { invalidated++; }; |
| 140 | + |
| 141 | + await boot(engine); |
| 142 | + |
| 143 | + expect(invalidated).toBe(1); |
| 144 | + }); |
| 145 | +}); |
0 commit comments