mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(links): relax link_source CHECK to kebab-case provenance + migration v114 Open link_source from a closed allowlist to a kebab-case format gate (^[a-z][a-z0-9]*(-[a-z0-9]+)*$, char_length<=64) so external derivers stamp their own provenance (e.g. citation-graph) without a per-deriver migration. Migration v114: Postgres NOT VALID + VALIDATE (lock-friendly, transaction:false); PGLite plain DROP+ADD. Updates the schema.sql + engine provenance contract comments. (#1941) * feat(links): expose link provenance on link ops + link-add/link-rm/link-sources add_link/remove_link now accept --link-source/--link-type; add_link guards the reconciliation-managed built-ins (markdown/frontmatter/mentions/ wikilink-resolved) and defaults omitted provenance to 'manual' (was the misleading engine default 'markdown'). New cliHints.aliases mechanism with a startup collision guard registers link-add/link-rm; printOpHelp shows the invoked alias name. New list_link_sources read op + listLinkSources engine method (both engines, {sourceId?,sourceIds?}, deterministic order) powers `gbrain link-sources`, added to the minion read allowlist. (#1941) * test(links): kebab provenance, op guard, link-sources, aliases + parity Covers the v114 regex/length boundaries, upgrade-path constraint swap on existing data, the managed-built-in op guard + manual default, remove_link type/source filters, list_link_sources scoping (scalar + federated) and PG/PGLite parity, and alias resolution/collision/help. Fixes the prior 'inferred'-rejection assertion (now valid kebab) in the mentions test. (#1941) * chore: bump version and changelog (v0.42.31.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: update KEY_FILES for v0.42.31.0 link provenance surface KEY_FILES.md current-state updates for #1941: link_source now an open kebab-case provenance (migration v114), the add_link/remove_link guard + defaults, list_link_sources + listLinkSources, and cliHints.aliases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(minions): supervisor queue-singleton keying + pidfile cleanup (follow-up #1849) Two correctness bugs in the v0.42.29.0 supervisor-singleton work, caught by adversarial review: - supervisorLockId mixed a config-derived DB identity into the key, but the lock row already lives inside the target database. Two supervisors on the same physical DB via different-but-equivalent URLs (pooler vs direct port, host alias, trailing params) computed different ids and BOTH acquired the "singleton" lock. Key on the queue alone; the database half of the mutex is physical. Removes the now-dead currentDbIdentity() from worker-registry. - The pidfile-cleanup process.on('exit') listener was installed AFTER the DB-lock acquire, so the LOCK_HELD early-exit stranded the pidfile this process had just created. Install the listener first. Regression test pins the listener-before-lock ordering; updates the lockId test to the queue-only invariant; KEY_FILES updated to current state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
144 lines
5.6 KiB
TypeScript
144 lines
5.6 KiB
TypeScript
/**
|
|
* Regression tests for migration v95 (link_source CHECK widening).
|
|
*
|
|
* Pins three contracts:
|
|
* 1. Fresh-init brain accepts link_source='mentions' (schema-embedded.ts
|
|
* + pglite-schema.ts widened CHECK is the source of truth for fresh
|
|
* installs).
|
|
* 2. Migration v95 is registered with the expected name + shape.
|
|
* 3. Migration v95 is idempotent — re-running on an already-migrated
|
|
* brain is a no-op (the DROP IF EXISTS + ADD CONSTRAINT pattern).
|
|
*
|
|
* Hermetic via PGLite. No DATABASE_URL needed.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { MIGRATIONS, LATEST_VERSION } from '../src/core/migrate.ts';
|
|
|
|
const MIGRATION_VERSION = 95;
|
|
const MIGRATION_NAME = 'links_link_source_check_includes_mentions';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
describe('migration v95 — links_link_source_check_includes_mentions', () => {
|
|
test('registered with expected version + name', () => {
|
|
const m = MIGRATIONS.find(m => m.version === MIGRATION_VERSION);
|
|
expect(m).toBeDefined();
|
|
expect(m!.name).toBe(MIGRATION_NAME);
|
|
});
|
|
|
|
test('LATEST_VERSION >= 95 so the migration is part of canonical sequence', () => {
|
|
expect(LATEST_VERSION).toBeGreaterThanOrEqual(MIGRATION_VERSION);
|
|
});
|
|
|
|
test('SQL shape — widens CHECK to include all 4 source values', () => {
|
|
const m = MIGRATIONS.find(m => m.version === MIGRATION_VERSION)!;
|
|
const sql = (m.sql || '') + ' ' + ((m.sqlFor?.pglite as string) || '');
|
|
expect(sql).toContain("'mentions'");
|
|
expect(sql).toContain("'markdown'");
|
|
expect(sql).toContain("'frontmatter'");
|
|
expect(sql).toContain("'manual'");
|
|
// DROP IF EXISTS pattern for re-runnability
|
|
expect(sql).toMatch(/DROP CONSTRAINT IF EXISTS links_link_source_check/i);
|
|
});
|
|
|
|
test('PGLite branch present (engine parity)', () => {
|
|
const m = MIGRATIONS.find(m => m.version === MIGRATION_VERSION)!;
|
|
expect(m.sqlFor?.pglite).toBeDefined();
|
|
expect(m.sqlFor!.pglite!.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('fresh-init brain (post-migration v95) accepts link_source=mentions', () => {
|
|
test('two pages can be linked with link_source=mentions', async () => {
|
|
const slugA = `mentions-source-${Math.random().toString(36).slice(2, 8)}`;
|
|
const slugB = `mentions-target-${Math.random().toString(36).slice(2, 8)}`;
|
|
await engine.putPage(slugA, {
|
|
type: 'note',
|
|
title: 'A',
|
|
compiled_truth: 'a body',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
});
|
|
await engine.putPage(slugB, {
|
|
type: 'person',
|
|
title: 'B',
|
|
compiled_truth: 'b body',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
});
|
|
await engine.addLinksBatch([
|
|
{
|
|
from_slug: slugA,
|
|
to_slug: slugB,
|
|
link_type: 'mentions',
|
|
link_source: 'mentions',
|
|
context: 'auto-link test',
|
|
},
|
|
]);
|
|
const rows = await engine.executeRaw<{ link_source: string }>(
|
|
`SELECT l.link_source
|
|
FROM links l
|
|
JOIN pages p ON p.id = l.from_page_id
|
|
WHERE p.slug = $1`,
|
|
[slugA],
|
|
);
|
|
expect(rows.some(r => r.link_source === 'mentions')).toBe(true);
|
|
});
|
|
|
|
test('CHECK still rejects a malformed source value (gate not nullified)', async () => {
|
|
const slugA = `bad-source-a-${Math.random().toString(36).slice(2, 8)}`;
|
|
const slugB = `bad-source-b-${Math.random().toString(36).slice(2, 8)}`;
|
|
await engine.putPage(slugA, { type: 'note', title: 'A', compiled_truth: 'a', timeline: '', frontmatter: {} });
|
|
await engine.putPage(slugB, { type: 'person', title: 'B', compiled_truth: 'b', timeline: '', frontmatter: {} });
|
|
// v114 (#1941): the closed allowlist became a kebab-case regex, so a once-
|
|
// illegal-but-kebab value like 'inferred' is now ACCEPTED. The gate still
|
|
// bites on MALFORMED values — 'Inferred_X' has an uppercase + underscore.
|
|
await expect(
|
|
engine.addLinksBatch([
|
|
{
|
|
from_slug: slugA,
|
|
to_slug: slugB,
|
|
link_type: 'mentions',
|
|
link_source: 'Inferred_X' as any,
|
|
context: 'should reject',
|
|
},
|
|
]),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
test('idempotent re-application via runMigration — DROP IF EXISTS + ADD pattern survives second run', async () => {
|
|
const m = MIGRATIONS.find(m => m.version === MIGRATION_VERSION)!;
|
|
const pgliteSql = m.sqlFor!.pglite!;
|
|
// runMigration uses engine.db.exec which handles multi-statement SQL,
|
|
// unlike executeRaw which goes through db.query (single statement only).
|
|
await expect(engine.runMigration(MIGRATION_VERSION, pgliteSql)).resolves.toBeUndefined();
|
|
// Insert with link_source='mentions' must still work after re-running.
|
|
const slugA = `idem-a-${Math.random().toString(36).slice(2, 8)}`;
|
|
const slugB = `idem-b-${Math.random().toString(36).slice(2, 8)}`;
|
|
await engine.putPage(slugA, { type: 'note', title: 'A', compiled_truth: 'a', timeline: '', frontmatter: {} });
|
|
await engine.putPage(slugB, { type: 'company', title: 'B', compiled_truth: 'b', timeline: '', frontmatter: {} });
|
|
await engine.addLinksBatch([
|
|
{ from_slug: slugA, to_slug: slugB, link_type: 'mentions', link_source: 'mentions', context: '' },
|
|
]);
|
|
const rows = await engine.executeRaw<{ count: string }>(
|
|
`SELECT COUNT(*)::text AS count FROM links l
|
|
JOIN pages p ON p.id = l.from_page_id
|
|
WHERE p.slug = $1 AND l.link_source = 'mentions'`,
|
|
[slugA],
|
|
);
|
|
expect(Number(rows[0]?.count ?? 0)).toBeGreaterThan(0);
|
|
});
|
|
});
|