Files
gbrain/test/schema-migrate-link-source-mentions.test.ts
T
f401d7407e v0.42.31.0 feat(links): open link_source provenance + link-add/link-rm/link-sources (#1941) (#1957)
* 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>
2026-06-07 19:12:50 -07:00

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);
});
});