Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Fable 5 d56aedb67e fix(test): use generic path in doctor image-assets test — check:privacy banned-path
/data/brain/ is on the check-privacy.sh BANNED_PATHS list; the #1835 test
only needs any posix-absolute path, so use /srv/assets/ instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:49:15 -07:00
Garry TanandClaude Fable 5 a9346bd808 fix(core): four singleton P0s — per-claim take idempotency, --source __all__, phantom fact wipe guard, WSL image paths
- #2138 propose_takes: the take_proposals idempotency key was per-page, so
  ON CONFLICT DO NOTHING silently dropped claim #2+ on multi-claim pages.
  Migration v125 folds md5(claim_text) into the unique index (expression
  index: no new column, no backfill); the INSERT's conflict target matches
  and now counts via RETURNING so extractor-repeated claims don't inflate
  proposals_inserted. Fresh-install blobs (schema.sql / schema-embedded.ts /
  pglite-schema.ts) updated in lockstep.

- #2289 cli: '--source __all__' was rejected by SOURCE_ID_RE and silently
  fell back to 'default'. makeContext now maps the sentinel to the op-level
  source_id='__all__' (resolveRequestedScope spans the brain for local
  callers), errors loudly on an explicit --source that fails to resolve,
  and leaves ops that declare their OWN 'source' param (put_raw_data,
  add_timeline_entry, ...) untouched.

- #2412 phantom-redirect: the post-migration straggler fact wipe now passes
  excludeSourcePrefixes:['cli:'] (parity with extract-facts' #1928 guard) so
  cli: conversation facts' supersession audit trail survives a redirect.

- #1835 doctor: image_assets resolves Windows drive-form storage_path
  ('D:/...') to the WSL /mnt/<drive>/ mount on non-Windows platforms instead
  of joining it onto repoRoot and flagging every asset missing.

Fixes #2138
Fixes #2289
Fixes #2412
Fixes #1835

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:49:38 -07:00
13 changed files with 411 additions and 27 deletions
+23 -6
View File
@@ -429,7 +429,7 @@ async function main() {
let ctx: Awaited<ReturnType<typeof makeContext>>;
try {
ctx = await withTimeout(
makeContext(engine, params),
makeContext(engine, params, op),
wallclockMs,
`gbrain ${command}: context`,
);
@@ -802,19 +802,36 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
return params;
}
async function makeContext(engine: BrainEngine, params: Record<string, unknown>): Promise<OperationContext> {
// Exported for tests (same import-safety contract as cliAliases/printOpHelp).
export async function makeContext(engine: BrainEngine, params: Record<string, unknown>, op?: Operation): Promise<OperationContext> {
// v0.31.8 (D11): resolve sourceId via the canonical 6-tier chain. Honors
// --source / GBRAIN_SOURCE / .gbrain-source / path-match / brain default /
// 'default'. Wrapped in try/catch so a doctor / single-source brain that
// never set up sources still returns 'default' silently.
let sourceId: string | undefined;
// params.source is set when a CLI flag was parsed for the op (rare; most
// CLI ops don't take --source). Falls through to env/dotfile/path-match.
// #2289: ops that declare their OWN `source` param (put_raw_data's data
// source, add_timeline_entry's provenance, ...) own the flag — it is not
// the brain-source selector, so it never feeds resolveSourceId.
const opOwnsSource = op?.params?.source !== undefined;
let explicit = opOwnsSource ? null : ((params.source as string | undefined) ?? null);
// #2289: '--source __all__' is the op-level all-sources sentinel, not a
// source id — SOURCE_ID_RE rejects it, so pre-fix it silently fell back to
// 'default'. Map it to params.source_id (understood by resolveRequestedScope
// in operations.ts; local callers span the whole brain) and let ctx.sourceId
// resolve through the normal non-explicit chain.
if (explicit === '__all__') {
if (params.source_id === undefined) params.source_id = '__all__';
explicit = null;
}
try {
const { resolveSourceId } = await import('./core/source-resolver.ts');
// params.source is set when a CLI flag was parsed for the op (rare; most
// CLI ops don't take --source). Falls through to env/dotfile/path-match.
const explicit = (params.source as string | undefined) ?? null;
sourceId = await resolveSourceId(engine, explicit);
} catch {
} catch (err) {
// #2289: an EXPLICIT --source that fails to resolve (bad format, unknown
// source) must error loudly — silently defaulting misroutes the query.
if (explicit) throw err;
// Source resolution failed (e.g. sources table doesn't exist on a fresh
// pre-init brain). Leave sourceId unset; engine read methods fall through
// to the cross-source view (D16 back-compat path).
+23 -4
View File
@@ -52,6 +52,28 @@ import { isUndefinedColumnError } from '../core/utils.ts';
import { resolveHardExcludes, DEFAULT_HARD_EXCLUDES } from '../core/search/source-boost.ts';
import { escapeLikePattern, buildVisibilityClause } from '../core/search/sql-ranking.ts';
/**
* #1835: resolve a files.storage_path to the absolute path the image_assets
* check should stat. Windows-form paths ('D:/brain/img.png', 'D:\\brain\\img.png')
* are NOT absolute under posix path semantics, so pre-fix they were joined onto
* repoRoot and every asset ingested from the Windows side was flagged missing
* under WSL. Translate them to the WSL drive mount (/mnt/d/brain/img.png)
* instead. On native Windows isAbsolute() already accepts the drive form, so
* the translation branch never fires there.
*/
export function resolveImageAssetPath(
storagePath: string,
repoRoot: string,
platform: NodeJS.Platform = process.platform,
): string {
if (isAbsolute(storagePath)) return storagePath;
if (platform !== 'win32') {
const drive = /^([A-Za-z]):[\\/](.*)$/.exec(storagePath);
if (drive) return `/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}`;
}
return join(repoRoot, storagePath);
}
export interface Check {
name: string;
status: 'ok' | 'warn' | 'fail';
@@ -7178,15 +7200,12 @@ export async function buildChecks(
let vanished = 0;
const vanishedPaths: string[] = [];
const fs = await import('node:fs');
const nodePath = await import('node:path');
// storage_path is repo-relative for sync-ingested assets. Resolving
// against cwd made this check a false-positive WARN whenever doctor
// ran outside the brain repo.
const repoRoot = (await engine.getConfig('sync.repo_path')) ?? process.cwd();
for (const r of rows) {
const abs = nodePath.isAbsolute(r.storage_path)
? r.storage_path
: nodePath.join(repoRoot, r.storage_path);
const abs = resolveImageAssetPath(r.storage_path, repoRoot);
try {
fs.statSync(abs);
} catch {
+3 -1
View File
@@ -483,7 +483,9 @@ export async function tryRedirectPhantom(
await engine.softDeletePage(page.slug, { sourceId });
// Wipe any stale phantom DB facts that may have escaped the migration
// (e.g. expired rows that the migration WHERE clause skipped).
await engine.deleteFactsForPage(page.slug, sourceId);
// #2412: same #1928 guard as extract-facts.ts — `cli:` conversation facts
// are DB-only (not fence-owned) and must survive the straggler wipe.
await engine.deleteFactsForPage(page.slug, sourceId, { excludeSourcePrefixes: ['cli:'] });
const phantomPath = path.join(brainDir, `${page.slug}.md`);
if (fs.existsSync(phantomPath)) {
try {
+10 -6
View File
@@ -388,16 +388,20 @@ class ProposeTakesPhase extends BaseCyclePhase {
continue;
}
// Write proposals to take_proposals. Each row is a separate INSERT
// because the composite idempotency key is on the per-page tuple — a
// bulk UPSERT would collapse a same-page-multi-claim run into one row.
// Write proposals to take_proposals. #2138: the idempotency key is
// per-CLAIM — take_proposals_idempotency_idx folds md5(claim_text) into
// the per-page tuple (migration v125), so a multi-claim page keeps every
// claim; the old per-page key made ON CONFLICT DO NOTHING silently drop
// claim #2+. RETURNING id so a conflict-dropped duplicate (extractor
// repeating itself) doesn't inflate proposals_inserted.
for (const p of proposals) {
await engine.executeRaw(
const inserted = await engine.executeRaw<{ id: number }>(
`INSERT INTO take_proposals
(source_id, page_slug, content_hash, prompt_version, proposal_run_id,
claim_text, kind, holder, weight, domain, dedup_against_fence_rows, model_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (source_id, page_slug, content_hash, prompt_version) DO NOTHING`,
ON CONFLICT (source_id, page_slug, content_hash, prompt_version, md5(claim_text)) DO NOTHING
RETURNING id`,
[
sourceId,
page.slug,
@@ -413,7 +417,7 @@ class ProposeTakesPhase extends BaseCyclePhase {
modelId,
],
);
result.proposals_inserted += 1;
result.proposals_inserted += inserted.length;
}
}
+23
View File
@@ -5671,6 +5671,29 @@ export const MIGRATIONS: Migration[] = [
`);
},
},
{
version: 125,
name: 'take_proposals_per_claim_idempotency',
// #2138: the idempotency key was per-PAGE — (source_id, page_slug,
// content_hash, prompt_version), where content_hash is a hash of the whole
// page body — so propose_takes' per-claim INSERT ... ON CONFLICT DO NOTHING
// silently dropped claim #2+ on every multi-claim page. Fold
// md5(claim_text) into the unique index so the key is per-claim. An
// expression index needs no new column or backfill, and the new key is
// strictly finer than the old one, so the recreate cannot hit duplicate
// rows. The per-page reprocessing cache is unaffected: propose-takes.ts
// still short-circuits via its (source_id, page_slug, content_hash,
// prompt_version) SELECT before calling the extractor.
//
// Mirrors in src/schema.sql / schema-embedded.ts / pglite-schema.ts
// (fresh-install blobs) updated in the same commit.
idempotent: true,
sql: `
DROP INDEX IF EXISTS take_proposals_idempotency_idx;
CREATE UNIQUE INDEX IF NOT EXISTS take_proposals_idempotency_idx
ON take_proposals (source_id, page_slug, content_hash, prompt_version, md5(claim_text));
`,
},
];
export const LATEST_VERSION = MIGRATIONS.length > 0
+1 -1
View File
@@ -777,7 +777,7 @@ CREATE TABLE IF NOT EXISTS take_proposals (
predicted_brier_bucket_n INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS take_proposals_idempotency_idx
ON take_proposals (source_id, page_slug, content_hash, prompt_version);
ON take_proposals (source_id, page_slug, content_hash, prompt_version, md5(claim_text));
CREATE INDEX IF NOT EXISTS take_proposals_pending_idx
ON take_proposals (source_id, status, proposed_at DESC)
WHERE status = 'pending';
+4 -4
View File
@@ -1273,9 +1273,9 @@ CREATE INDEX IF NOT EXISTS calibration_profiles_published_idx
ON calibration_profiles (source_id, published, holder)
WHERE published = true;
-- take_proposals: propose_takes phase queue. Idempotency cache via the
-- composite unique index (source_id, page_slug, content_hash, prompt_version)
-- mirrors v0.23 dream_verdicts. proposal_run_id supports --rollback by run.
-- take_proposals: propose_takes phase queue. Per-claim idempotency via the
-- composite unique index (source_id, page_slug, content_hash, prompt_version,
-- md5(claim_text)) — #2138: the old per-page key dropped claim #2+. proposal_run_id supports --rollback by run.
CREATE TABLE IF NOT EXISTS take_proposals (
id BIGSERIAL PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
@@ -1301,7 +1301,7 @@ CREATE TABLE IF NOT EXISTS take_proposals (
predicted_brier_bucket_n INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS take_proposals_idempotency_idx
ON take_proposals (source_id, page_slug, content_hash, prompt_version);
ON take_proposals (source_id, page_slug, content_hash, prompt_version, md5(claim_text));
CREATE INDEX IF NOT EXISTS take_proposals_pending_idx
ON take_proposals (source_id, status, proposed_at DESC)
WHERE status = 'pending';
+4 -4
View File
@@ -1269,9 +1269,9 @@ CREATE INDEX IF NOT EXISTS calibration_profiles_published_idx
ON calibration_profiles (source_id, published, holder)
WHERE published = true;
-- take_proposals: propose_takes phase queue. Idempotency cache via the
-- composite unique index (source_id, page_slug, content_hash, prompt_version)
-- mirrors v0.23 dream_verdicts. proposal_run_id supports --rollback by run.
-- take_proposals: propose_takes phase queue. Per-claim idempotency via the
-- composite unique index (source_id, page_slug, content_hash, prompt_version,
-- md5(claim_text)) — #2138: the old per-page key dropped claim #2+. proposal_run_id supports --rollback by run.
CREATE TABLE IF NOT EXISTS take_proposals (
id BIGSERIAL PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
@@ -1297,7 +1297,7 @@ CREATE TABLE IF NOT EXISTS take_proposals (
predicted_brier_bucket_n INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS take_proposals_idempotency_idx
ON take_proposals (source_id, page_slug, content_hash, prompt_version);
ON take_proposals (source_id, page_slug, content_hash, prompt_version, md5(claim_text));
CREATE INDEX IF NOT EXISTS take_proposals_pending_idx
ON take_proposals (source_id, status, proposed_at DESC)
WHERE status = 'pending';
+90
View File
@@ -0,0 +1,90 @@
/**
* #2289 — `gbrain <op> --source __all__` must span all sources, not silently
* fall back to 'default'.
*
* Pre-fix: makeContext passed '__all__' to resolveSourceId, whose
* SOURCE_ID_RE rejects underscores; the swallowing catch then defaulted
* sourceId to 'default', silently scoping an "all sources" query to one
* source. Same catch also swallowed genuinely-bad explicit --source values.
*/
import { describe, test, expect } from 'bun:test';
import { makeContext } from '../src/cli.ts';
import { withEnv } from './helpers/with-env.ts';
import type { BrainEngine } from '../src/core/engine.ts';
// Fresh-brain stub: sources table empty, no config. The non-explicit chain
// terminates at tier 6 ('default').
const stubEngine = {
async executeRaw() {
return [];
},
async getConfig() {
return null;
},
} as unknown as BrainEngine;
describe('makeContext --source handling (#2289)', () => {
test("--source __all__ maps to op-level source_id='__all__', not a silent 'default'", async () => {
await withEnv({ GBRAIN_SOURCE: undefined }, async () => {
const params: Record<string, unknown> = { source: '__all__' };
const ctx = await makeContext(stubEngine, params);
// The sentinel is handed to resolveRequestedScope via params.source_id;
// local callers (remote:false) span the whole brain there.
expect(params.source_id).toBe('__all__');
expect(ctx.remote).toBe(false);
// ctx.sourceId falls through the normal non-explicit chain.
expect(ctx.sourceId).toBe('default');
});
});
test('--source __all__ does not clobber an explicit source_id param', async () => {
await withEnv({ GBRAIN_SOURCE: undefined }, async () => {
const params: Record<string, unknown> = { source: '__all__', source_id: 'wiki' };
await makeContext(stubEngine, params);
expect(params.source_id).toBe('wiki');
});
});
test('invalid explicit --source errors loudly instead of defaulting', async () => {
await withEnv({ GBRAIN_SOURCE: undefined }, async () => {
await expect(makeContext(stubEngine, { source: 'Not_A_Source' })).rejects.toThrow(
/Invalid --source/,
);
});
});
test('explicit --source naming an unregistered source errors loudly', async () => {
await withEnv({ GBRAIN_SOURCE: undefined }, async () => {
await expect(makeContext(stubEngine, { source: 'nope' })).rejects.toThrow(/not found/);
});
});
test("op-owned `source` param (e.g. put_raw_data's data source) is NOT the brain-source selector", async () => {
await withEnv({ GBRAIN_SOURCE: undefined }, async () => {
const op = { params: { source: { type: 'string' } } } as never;
// 'crustdata' is a data-source tag, not a registered brain source —
// must neither throw nor be fed into resolveSourceId.
const params: Record<string, unknown> = { source: 'crustdata' };
const ctx = await makeContext(stubEngine, params, op);
expect(ctx.sourceId).toBe('default');
expect(params.source).toBe('crustdata');
expect(params.source_id).toBeUndefined();
});
});
test('no --source: resolution failure still falls back silently (fresh pre-init brain)', async () => {
const throwingEngine = {
async executeRaw() {
throw new Error('relation "sources" does not exist');
},
async getConfig() {
throw new Error('no config yet');
},
} as unknown as BrainEngine;
await withEnv({ GBRAIN_SOURCE: undefined }, async () => {
const ctx = await makeContext(throwingEngine, {});
expect(ctx.sourceId).toBe('default');
});
});
});
+48
View File
@@ -0,0 +1,48 @@
/**
* #1835 — doctor image_assets: Windows drive-form storage_path under WSL.
*
* A brain synced from the Windows side records storage_path like
* 'D:/brain/assets/img.png'. Under WSL (posix path semantics) that is NOT
* absolute, so pre-fix it was joined onto repoRoot and every such asset was
* flagged missing. resolveImageAssetPath translates drive-form paths to the
* WSL mount (/mnt/d/...) on non-Windows platforms.
*/
import { describe, test, expect } from 'bun:test';
import { resolveImageAssetPath } from '../src/commands/doctor.ts';
describe('resolveImageAssetPath (#1835)', () => {
test('posix absolute path passes through', () => {
expect(resolveImageAssetPath('/srv/assets/img.png', '/repo', 'linux')).toBe(
'/srv/assets/img.png',
);
});
test('repo-relative path joins onto repoRoot', () => {
expect(resolveImageAssetPath('assets/img.png', '/repo', 'linux')).toBe(
'/repo/assets/img.png',
);
});
test('Windows drive form translates to the WSL mount on Linux', () => {
expect(resolveImageAssetPath('D:/brain/assets/img.png', '/repo', 'linux')).toBe(
'/mnt/d/brain/assets/img.png',
);
});
test('backslash Windows drive form also translates', () => {
expect(resolveImageAssetPath('D:\\brain\\assets\\img.png', '/repo', 'linux')).toBe(
'/mnt/d/brain/assets/img.png',
);
});
test('drive letter is lowercased for the mount point', () => {
expect(resolveImageAssetPath('C:/x/y.png', '/repo', 'linux')).toBe('/mnt/c/x/y.png');
});
test('non-drive relative path with a colon is NOT translated', () => {
expect(resolveImageAssetPath('notes:today/img.png', '/repo', 'linux')).toBe(
'/repo/notes:today/img.png',
);
});
});
+33
View File
@@ -266,6 +266,39 @@ describe('tryRedirectPhantom (single phantom orchestration)', () => {
});
});
test('#2412: straggler wipe preserves cli:-sourced facts (#1928 guard parity with extract-facts)', async () => {
await withTempDirs(async ({ brainDir }) => {
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
await putPage('alice', STUB_BODY);
writeMd(brainDir, 'alice', STUB_BODY);
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
// An EXPIRED cli: conversation fact on the phantom slug. The canonical
// migration only moves active rows (expired_at IS NULL), so this row is
// exactly what the post-migration straggler wipe sees. cli: facts are
// DB-only (never fence-owned) — the wipe must not destroy their
// supersession audit trail.
await engine.executeRaw(
`INSERT INTO facts (source_id, entity_slug, fact, kind, valid_from, source, source_markdown_slug, expired_at)
VALUES ('default', 'alice', 'Superseded CLI claim', 'fact', '2020-01-01'::date, 'cli:session-1', 'alice', now())`,
);
// A plain expired straggler with no protected prefix — still wiped.
await engine.executeRaw(
`INSERT INTO facts (source_id, entity_slug, fact, kind, valid_from, source, source_markdown_slug, expired_at)
VALUES ('default', 'alice', 'Stale fence straggler', 'fact', '2020-01-01'::date, 'linkedin', 'alice', now())`,
);
const phantom = await engine.getPage('alice', { sourceId: 'default' });
const result = await tryRedirectPhantom(engine, phantom!, 'default', brainDir, false);
expect(result.outcome).toBe('redirected');
const remaining = await engine.executeRaw<{ fact: string; source: string }>(
`SELECT fact, source FROM facts WHERE source_markdown_slug = 'alice' AND source_id = 'default'`,
);
expect(remaining.map((r) => r.source)).toEqual(['cli:session-1']);
});
});
test('codex #2: real top-level fact-bearing page → not_phantom (residue gate)', async () => {
await withTempDirs(async ({ brainDir }) => {
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
+123
View File
@@ -0,0 +1,123 @@
/**
* #2138 — take_proposals per-claim idempotency (DB-level proof).
*
* Hermetic PGLite. The mock-engine tests in propose-takes.test.ts can't
* observe an ON CONFLICT DO NOTHING drop — only a real unique index can.
* Pre-fix the idempotency index was per-PAGE (source_id, page_slug,
* content_hash, prompt_version), so every claim after the first on a
* multi-claim page was silently dropped. Covers:
* - fresh-install blob: every claim on a multi-claim page lands
* - extractor repeating a claim: dropped without inflating the counter
* - re-run: page-level cache hit, no duplicate rows
* - migration v125: upgrades the old per-page index to the per-claim key
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import {
runPhaseProposeTakes,
type ProposeTakesExtractor,
} from '../src/core/cycle/propose-takes.ts';
import { MIGRATIONS } from '../src/core/migrate.ts';
import type { OperationContext } from '../src/core/operations.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
function ctx(): OperationContext {
return {
engine,
config: {} as never,
logger: { info() {}, warn() {}, error() {} } as never,
dryRun: false,
remote: false,
sourceId: 'default',
};
}
async function countProposals(slug: string): Promise<number> {
const rows = await engine.executeRaw<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM take_proposals WHERE page_slug = $1 AND source_id = 'default'`,
[slug],
);
return parseInt(rows[0]!.n, 10);
}
describe('#2138: per-claim idempotency against the real unique index', () => {
test('multi-claim page keeps every claim; dup claim dropped; re-run cache-hits', async () => {
await engine.putPage('wiki/essays/thesis', {
title: 'thesis',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: 'analysis' as any,
compiled_truth: 'Two strong claims live in this essay.',
frontmatter: {},
timeline: '',
});
const extractor: ProposeTakesExtractor = async () => [
{ claim_text: 'Claim one', kind: 'take', holder: 'brain', weight: 0.6 },
{ claim_text: 'Claim two', kind: 'bet', holder: 'brain', weight: 0.8 },
// Extractor repeating itself — the per-claim key conflicts it away.
{ claim_text: 'Claim one', kind: 'take', holder: 'brain', weight: 0.6 },
];
const result = await runPhaseProposeTakes(ctx(), { extractor });
const details = result.details as Record<string, unknown>;
expect(details.proposals_inserted).toBe(2);
expect(await countProposals('wiki/essays/thesis')).toBe(2);
// Re-run: page-level (content_hash, prompt_version) cache hit — the
// extractor is not consulted for the thesis page and no rows are added
// for it. (Run 1's receipt page also gets scanned on the re-run — that's
// existing Wave B3 behavior, so assertions stay scoped to the thesis slug.)
const again = await runPhaseProposeTakes(ctx(), { extractor });
const d2 = again.details as Record<string, unknown>;
expect(d2.cache_hits).toBe(1);
expect(await countProposals('wiki/essays/thesis')).toBe(2);
});
test('migration v125 upgrades the old per-page index to the per-claim key', async () => {
// Recreate the pre-v125 shape (table exists, per-page unique index).
await engine.executeRaw(`DROP INDEX IF EXISTS take_proposals_idempotency_idx`);
await engine.executeRaw(
`CREATE UNIQUE INDEX take_proposals_idempotency_idx
ON take_proposals (source_id, page_slug, content_hash, prompt_version)`,
);
const m = MIGRATIONS.find((x) => x.version === 125);
expect(m).toBeDefined();
for (const stmt of m!.sql!.split(';').map((s) => s.trim()).filter(Boolean)) {
await engine.executeRaw(stmt);
}
await engine.putPage('wiki/essays/thesis', {
title: 'thesis',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: 'analysis' as any,
compiled_truth: 'Two strong claims live in this essay.',
frontmatter: {},
timeline: '',
});
const extractor: ProposeTakesExtractor = async () => [
{ claim_text: 'Claim one', kind: 'take', holder: 'brain', weight: 0.6 },
{ claim_text: 'Claim two', kind: 'bet', holder: 'brain', weight: 0.8 },
];
const result = await runPhaseProposeTakes(ctx(), { extractor });
const details = result.details as Record<string, unknown>;
expect(details.proposals_inserted).toBe(2);
expect(await countProposals('wiki/essays/thesis')).toBe(2);
});
});
+26 -1
View File
@@ -59,7 +59,10 @@ function buildMockEngine(opts: {
if (existing.has(key)) return [{ id: 1 } as unknown as T];
return [];
}
// INSERT — return nothing
// INSERT ... RETURNING id — one row per successful insert (#2138)
if (sql.includes('INSERT INTO take_proposals')) {
return [{ id: captured.length } as unknown as T];
}
return [];
},
} as unknown as BrainEngine;
@@ -267,6 +270,28 @@ describe('runPhaseProposeTakes — phase integration', () => {
expect(inserts[0]!.params[9]).toBe('market'); // domain
});
test('#2138: multi-claim page inserts EVERY claim with a per-claim conflict target', async () => {
const pages = [buildPage({ slug: 'wiki/essays/thesis', body: 'Two strong claims live in this essay.' })];
const { engine, captured } = buildMockEngine({ pages });
const extractor: ProposeTakesExtractor = async () => [
{ claim_text: 'Claim one', kind: 'take', holder: 'brain', weight: 0.6 },
{ claim_text: 'Claim two', kind: 'bet', holder: 'brain', weight: 0.8 },
];
const result = await runPhaseProposeTakes(buildCtx(engine), { extractor });
const details = result.details as Record<string, unknown>;
expect(details.proposals_inserted).toBe(2);
const inserts = captured.filter(c => c.sql.includes('INSERT INTO take_proposals'));
expect(inserts).toHaveLength(2);
// The conflict target MUST be per-claim (md5(claim_text) folded in) —
// the old per-page target made ON CONFLICT DO NOTHING drop claim #2+.
for (const ins of inserts) {
expect(ins.sql).toContain('md5(claim_text)');
}
expect(inserts.map(i => i.params[5])).toEqual(['Claim one', 'Claim two']);
});
test('cache hit: page already in take_proposals is skipped', async () => {
const body = 'A page that was already processed.';
const pages = [buildPage({ slug: 'wiki/old-page', body })];