|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// test-budget: 80 — spawns the real producer once per single-cause fixture: four spawns of the source CLI over one staged fixture (os dev to provision the declared datasource, os dev over the staged drift, os migrate plan --json, os migrate unmapped-columns --json); 56.1 s, the slower of two local runs on a shared container, x 1.25, no CI reading yet |
| 3 | + |
| 4 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 5 | +import { spawnSync } from 'node:child_process'; |
| 6 | +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; |
| 7 | +import { createRequire } from 'node:module'; |
| 8 | +import { createServer } from 'node:net'; |
| 9 | +import { tmpdir } from 'node:os'; |
| 10 | +import { dirname, join, resolve } from 'node:path'; |
| 11 | +import { fileURLToPath } from 'node:url'; |
| 12 | +import Database from 'better-sqlite3'; |
| 13 | + |
| 14 | +/** |
| 15 | + * [#24120] On a project whose primary store is a DECLARED datasource that |
| 16 | + * `datasourceMapping` routes as the default, `os migrate plan` examines the |
| 17 | + * drift `os dev` reports — the same set, in the same database. |
| 18 | + * |
| 19 | + * ## The defect |
| 20 | + * |
| 21 | + * `datasources: [{ name: 'qa_main', … }]` with |
| 22 | + * `datasourceMapping: [{ default: true, datasource: 'qa_main' }]`. `os dev` |
| 23 | + * connects `qa_main` under its declared name (through the |
| 24 | + * `datasource-connection` service `AppPlugin` connects declared datasources |
| 25 | + * by), so every object — platform objects included — lives there, and its boot |
| 26 | + * prints a `[schema-drift]` line per divergence, each naming `os migrate` as |
| 27 | + * the remedy. `os migrate plan` resolved the same database FILE as its primary |
| 28 | + * ("the project config's default datasource qa_main") but published no such |
| 29 | + * service, so `qa_main` stayed metadata-only, every object routed to it was |
| 30 | + * bound to no driver, and the plan printed `Examined 0 managed table(s)` and a |
| 31 | + * PARTIAL plan with no drift at all. |
| 32 | + * |
| 33 | + * ## What is pinned |
| 34 | + * |
| 35 | + * Two drifts are staged in the declared datasource's database, one per kind of |
| 36 | + * object the default rule routes: a column the app object no longer declares, |
| 37 | + * and a column no platform object declares on `sys_user`. Then: |
| 38 | + * |
| 39 | + * - the plan's drift set (`table.column` of each `changes` entry) EQUALS the |
| 40 | + * set of `[schema-drift]` lines `os dev` prints over the same database — and |
| 41 | + * holds both staged drifts, so the equality is not two empty sets; |
| 42 | + * - the plan examined every declared object (`unexaminedObjects: 0`) and |
| 43 | + * names who chose its database (`config-datasource`, `qa_main`); |
| 44 | + * - its unmanaged-table sweep counts that database's tables once, although |
| 45 | + * the primary and `qa_main` are two connections to the one file; |
| 46 | + * - `os migrate unmapped-columns`, a filter over the plan's own findings, |
| 47 | + * reads the platform object's unmapped column from the same datasource. |
| 48 | + * |
| 49 | + * `os dev` runs as the operator runs it, compiling the config, with |
| 50 | + * `OS_MIGRATE_AND_EXIT=1` (its `serve --dev` child boots, syncs and exits) and |
| 51 | + * `--no-restart` (so the parent exits with it). The datasource's filename is |
| 52 | + * RELATIVE, as an author writes it: the primary resolves it against the project |
| 53 | + * root, the declared connection opens it from the project directory. |
| 54 | + */ |
| 55 | + |
| 56 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 57 | +const CLI_ROOT = resolve(HERE, '..', '..', '..'); |
| 58 | +const RUN_DEV = resolve(CLI_ROOT, 'bin', 'run-dev.js'); |
| 59 | +const TSX = resolve(CLI_ROOT, '..', '..', 'node_modules', '.bin', 'tsx'); |
| 60 | +const CASE_TIMEOUT_MS = 600_000; |
| 61 | +const SPAWN_TIMEOUT_MS = 240_000; |
| 62 | + |
| 63 | +const APP_OBJECT = 'fx24120_thing'; |
| 64 | +const DATABASE = join('data', 'main.db'); |
| 65 | +/** The `[schema-drift]` line the SQL driver prints at boot: `[schema-drift] TABLE.COLUMN: …`. */ |
| 66 | +const DRIFT_LINE = /\[schema-drift\] ([a-z0-9_]+\.[a-z0-9_]+):/g; |
| 67 | + |
| 68 | +let dir: string; |
| 69 | +let port: number; |
| 70 | + |
| 71 | +/** |
| 72 | + * The config, in the one shape `os build` accepts (`defineStack`). The project |
| 73 | + * resolves `@objectstack/spec` through a link to the workspace package |
| 74 | + * (`test/helpers/define-stack-fixture.ts` does the same, out of reach of this |
| 75 | + * `src` program's `rootDir`). |
| 76 | + */ |
| 77 | +function writeConfig(fields: Record<string, unknown>): void { |
| 78 | + const stack = { |
| 79 | + manifest: { id: 'com.example.fx24120', name: 'fx24120', version: '0.0.0', type: 'app', engines: { protocol: '^18' } }, |
| 80 | + datasources: [{ name: 'qa_main', driver: 'sqlite', config: { filename: `./${DATABASE}` } }], |
| 81 | + datasourceMapping: [{ default: true, datasource: 'qa_main' }], |
| 82 | + objects: [{ name: APP_OBJECT, sharingModel: 'private', fields }], |
| 83 | + }; |
| 84 | + writeFileSync( |
| 85 | + join(dir, 'objectstack.config.ts'), |
| 86 | + `import { defineStack } from '@objectstack/spec';\n\nexport default defineStack(${JSON.stringify(stack, null, 2)});\n`, |
| 87 | + ); |
| 88 | +} |
| 89 | + |
| 90 | +/** |
| 91 | + * The child's environment: this process's, minus the families |
| 92 | + * `test/helpers/serve-process.ts` `childEnv()` strips (its header says why), |
| 93 | + * and nothing that names a database, a home, an artifact or a telemetry sibling. |
| 94 | + */ |
| 95 | +function env(extra: Record<string, string> = {}): Record<string, string | undefined> { |
| 96 | + const inherited: Record<string, string | undefined> = {}; |
| 97 | + for (const [key, value] of Object.entries(process.env)) { |
| 98 | + if (key === 'TEST' || key === 'VITEST' || key.startsWith('VITEST_') || key === 'NODE_PATH') continue; |
| 99 | + inherited[key] = value; |
| 100 | + } |
| 101 | + return { |
| 102 | + ...inherited, |
| 103 | + OS_DATABASE_URL: undefined, |
| 104 | + DATABASE_URL: undefined, |
| 105 | + TURSO_DATABASE_URL: undefined, |
| 106 | + OS_DATABASE_DRIVER: undefined, |
| 107 | + OS_HOME: undefined, |
| 108 | + OS_ARTIFACT_PATH: undefined, |
| 109 | + OS_TELEMETRY_DB: '0', |
| 110 | + OS_CLOUD_URL: 'off', |
| 111 | + OS_TELEMETRY_DISABLED: '1', |
| 112 | + OS_AUTH_SECRET: 'os24120-declared-default-secret-at-least-32-characters', |
| 113 | + NO_COLOR: '1', |
| 114 | + ...extra, |
| 115 | + }; |
| 116 | +} |
| 117 | + |
| 118 | +/** A port nothing listens on: the boot exits before it would listen, but `os dev` takes one. */ |
| 119 | +async function freePort(): Promise<number> { |
| 120 | + return new Promise((done, fail) => { |
| 121 | + const server = createServer(); |
| 122 | + server.once('error', fail); |
| 123 | + server.listen(0, '127.0.0.1', () => { |
| 124 | + const address = server.address(); |
| 125 | + server.close(() => (address && typeof address === 'object' ? done(address.port) : fail(new Error('no port')))); |
| 126 | + }); |
| 127 | + }); |
| 128 | +} |
| 129 | + |
| 130 | +/** |
| 131 | + * `os dev`, booted once and exited: its output. `--compile` because a config |
| 132 | + * newer than its artifact is otherwise served STALE (with a warning), and the |
| 133 | + * staged edit below is exactly such a config. |
| 134 | + */ |
| 135 | +function devBootOnce(): string { |
| 136 | + const r = spawnSync( |
| 137 | + TSX, |
| 138 | + [RUN_DEV, 'dev', '--port', String(port), '--compile', '--no-watch', '--no-restart', '--no-seed-admin'], |
| 139 | + { cwd: dir, env: env({ OS_MIGRATE_AND_EXIT: '1' }), encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS }, |
| 140 | + ); |
| 141 | + const output = `${r.stdout}\n${r.stderr}`; |
| 142 | + expect(r.status, `os dev did not boot and exit:\n${output.slice(-1500)}`).toBe(0); |
| 143 | + return output; |
| 144 | +} |
| 145 | + |
| 146 | +/** One read-only `os migrate` door's `--json` document. */ |
| 147 | +function migrateJson(args: string[]): any { |
| 148 | + const r = spawnSync(TSX, [RUN_DEV, 'migrate', ...args, '--json'], { |
| 149 | + cwd: dir, env: env(), encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS, |
| 150 | + }); |
| 151 | + expect(r.status, `os migrate ${args.join(' ')} failed:\n${`${r.stdout}\n${r.stderr}`.slice(-1500)}`).toBe(0); |
| 152 | + return JSON.parse(r.stdout); |
| 153 | +} |
| 154 | + |
| 155 | +function tableCount(file: string): number { |
| 156 | + const db = new Database(file, { readonly: true }); |
| 157 | + try { |
| 158 | + return (db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").get() as { n: number }).n; |
| 159 | + } finally { |
| 160 | + db.close(); |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +beforeAll(async () => { |
| 165 | + dir = mkdtempSync(join(tmpdir(), 'os-24120-declared-default-')); |
| 166 | + const specRoot = dirname(createRequire(import.meta.url).resolve('@objectstack/spec/package.json')); |
| 167 | + mkdirSync(join(dir, 'node_modules', '@objectstack'), { recursive: true }); |
| 168 | + symlinkSync(specRoot, join(dir, 'node_modules', '@objectstack', 'spec'), 'dir'); |
| 169 | + port = await freePort(); |
| 170 | +}); |
| 171 | + |
| 172 | +afterAll(() => { |
| 173 | + if (dir) rmSync(dir, { recursive: true, force: true }); |
| 174 | +}); |
| 175 | + |
| 176 | +describe('os migrate plan on a datasourceMapping-default declared datasource (#24120)', () => { |
| 177 | + it('examines the drift os dev reports there — the same set', () => { |
| 178 | + // Provision: the first boot creates every table in the declared datasource. |
| 179 | + writeConfig({ title: { type: 'text' }, extra: { type: 'text' } }); |
| 180 | + devBootOnce(); |
| 181 | + |
| 182 | + // Stage: the app object stops declaring `extra`, and `sys_user` (a platform |
| 183 | + // object the same rule routes) gains a column no object declares. |
| 184 | + writeConfig({ title: { type: 'text' } }); |
| 185 | + const db = new Database(join(dir, DATABASE)); |
| 186 | + try { db.exec('ALTER TABLE sys_user ADD COLUMN fx24120_legacy TEXT'); } finally { db.close(); } |
| 187 | + |
| 188 | + const reported = new Set([...devBootOnce().matchAll(DRIFT_LINE)].map((m) => m[1])); |
| 189 | + const plan = migrateJson(['plan']); |
| 190 | + const planned = new Set((plan.changes as Array<{ table: string; column?: string }>).map((c) => `${c.table}.${c.column}`)); |
| 191 | + |
| 192 | + expect([...reported].sort()).toEqual(expect.arrayContaining([`${APP_OBJECT}.extra`, 'sys_user.fx24120_legacy'])); |
| 193 | + expect([...planned].sort()).toEqual([...reported].sort()); |
| 194 | + expect(plan.databaseSource).toEqual({ kind: 'config-datasource', datasource: 'qa_main' }); |
| 195 | + expect(plan.composition?.coverage?.unexaminedObjects, JSON.stringify(plan.composition?.coverage)).toBe(0); |
| 196 | + expect(plan.managedTables).toBeGreaterThan(0); |
| 197 | + expect(plan.unmanagedTables?.status, JSON.stringify(plan.unmanagedTables)).toBe('read'); |
| 198 | + expect(plan.unmanagedTables.physicalTables).toBe(tableCount(join(dir, DATABASE))); |
| 199 | + |
| 200 | + // The door an unmapped column's finding leads to filters the plan's own |
| 201 | + // findings over the same set: the platform object's column is read there, |
| 202 | + // not refused as "not in the set os migrate plan diffs". |
| 203 | + const unmapped = migrateJson(['unmapped-columns', '--object', 'sys_user']); |
| 204 | + expect((unmapped.columns as Array<{ column: string }>).map((c) => c.column)).toEqual(['fx24120_legacy']); |
| 205 | + }, CASE_TIMEOUT_MS); |
| 206 | +}); |
0 commit comments