Files
gbrain/test/migration-in-process.serial.test.ts
T
6f26d5e4df v0.41.37.0 fix: critical fix wave — reindex tag wipe, grandfather hang, Windows migration spawn, sync ReDoS (#1621 #1581 #1605 #1569) (#1665)
* fix(reindex): add-only tag reconciliation + DB-only re-chunk preserves frontmatter (#1621)

reindex --markdown and re-import no longer wipe DB-side enrichment tags.
Tag reconciliation is now ADD-ONLY (import-file.ts): re-import adds current
frontmatter tags and never deletes, so auto/dream/signal-detector tags survive.
The reindex DB-only fallback reconstructs full markdown via serializeMarkdown
so re-chunking a page with no on-disk source preserves frontmatter/title/timeline.

* fix(migrations): v0.13.1 grandfather chunked, source-safe, soft-delete-filtered (#1581)

phaseCGrandfather rewritten from a per-page getPage+putPage loop (which hung
70+ min on an 82K-page PGLite brain) to a chunked bulk SQL pass keyed on
pages.id (NOT slug — slug isn't globally unique), filtering deleted_at IS NULL,
with a batched rollback log carrying source identity.

* fix(migrations): run schema phases in-process to fix Windows getaddrinfo ENOTFOUND (#1605)

The 9 'gbrain init --migrate-only' execSync spawns died on Windows+bun+Supabase
(child DNS resolution). runMigrateOnlyCore (extracted from initMigrateOnly) runs
the schema bring-up in-process for all engines, unblocking schema_version
advancement. Includes async-call-site audit, a wall-clock guard, and a
runGbrainSubprocess stderr-capture wrapper for the remaining backfill spawns.

* fix(sync): ReDoS hardening + diagnostics for schema-pack regexes (#1569)

Input-length cap in runRegexBounded + route the unbounded link-inference path
through it (closes the only no-timeout ReDoS hole); star-height lint rule warns
on nested-quantifier patterns; --no-schema-pack sync escape hatch; GBRAIN_SYNC_TRACE
per-file begin heartbeat; PGLite serve/sync concurrency doc. Defensive hardening +
diagnostics — the deterministic ~3100-file wedge root cause remains open (no repro).

* docs(todos): file v0.41.37.0 fix-wave follow-ups (#1621/#1605/#1569)

* chore: bump version and changelog (v0.41.37.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: sync README + CLAUDE.md for v0.41.37.0 critical fix wave

Add reindex add-only tag reconciliation (#1621), v0.13.1 grandfather +
Windows in-process migration (#1581/#1605), and schema-pack ReDoS
hardening + sync --no-schema-pack / GBRAIN_SYNC_TRACE triage (#1569)
to CLAUDE.md key-files annotations and README Troubleshooting.
Regenerated llms-full.txt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): bump llms-full.txt budget 700KB→750KB (CLAUDE.md crossed 700KB after master merge)

The build-llms size-budget test failed: llms-full.txt is 703,244 bytes after the
v0.41.37.0 key-files annotations merged on top of master's v0.41.34/35/36 CLAUDE.md
additions. Matches the v0.41.9.0 precedent (600→700); the single-fetch bundle still
fits comfortably in modern long-context models.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:56:26 -07:00

113 lines
4.4 KiB
TypeScript

// v0.41.37.0 #1605 — migration schema phases run IN-PROCESS (was a
// `gbrain init --migrate-only` subprocess that died with getaddrinfo ENOTFOUND
// on Windows+bun+Supabase). runMigrateOnlyCore is the single in-process path;
// runGbrainSubprocess captures child stderr for the remaining backfill spawns.
import { describe, test, expect } from 'bun:test';
import { tmpdir } from 'os';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { withEnv } from './helpers/with-env.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import {
runMigrateOnlyCore,
runGbrainSubprocess,
MigrateOnlyError,
} from '../src/commands/migrations/in-process.ts';
const MIGRATION_DIR = join(import.meta.dir, '..', 'src', 'commands', 'migrations');
const SCHEMA_PHASE_FILES = [
'v0_11_0', 'v0_12_0', 'v0_12_2', 'v0_13_0', 'v0_16_0',
'v0_18_0', 'v0_18_1', 'v0_21_0', 'v0_29_1',
];
describe('#1605 runMigrateOnlyCore (in-process schema)', () => {
test('brings a fresh PGLite brain to head without spawning', async () => {
const home = mkdtempSync(join(tmpdir(), 'mip-'));
const dataDir = join(home, 'data');
mkdirSync(join(home, '.gbrain'), { recursive: true });
writeFileSync(
join(home, '.gbrain', 'config.json'),
JSON.stringify({ engine: 'pglite', database_path: dataDir }),
);
const result = await withEnv(
{ GBRAIN_HOME: home, DATABASE_URL: undefined, GBRAIN_DATABASE_URL: undefined },
() => runMigrateOnlyCore(),
);
expect(result.engine).toBe('pglite');
// Verify schema landed: reconnect a fresh engine to the same data dir and
// confirm a core table exists (proves initSchema ran in-process).
const verify = new PGLiteEngine();
await verify.connect({ database_path: dataDir });
try {
const rows = await verify.executeRaw<{ t: string | null }>(
"SELECT to_regclass('public.pages')::text AS t",
);
expect(rows[0]?.t).toBe('pages');
} finally {
await verify.disconnect();
}
});
test('throws MigrateOnlyError when no brain is configured', async () => {
const home = mkdtempSync(join(tmpdir(), 'mip-noconf-'));
await expect(
withEnv(
{ GBRAIN_HOME: home, DATABASE_URL: undefined, GBRAIN_DATABASE_URL: undefined },
() => runMigrateOnlyCore(),
),
).rejects.toBeInstanceOf(MigrateOnlyError);
});
});
describe('#1605 runGbrainSubprocess (stderr capture)', () => {
test('folds child stderr into the thrown error', () => {
let msg = '';
try {
runGbrainSubprocess("sh -c 'echo BOOM_STDERR 1>&2; exit 1'");
} catch (e) {
msg = e instanceof Error ? e.message : String(e);
}
expect(msg).toContain('BOOM_STDERR');
});
test('returns child stdout on success', () => {
const out = runGbrainSubprocess("sh -c 'echo hello-stdout'");
expect(out).toContain('hello-stdout');
});
});
describe('#1605 structural guard: schema phases are in-process', () => {
test('no schema phase still execSyncs `gbrain init --migrate-only`', () => {
for (const f of SCHEMA_PHASE_FILES) {
const src = readFileSync(join(MIGRATION_DIR, `${f}.ts`), 'utf-8');
expect(src).not.toContain("execSync('gbrain init --migrate-only'");
}
});
test('every schema phase calls runMigrateOnlyCore + is awaited', () => {
for (const f of SCHEMA_PHASE_FILES) {
const src = readFileSync(join(MIGRATION_DIR, `${f}.ts`), 'utf-8');
expect(src).toContain('runMigrateOnlyCore()');
// phaseASchema must be async + awaited at its call site.
expect(src).toContain('async function phaseASchema');
const awaited = src.includes('await phaseASchema(opts)') ||
src.includes('push(await phaseASchema(opts))');
expect(awaited).toBe(true);
}
});
test('NO migration orchestrator anywhere spawns `gbrain init --migrate-only`', () => {
// All-files invariant (not just the 9): the subprocess spawn is the
// Windows-ENOTFOUND bug class. Other files (v0_22_4, v0_28_0, v0_31_0,
// v0_14_0, v0_32_2) define an in-process phaseASchema that never spawned —
// those are fine. We only ban the spawn literal.
const files = readdirSync(MIGRATION_DIR).filter(n => /^v\d/.test(n) && n.endsWith('.ts'));
for (const n of files) {
const src = readFileSync(join(MIGRATION_DIR, n), 'utf-8');
expect(src).not.toContain("execSync('gbrain init --migrate-only'");
}
});
});