mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-15 17:32:37 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89579780e0 | ||
|
|
cf2deedfc6 |
+14
-1
@@ -1,5 +1,5 @@
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { embedBatch, currentEmbeddingSignature } from '../core/embedding.ts';
|
||||
import { embedBatch, currentEmbeddingSignature, resolveEmbeddingModelLabel } from '../core/embedding.ts';
|
||||
import type { ChunkInput } from '../core/types.ts';
|
||||
import { chunkText } from '../core/chunkers/recursive.ts';
|
||||
import { createProgress, type ProgressReporter } from '../core/progress.ts';
|
||||
@@ -581,11 +581,16 @@ async function embedPage(
|
||||
for (let j = 0; j < toEmbed.length; j++) {
|
||||
embeddingMap.set(toEmbed[j].chunk_index, embeddings[j]);
|
||||
}
|
||||
// #1717: label each (re)embedded chunk with the model that actually
|
||||
// produced its vector. Preserved chunks (not re-embedded this pass) keep
|
||||
// their existing model so a mixed-model page isn't relabeled wholesale.
|
||||
const embedModelLabel = resolveEmbeddingModelLabel();
|
||||
const updated: ChunkInput[] = chunks.map(c => ({
|
||||
chunk_index: c.chunk_index,
|
||||
chunk_text: c.chunk_text,
|
||||
chunk_source: c.chunk_source,
|
||||
embedding: embeddingMap.get(c.chunk_index),
|
||||
model: embeddingMap.has(c.chunk_index) && embedModelLabel ? embedModelLabel : c.model,
|
||||
token_count: c.token_count || Math.ceil(c.chunk_text.length / 4),
|
||||
}));
|
||||
|
||||
@@ -717,12 +722,16 @@ async function embedAll(
|
||||
for (let j = 0; j < toEmbed.length; j++) {
|
||||
embeddingMap.set(toEmbed[j].chunk_index, embeddings[j]);
|
||||
}
|
||||
// #1717: stamp the resolved embedding model on (re)embedded chunks;
|
||||
// preserve the existing model on chunks left untouched.
|
||||
const embedModelLabel = resolveEmbeddingModelLabel();
|
||||
// Preserve ALL chunks, only update embeddings for stale ones
|
||||
const updated: ChunkInput[] = chunks.map(c => ({
|
||||
chunk_index: c.chunk_index,
|
||||
chunk_text: c.chunk_text,
|
||||
chunk_source: c.chunk_source,
|
||||
embedding: embeddingMap.get(c.chunk_index) ?? undefined,
|
||||
model: embeddingMap.has(c.chunk_index) && embedModelLabel ? embedModelLabel : c.model,
|
||||
token_count: c.token_count || Math.ceil(c.chunk_text.length / 4),
|
||||
}));
|
||||
await observed(pacer, () => engine.upsertChunks(page.slug, updated, pageOpts));
|
||||
@@ -1012,11 +1021,15 @@ async function embedAllStale(
|
||||
for (let j = 0; j < stale.length; j++) {
|
||||
staleIdxToEmbedding.set(stale[j].chunk_index, embeddings[j]);
|
||||
}
|
||||
// #1717: label the re-embedded (stale) chunks with the resolved
|
||||
// model; preserve the existing model on the non-stale chunks.
|
||||
const embedModelLabel = resolveEmbeddingModelLabel();
|
||||
const merged: ChunkInput[] = existing.map(c => ({
|
||||
chunk_index: c.chunk_index,
|
||||
chunk_text: c.chunk_text,
|
||||
chunk_source: c.chunk_source,
|
||||
embedding: staleIdxToEmbedding.get(c.chunk_index) ?? undefined,
|
||||
model: staleIdxToEmbedding.has(c.chunk_index) && embedModelLabel ? embedModelLabel : c.model,
|
||||
token_count: c.token_count || Math.ceil(c.chunk_text.length / 4),
|
||||
}));
|
||||
await observed(pacer, () => engine.upsertChunks(slug, merged, { sourceId: keySourceId }));
|
||||
|
||||
@@ -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 per-file import/sync failures (Bug 9)
|
||||
failedFiles?: number; // count of parse 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 import (open ledger
|
||||
// issue #1939 adversarial finding #1: a file that failed to parse (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 import:\n` +
|
||||
`\nSync blocked: ${fileFailCount} file(s) failed to parse:\n` +
|
||||
`${codeBreakdown}\n\n` +
|
||||
`Fix the listed file errors and re-run, or use 'gbrain sync --skip-failed' to ` +
|
||||
`Fix the frontmatter 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 import.`);
|
||||
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to parse.`);
|
||||
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;
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
import type { ChunkInput } from './types.ts';
|
||||
import { embedBatchWithBackoff } from '../commands/embed.ts';
|
||||
import { resolveEmbeddingModelLabel } from './embedding.ts';
|
||||
import { type DbPacer, createNoopPacer, observed } from './db-pacer.ts';
|
||||
import { AbortError } from './abort-check.ts';
|
||||
|
||||
@@ -200,11 +201,17 @@ export async function embedStaleForSource(
|
||||
for (let j = 0; j < stale.length; j++) {
|
||||
staleIdxToEmbedding.set(stale[j].chunk_index, embeddings[j]);
|
||||
}
|
||||
// #1717: label re-embedded chunks with the model that produced the
|
||||
// vector; preserved chunks keep their existing model. Without this,
|
||||
// upsertChunks falls back to DEFAULT_EMBEDDING_MODEL for every chunk
|
||||
// (the same mislabel the embed.ts paths fixed).
|
||||
const embedModelLabel = resolveEmbeddingModelLabel();
|
||||
const merged: ChunkInput[] = existing.map((c) => ({
|
||||
chunk_index: c.chunk_index,
|
||||
chunk_text: c.chunk_text,
|
||||
chunk_source: c.chunk_source,
|
||||
embedding: staleIdxToEmbedding.get(c.chunk_index) ?? undefined,
|
||||
model: staleIdxToEmbedding.has(c.chunk_index) && embedModelLabel ? embedModelLabel : c.model,
|
||||
token_count: c.token_count || Math.ceil(c.chunk_text.length / 4),
|
||||
// Carry through per-chunk metadata. upsertChunks writes these as
|
||||
// EXCLUDED.<col> (not COALESCE), so omitting them here resets image
|
||||
|
||||
@@ -113,6 +113,21 @@ export async function embedBatch(
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the embedding model label (`provider:model`) to stamp onto
|
||||
* `content_chunks.model`, so each chunk records the model that actually
|
||||
* produced its vector instead of the engine's hardcoded default (#1717).
|
||||
* Returns undefined if the gateway is unconfigured; callers then fall back
|
||||
* to the chunk's existing model rather than mislabeling it.
|
||||
*/
|
||||
export function resolveEmbeddingModelLabel(): string | undefined {
|
||||
try {
|
||||
return gatewayGetModel();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Currently-configured embedding model (short form without provider prefix). */
|
||||
export function getEmbeddingModelName(): string {
|
||||
return gatewayGetModel().split(':').slice(1).join(':') || 'text-embedding-3-large';
|
||||
|
||||
+11
-1
@@ -8,7 +8,7 @@ import { chunkText } from './chunkers/recursive.ts';
|
||||
import { chunkCodeText, chunkCodeTextFull, detectCodeLanguage, CHUNKER_VERSION } from './chunkers/code.ts';
|
||||
import { findChunkForOffset } from './chunkers/edge-extractor.ts';
|
||||
import { extractCodeRefs, imageOfCandidates } from './link-extraction.ts';
|
||||
import { embedBatch, embedMultimodal, currentEmbeddingSignature } from './embedding.ts';
|
||||
import { embedBatch, embedMultimodal, currentEmbeddingSignature, resolveEmbeddingModelLabel } from './embedding.ts';
|
||||
import { slugifyPath, slugifyCodePath, isCodeFilePath } from './sync.ts';
|
||||
import type { ChunkInput, PageInput, PageType } from './types.ts';
|
||||
import { computeEffectiveDate } from './effective-date.ts';
|
||||
@@ -716,8 +716,12 @@ export async function importFromContent(
|
||||
? chunks.map((c) => wrapChunkForEmbedding(c.chunk_text, prefix, c.chunk_source))
|
||||
: chunks.map((c) => c.chunk_text);
|
||||
const embeddings = await embedBatch(wrappedTexts);
|
||||
// #1717: label each chunk with the model that actually produced its
|
||||
// vector, not the engine's hardcoded default.
|
||||
const embedModelLabel = resolveEmbeddingModelLabel();
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
chunks[i].embedding = embeddings[i];
|
||||
if (embedModelLabel) chunks[i].model = embedModelLabel;
|
||||
// token_count tracks the wrapped string length so cost reporting
|
||||
// reflects what we actually sent to the embedder.
|
||||
chunks[i].token_count = Math.ceil(wrappedTexts[i].length / 4);
|
||||
@@ -1141,7 +1145,10 @@ export async function importCodeFile(
|
||||
const matched = existingByKey.get(key);
|
||||
if (matched && matched.embedding) {
|
||||
// Reuse the existing embedding verbatim. No API call, no cost.
|
||||
// #1717: carry the existing model label along with the reused vector
|
||||
// so the upsert doesn't relabel it with the engine default.
|
||||
chunks[i]!.embedding = matched.embedding as Float32Array;
|
||||
chunks[i]!.model = matched.model ?? undefined;
|
||||
chunks[i]!.token_count = matched.token_count ?? undefined;
|
||||
} else {
|
||||
needsEmbedIndexes.push(i);
|
||||
@@ -1153,9 +1160,12 @@ export async function importCodeFile(
|
||||
try {
|
||||
const textsToEmbed = needsEmbedIndexes.map((i) => chunks[i]!.chunk_text);
|
||||
const embeddings = await embedBatch(textsToEmbed);
|
||||
// #1717: stamp the model that produced these vectors.
|
||||
const embedModelLabel = resolveEmbeddingModelLabel();
|
||||
for (let j = 0; j < needsEmbedIndexes.length; j++) {
|
||||
const i = needsEmbedIndexes[j]!;
|
||||
chunks[i]!.embedding = embeddings[j]!;
|
||||
if (embedModelLabel) chunks[i]!.model = embedModelLabel;
|
||||
chunks[i]!.token_count = Math.ceil(chunks[i]!.chunk_text.length / 4);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
|
||||
@@ -1058,16 +1058,6 @@ 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>);
|
||||
}
|
||||
|
||||
|
||||
@@ -1119,16 +1119,6 @@ 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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:tes
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
import { embedStaleForSource } from '../src/core/embed-stale.ts';
|
||||
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
|
||||
import type { ChunkInput } from '../src/core/types.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
@@ -276,4 +277,49 @@ describe('embedStaleForSource', () => {
|
||||
// The stale text row actually got its embedding.
|
||||
expect(txtRow.embedded_at).not.toBeNull();
|
||||
});
|
||||
|
||||
// #1717: the backfill path must label re-embedded chunks with the model
|
||||
// that produced the vector, and preserve the existing label on chunks it
|
||||
// did not touch (before the fix, both were reset to the engine default).
|
||||
test('labels re-embedded chunks with the gateway model, preserves untouched labels (#1717)', async () => {
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
env: { OPENAI_API_KEY: 'sk-test-embed-stale-1717' },
|
||||
});
|
||||
try {
|
||||
await engine.putPage('notes/model-label', {
|
||||
type: 'note',
|
||||
title: 'model-label',
|
||||
compiled_truth: '# model-label\n\nseeded',
|
||||
});
|
||||
await engine.upsertChunks('notes/model-label', [
|
||||
{
|
||||
chunk_index: 0,
|
||||
chunk_text: 'already embedded elsewhere',
|
||||
chunk_source: 'compiled_truth',
|
||||
embedding: new Float32Array(1536).fill(0.01),
|
||||
model: 'voyage:voyage-3',
|
||||
token_count: 4,
|
||||
},
|
||||
{
|
||||
chunk_index: 1,
|
||||
chunk_text: 'stale chunk needing embed',
|
||||
chunk_source: 'compiled_truth',
|
||||
token_count: 5,
|
||||
embedding: undefined, // stale
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await embedStaleForSource(engine, 'default', { embedFn: fakeEmbedFn });
|
||||
expect(result.embedded).toBe(1);
|
||||
|
||||
const after = await engine.getChunks('notes/model-label');
|
||||
const preserved = after.find((c) => c.chunk_index === 0)!;
|
||||
const reembedded = after.find((c) => c.chunk_index === 1)!;
|
||||
expect(reembedded.model).toBe('openai:text-embedding-3-large');
|
||||
expect(preserved.model).toBe('voyage:voyage-3');
|
||||
} finally {
|
||||
resetGateway();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,8 @@ mock.module('../src/core/embedding.ts', () => ({
|
||||
// setPageEmbeddingSignature / invalidateStaleSignatureEmbeddings resolve to
|
||||
// null via the Proxy default, so the signature value is inert here.
|
||||
currentEmbeddingSignature: () => 'test:model:1536',
|
||||
// #1717: embed paths stamp this label on (re)embedded chunks.
|
||||
resolveEmbeddingModelLabel: () => 'openai:text-embedding-3-large',
|
||||
}));
|
||||
|
||||
// Import AFTER mocking.
|
||||
@@ -803,3 +805,34 @@ describe('embedAllStale --source threading (D7)', () => {
|
||||
expect((firstCallOpts as { sourceId?: string }).sourceId).toBe('media-corpus');
|
||||
});
|
||||
});
|
||||
|
||||
// #1717: content_chunks.model must record the model that actually produced
|
||||
// each vector, not the gateway/engine default.
|
||||
describe('content_chunks.model labeling (#1717)', () => {
|
||||
test('stamps the resolved embedding model on re-embedded chunks, preserves it on untouched chunks', async () => {
|
||||
let upserted: any[] | undefined;
|
||||
// Chunk 0 is stale (no embedded_at) → gets re-embedded this pass.
|
||||
// Chunk 1 is already embedded with a DIFFERENT model → must be preserved,
|
||||
// not relabeled to the current model.
|
||||
const chunks = [
|
||||
{ chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth', embedded_at: null, model: 'zeroentropyai:zembed-1', token_count: 1 },
|
||||
{ chunk_index: 1, chunk_text: 'b', chunk_source: 'compiled_truth', embedded_at: '2026-01-01', embedding: new Float32Array(1536), model: 'voyage:voyage-3', token_count: 1 },
|
||||
];
|
||||
const engine = mockEngine({
|
||||
getPage: async () => ({ slug: 'notes/x', compiled_truth: 'a', timeline: '', source_id: 'default' }),
|
||||
getChunks: async () => chunks,
|
||||
upsertChunks: async (_slug: string, c: any[]) => { upserted = c; },
|
||||
setPageEmbeddingSignature: async () => null,
|
||||
});
|
||||
|
||||
await runEmbedCore(engine, { slugs: ['notes/x'] });
|
||||
|
||||
expect(upserted).toBeDefined();
|
||||
const byIdx = Object.fromEntries(upserted!.map(c => [c.chunk_index, c]));
|
||||
// Re-embedded chunk carries the model that produced its vector (was
|
||||
// mislabeled with the default before the fix).
|
||||
expect(byIdx[0].model).toBe('openai:text-embedding-3-large');
|
||||
// Untouched chunk keeps its original model — no wholesale relabel.
|
||||
expect(byIdx[1].model).toBe('voyage:voyage-3');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,4 +73,21 @@ describe('importFromContent embedding_signature stamping (F1)', () => {
|
||||
await importFromContent(engine, 'concepts/unstamped', '# Unstamped\n\nbody content.', { noEmbed: true });
|
||||
expect(await signatureOf('concepts/unstamped')).toBeNull();
|
||||
});
|
||||
|
||||
// #1717: content_chunks.model must record the model that produced the
|
||||
// vector (the configured gateway model), not the engine's hardcoded
|
||||
// default. The gateway here is configured to openai:text-embedding-3-large,
|
||||
// which differs from DEFAULT_EMBEDDING_MODEL — so this fails without the
|
||||
// import-path model stamping.
|
||||
test('inline embed labels content_chunks.model with the configured model (#1717)', async () => {
|
||||
await importFromContent(engine, 'concepts/labeled', '# Labeled\n\nsome body content to chunk and embed.', {});
|
||||
const rows = await engine.executeRaw<{ model: string }>(
|
||||
`SELECT cc.model FROM content_chunks cc
|
||||
JOIN pages p ON p.id = cc.page_id
|
||||
WHERE p.slug = $1 AND p.source_id = 'default'`,
|
||||
['concepts/labeled'],
|
||||
);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
for (const r of rows) expect(r.model).toBe('openai:text-embedding-3-large');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// #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,31 +375,6 @@ 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