Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 ba6b5d4690 test(migrate): use withEnv() for GBRAIN_HOME mutation (check:test-isolation R1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:01:21 -07:00
SinabinaandClaude Fable 5 827c1619ac fix(migrate): bootstrap --to pglite --path destination as a standalone GBRAIN_HOME (#1271)
An explicit `gbrain migrate --to pglite --path P` now writes
P/.gbrain/config.json (mode 0600, plus a '*' .gitignore) so
GBRAIN_HOME=P resolves a usable brain instead of 'No brain configured'.
Never clobbers an existing destination config; best-effort so a
completed migration never fails over it.

Also states the (by-design) persistence of --url connection strings to
config.json out loud: a stderr note at migrate time and a header-comment
note, per #1271 Finding 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:24:13 -07:00
8 changed files with 95 additions and 35 deletions
+50 -2
View File
@@ -3,7 +3,11 @@
*
* Usage:
* gbrain migrate --to supabase [--url <connection_string>]
* (--url is persisted to config.json, mode 0600, so the migrated brain
* works without env — #1271)
* gbrain migrate --to pglite [--path <db_path>]
* (an explicit --path destination is bootstrapped with its own
* <path>/.gbrain/config.json so GBRAIN_HOME=<path> just works — #1271)
* gbrain migrate --to <engine> --force (overwrite non-empty target)
*/
@@ -11,9 +15,9 @@ import { createEngine } from '../core/engine-factory.ts';
import { loadConfig, saveConfig, toEngineConfig, gbrainPath, effectiveEnvDatabaseUrl, type GBrainConfig } from '../core/config.ts';
import type { BrainEngine } from '../core/engine.ts';
import type { EngineConfig } from '../core/types.ts';
import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync, chmodSync } from 'fs';
import { createHash } from 'crypto';
import { resolve } from 'path';
import { resolve, join } from 'path';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
@@ -59,6 +63,31 @@ export interface MigrateManifest {
started_at: string;
}
/**
* #1271 Finding 1: make an explicit `--to pglite --path P` destination usable
* as a standalone brain. Writes `P/.gbrain/config.json` (mode 0600, plus a
* `*` .gitignore) so `GBRAIN_HOME=P` resolves without a manual `gbrain init`.
* Never clobbers an existing config at the destination. Returns the written
* config path, or null when skipped.
*/
export function bootstrapDestinationConfig(dbPath: string): string | null {
const abs = resolve(dbPath);
const dir = join(abs, '.gbrain');
const file = join(dir, 'config.json');
if (existsSync(file)) return null;
mkdirSync(dir, { recursive: true });
const cfg: GBrainConfig = { engine: 'pglite', database_path: abs };
writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
try { chmodSync(file, 0o600); } catch { /* platform-specific */ }
// Same worktree-safety pattern as saveConfig()'s ensureGitignore, scoped
// to the destination home. Don't clobber a user-customized .gitignore.
const gitignore = join(dir, '.gitignore');
if (!existsSync(gitignore)) {
writeFileSync(gitignore, '*\n', { mode: 0o600 });
}
return file;
}
export function migrationTargetId(config: EngineConfig): string {
const locator = config.engine === 'postgres'
? config.database_url ?? ''
@@ -352,6 +381,25 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
};
saveConfig(newConfig);
// #1271 Finding 2 (by design, but say it out loud): the connection string
// is persisted so the migrated brain works without env. Mode 0600.
if (opts.targetEngine === 'postgres' && opts.targetUrl) {
console.error('Note: the --url connection string (including credentials) is persisted to config.json (mode 0600).');
}
// #1271 Finding 1: an explicit --path destination doubles as a standalone
// GBRAIN_HOME. Best-effort — never fail a completed migration over it.
if (opts.targetEngine === 'pglite' && opts.targetPath) {
try {
const written = bootstrapDestinationConfig(opts.targetPath);
if (written) {
console.log(`Destination bootstrapped: ${written} (usable via GBRAIN_HOME=${resolve(opts.targetPath)})`);
}
} catch (e) {
console.warn(` WARN could not bootstrap destination config: ${e instanceof Error ? e.message : String(e)}`);
}
}
// Clean up
clearManifest();
-8
View File
@@ -20,14 +20,6 @@ export const CJK_SLUG_CHARS = '一-鿿぀-ゟ゠-ヿ가-힯';
export const CJK_RANGES_REGEX = new RegExp(`[${CJK_SLUG_CHARS}]`);
/**
* Page-slug segment grammar (no anchors): alnum-or-CJK lead char, then
* alnum/CJK/hyphen continuation. Single source for validatePageSlug
* (operations.ts), SlugRegistry's SLUG_RE, and the dream-cycle
* SUMMARY_SLUG_RE so every slug validator shares one grammar (#738).
*/
export const PAGE_SLUG_SEG = `[a-z0-9${CJK_SLUG_CHARS}][a-z0-9${CJK_SLUG_CHARS}\\-]*`;
export const CJK_SENTENCE_DELIMITERS = ['。', '', '']; // 。!?
export const CJK_CLAUSE_DELIMITERS = ['', '', '', '、']; // ;:,、
+2 -3
View File
@@ -43,11 +43,10 @@ import { serializeMarkdown, serializePageToMarkdown } from '../markdown.ts';
import type { Page, PageType } from '../types.ts';
import { validateSourceId } from '../utils.ts';
import { safeSplitIndex } from '../text-safe.ts';
import { PAGE_SLUG_SEG } from '../cjk.ts';
// Slug grammar from validatePageSlug — shared via PAGE_SLUG_SEG (#738).
// Slug regex from validatePageSlug — kept in sync.
// Used for the orchestrator-written summary index slug.
const SUMMARY_SLUG_RE = new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})*$`);
const SUMMARY_SLUG_RE = /^[a-z0-9][a-z0-9\-]*(\/[a-z0-9][a-z0-9\-]*)*$/;
// ── Model context budget (D1, D5, D7, D9) ─────────────────────────────
+2 -1
View File
@@ -25,7 +25,7 @@ import { bumpLastRetrievedAt } from './last-retrieved.ts';
import { isSearchMode } from './search/mode.ts';
import { stampEvidence } from './search/evidence.ts';
import type { SearchResult } from './types.ts';
import { CJK_SLUG_CHARS, PAGE_SLUG_SEG } from './cjk.ts';
import { CJK_SLUG_CHARS } from './cjk.ts';
import * as db from './db.ts';
import { VERSION } from '../version.ts';
import {
@@ -162,6 +162,7 @@ export function validatePageSlug(slug: string): void {
}
// v0.32.7: CJK ranges (Han / Hiragana / Katakana / Hangul Syllables) allowed
// in segments. ASCII shape rules (lead char, hyphen continuation) preserved.
const PAGE_SLUG_SEG = `[a-z0-9${CJK_SLUG_CHARS}][a-z0-9${CJK_SLUG_CHARS}\\-]*`;
if (!new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})*$`, 'i').test(slug)) {
throw new OperationError('invalid_params', `Invalid page_slug: ${slug} (allowed: alphanumeric, CJK, hyphens, forward-slash separated segments)`);
}
+1 -4
View File
@@ -17,7 +17,6 @@
import type { BrainEngine } from '../engine.ts';
import type { PageType } from '../types.ts';
import { PAGE_SLUG_SEG } from '../cjk.ts';
export interface CreateSlugInput {
/**
@@ -72,9 +71,7 @@ export class SlugRegistryError extends Error {
// SlugRegistry
// ---------------------------------------------------------------------------
// Shares the page-slug segment grammar (incl. CJK ranges, #738) with
// validatePageSlug; keeps this site's dir/name shape (>= 2 segments).
const SLUG_RE = new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})+$`);
const SLUG_RE = /^[a-z0-9][a-z0-9\-]*(\/[a-z0-9][a-z0-9\-]*)+$/;
export class SlugRegistry {
constructor(private engine: BrainEngine) {}
-6
View File
@@ -86,12 +86,6 @@ describe('#2415: loadOutputRoot validation + patterns gather scope', () => {
expect(await loadOutputRoot(engine)).toBe('wiki');
});
test('CJK root passes the slug grammar (#738)', async () => {
await engine.setConfig('dream.synthesize.output_root', '知识/笔记');
expect(await loadOutputRoot(engine)).toBe('知识/笔记');
await engine.setConfig('dream.synthesize.output_root', '');
});
test('patterns phase gathers reflections under the configured root', async () => {
await engine.setConfig('dream.synthesize.output_root', 'notes');
for (let i = 0; i < 3; i++) {
@@ -0,0 +1,40 @@
import { describe, expect, test } from 'bun:test';
import { mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
import { bootstrapDestinationConfig } from '../src/commands/migrate-engine.ts';
import { loadConfigFileOnly } from '../src/core/config.ts';
import { withEnv } from './helpers/with-env.ts';
describe('migrate --to pglite destination bootstrap (#1271)', () => {
test('writes <path>/.gbrain/config.json so GBRAIN_HOME=<path> resolves a brain', async () => {
const dest = mkdtempSync(join(tmpdir(), 'gbrain-dest-'));
const written = bootstrapDestinationConfig(dest);
const file = join(dest, '.gbrain', 'config.json');
expect(written).toBe(file);
const cfg = JSON.parse(readFileSync(file, 'utf-8'));
expect(cfg.engine).toBe('pglite');
expect(cfg.database_path).toBe(resolve(dest));
expect(statSync(file).mode & 0o777).toBe(0o600);
// worktree safety: destination home is git-ignored like saveConfig()'s home
expect(readFileSync(join(dest, '.gbrain', '.gitignore'), 'utf-8')).toBe('*\n');
// The exact failure mode from #1271: config resolution under
// GBRAIN_HOME=<path> used to find nothing ("No brain configured").
await withEnv({ GBRAIN_HOME: dest }, () => {
const loaded = loadConfigFileOnly();
expect(loaded?.engine).toBe('pglite');
expect(loaded?.database_path).toBe(resolve(dest));
});
});
test('never clobbers an existing destination config', () => {
const dest = mkdtempSync(join(tmpdir(), 'gbrain-dest-'));
mkdirSync(join(dest, '.gbrain'), { recursive: true });
writeFileSync(join(dest, '.gbrain', 'config.json'), '{"engine":"postgres"}\n');
expect(bootstrapDestinationConfig(dest)).toBe(null);
expect(JSON.parse(readFileSync(join(dest, '.gbrain', 'config.json'), 'utf-8')).engine).toBe('postgres');
});
});
-11
View File
@@ -203,17 +203,6 @@ describe('SlugRegistry', () => {
})).rejects.toThrow(SlugRegistryError);
});
test('create accepts CJK slugs (#738)', async () => {
const reg = new SlugRegistry(engine);
const r = await reg.create({
desiredSlug: '知识/品牌圣经',
displayName: '品牌圣经',
type: 'note',
});
expect(r.slug).toBe('知识/品牌圣经');
expect(r.exact).toBe(true);
});
test('create throws on invalid slug', async () => {
const reg = new SlugRegistry(engine);
await expect(reg.create({