diff --git a/src/commands/import.ts b/src/commands/import.ts index 2c367d592..2cf7fc424 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -28,7 +28,7 @@ export interface RunImportResult { failures: Array<{ path: string; error: string }>; } -export async function runImport(engine: BrainEngine, args: string[], opts: { commit?: string } = {}): Promise { +export async function runImport(engine: BrainEngine, args: string[], opts: { commit?: string; sourceId?: string } = {}): Promise { const noEmbed = args.includes('--no-embed'); const fresh = args.includes('--fresh'); const jsonOutput = args.includes('--json'); @@ -95,7 +95,7 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com async function processFile(eng: BrainEngine, filePath: string) { const relativePath = relative(dir, filePath); try { - const result = await importFile(eng, filePath, relativePath, { noEmbed }); + const result = await importFile(eng, filePath, relativePath, { noEmbed, sourceId: opts.sourceId }); if (result.status === 'imported') { imported++; chunksCreated += result.chunks; diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 2bb28cb4e..ca5b0802c 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -462,7 +462,7 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise< // Reimport at new path (picks up content changes) const filePath = join(repoPath, to); if (existsSync(filePath)) { - const result = await importFile(engine, filePath, to, { noEmbed }); + const result = await importFile(engine, filePath, to, { noEmbed, sourceId: opts.sourceId }); if (result.status === 'imported') chunksCreated += result.chunks; } pagesAffected.push(newSlug); @@ -496,7 +496,7 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise< continue; } try { - const result = await importFile(engine, filePath, path, { noEmbed }); + const result = await importFile(engine, filePath, path, { noEmbed, sourceId: opts.sourceId }); if (result.status === 'imported') { chunksCreated += result.chunks; pagesAffected.push(result.slug); @@ -656,7 +656,7 @@ async function performFullSync( const { runImport } = await import('./import.ts'); const importArgs = [repoPath]; if (opts.noEmbed) importArgs.push('--no-embed'); - const result = await runImport(engine, importArgs, { commit: headCommit }); + const result = await runImport(engine, importArgs, { commit: headCommit, sourceId: opts.sourceId }); // Bug 9 — gate the full-sync bookmark on success. runImport already // writes its own sync.last_commit conditionally (import.ts), but diff --git a/src/core/engine.ts b/src/core/engine.ts index 00c5f300b..217bee88e 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -113,9 +113,9 @@ export interface BrainEngine { withReservedConnection(fn: (conn: ReservedConnection) => Promise): Promise; // Pages CRUD - getPage(slug: string): Promise; + getPage(slug: string, sourceId?: string): Promise; putPage(slug: string, page: PageInput): Promise; - deletePage(slug: string): Promise; + deletePage(slug: string, sourceId?: string): Promise; listPages(filters?: PageFilters): Promise; resolveSlugs(partial: string): Promise; /** @@ -131,8 +131,8 @@ export interface BrainEngine { getEmbeddingsByChunkIds(ids: number[]): Promise>; // Chunks - upsertChunks(slug: string, chunks: ChunkInput[]): Promise; - getChunks(slug: string): Promise; + upsertChunks(slug: string, chunks: ChunkInput[], sourceId?: string): Promise; + getChunks(slug: string, sourceId?: string): Promise; /** * Count chunks across the entire brain where embedded_at IS NULL. * Pre-flight short-circuit for `embed --stale` so a 100%-embedded brain @@ -148,7 +148,7 @@ export interface BrainEngine { * Bounded by an internal LIMIT of 100000 to mirror listPages. */ listStaleChunks(): Promise; - deleteChunks(slug: string): Promise; + deleteChunks(slug: string, sourceId?: string): Promise; // Links /** @@ -229,9 +229,9 @@ export interface BrainEngine { findOrphanPages(): Promise>; // Tags - addTag(slug: string, tag: string): Promise; - removeTag(slug: string, tag: string): Promise; - getTags(slug: string): Promise; + addTag(slug: string, tag: string, sourceId?: string): Promise; + removeTag(slug: string, tag: string, sourceId?: string): Promise; + getTags(slug: string, sourceId?: string): Promise; // Timeline /** @@ -259,7 +259,7 @@ export interface BrainEngine { getRawData(slug: string, source?: string): Promise; // Versions - createVersion(slug: string): Promise; + createVersion(slug: string, sourceId?: string): Promise; getVersions(slug: string): Promise; revertToVersion(slug: string, versionId: number): Promise; diff --git a/src/core/import-file.ts b/src/core/import-file.ts index 1b9cdc1c5..64b65ce76 100644 --- a/src/core/import-file.ts +++ b/src/core/import-file.ts @@ -185,7 +185,7 @@ export async function importFromContent( engine: BrainEngine, slug: string, content: string, - opts: { noEmbed?: boolean } = {}, + opts: { noEmbed?: boolean; sourceId?: string } = {}, ): Promise { // Reject oversized payloads before any parsing, chunking, or embedding happens. // Uses Buffer.byteLength to count UTF-8 bytes the same way disk size would, @@ -223,7 +223,7 @@ export async function importFromContent( tags: parsed.tags, }; - const existing = await engine.getPage(slug); + const existing = await engine.getPage(slug, opts.sourceId); if (existing?.content_hash === hash) { return { slug, status: 'skipped', chunks: 0, parsedPage }; } @@ -261,7 +261,7 @@ export async function importFromContent( // Transaction wraps all DB writes await engine.transaction(async (tx) => { - if (existing) await tx.createVersion(slug); + if (existing) await tx.createVersion(slug, opts.sourceId); await tx.putPage(slug, { type: parsed.type, @@ -270,23 +270,24 @@ export async function importFromContent( timeline: parsed.timeline || '', frontmatter: parsed.frontmatter, content_hash: hash, + source_id: opts.sourceId, }); // Tag reconciliation: remove stale, add current - const existingTags = await tx.getTags(slug); + const existingTags = await tx.getTags(slug, opts.sourceId); const newTags = new Set(parsed.tags); for (const old of existingTags) { - if (!newTags.has(old)) await tx.removeTag(slug, old); + if (!newTags.has(old)) await tx.removeTag(slug, old, opts.sourceId); } for (const tag of parsed.tags) { - await tx.addTag(slug, tag); + await tx.addTag(slug, tag, opts.sourceId); } if (chunks.length > 0) { - await tx.upsertChunks(slug, chunks); + await tx.upsertChunks(slug, chunks, opts.sourceId); } else { // Content is empty — delete stale chunks so they don't ghost in search results - await tx.deleteChunks(slug); + await tx.deleteChunks(slug, opts.sourceId); } // v0.19.0 E1 — doc↔impl linking: if this markdown page cites code paths @@ -333,7 +334,7 @@ export async function importFromFile( engine: BrainEngine, filePath: string, relativePath: string, - opts: { noEmbed?: boolean } = {}, + opts: { noEmbed?: boolean; sourceId?: string } = {}, ): Promise { // Defense-in-depth: reject symlinks before reading content. const lstat = lstatSync(filePath); @@ -384,7 +385,7 @@ export async function importCodeFile( engine: BrainEngine, relativePath: string, content: string, - opts: { noEmbed?: boolean; force?: boolean } = {}, + opts: { noEmbed?: boolean; force?: boolean; sourceId?: string } = {}, ): Promise { const slug = slugifyCodePath(relativePath); const lang = detectCodeLanguage(relativePath) || 'unknown'; @@ -401,7 +402,7 @@ export async function importCodeFile( .update(JSON.stringify({ title, type: 'code', content, lang, chunker_version: CHUNKER_VERSION })) .digest('hex'); - const existing = await engine.getPage(slug); + const existing = await engine.getPage(slug, opts.sourceId); if (!opts.force && existing?.content_hash === hash) { return { slug, status: 'skipped', chunks: 0 }; } @@ -439,7 +440,7 @@ export async function importCodeFile( // OpenAI API. Order matters: our chunk_index is semantic (tree-sitter // order), so a matching (chunk_index, text_hash) means a verbatim // preserved symbol. - const existingChunks = existing ? await engine.getChunks(slug) : []; + const existingChunks = existing ? await engine.getChunks(slug, opts.sourceId) : []; const existingByKey = new Map(); for (const ec of existingChunks) { existingByKey.set(`${ec.chunk_index}:${ec.chunk_text}`, ec); @@ -474,7 +475,7 @@ export async function importCodeFile( // Store await engine.transaction(async (tx) => { - if (existing) await tx.createVersion(slug); + if (existing) await tx.createVersion(slug, opts.sourceId); await tx.putPage(slug, { type: 'code' as PageType, @@ -484,15 +485,16 @@ export async function importCodeFile( timeline: '', frontmatter: { language: lang, file: relativePath }, content_hash: hash, + source_id: opts.sourceId, }); - await tx.addTag(slug, 'code'); - await tx.addTag(slug, lang); + await tx.addTag(slug, 'code', opts.sourceId); + await tx.addTag(slug, lang, opts.sourceId); if (chunks.length > 0) { - await tx.upsertChunks(slug, chunks); + await tx.upsertChunks(slug, chunks, opts.sourceId); } else { - await tx.deleteChunks(slug); + await tx.deleteChunks(slug, opts.sourceId); } }); @@ -503,7 +505,7 @@ export async function importCodeFile( // chunk IDs are stable. if (extractedEdges.length > 0 && chunks.length > 0) { try { - const persistedChunks = await engine.getChunks(slug); + const persistedChunks = await engine.getChunks(slug, opts.sourceId); const byIndex = new Map(); for (const pc of persistedChunks) { byIndex.set(pc.chunk_index, pc); diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index ee64b964c..54e2c61ab 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -236,11 +236,12 @@ export class PGLiteEngine implements BrainEngine { } // Pages CRUD - async getPage(slug: string): Promise { + async getPage(slug: string, sourceId?: string): Promise { + const useDefault = sourceId === undefined; const { rows } = await this.db.query( `SELECT id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at - FROM pages WHERE slug = $1`, - [slug] + FROM pages WHERE slug = $1 AND source_id = ${useDefault ? "'default'" : '$2'}`, + useDefault ? [slug] : [slug, sourceId] ); if (rows.length === 0) return null; return rowToPage(rows[0] as Record); @@ -250,16 +251,12 @@ export class PGLiteEngine implements BrainEngine { slug = validateSlug(slug); const hash = page.content_hash || contentHash(page); const frontmatter = page.frontmatter || {}; + const sourceId = page.source_id || 'default'; - // v0.18.0 Step 2: source_id relies on the schema DEFAULT 'default' so - // existing callers still target the default source without threading - // a parameter. ON CONFLICT target becomes (source_id, slug) since the - // global UNIQUE(slug) was dropped in migration v17. Step 5+ will - // surface an explicit sourceId param on putPage for multi-source sync. const pageKind = page.page_kind || 'markdown'; const { rows } = await this.db.query( - `INSERT INTO pages (slug, type, page_kind, title, compiled_truth, timeline, frontmatter, content_hash, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, now()) + `INSERT INTO pages (slug, source_id, type, page_kind, title, compiled_truth, timeline, frontmatter, content_hash, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, now()) ON CONFLICT (source_id, slug) DO UPDATE SET type = EXCLUDED.type, page_kind = EXCLUDED.page_kind, @@ -270,13 +267,17 @@ export class PGLiteEngine implements BrainEngine { content_hash = EXCLUDED.content_hash, updated_at = now() RETURNING id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at`, - [slug, page.type, pageKind, page.title, page.compiled_truth, page.timeline || '', JSON.stringify(frontmatter), hash] + [slug, sourceId, page.type, pageKind, page.title, page.compiled_truth, page.timeline || '', JSON.stringify(frontmatter), hash] ); return rowToPage(rows[0] as Record); } - async deletePage(slug: string): Promise { - await this.db.query('DELETE FROM pages WHERE slug = $1', [slug]); + async deletePage(slug: string, sourceId?: string): Promise { + if (sourceId === undefined) { + await this.db.query(`DELETE FROM pages WHERE slug = $1 AND source_id = 'default'`, [slug]); + return; + } + await this.db.query('DELETE FROM pages WHERE slug = $1 AND source_id = $2', [slug, sourceId]); } async listPages(filters?: PageFilters): Promise { @@ -551,9 +552,11 @@ export class PGLiteEngine implements BrainEngine { } // Chunks - async upsertChunks(slug: string, chunks: ChunkInput[]): Promise { + async upsertChunks(slug: string, chunks: ChunkInput[], sourceId?: string): Promise { // Get page_id - const pageResult = await this.db.query('SELECT id FROM pages WHERE slug = $1', [slug]); + const pageResult = sourceId === undefined + ? await this.db.query(`SELECT id FROM pages WHERE slug = $1 AND source_id = 'default'`, [slug]) + : await this.db.query('SELECT id FROM pages WHERE slug = $1 AND source_id = $2', [slug, sourceId]); if (pageResult.rows.length === 0) throw new Error(`Page not found: ${slug}`); const pageId = (pageResult.rows[0] as { id: number }).id; @@ -638,13 +641,18 @@ export class PGLiteEngine implements BrainEngine { ); } - async getChunks(slug: string): Promise { + async getChunks(slug: string, sourceId?: string): Promise { const { rows } = await this.db.query( - `SELECT cc.* FROM content_chunks cc - JOIN pages p ON p.id = cc.page_id - WHERE p.slug = $1 - ORDER BY cc.chunk_index`, - [slug] + sourceId === undefined + ? `SELECT cc.* FROM content_chunks cc + JOIN pages p ON p.id = cc.page_id + WHERE p.slug = $1 AND p.source_id = 'default' + ORDER BY cc.chunk_index` + : `SELECT cc.* FROM content_chunks cc + JOIN pages p ON p.id = cc.page_id + WHERE p.slug = $1 AND p.source_id = $2 + ORDER BY cc.chunk_index`, + sourceId === undefined ? [slug] : [slug, sourceId] ); return (rows as Record[]).map(r => rowToChunk(r)); } @@ -672,11 +680,14 @@ export class PGLiteEngine implements BrainEngine { return rows as unknown as StaleChunkRow[]; } - async deleteChunks(slug: string): Promise { + async deleteChunks(slug: string, sourceId?: string): Promise { await this.db.query( - `DELETE FROM content_chunks - WHERE page_id = (SELECT id FROM pages WHERE slug = $1)`, - [slug] + sourceId === undefined + ? `DELETE FROM content_chunks + WHERE page_id = (SELECT id FROM pages WHERE slug = $1 AND source_id = 'default')` + : `DELETE FROM content_chunks + WHERE page_id = (SELECT id FROM pages WHERE slug = $1 AND source_id = $2)`, + sourceId === undefined ? [slug] : [slug, sourceId] ); } @@ -1017,30 +1028,52 @@ export class PGLiteEngine implements BrainEngine { } // Tags - async addTag(slug: string, tag: string): Promise { + async addTag(slug: string, tag: string, sourceId?: string): Promise { + if (sourceId === undefined) { + await this.db.query( + `INSERT INTO tags (page_id, tag) + SELECT id, $2 FROM pages WHERE slug = $1 AND source_id = 'default' + ON CONFLICT (page_id, tag) DO NOTHING`, + [slug, tag] + ); + return; + } await this.db.query( `INSERT INTO tags (page_id, tag) - SELECT id, $2 FROM pages WHERE slug = $1 + SELECT id, $3 FROM pages WHERE slug = $1 AND source_id = $2 ON CONFLICT (page_id, tag) DO NOTHING`, - [slug, tag] + [slug, sourceId, tag] ); } - async removeTag(slug: string, tag: string): Promise { + async removeTag(slug: string, tag: string, sourceId?: string): Promise { + if (sourceId === undefined) { + await this.db.query( + `DELETE FROM tags + WHERE page_id = (SELECT id FROM pages WHERE slug = $1 AND source_id = 'default') + AND tag = $2`, + [slug, tag] + ); + return; + } await this.db.query( `DELETE FROM tags - WHERE page_id = (SELECT id FROM pages WHERE slug = $1) - AND tag = $2`, - [slug, tag] + WHERE page_id = (SELECT id FROM pages WHERE slug = $1 AND source_id = $2) + AND tag = $3`, + [slug, sourceId, tag] ); } - async getTags(slug: string): Promise { + async getTags(slug: string, sourceId?: string): Promise { const { rows } = await this.db.query( - `SELECT tag FROM tags - WHERE page_id = (SELECT id FROM pages WHERE slug = $1) - ORDER BY tag`, - [slug] + sourceId === undefined + ? `SELECT tag FROM tags + WHERE page_id = (SELECT id FROM pages WHERE slug = $1 AND source_id = 'default') + ORDER BY tag` + : `SELECT tag FROM tags + WHERE page_id = (SELECT id FROM pages WHERE slug = $1 AND source_id = $2) + ORDER BY tag`, + sourceId === undefined ? [slug] : [slug, sourceId] ); return (rows as { tag: string }[]).map(r => r.tag); } @@ -1156,13 +1189,18 @@ export class PGLiteEngine implements BrainEngine { } // Versions - async createVersion(slug: string): Promise { + async createVersion(slug: string, sourceId?: string): Promise { const { rows } = await this.db.query( - `INSERT INTO page_versions (page_id, compiled_truth, frontmatter) - SELECT id, compiled_truth, frontmatter - FROM pages WHERE slug = $1 - RETURNING *`, - [slug] + sourceId === undefined + ? `INSERT INTO page_versions (page_id, compiled_truth, frontmatter) + SELECT id, compiled_truth, frontmatter + FROM pages WHERE slug = $1 AND source_id = 'default' + RETURNING *` + : `INSERT INTO page_versions (page_id, compiled_truth, frontmatter) + SELECT id, compiled_truth, frontmatter + FROM pages WHERE slug = $1 AND source_id = $2 + RETURNING *`, + sourceId === undefined ? [slug] : [slug, sourceId] ); return rows[0] as unknown as PageVersion; } diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index 32faf1a43..de984019b 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -285,12 +285,17 @@ export class PostgresEngine implements BrainEngine { } // Pages CRUD - async getPage(slug: string): Promise { + async getPage(slug: string, sourceId?: string): Promise { const sql = this.sql; - const rows = await sql` - SELECT id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at - FROM pages WHERE slug = ${slug} - `; + const rows = sourceId === undefined + ? await sql` + SELECT id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at + FROM pages WHERE slug = ${slug} AND source_id = 'default' + ` + : await sql` + SELECT id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at + FROM pages WHERE slug = ${slug} AND source_id = ${sourceId} + `; if (rows.length === 0) return null; return rowToPage(rows[0]); } @@ -300,15 +305,12 @@ export class PostgresEngine implements BrainEngine { const sql = this.sql; const hash = page.content_hash || contentHash(page); const frontmatter = page.frontmatter || {}; + const sourceId = page.source_id || 'default'; - // v0.18.0 Step 2: source_id relies on schema DEFAULT 'default'. ON - // CONFLICT target becomes (source_id, slug) since global UNIQUE(slug) - // was dropped in migration v17. See pglite-engine.ts for matching - // notes; multi-source sync (Step 5) will surface an explicit sourceId. const pageKind = page.page_kind || 'markdown'; const rows = await sql` - INSERT INTO pages (slug, type, page_kind, title, compiled_truth, timeline, frontmatter, content_hash, updated_at) - VALUES (${slug}, ${page.type}, ${pageKind}, ${page.title}, ${page.compiled_truth}, ${page.timeline || ''}, ${sql.json(frontmatter as Parameters[0])}, ${hash}, now()) + INSERT INTO pages (slug, source_id, type, page_kind, title, compiled_truth, timeline, frontmatter, content_hash, updated_at) + VALUES (${slug}, ${sourceId}, ${page.type}, ${pageKind}, ${page.title}, ${page.compiled_truth}, ${page.timeline || ''}, ${sql.json(frontmatter as Parameters[0])}, ${hash}, now()) ON CONFLICT (source_id, slug) DO UPDATE SET type = EXCLUDED.type, page_kind = EXCLUDED.page_kind, @@ -323,9 +325,13 @@ export class PostgresEngine implements BrainEngine { return rowToPage(rows[0]); } - async deletePage(slug: string): Promise { + async deletePage(slug: string, sourceId?: string): Promise { const sql = this.sql; - await sql`DELETE FROM pages WHERE slug = ${slug}`; + if (sourceId === undefined) { + await sql`DELETE FROM pages WHERE slug = ${slug} AND source_id = 'default'`; + return; + } + await sql`DELETE FROM pages WHERE slug = ${slug} AND source_id = ${sourceId}`; } async listPages(filters?: PageFilters): Promise { @@ -679,11 +685,13 @@ export class PostgresEngine implements BrainEngine { } // Chunks - async upsertChunks(slug: string, chunks: ChunkInput[]): Promise { + async upsertChunks(slug: string, chunks: ChunkInput[], sourceId?: string): Promise { const sql = this.sql; // Get page_id - const pages = await sql`SELECT id FROM pages WHERE slug = ${slug}`; + const pages = sourceId === undefined + ? await sql`SELECT id FROM pages WHERE slug = ${slug} AND source_id = 'default'` + : await sql`SELECT id FROM pages WHERE slug = ${slug} AND source_id = ${sourceId}`; if (pages.length === 0) throw new Error(`Page not found: ${slug}`); const pageId = pages[0].id; @@ -768,14 +776,21 @@ export class PostgresEngine implements BrainEngine { ); } - async getChunks(slug: string): Promise { + async getChunks(slug: string, sourceId?: string): Promise { const sql = this.sql; - const rows = await sql` - SELECT cc.* FROM content_chunks cc - JOIN pages p ON p.id = cc.page_id - WHERE p.slug = ${slug} - ORDER BY cc.chunk_index - `; + const rows = sourceId === undefined + ? await sql` + SELECT cc.* FROM content_chunks cc + JOIN pages p ON p.id = cc.page_id + WHERE p.slug = ${slug} AND p.source_id = 'default' + ORDER BY cc.chunk_index + ` + : await sql` + SELECT cc.* FROM content_chunks cc + JOIN pages p ON p.id = cc.page_id + WHERE p.slug = ${slug} AND p.source_id = ${sourceId} + ORDER BY cc.chunk_index + `; return rows.map((r) => rowToChunk(r as Record)); } @@ -803,11 +818,18 @@ export class PostgresEngine implements BrainEngine { return rows as unknown as StaleChunkRow[]; } - async deleteChunks(slug: string): Promise { + async deleteChunks(slug: string, sourceId?: string): Promise { const sql = this.sql; + if (sourceId === undefined) { + await sql` + DELETE FROM content_chunks + WHERE page_id = (SELECT id FROM pages WHERE slug = ${slug} AND source_id = 'default') + `; + return; + } await sql` DELETE FROM content_chunks - WHERE page_id = (SELECT id FROM pages WHERE slug = ${slug}) + WHERE page_id = (SELECT id FROM pages WHERE slug = ${slug} AND source_id = ${sourceId}) `; } @@ -1164,11 +1186,11 @@ export class PostgresEngine implements BrainEngine { } // Tags - async addTag(slug: string, tag: string): Promise { + async addTag(slug: string, tag: string, sourceId?: string): Promise { const sql = this.sql; - // Verify page exists before attempting insert (ON CONFLICT DO NOTHING - // swallows the "already tagged" case, but we still need to detect missing pages) - const page = await sql`SELECT id FROM pages WHERE slug = ${slug}`; + const page = sourceId === undefined + ? await sql`SELECT id FROM pages WHERE slug = ${slug} AND source_id = 'default'` + : await sql`SELECT id FROM pages WHERE slug = ${slug} AND source_id = ${sourceId}`; if (page.length === 0) throw new Error(`addTag failed: page "${slug}" not found`); await sql` INSERT INTO tags (page_id, tag) @@ -1177,22 +1199,36 @@ export class PostgresEngine implements BrainEngine { `; } - async removeTag(slug: string, tag: string): Promise { + async removeTag(slug: string, tag: string, sourceId?: string): Promise { const sql = this.sql; + if (sourceId === undefined) { + await sql` + DELETE FROM tags + WHERE page_id = (SELECT id FROM pages WHERE slug = ${slug} AND source_id = 'default') + AND tag = ${tag} + `; + return; + } await sql` DELETE FROM tags - WHERE page_id = (SELECT id FROM pages WHERE slug = ${slug}) + WHERE page_id = (SELECT id FROM pages WHERE slug = ${slug} AND source_id = ${sourceId}) AND tag = ${tag} `; } - async getTags(slug: string): Promise { + async getTags(slug: string, sourceId?: string): Promise { const sql = this.sql; - const rows = await sql` - SELECT tag FROM tags - WHERE page_id = (SELECT id FROM pages WHERE slug = ${slug}) - ORDER BY tag - `; + const rows = sourceId === undefined + ? await sql` + SELECT tag FROM tags + WHERE page_id = (SELECT id FROM pages WHERE slug = ${slug} AND source_id = 'default') + ORDER BY tag + ` + : await sql` + SELECT tag FROM tags + WHERE page_id = (SELECT id FROM pages WHERE slug = ${slug} AND source_id = ${sourceId}) + ORDER BY tag + `; return rows.map((r) => r.tag as string); } @@ -1307,14 +1343,21 @@ export class PostgresEngine implements BrainEngine { } // Versions - async createVersion(slug: string): Promise { + async createVersion(slug: string, sourceId?: string): Promise { const sql = this.sql; - const rows = await sql` - INSERT INTO page_versions (page_id, compiled_truth, frontmatter) - SELECT id, compiled_truth, frontmatter - FROM pages WHERE slug = ${slug} - RETURNING * - `; + const rows = sourceId === undefined + ? await sql` + INSERT INTO page_versions (page_id, compiled_truth, frontmatter) + SELECT id, compiled_truth, frontmatter + FROM pages WHERE slug = ${slug} AND source_id = 'default' + RETURNING * + ` + : await sql` + INSERT INTO page_versions (page_id, compiled_truth, frontmatter) + SELECT id, compiled_truth, frontmatter + FROM pages WHERE slug = ${slug} AND source_id = ${sourceId} + RETURNING * + `; if (rows.length === 0) throw new Error(`createVersion failed: page "${slug}" not found`); return rows[0] as unknown as PageVersion; } diff --git a/src/core/types.ts b/src/core/types.ts index fda549558..7cb8b3458 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -36,6 +36,12 @@ export interface PageInput { * `query --lang` filtering. */ page_kind?: PageKind; + /** + * v0.27.1: optional per-page source scope. Omitted callers keep the schema + * default ('default'); named-source sync/import threads this explicitly so + * writes land under the requested sources.id row. + */ + source_id?: string; } export interface PageFilters {