|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * End-to-end acceptance for #4498's CLI half: `os migrate meta --stored` still |
| 5 | + * covers `flow` rows after the command stopped threading `canonicalizeFlow`. |
| 6 | + * |
| 7 | + * #4454 wired flow coverage by resolving `automation` off the booted kernel in |
| 8 | + * the command body and handing `canonicalizeStoredFlow` to |
| 9 | + * `migrateStoredMetadata`. #4498 gave the protocol its own resolver — it is |
| 10 | + * constructed with an accessor for the kernel's service table, which is the |
| 11 | + * same table the inert engine registers into — so the command passes nothing |
| 12 | + * and the redundant second route is gone. |
| 13 | + * |
| 14 | + * That is exactly the kind of removal a unit test cannot defend: every flag test |
| 15 | + * still passes if the protocol silently fails to find the engine, and the only |
| 16 | + * symptom is flow rows quietly reporting `skipped` again. So this boots the |
| 17 | + * REAL stack the command boots (`bootSchemaStack` + |
| 18 | + * `buildDataMigrationPlugins({ automation: true })`), seeds a pre-17 flow row, |
| 19 | + * and asserts the rewrite lands in the database. |
| 20 | + */ |
| 21 | + |
| 22 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 23 | +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; |
| 24 | +import { tmpdir } from 'node:os'; |
| 25 | +import { join } from 'node:path'; |
| 26 | +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; |
| 27 | +import { bootSchemaStack } from '../../utils/schema-migrate.js'; |
| 28 | +import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js'; |
| 29 | + |
| 30 | +/** |
| 31 | + * `SchemaStack.kernel` is untyped, so a type argument is a TS2347 — the slot's |
| 32 | + * contract is stated on the RESULT instead. Narrowing, not erasing: `: any` |
| 33 | + * here would switch off checking on every `ql.*` call below while looking |
| 34 | + * identical to code that has it (the `slot-lookup` rule's whole point). |
| 35 | + */ |
| 36 | +const engineOf = (stack: { kernel: any }): IObjectQLEngine => |
| 37 | + stack.kernel.getService('objectql') as IObjectQLEngine; |
| 38 | + |
| 39 | +/** Elevated so the seed write bypasses RLS on a system object. */ |
| 40 | +const SYSTEM = { context: { isSystem: true } }; |
| 41 | + |
| 42 | +const ARTIFACT = { |
| 43 | + id: 'stored_flow_smoke', |
| 44 | + name: 'Stored Flow Smoke', |
| 45 | + objects: [{ name: 'sfs_lead', fields: { title: { type: 'text' } } }], |
| 46 | +}; |
| 47 | + |
| 48 | +/** |
| 49 | + * A pre-17 flow: `delete_record` carrying `config.filters`, which the |
| 50 | + * `flow-node-crud-filter-alias` conversion (toMajor 11) renames to `filter`. |
| 51 | + * Written straight into `sys_metadata`, bypassing today's schema gate — exactly |
| 52 | + * like a row saved years ago under an older protocol. |
| 53 | + */ |
| 54 | +const LEGACY_FLOW = { |
| 55 | + name: 'sfs_purge', |
| 56 | + label: 'Purge Stale Leads', |
| 57 | + type: 'autolaunched', |
| 58 | + status: 'active', |
| 59 | + nodes: [ |
| 60 | + { id: 'n0', type: 'start', label: 'Start' }, |
| 61 | + { |
| 62 | + id: 'n1', |
| 63 | + type: 'delete_record', |
| 64 | + label: 'Purge', |
| 65 | + config: { objectName: 'sfs_lead', filters: { title: 'stale' } }, |
| 66 | + }, |
| 67 | + ], |
| 68 | + edges: [{ id: 'e1', source: 'n0', target: 'n1' }], |
| 69 | +}; |
| 70 | + |
| 71 | +describe('os migrate meta --stored — the protocol resolves the engine itself (#4498)', () => { |
| 72 | + let dir: string; |
| 73 | + let dbFile: string; |
| 74 | + const savedEnv: Record<string, string | undefined> = {}; |
| 75 | + |
| 76 | + beforeEach(() => { |
| 77 | + dir = mkdtempSync(join(tmpdir(), 'os-stored-flow-')); |
| 78 | + mkdirSync(join(dir, 'dist'), { recursive: true }); |
| 79 | + mkdirSync(join(dir, 'data'), { recursive: true }); |
| 80 | + dbFile = join(dir, 'data', 'app.db'); |
| 81 | + writeFileSync(join(dir, 'dist', 'objectstack.json'), JSON.stringify(ARTIFACT)); |
| 82 | + |
| 83 | + savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH; |
| 84 | + savedEnv.NODE_ENV = process.env.NODE_ENV; |
| 85 | + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); |
| 86 | + process.env.NODE_ENV = 'production'; |
| 87 | + }); |
| 88 | + |
| 89 | + afterEach(() => { |
| 90 | + process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH; |
| 91 | + process.env.NODE_ENV = savedEnv.NODE_ENV; |
| 92 | + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } |
| 93 | + }); |
| 94 | + |
| 95 | + it('rewrites a pre-17 flow row with NO canonicalizeFlow passed by the command', async () => { |
| 96 | + const stack = await bootSchemaStack({ |
| 97 | + databaseUrl: `file:${dbFile}`, |
| 98 | + projectRoot: dir, |
| 99 | + extraPlugins: await buildDataMigrationPlugins({ automation: true }), |
| 100 | + }); |
| 101 | + try { |
| 102 | + const ql = engineOf(stack); |
| 103 | + await ql.insert('sys_metadata', { |
| 104 | + type: 'flow', |
| 105 | + name: 'sfs_purge', |
| 106 | + state: 'active', |
| 107 | + metadata: JSON.stringify(LEGACY_FLOW), |
| 108 | + }, SYSTEM); |
| 109 | + |
| 110 | + const protocol: any = stack.kernel.getService('protocol'); |
| 111 | + |
| 112 | + // The command's exact call since #4498 — no `canonicalizeFlow`. |
| 113 | + const report = await protocol.migrateStoredMetadata({ |
| 114 | + apply: true, |
| 115 | + types: ['flow'], |
| 116 | + actor: 'os migrate meta --stored', |
| 117 | + }); |
| 118 | + |
| 119 | + // Before the resolver this row came back `skipped` with "no automation |
| 120 | + // service is reachable". Asserted as the REASON rather than as a bare |
| 121 | + // count, so a regression here says what went wrong instead of just |
| 122 | + // "expected 1 to be 0". |
| 123 | + expect( |
| 124 | + report.rows |
| 125 | + .filter((r: any) => r.outcome === 'skipped' || r.outcome === 'failed') |
| 126 | + .map((r: any) => `${r.outcome}: ${r.reason}`), |
| 127 | + ).toEqual([]); |
| 128 | + expect(report.skipped).toBe(0); |
| 129 | + expect(report.failed).toBe(0); |
| 130 | + expect(report.rewritten).toBe(1); |
| 131 | + |
| 132 | + // …and the bytes on disk actually moved. |
| 133 | + const [row] = await ql.find('sys_metadata', { |
| 134 | + where: { type: 'flow', name: 'sfs_purge', state: 'active' }, |
| 135 | + }, SYSTEM); |
| 136 | + const stored = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; |
| 137 | + const node = stored.nodes.find((n: any) => n.id === 'n1'); |
| 138 | + expect(node.config).toEqual({ objectName: 'sfs_lead', filter: { title: 'stale' } }); |
| 139 | + expect(node.config).not.toHaveProperty('filters'); |
| 140 | + |
| 141 | + // The write-back must NOT carry the schema's defaults (#4454): persisting |
| 142 | + // a `version` / `runAs` the author never wrote would pin this row to |
| 143 | + // today's value while untouched rows follow tomorrow's. |
| 144 | + expect(stored).not.toHaveProperty('runAs'); |
| 145 | + expect(stored.edges[0]).not.toHaveProperty('isDefault'); |
| 146 | + |
| 147 | + // A second pass has nothing left to do — the finish line the whole |
| 148 | + // feature exists to provide. |
| 149 | + const rerun = await protocol.migrateStoredMetadata({ types: ['flow'] }); |
| 150 | + expect(rerun.scanned).toBe(1); |
| 151 | + expect(rerun.canonical).toBe(1); |
| 152 | + expect(rerun.pending).toBe(0); |
| 153 | + } finally { |
| 154 | + await stack.shutdown(); |
| 155 | + } |
| 156 | + }, 120_000); |
| 157 | + |
| 158 | + it('without the automation plugin the row is skipped with the reason, never counted done', async () => { |
| 159 | + // The honest negative: the coverage comes from the engine being present, |
| 160 | + // not from the report defaulting to optimistic. |
| 161 | + const stack = await bootSchemaStack({ |
| 162 | + databaseUrl: `file:${dbFile}`, |
| 163 | + projectRoot: dir, |
| 164 | + extraPlugins: await buildDataMigrationPlugins(), |
| 165 | + }); |
| 166 | + try { |
| 167 | + const ql = engineOf(stack); |
| 168 | + await ql.insert('sys_metadata', { |
| 169 | + type: 'flow', |
| 170 | + name: 'sfs_purge', |
| 171 | + state: 'active', |
| 172 | + metadata: JSON.stringify(LEGACY_FLOW), |
| 173 | + }, SYSTEM); |
| 174 | + |
| 175 | + const protocol: any = stack.kernel.getService('protocol'); |
| 176 | + const report = await protocol.migrateStoredMetadata({ apply: true, types: ['flow'] }); |
| 177 | + |
| 178 | + expect(report.rewritten).toBe(0); |
| 179 | + expect(report.skipped).toBe(1); |
| 180 | + expect(report.rows[0].reason).toMatch(/no automation service is reachable/); |
| 181 | + } finally { |
| 182 | + await stack.shutdown(); |
| 183 | + } |
| 184 | + }, 120_000); |
| 185 | +}); |
0 commit comments