mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-16 01:42:23 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
583975dee8 |
@@ -197,7 +197,7 @@ export interface SyncResult {
|
||||
/** Pages re-embedded during this sync's auto-embed step. 0 if --no-embed or skipped. */
|
||||
embedded: number;
|
||||
pagesAffected: string[];
|
||||
failedFiles?: number; // count of parse failures (Bug 9)
|
||||
failedFiles?: number; // count of per-file import/sync failures (Bug 9)
|
||||
/**
|
||||
* v0.41.13.0 partial-sync fields (only set when status === 'partial').
|
||||
*
|
||||
@@ -3183,7 +3183,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
await clearOpCheckpoint(engine, ckpt.target);
|
||||
};
|
||||
|
||||
// issue #1939 adversarial finding #1: a file that failed to parse (open ledger
|
||||
// issue #1939 adversarial finding #1: a file that failed to import (open ledger
|
||||
// row) and is then deleted/renamed-away never re-enters failedFiles and never
|
||||
// imports, so its row would never clear and would age doctor to a permanent
|
||||
// FAIL. Treat removed paths as resolved so the ledger self-heals.
|
||||
@@ -3215,9 +3215,9 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
} else {
|
||||
const fileFailCount = failedFiles.filter(f => isSkippablePath(f.path)).length;
|
||||
serr(
|
||||
`\nSync blocked: ${fileFailCount} file(s) failed to parse:\n` +
|
||||
`\nSync blocked: ${fileFailCount} file(s) failed to import:\n` +
|
||||
`${codeBreakdown}\n\n` +
|
||||
`Fix the frontmatter and re-run, or use 'gbrain sync --skip-failed' to ` +
|
||||
`Fix the listed file errors and re-run, or use 'gbrain sync --skip-failed' to ` +
|
||||
`acknowledge and move on. A file that keeps failing auto-skips after ` +
|
||||
`${resolveAutoSkipThreshold()} consecutive syncs.`,
|
||||
);
|
||||
@@ -5355,7 +5355,7 @@ function printSyncResult(result: SyncResult, sink: NodeJS.WriteStream = process.
|
||||
case 'dry_run':
|
||||
break; // already printed in performSync
|
||||
case 'blocked_by_failures':
|
||||
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to parse.`);
|
||||
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to import.`);
|
||||
write(` See ~/.gbrain/sync-failures.jsonl for details, or run 'gbrain doctor'.`);
|
||||
write(` Fix the files then re-run 'gbrain sync', or 'gbrain sync --skip-failed' to move on.`);
|
||||
break;
|
||||
|
||||
@@ -908,10 +908,6 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
|
||||
'facts.extraction_model',
|
||||
// #2113: output-token cap for the per-turn facts extractor (default 4000).
|
||||
'facts.extraction_max_tokens',
|
||||
// Owner opt-in: let the local stdio MCP pipe read this owner's private facts
|
||||
// (find_trajectory / recall). Default off; HTTP transport ignores it. See
|
||||
// src/core/facts/reader-trust.ts.
|
||||
'facts.trust_local_reads',
|
||||
// Dream cycle config
|
||||
'dream.synthesize.session_corpus_dir',
|
||||
'dream.synthesize.meeting_transcripts_dir',
|
||||
|
||||
@@ -591,8 +591,6 @@ export interface TrajectoryOpts {
|
||||
sourceIds?: string[];
|
||||
/** When true, filters to visibility='world' only. Set by MCP layer from ctx.remote. */
|
||||
remote?: boolean;
|
||||
/** Owner opt-in: read private facts despite `remote`. See facts/reader-trust.ts. */
|
||||
trustedFactReads?: boolean;
|
||||
/** Metric filter. When set, only facts with this canonical metric label participate. */
|
||||
metric?: string;
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import type { OperationContext } from './../operations.ts';
|
||||
import type { FactRow } from './../engine.ts';
|
||||
import { effectiveConfidence } from './decay.ts';
|
||||
import { readableFactVisibilities } from './reader-trust.ts';
|
||||
|
||||
const DEFAULT_TTL_MS = 30_000;
|
||||
const DEFAULT_TOP_K = 10;
|
||||
@@ -51,13 +50,7 @@ export async function getBrainHotMemoryMeta(
|
||||
const sessionId = (ctx as { source_session?: string }).source_session
|
||||
?? null;
|
||||
const allowListHash = hashAllowList(ctx.takesHoldersAllowList);
|
||||
// Visibility tier: untrusted remote → world-only; trusted local +
|
||||
// owner-trusted reads → all rows. Folded into the cache key (the header's
|
||||
// "cache entries don't bleed across tiers" invariant): trustedFactReads is
|
||||
// re-read from config per call, so a mid-session opt-out must not keep
|
||||
// serving a private-inclusive cached payload for the TTL window.
|
||||
const visibility = readableFactVisibilities(ctx);
|
||||
const cacheKey = `${sourceId}::${sessionId ?? '_'}::${allowListHash}::${visibility ? 'world' : 'all'}`;
|
||||
const cacheKey = `${sourceId}::${sessionId ?? '_'}::${allowListHash}`;
|
||||
|
||||
const ttl = Math.max(1000, opts.ttlMs ?? DEFAULT_TTL_MS);
|
||||
const topK = Math.max(1, Math.min(opts.topK ?? DEFAULT_TOP_K, 25));
|
||||
@@ -68,6 +61,10 @@ export async function getBrainHotMemoryMeta(
|
||||
return cached.payload;
|
||||
}
|
||||
|
||||
// Build a fresh payload. Visibility tier: remote → world-only;
|
||||
// local → all rows.
|
||||
const visibility = ctx.remote === false ? undefined : ['world'] as ('world' | 'private')[];
|
||||
|
||||
let rows: FactRow[] = [];
|
||||
if (sessionId) {
|
||||
rows = await ctx.engine.listFactsBySession(sourceId, sessionId, {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Fact-read visibility trust.
|
||||
*
|
||||
* Fact rows are tagged `private` | `world`. Remote/untrusted callers
|
||||
* (`remote === true`) see only `world` rows — the posture that keeps a
|
||||
* published or HTTP-served brain from leaking private claims to strangers.
|
||||
*
|
||||
* But the stdio MCP server is an unauthenticated LOCAL pipe: on a single-owner
|
||||
* machine the caller IS the owner, yet it still defaults `remote: true` for
|
||||
* safety, so the owner's own agent is denied the owner's own private facts
|
||||
* (e.g. `find_trajectory` returns empty over MCP even though the facts exist).
|
||||
*
|
||||
* `trustedFactReads` is a narrow, READ-ONLY trust elevation, deliberately
|
||||
* DECOUPLED from `remote` so every other remote protection — file_upload
|
||||
* confinement, source isolation, fence stripping, takes-holder scoping — stays
|
||||
* fully in force. The stdio MCP server sets it ONLY when the brain owner opts
|
||||
* in via the `facts.trust_local_reads` config (default off). The HTTP/published
|
||||
* transport never sets it, so a served brain stays world-only regardless.
|
||||
*/
|
||||
export interface FactReaderTrust {
|
||||
/**
|
||||
* Mirrors OperationContext.remote. FAIL-CLOSED: anything not strictly
|
||||
* `false` is treated as remote/untrusted (CLAUDE.md trust invariant).
|
||||
*/
|
||||
remote?: boolean;
|
||||
/** Owner opt-in: this remote caller may read private facts. */
|
||||
trustedFactReads?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the reader is restricted to `visibility = 'world'` rows.
|
||||
* Fail-closed: an unset/undefined `remote` is untrusted — only an explicit
|
||||
* `remote: false` (trusted local CLI) or an explicit owner opt-in
|
||||
* (`trustedFactReads: true`) reads private rows.
|
||||
*/
|
||||
export function factsWorldOnly(t: FactReaderTrust): boolean {
|
||||
return t.remote !== false && t.trustedFactReads !== true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility filter for list-style fact reads: `['world']` when the reader is
|
||||
* world-only, `undefined` (no filter — all rows) when it is trusted.
|
||||
*/
|
||||
export function readableFactVisibilities(
|
||||
t: FactReaderTrust,
|
||||
): ('private' | 'world')[] | undefined {
|
||||
return factsWorldOnly(t) ? ['world'] : undefined;
|
||||
}
|
||||
+7
-14
@@ -18,7 +18,6 @@ import { captureEvalCandidate, isEvalCaptureEnabled, isEvalScrubEnabled } from '
|
||||
import type { HybridSearchMeta } from './types.ts';
|
||||
import { extractPageLinks, isAutoLinkEnabled, isAutoTimelineEnabled, isGlobalBasenameEnabled, parseTimelineEntries, makeResolver, type UnresolvedFrontmatterRef } from './link-extraction.ts';
|
||||
import { isFactsBackstopEligible } from './facts/eligibility.ts';
|
||||
import { readableFactVisibilities } from './facts/reader-trust.ts';
|
||||
import { stripTakesFence } from './takes-fence.ts';
|
||||
import { stripFactsFence } from './facts-fence.ts';
|
||||
import { getContentFlag } from './quarantine.ts';
|
||||
@@ -333,15 +332,6 @@ export interface OperationContext {
|
||||
* remote/untrusted (defense in depth in case the type is bypassed via cast).
|
||||
*/
|
||||
remote: boolean;
|
||||
/**
|
||||
* Owner opt-in (`facts.trust_local_reads`): allow this remote caller to read
|
||||
* `private` facts. A NARROW, read-only trust elevation decoupled from
|
||||
* `remote` — every other remote protection (file confinement, source
|
||||
* isolation, fence stripping, takes scoping) stays in force. Set ONLY by the
|
||||
* stdio MCP server when the config is on; the HTTP transport never sets it.
|
||||
* Consulted via `src/core/facts/reader-trust.ts`.
|
||||
*/
|
||||
trustedFactReads?: boolean;
|
||||
/**
|
||||
* Subagent runtime context (v0.16+). Set by the subagent tool dispatcher when
|
||||
* dispatching an op as a tool call from an LLM loop. Used to enforce per-op
|
||||
@@ -3639,7 +3629,6 @@ const find_trajectory: Operation = {
|
||||
entitySlug: p.entity_slug,
|
||||
...scope,
|
||||
remote: ctx.remote === true,
|
||||
trustedFactReads: ctx.trustedFactReads === true,
|
||||
metric,
|
||||
kind,
|
||||
since,
|
||||
@@ -4000,9 +3989,13 @@ const recall: Operation = {
|
||||
const includeExpired = p.include_expired === true;
|
||||
const grep = typeof p.grep === 'string' ? p.grep.toLowerCase() : null;
|
||||
|
||||
// Visibility filter: world-only for untrusted remote callers; all rows for
|
||||
// trusted local CLI and owner-trusted reads (facts.trust_local_reads).
|
||||
const visibility = readableFactVisibilities(ctx);
|
||||
// Visibility filter: remote callers see world-only unless their token
|
||||
// grants elevated visibility (future-proofing; v0.31 ships world-only
|
||||
// for remote, all for local CLI).
|
||||
const visibility =
|
||||
ctx.remote === false
|
||||
? undefined
|
||||
: ['world'] as ('private' | 'world')[];
|
||||
|
||||
let rows: Awaited<ReturnType<typeof ctx.engine.listFactsByEntity>> = [];
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
} from './engine.ts';
|
||||
import { MAX_SEARCH_LIMIT, clampSearchLimit } from './engine.ts';
|
||||
import { withRetry, BULK_RETRY_OPTS, resolveBulkRetryOpts, computeNextDelay, type BatchAuditSite } from './retry.ts';
|
||||
import { factsWorldOnly } from './facts/reader-trust.ts';
|
||||
import { logBatchRetry as auditLogBatchRetry, logBatchExhausted as auditLogBatchExhausted } from './audit/batch-retry-audit.ts';
|
||||
import { runMigrations } from './migrate.ts';
|
||||
import { PGLITE_SCHEMA_SQL, getPGLiteSchema } from './pglite-schema.ts';
|
||||
@@ -1059,6 +1058,16 @@ export class PGLiteEngine implements BrainEngine {
|
||||
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at`,
|
||||
[sourceId, slug, page.type, pageKind, page.title, page.compiled_truth, page.timeline || '', JSON.stringify(frontmatter), hash, effectiveDate, effectiveDateSource, importFilename, chunkerVersion, sourcePath, sourceKind, sourceUri, ingestedVia, ingestedAt]
|
||||
);
|
||||
// #2189: an INSERT … ON CONFLICT DO UPDATE … RETURNING that yields 0 rows
|
||||
// (e.g. a BEFORE trigger suppressing the write) previously crashed in
|
||||
// rowToPage with an opaque "undefined is not an object (row.deleted_at)".
|
||||
// Throw a diagnosable error naming the row instead. Mirrors postgres-engine.ts.
|
||||
if (!rows[0]) {
|
||||
throw new Error(
|
||||
`putPage: INSERT … RETURNING produced no row for slug='${slug}' source_id='${sourceId}'. ` +
|
||||
`A trigger or rule on the pages table may be suppressing the write.`
|
||||
);
|
||||
}
|
||||
return rowToPage(rows[0] as Record<string, unknown>);
|
||||
}
|
||||
|
||||
@@ -4319,10 +4328,7 @@ export class PGLiteEngine implements BrainEngine {
|
||||
const useArray = Array.isArray(opts.sourceIds) && opts.sourceIds.length > 0;
|
||||
const sourceIds = useArray ? opts.sourceIds! : null;
|
||||
const sourceId = opts.sourceId ?? 'default';
|
||||
// Direct-engine contract: unset `remote` here means a trusted in-process
|
||||
// caller (CLI/tests) — the fail-closed default lives in the op layer, which
|
||||
// always passes explicit booleans. Normalize before the fail-closed helper.
|
||||
const remoteFilter = factsWorldOnly({ remote: opts.remote === true, trustedFactReads: opts.trustedFactReads === true });
|
||||
const remoteFilter = opts.remote === true;
|
||||
|
||||
// Build SQL dynamically. PGLite uses $N positional params; we
|
||||
// assemble the WHERE clauses + params array in tandem to keep them
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
SourceRow,
|
||||
} from './engine.ts';
|
||||
import { withRetry, BULK_RETRY_OPTS, resolveBulkRetryOpts, computeNextDelay, type BatchAuditSite } from './retry.ts';
|
||||
import { factsWorldOnly } from './facts/reader-trust.ts';
|
||||
import { logBatchRetry as auditLogBatchRetry, logBatchExhausted as auditLogBatchExhausted } from './audit/batch-retry-audit.ts';
|
||||
import type {
|
||||
DomainBankSampleOpts, CorpusSampleOpts, DomainBankRow,
|
||||
@@ -1120,6 +1119,16 @@ export class PostgresEngine implements BrainEngine {
|
||||
ingested_at = COALESCE(EXCLUDED.ingested_at, pages.ingested_at)
|
||||
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at
|
||||
`;
|
||||
// #2189: an INSERT … ON CONFLICT DO UPDATE … RETURNING that yields 0 rows
|
||||
// (e.g. a BEFORE trigger suppressing the write) previously crashed in
|
||||
// rowToPage with an opaque "undefined is not an object (row.deleted_at)".
|
||||
// Throw a diagnosable error naming the row instead. Mirrors pglite-engine.ts.
|
||||
if (!rows[0]) {
|
||||
throw new Error(
|
||||
`putPage: INSERT … RETURNING produced no row for slug='${slug}' source_id='${sourceId}'. ` +
|
||||
`A trigger or rule on the pages table may be suppressing the write.`
|
||||
);
|
||||
}
|
||||
return rowToPage(rows[0]);
|
||||
}
|
||||
|
||||
@@ -4533,10 +4542,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
const useArray = Array.isArray(opts.sourceIds) && opts.sourceIds.length > 0;
|
||||
const sourceIds = useArray ? opts.sourceIds! : null;
|
||||
const sourceId = opts.sourceId ?? 'default';
|
||||
// Direct-engine contract: unset `remote` here means a trusted in-process
|
||||
// caller (CLI/tests) — the fail-closed default lives in the op layer, which
|
||||
// always passes explicit booleans. Normalize before the fail-closed helper.
|
||||
const remoteFilter = factsWorldOnly({ remote: opts.remote === true, trustedFactReads: opts.trustedFactReads === true });
|
||||
const remoteFilter = opts.remote === true;
|
||||
|
||||
// Source-scope predicate: array path (federated) wins over scalar.
|
||||
// Engine.ts contract: returns chronological points; regressions +
|
||||
|
||||
@@ -30,13 +30,6 @@ export interface ToolResult {
|
||||
export interface DispatchOpts {
|
||||
/** Defaults to true (remote/untrusted). Local CLI callers (`gbrain call`) pass false. */
|
||||
remote?: boolean;
|
||||
/**
|
||||
* Owner opt-in (`facts.trust_local_reads`): let this remote caller read
|
||||
* `private` facts. Set ONLY by the stdio MCP server; the HTTP transport
|
||||
* leaves it unset so a served brain stays world-only. See
|
||||
* `src/core/facts/reader-trust.ts`.
|
||||
*/
|
||||
trustedFactReads?: boolean;
|
||||
/** Override the default stderr logger (e.g. CLI uses console.* directly). */
|
||||
logger?: OperationContext['logger'];
|
||||
/**
|
||||
@@ -210,7 +203,6 @@ export function buildOperationContext(
|
||||
logger: opts.logger || stderrLogger,
|
||||
dryRun: !!params.dry_run,
|
||||
remote: opts.remote ?? true,
|
||||
trustedFactReads: opts.trustedFactReads === true,
|
||||
takesHoldersAllowList: opts.takesHoldersAllowList,
|
||||
// v0.34 D4: sourceId is REQUIRED at the type level. Auto-fill 'default'
|
||||
// for single-source brains and any caller who didn't resolve a sourceId.
|
||||
|
||||
@@ -35,16 +35,6 @@ export async function startMcpServer(engine: BrainEngine) {
|
||||
// shape and cast through `any` (the SDK accepts it via the ServerResult union).
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request: any): Promise<any> => {
|
||||
const { name, arguments: params } = request.params;
|
||||
// Owner opt-in: the stdio pipe is local + unauthenticated, so on a
|
||||
// single-owner machine its caller is the owner. When facts.trust_local_reads
|
||||
// is on, let fact reads (find_trajectory / recall) see this owner's own
|
||||
// private facts. Narrow + read-only — every other remote protection stays
|
||||
// on (remote stays true). HTTP transport never sets this. Best-effort: a
|
||||
// config read blip falls back to the safe world-only default.
|
||||
let trustedFactReads = false;
|
||||
try {
|
||||
trustedFactReads = (await engine.getConfig('facts.trust_local_reads')) === 'true';
|
||||
} catch { /* keep world-only default */ }
|
||||
// v0.28: stdio MCP has no per-token auth (local pipe). Default the
|
||||
// takes-holder allow-list to ['world'] so agent-facing callers don't
|
||||
// see private hunches via takes_list / takes_search / query. Operators
|
||||
@@ -52,7 +42,6 @@ export async function startMcpServer(engine: BrainEngine) {
|
||||
// `gbrain call <op>` (sets remote=false in src/cli.ts).
|
||||
return dispatchToolCall(engine, name, params, {
|
||||
remote: true,
|
||||
trustedFactReads,
|
||||
takesHoldersAllowList: ['world'],
|
||||
// v0.31: source defaults to 'default' for stdio (no per-token scope).
|
||||
// Operators who want a different source on stdio MCP should set
|
||||
|
||||
@@ -165,22 +165,6 @@ describe('findTrajectory — visibility filter (D-CDX-1 / R6)', () => {
|
||||
const all = await engine.findTrajectory({ entitySlug: 'traj-vis-default' });
|
||||
expect(all.length).toBe(2);
|
||||
});
|
||||
|
||||
test('remote=true + trustedFactReads bypasses world-only (owner-trusted reads)', async () => {
|
||||
await insertTyped({ entity_slug: 'traj-vis-trusted', metric: 'mrr', value: 50000, visibility: 'private', valid_from: new Date('2026-01-15') });
|
||||
await insertTyped({ entity_slug: 'traj-vis-trusted', metric: 'mrr', value: 99999, visibility: 'world', valid_from: new Date('2026-04-12') });
|
||||
|
||||
// Untrusted remote: world only.
|
||||
const untrusted = await engine.findTrajectory({ entitySlug: 'traj-vis-trusted', remote: true });
|
||||
expect(untrusted.length).toBe(1);
|
||||
expect(untrusted[0].value).toBe(99999);
|
||||
|
||||
// Owner-trusted remote: sees the private point too. remote stays true — only
|
||||
// fact-read visibility is elevated.
|
||||
const trusted = await engine.findTrajectory({ entitySlug: 'traj-vis-trusted', remote: true, trustedFactReads: true });
|
||||
expect(trusted.length).toBe(2);
|
||||
expect(trusted.map(p => p.value).sort((a, b) => (a! - b!))).toEqual([50000, 99999]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTrajectory — metric + since + until filters', () => {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
factsWorldOnly,
|
||||
readableFactVisibilities,
|
||||
} from '../src/core/facts/reader-trust.ts';
|
||||
|
||||
describe('factsWorldOnly', () => {
|
||||
test('FAIL-CLOSED: unset remote is untrusted (world-only)', () => {
|
||||
expect(factsWorldOnly({})).toBe(true);
|
||||
expect(factsWorldOnly({ remote: undefined })).toBe(true);
|
||||
expect(factsWorldOnly({ trustedFactReads: false })).toBe(true);
|
||||
});
|
||||
|
||||
test('explicit remote=false (trusted local CLI) sees all', () => {
|
||||
expect(factsWorldOnly({ remote: false })).toBe(false);
|
||||
expect(factsWorldOnly({ remote: false, trustedFactReads: false })).toBe(false);
|
||||
});
|
||||
|
||||
test('untrusted remote callers are world-only', () => {
|
||||
expect(factsWorldOnly({ remote: true })).toBe(true);
|
||||
expect(factsWorldOnly({ remote: true, trustedFactReads: false })).toBe(true);
|
||||
});
|
||||
|
||||
test('owner-trusted remote reads bypass the world-only filter', () => {
|
||||
expect(factsWorldOnly({ remote: true, trustedFactReads: true })).toBe(false);
|
||||
});
|
||||
|
||||
test('trustedFactReads is a no-op for an already-trusted local caller', () => {
|
||||
expect(factsWorldOnly({ remote: false, trustedFactReads: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readableFactVisibilities', () => {
|
||||
test("world-only readers get the ['world'] filter (incl. unset remote)", () => {
|
||||
expect(readableFactVisibilities({ remote: true })).toEqual(['world']);
|
||||
expect(readableFactVisibilities({})).toEqual(['world']);
|
||||
});
|
||||
|
||||
test('trusted readers get undefined (no filter — all rows)', () => {
|
||||
expect(readableFactVisibilities({ remote: false })).toBeUndefined();
|
||||
expect(readableFactVisibilities({ remote: true, trustedFactReads: true })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// #2189 regression guard: putPage's INSERT … ON CONFLICT DO UPDATE … RETURNING
|
||||
// can yield 0 rows when brain-local DB state (e.g. a BEFORE INSERT trigger)
|
||||
// suppresses the write. Pre-fix, rowToPage(rows[0]) crashed with the opaque
|
||||
// "undefined is not an object (evaluating 'row.deleted_at')" that failed
|
||||
// ~all files of a code sync. Post-fix, putPage throws a descriptive error
|
||||
// naming the slug + source_id so the failure is diagnosable per-file.
|
||||
//
|
||||
// Same guard lands in postgres-engine.ts (engine-parity invariant); this test
|
||||
// exercises the PGLite side, where the issue was reported.
|
||||
|
||||
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
// Simulate the reporter's state-dependent failure: a trigger that
|
||||
// suppresses inserts for one slug, making RETURNING produce no row.
|
||||
await engine.executeRaw(`
|
||||
CREATE OR REPLACE FUNCTION suppress_pages_insert() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.slug = 'suppressed-page' THEN RETURN NULL; END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
`);
|
||||
await engine.executeRaw(`
|
||||
CREATE TRIGGER suppress_pages_insert_trg
|
||||
BEFORE INSERT ON pages
|
||||
FOR EACH ROW EXECUTE FUNCTION suppress_pages_insert();
|
||||
`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.executeRaw('DROP TRIGGER IF EXISTS suppress_pages_insert_trg ON pages');
|
||||
await engine.executeRaw('DROP FUNCTION IF EXISTS suppress_pages_insert');
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
describe('putPage RETURNING guard (#2189)', () => {
|
||||
test('0-row RETURNING throws a descriptive error, not row.deleted_at TypeError', async () => {
|
||||
let err: Error | undefined;
|
||||
try {
|
||||
await engine.putPage('suppressed-page', {
|
||||
type: 'code',
|
||||
title: 'Suppressed',
|
||||
compiled_truth: 'x',
|
||||
timeline: '',
|
||||
});
|
||||
} catch (e) {
|
||||
err = e as Error;
|
||||
}
|
||||
expect(err).toBeDefined();
|
||||
expect(err!.message).toContain('putPage');
|
||||
expect(err!.message).toContain("slug='suppressed-page'");
|
||||
expect(err!.message).toContain("source_id='default'");
|
||||
// The pre-fix crash signature must be gone.
|
||||
expect(err!.message).not.toContain('deleted_at');
|
||||
});
|
||||
|
||||
test('unsuppressed slugs still upsert normally with the trigger installed', async () => {
|
||||
const page = await engine.putPage('normal-page', {
|
||||
type: 'concept',
|
||||
title: 'Normal',
|
||||
compiled_truth: 'y',
|
||||
timeline: '',
|
||||
});
|
||||
expect(page.slug).toBe('normal-page');
|
||||
expect(page.source_id).toBe('default');
|
||||
});
|
||||
});
|
||||
@@ -375,6 +375,31 @@ describe('performSync dry-run never writes', () => {
|
||||
expect(messages.some(m => m.includes('git pull failed'))).toBe(false);
|
||||
});
|
||||
|
||||
test('first PGLite code sync imports code files without runtime failures', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
mkdirSync(join(repoPath, 'src'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(repoPath, 'src/example.ts'),
|
||||
'export function add(left: number, right: number) { return left + right; }\n',
|
||||
);
|
||||
execSync('git add -A && git commit -m "add code file"', { cwd: repoPath, stdio: 'pipe' });
|
||||
|
||||
const result = await performSync(engine, {
|
||||
repoPath,
|
||||
noPull: true,
|
||||
noEmbed: true,
|
||||
noExtract: true,
|
||||
strategy: 'code',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('first_sync');
|
||||
expect(result.added).toBe(1);
|
||||
expect(result.failedFiles ?? 0).toBe(0);
|
||||
const page = await engine.getPage('src-example-ts');
|
||||
expect(page?.type).toBe('code');
|
||||
expect(page?.frontmatter).toMatchObject({ file: 'src/example.ts', language: 'typescript' });
|
||||
});
|
||||
|
||||
test('incremental dry-run does NOT write to DB or advance the bookmark', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
// First do a real sync to seed the bookmark.
|
||||
|
||||
Reference in New Issue
Block a user