mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 17:02:19 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d56aedb67e | ||
|
|
a9346bd808 |
+23
-6
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 })];
|
||||
|
||||
Reference in New Issue
Block a user