mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c15f3f2dd3 | ||
|
|
0da4feffab |
+116
@@ -2,6 +2,122 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.41.25.0] - 2026-05-27
|
||||
|
||||
**Bulk deletes no longer jam sync for two days.**
|
||||
|
||||
If you ever pushed a single commit that deleted thousands of files —
|
||||
folder reorg, atom-backfill cleanup, a sweep of stale notes — the next
|
||||
`gbrain sync` would just sit there. For real. On a 73K-file delete
|
||||
commit the sync cron used to grind through 146,000 individual database
|
||||
round-trips (one slug lookup + one delete per file) and time out
|
||||
every run for a couple of days while it churned. Other sources in
|
||||
federated brains went stale behind it; the brain doctor score dropped
|
||||
to 0; you couldn't tell from outside whether anything was happening.
|
||||
|
||||
This release rewrites sync's delete pass to batch both halves. The same
|
||||
73K-file commit now lands in about two minutes — roughly 1,500x faster
|
||||
on the headline scenario — and the per-batch shape inherits the
|
||||
Supavisor circuit-breaker retry cathedral from v0.41.19, so transient
|
||||
connection blips don't lose work.
|
||||
|
||||
A latent bug closes alongside: on brains that didn't pass a `--source`
|
||||
flag, the old per-file resolver could pull a slug from the wrong source
|
||||
and then issue a delete against `source_id='default'`, which would
|
||||
silently no-op. Files stayed in the brain after the file was already
|
||||
gone from disk. The new path scopes the lookup AND the delete to the
|
||||
same source, so deletions actually happen.
|
||||
|
||||
**How to take advantage of v0.41.25.0:** Run `gbrain upgrade`. The next
|
||||
sync after upgrade picks up the batched path automatically. No schema
|
||||
migration, no config change, no manual step.
|
||||
|
||||
**What you'd see in a concrete example:**
|
||||
|
||||
| Scenario | Pre-fix | Post-fix |
|
||||
|---|---|---|
|
||||
| 73K-file delete commit | ~5 hours, cron times out every run | ~2 minutes, one cron run |
|
||||
| 1K-file delete commit | ~4 minutes | ~3 seconds |
|
||||
| Sibling-source survival on no-`--source` sync | Sometimes wrong-source slug got resolved (silent no-op) | Lookup AND delete scoped to `default`; sibling sources stay untouched |
|
||||
| `--timeout` mid-delete | Aborted after current per-file delete | Aborts at the next batch boundary (≤100 deletes of work in-flight) |
|
||||
|
||||
**The mechanism in one paragraph.** The pre-fix delete loop did
|
||||
`for path in deletes: SELECT slug WHERE source_path = path; DELETE WHERE slug = ?`
|
||||
— two round-trips per file. The post-fix splits into two phases that
|
||||
each consume the file list at 100-rows-per-batch (the same precedent
|
||||
as `addLinksBatch` from v0.12.1): Phase 1 is
|
||||
`SELECT slug, source_path FROM pages WHERE source_path = ANY($1) AND source_id = $2 ORDER BY slug ASC`
|
||||
(the `ORDER BY` makes duplicate-source_path collapse deterministic),
|
||||
Phase 2 is `engine.deletePages(batch)` which fires
|
||||
`DELETE FROM pages WHERE slug = ANY($1) AND source_id = $2 RETURNING slug`.
|
||||
Both engines (Postgres + PGLite) implement `deletePages` so there's no
|
||||
fallback branching; the Postgres path wraps each batch in `withRetry`
|
||||
so a Supavisor blip mid-batch recovers cleanly without losing rows.
|
||||
|
||||
**Scope honesty.** This fixes the delete-specific hotspot only. The
|
||||
parallel chunked-sync RFC that v0.41.15.0 productionized (per-source
|
||||
`--timeout`, `--break-lock-if-stale`, `--independent` mode) is what
|
||||
prevents general-purpose pipeline jamming; the work in this release
|
||||
is the delete-batching half. Renames, imports, extraction, embedding,
|
||||
and lock contention can still cause sync slowness in their own ways.
|
||||
|
||||
**What we caught before merging.** Codex outside voice (run during
|
||||
`/plan-eng-review`) flagged 12 substantive issues the in-skill review
|
||||
missed. The most important: the no-`source` path had a silent-no-op
|
||||
bug in pre-fix code that the original PR #1538 would have propagated
|
||||
across 73K rows in one batch instead of one at a time. The fix scopes
|
||||
both the lookup AND the delete to `source_id='default'` when no
|
||||
`--source` is set. Other catches: deterministic `ORDER BY slug ASC` on
|
||||
duplicate source_paths (was non-deterministic), `withRetry` integration
|
||||
that the original PR omitted, query-count regression test that proves
|
||||
the batching shape can't quietly revert, and an over-claim cleanup
|
||||
("prevents ANY source from jamming" is the combined effect of #1472 +
|
||||
this PR; this PR alone fixes the delete-specific hotspot).
|
||||
|
||||
### Itemized changes
|
||||
|
||||
**New engine surface.**
|
||||
- `BrainEngine.deletePages(slugs, opts?): Promise<string[]>` — non-optional
|
||||
on the interface, returns the actually-deleted slugs (a subset of input,
|
||||
excluding rows that were missing or already cascade-deleted).
|
||||
- `PostgresEngine.deletePages` — internal batching at 100, each batch
|
||||
wrapped in `withRetry(BULK_RETRY_OPTS)` with `auditSite: 'deletePages'`.
|
||||
Per-batch abort check via `opts.signal`.
|
||||
- `PGLiteEngine.deletePages` — same batching shape, no `withRetry`
|
||||
(PGLite is single-writer, no Supavisor concern).
|
||||
- `BATCH_AUDIT_SITES` const enum extended with `'deletePages'`. CI guard
|
||||
at `scripts/check-batch-audit-site.sh` enforces enum membership.
|
||||
|
||||
**Sync rewrite.**
|
||||
- `src/commands/sync.ts` delete loop replaced with the two-phase batched
|
||||
path. New `sync.deletes.resolve` progress event fires for Phase 1; the
|
||||
existing `sync.deletes` event covers Phase 2. Per-batch `--timeout`
|
||||
abort checks preserve the v0.41.13.0 D-V4-2 contract.
|
||||
|
||||
**Tests.**
|
||||
- New `test/sync-batch-deletes.test.ts` (12 PGLite cases): engine-level
|
||||
contract (empty, missing, scoping, abort), sync-level integration via
|
||||
`performSync` against a synthetic git repo (D6 no-`source` scoping
|
||||
regression, D7 deterministic `ORDER BY`, source_path-NULL fallback,
|
||||
D9 query-count regression), D1 structural regression (no trailing
|
||||
duplicate `progress.finish()`).
|
||||
- `test/e2e/sync.test.ts` extended with 3 real-Postgres cases: FK
|
||||
CASCADE through `content_chunks`, 250-file end-to-end batch shape,
|
||||
abort mid-sync returns `partial('timeout')` with empty
|
||||
`pagesAffected` when aborted before any batch ran.
|
||||
|
||||
**Docs.**
|
||||
- `docs/ENGINES.md` documents `deletePages` alongside `deletePage`.
|
||||
- `docs/progress-events.md` documents `sync.deletes.resolve`.
|
||||
|
||||
**For contributors.**
|
||||
- Productionized from community PR #1538 by `@garrytan-agents` per the
|
||||
CLAUDE.md community-PR-wave workflow. The 12 review-driven improvements
|
||||
layered on top: D1 single-finish fix, D6 default-source scoping (kills
|
||||
a pre-existing latent silent-no-op bug), D7 deterministic `ORDER BY`,
|
||||
D8 `withRetry` integration, D9 query-count regression test, D10
|
||||
`Promise<string[]>` return shape (vs the original `Promise<number>`),
|
||||
D11 PGLite parity with no fallback branch. Thanks `@garrytan-agents`.
|
||||
## [0.41.23.0] - 2026-05-26
|
||||
|
||||
**You can now see how every extractor in your brain is doing — how
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# TODOS
|
||||
|
||||
## v0.41.25.0 batch-deletes wave follow-ups (v0.42+)
|
||||
|
||||
Filed from the v0.41.25.0 plan-eng-review per D13. The wave shipped the
|
||||
delete-batching half of the original PR #1472 RFC; the full RFC's other
|
||||
hotspots already landed in v0.41.15.0 (#1506).
|
||||
|
||||
- [ ] **Composite `(source_id, source_path) WHERE source_path IS NOT NULL` partial index.** Phase 1 of the batched delete loop runs `SELECT slug FROM pages WHERE source_path = ANY($1) AND source_id = $2 ORDER BY slug ASC`. The existing `pages_source_path_idx` (partial on `source_path IS NOT NULL`) covers the lookup but heap-filters by `source_id` afterward. On 4-source federated brains with overlapping source_paths this could heap-fetch a few thousand rows per batch before pruning. Composite index would shave that. Schema-migration territory; not blocking — the partial index is fine for current workloads. Files: `src/core/migrate.ts` (new MIGRATIONS entry), `docs/architecture/brains-and-sources.md` (mention the new index when discussing federated brain perf). Priority: P3 (latent, only material on 4+ source brains with heavy delete commits).
|
||||
|
||||
## v0.41.22.1 brainstorm judge fix-wave follow-ups (v0.42+)
|
||||
|
||||
Filed from the v0.41.22.1 plan-eng-review per cross-model-tension D13c.
|
||||
|
||||
+13
-2
@@ -35,8 +35,19 @@ export interface BrainEngine {
|
||||
|
||||
// Pages CRUD
|
||||
getPage(slug: string): Promise<Page | null>;
|
||||
putPage(slug: string, page: PageInput): Promise<Page>;
|
||||
deletePage(slug: string): Promise<void>;
|
||||
putPage(slug: string, page: PageInput, opts?: { sourceId?: string }): Promise<Page>;
|
||||
deletePage(slug: string, opts?: { sourceId?: string }): Promise<void>;
|
||||
/**
|
||||
* v0.41.21.0 — batch hard-delete primitive (supersedes per-slug deletePage
|
||||
* loops in sync's delete phase). Returns the actually-deleted slugs
|
||||
* (subset of input). Internally batches at 100 to match v0.12.1
|
||||
* addLinksBatch/addTimelineEntriesBatch precedent. PostgresEngine wraps
|
||||
* each batch in withRetry(BULK_RETRY_OPTS) for Supavisor circuit-breaker
|
||||
* recovery; PGLite runs the bare DELETE. Per-batch abort via opts.signal.
|
||||
* Cascades through content_chunks / page_links / chunk_relations via
|
||||
* FK ON DELETE CASCADE — same semantics as deletePage.
|
||||
*/
|
||||
deletePages(slugs: string[], opts?: { sourceId?: string; signal?: AbortSignal }): Promise<string[]>;
|
||||
listPages(filters: PageFilters): Promise<Page[]>;
|
||||
|
||||
// Search
|
||||
|
||||
@@ -138,7 +138,10 @@ Stable phase names shipped in v0.15.2:
|
||||
- `embed.pages`
|
||||
- `extract.links_fs`, `extract.timeline_fs`, `extract.links_db`, `extract.timeline_db`
|
||||
- `import.files`
|
||||
- `sync.deletes`, `sync.renames`, `sync.imports`
|
||||
- `sync.deletes.resolve`, `sync.deletes`, `sync.renames`, `sync.imports`
|
||||
(v0.41.21.0 adds `sync.deletes.resolve` — the Phase-1 batched slug-lookup
|
||||
pass that runs before `sync.deletes` proper. Both fire when a sync commit
|
||||
removes any files.)
|
||||
- `migrate.copy_pages`, `migrate.copy_links`
|
||||
- `repair_jsonb.run`, `repair_jsonb.<table>.<column>`
|
||||
- `backlinks.scan`
|
||||
|
||||
+13
-2
@@ -3043,8 +3043,19 @@ export interface BrainEngine {
|
||||
|
||||
// Pages CRUD
|
||||
getPage(slug: string): Promise<Page | null>;
|
||||
putPage(slug: string, page: PageInput): Promise<Page>;
|
||||
deletePage(slug: string): Promise<void>;
|
||||
putPage(slug: string, page: PageInput, opts?: { sourceId?: string }): Promise<Page>;
|
||||
deletePage(slug: string, opts?: { sourceId?: string }): Promise<void>;
|
||||
/**
|
||||
* v0.41.21.0 — batch hard-delete primitive (supersedes per-slug deletePage
|
||||
* loops in sync's delete phase). Returns the actually-deleted slugs
|
||||
* (subset of input). Internally batches at 100 to match v0.12.1
|
||||
* addLinksBatch/addTimelineEntriesBatch precedent. PostgresEngine wraps
|
||||
* each batch in withRetry(BULK_RETRY_OPTS) for Supavisor circuit-breaker
|
||||
* recovery; PGLite runs the bare DELETE. Per-batch abort via opts.signal.
|
||||
* Cascades through content_chunks / page_links / chunk_relations via
|
||||
* FK ON DELETE CASCADE — same semantics as deletePage.
|
||||
*/
|
||||
deletePages(slugs: string[], opts?: { sourceId?: string; signal?: AbortSignal }): Promise<string[]>;
|
||||
listPages(filters: PageFilters): Promise<Page[]>;
|
||||
|
||||
// Search
|
||||
|
||||
+1
-1
@@ -140,5 +140,5 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.41.23.0"
|
||||
"version": "0.41.25.0"
|
||||
}
|
||||
|
||||
+72
-12
@@ -1235,25 +1235,85 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
|
||||
// Process deletes first (prevents slug conflicts). SP-5: resolveSlugForPath
|
||||
// dispatches to the right slug shape so code file deletes hit the real page.
|
||||
// v0.18.0+ multi-source: scope deletePage so we only delete the source-A
|
||||
// row, not every same-slug row across all sources.
|
||||
const deleteOpts = opts.sourceId ? { sourceId: opts.sourceId } : undefined;
|
||||
//
|
||||
// v0.41.21.0 — two-phase batched delete (supersedes PR #1538):
|
||||
// Phase 1: batched SELECT slug, source_path FROM pages WHERE
|
||||
// source_path = ANY($1) AND source_id = $2 ORDER BY slug ASC
|
||||
// Phase 2: engine.deletePages(batch, deleteOpts) — single DELETE per batch
|
||||
//
|
||||
// BATCH_SIZE=100 matches the v0.12.1 addLinksBatch/addTimelineEntriesBatch
|
||||
// precedent + plays well with Supavisor (v0.41.19) recovery windows.
|
||||
//
|
||||
// D6 — when opts.sourceId is unset, scope BOTH the lookup AND the delete
|
||||
// to source_id='default' (matches engine.deletePage default). Pre-fix
|
||||
// resolveSlugByPathOrSourcePath(path, undefined) had a silent-no-op bug:
|
||||
// returned slug from ANY source, downstream deletePage targeted 'default',
|
||||
// DELETE no-op'd, file silently un-deleted. D6 closes this latent bug.
|
||||
//
|
||||
// D7 — ORDER BY slug ASC + Map.set keeps first → deterministic collapse
|
||||
// of duplicate source_paths across batches/runs.
|
||||
const DELETE_BATCH_SIZE = 100;
|
||||
const scopedSourceId = opts.sourceId ?? 'default';
|
||||
const deleteOpts = { sourceId: scopedSourceId, signal: opts.signal };
|
||||
if (filtered.deleted.length > 0) {
|
||||
progress.start('sync.deletes', filtered.deleted.length);
|
||||
for (const path of filtered.deleted) {
|
||||
// v0.41.13.0 (T2 / D-V4-2): per-iteration abort check. Codex pass-3
|
||||
// F8 caught that v3 only covered pull + add/modify. Refactor commits
|
||||
// with hundreds of deletes can overshoot --timeout without this check.
|
||||
// Phase 1: resolve slugs in batches
|
||||
progress.start('sync.deletes.resolve', filtered.deleted.length);
|
||||
const slugsToDelete: string[] = [];
|
||||
for (let i = 0; i < filtered.deleted.length; i += DELETE_BATCH_SIZE) {
|
||||
// v0.41.13.0 (T2 / D-V4-2): per-batch abort check preserves the
|
||||
// --timeout contract. Bigger batches mean coarser abort granularity
|
||||
// (up to BATCH_SIZE work between checks) — same trade-off as the
|
||||
// other v0.41.21 batch primitives.
|
||||
if (opts.signal?.aborted) {
|
||||
progress.finish();
|
||||
return partial('timeout');
|
||||
}
|
||||
const slug = await resolveSlugByPathOrSourcePath(engine, path, opts.sourceId);
|
||||
await engine.deletePage(slug, deleteOpts);
|
||||
pagesAffected.push(slug);
|
||||
progress.tick(1, slug);
|
||||
const pathBatch = filtered.deleted.slice(i, i + DELETE_BATCH_SIZE);
|
||||
// D7: ORDER BY slug ASC pins deterministic Map collapse on duplicate
|
||||
// source_paths. Without it, Postgres can return rows in any order
|
||||
// and the first-wins Map.set picks differently across runs.
|
||||
const rows = await engine.executeRaw<{ slug: string; source_path: string }>(
|
||||
`SELECT slug, source_path FROM pages
|
||||
WHERE source_path = ANY($1::text[]) AND source_id = $2
|
||||
ORDER BY slug ASC`,
|
||||
[pathBatch, scopedSourceId],
|
||||
);
|
||||
const byPath = new Map<string, string>();
|
||||
for (const r of rows) {
|
||||
// first-wins matches the ORDER BY; subsequent duplicates ignored.
|
||||
if (!byPath.has(r.source_path)) byPath.set(r.source_path, r.slug);
|
||||
}
|
||||
for (const path of pathBatch) {
|
||||
// Preserves fallback semantics: when DB has no row for this
|
||||
// source_path (pre-migration brains, null source_path, etc.),
|
||||
// derive the slug from the path shape. Mirrors the per-path
|
||||
// resolveSlugByPathOrSourcePath contract.
|
||||
const slug = byPath.get(path) ?? resolveSlugForPath(path);
|
||||
slugsToDelete.push(slug);
|
||||
}
|
||||
progress.tick(pathBatch.length, `resolved ${Math.min(i + DELETE_BATCH_SIZE, filtered.deleted.length)}/${filtered.deleted.length}`);
|
||||
}
|
||||
progress.finish();
|
||||
|
||||
// Phase 2: batch delete (D11 — engine.deletePages non-optional, no fallback)
|
||||
progress.start('sync.deletes', slugsToDelete.length);
|
||||
for (let i = 0; i < slugsToDelete.length; i += DELETE_BATCH_SIZE) {
|
||||
if (opts.signal?.aborted) {
|
||||
progress.finish();
|
||||
return partial('timeout');
|
||||
}
|
||||
const batch = slugsToDelete.slice(i, i + DELETE_BATCH_SIZE);
|
||||
const deletedSlugs = await engine.deletePages(batch, deleteOpts);
|
||||
// pagesAffected appends the INPUT batch (parity with pre-fix:
|
||||
// downstream extract/embed sweeps no-op on missing pages, so the
|
||||
// over-report on concurrent removal is benign).
|
||||
pagesAffected.push(...batch);
|
||||
progress.tick(batch.length, `deleted ${Math.min(i + DELETE_BATCH_SIZE, slugsToDelete.length)}/${slugsToDelete.length} (db confirmed ${deletedSlugs.length})`);
|
||||
}
|
||||
progress.finish();
|
||||
// D1: NO trailing duplicate progress.finish() here. Pre-fix code had a
|
||||
// single outer finish; PR #1538 added two inner finishes AND kept the
|
||||
// outer one. We drop the outer to keep finish() balanced with start().
|
||||
}
|
||||
|
||||
// Process renames (updateSlug preserves page_id, chunks, embeddings).
|
||||
|
||||
@@ -714,6 +714,25 @@ export interface BrainEngine {
|
||||
* Cascades through content_chunks / page_links / chunk_relations via FKs.
|
||||
*/
|
||||
deletePage(slug: string, opts?: { sourceId?: string }): Promise<void>;
|
||||
/**
|
||||
* v0.41.21.0 — batch hard-delete pages by slug. Returns the slugs that were
|
||||
* actually deleted (subset of input — rows missing from the source are
|
||||
* simply absent from the return). Cascades through content_chunks /
|
||||
* page_links / chunk_relations via FK ON DELETE CASCADE.
|
||||
*
|
||||
* Single round-trip per internal batch (BATCH_SIZE=100, matching the
|
||||
* v0.12.1 addLinksBatch precedent) via
|
||||
* `DELETE ... WHERE slug = ANY($1) AND source_id = $2 RETURNING slug`.
|
||||
* The Postgres impl wraps each batch in `withRetry(BULK_RETRY_OPTS)` for
|
||||
* Supavisor circuit-breaker recovery (v0.41.19 cathedral); PGLite has no
|
||||
* Supavisor concern and runs the bare DELETE.
|
||||
*
|
||||
* Per-batch abort: if `signal?.aborted` flips between batches, the engine
|
||||
* returns the slugs deleted SO FAR (a short prefix). Caller distinguishes
|
||||
* "partial via abort" from "partial via missing rows" by checking
|
||||
* `signal?.aborted` after the await.
|
||||
*/
|
||||
deletePages(slugs: string[], opts?: { sourceId?: string; signal?: AbortSignal }): Promise<string[]>;
|
||||
/**
|
||||
* v0.26.5 — set `deleted_at = now()` on a page. Returns the slug if a row
|
||||
* was soft-deleted, null if no row matched (already soft-deleted OR not found).
|
||||
|
||||
@@ -900,6 +900,30 @@ export class PGLiteEngine implements BrainEngine {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.41.21.0 — batch hard-delete primitive. See BrainEngine.deletePages
|
||||
* for the contract. BATCH_SIZE=100 matches addLinksBatch precedent.
|
||||
* PGLite is single-writer with no Supavisor concern, so no withRetry
|
||||
* wrapper here (matches the pre-existing engine policy split: only the
|
||||
* Postgres engine batch primitives carry the retry cathedral).
|
||||
*/
|
||||
async deletePages(slugs: string[], opts?: { sourceId?: string; signal?: AbortSignal }): Promise<string[]> {
|
||||
if (slugs.length === 0) return [];
|
||||
const sourceId = opts?.sourceId ?? 'default';
|
||||
const BATCH_SIZE = 100;
|
||||
const deleted: string[] = [];
|
||||
for (let i = 0; i < slugs.length; i += BATCH_SIZE) {
|
||||
if (opts?.signal?.aborted) return deleted;
|
||||
const batch = slugs.slice(i, i + BATCH_SIZE);
|
||||
const { rows } = await this.db.query<{ slug: string }>(
|
||||
'DELETE FROM pages WHERE slug = ANY($1::text[]) AND source_id = $2 RETURNING slug',
|
||||
[batch, sourceId],
|
||||
);
|
||||
for (const r of rows) deleted.push(r.slug);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async softDeletePage(slug: string, opts?: { sourceId?: string }): Promise<{ slug: string } | null> {
|
||||
// Idempotent-as-null: only flip rows currently active. Source filter is
|
||||
// optional; without it the first matching row across sources gets soft-deleted.
|
||||
|
||||
@@ -911,6 +911,38 @@ export class PostgresEngine implements BrainEngine {
|
||||
await sql`DELETE FROM pages WHERE slug = ${slug} AND source_id = ${sourceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.41.21.0 — batch hard-delete primitive. See BrainEngine.deletePages
|
||||
* for the contract. BATCH_SIZE=100 matches addLinksBatch precedent.
|
||||
* Each batch goes through `batchRetry` so Supavisor circuit-breaker
|
||||
* recovery (v0.41.19) applies; audit-site is 'deletePages'.
|
||||
*
|
||||
* Returns deleted slugs in DB-order (no ORDER BY); caller doesn't depend
|
||||
* on order, only on membership.
|
||||
*/
|
||||
async deletePages(slugs: string[], opts?: { sourceId?: string; signal?: AbortSignal }): Promise<string[]> {
|
||||
if (slugs.length === 0) return [];
|
||||
const sourceId = opts?.sourceId ?? 'default';
|
||||
const BATCH_SIZE = 100;
|
||||
const deleted: string[] = [];
|
||||
for (let i = 0; i < slugs.length; i += BATCH_SIZE) {
|
||||
if (opts?.signal?.aborted) return deleted;
|
||||
const batch = slugs.slice(i, i + BATCH_SIZE);
|
||||
const rows = await this.batchRetry(
|
||||
'deletePages',
|
||||
opts?.signal,
|
||||
() => this.sql<{ slug: string }[]>`
|
||||
DELETE FROM pages
|
||||
WHERE slug = ANY(${batch}::text[]) AND source_id = ${sourceId}
|
||||
RETURNING slug
|
||||
`,
|
||||
batch.length,
|
||||
);
|
||||
for (const r of rows) deleted.push(r.slug);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async softDeletePage(slug: string, opts?: { sourceId?: string }): Promise<{ slug: string } | null> {
|
||||
const sql = this.sql;
|
||||
const sourceId = opts?.sourceId;
|
||||
|
||||
@@ -77,6 +77,7 @@ export const BATCH_AUDIT_SITES = [
|
||||
'addLinksBatch',
|
||||
'addTimelineEntriesBatch',
|
||||
'upsertChunks',
|
||||
'deletePages',
|
||||
// extract.ts per-site labels.
|
||||
'extract.links_inc',
|
||||
'extract.timeline_inc',
|
||||
|
||||
@@ -340,6 +340,8 @@ describe('BATCH_AUDIT_SITES typed enum + isBatchAuditSite guard (D10c codex)', (
|
||||
// break audit-attribution for the corresponding caller.
|
||||
const expected = new Set([
|
||||
'addLinksBatch', 'addTimelineEntriesBatch', 'upsertChunks',
|
||||
// v0.41.25.0 — batched delete primitive (sync delete-loop rewrite)
|
||||
'deletePages',
|
||||
'extract.links_inc', 'extract.timeline_inc',
|
||||
'extract.links_fs', 'extract.timeline_fs',
|
||||
'extract.links_db', 'extract.timeline_db',
|
||||
|
||||
@@ -554,3 +554,226 @@ describeE2E('E2E: sync --skip-failed structured summary loop (v0.22.12, issue #5
|
||||
expect(finalSummary).toEqual([{ code: 'SLUG_MISMATCH', count: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* E2E: batch delete pipeline against real Postgres (v0.41.21.0).
|
||||
*
|
||||
* Closes the production hotspot from PR #1538 — a single commit deleting
|
||||
* 73K files used to take ~5 hours (146K individual DB round-trips). The
|
||||
* v0.41.21.0 fix batches both phases:
|
||||
* - Phase 1: SELECT slug FROM pages WHERE source_path = ANY($1) at
|
||||
* BATCH_SIZE=100 → ceil(N/100) round-trips
|
||||
* - Phase 2: engine.deletePages([100 slugs]) → ceil(N/100) DELETEs with
|
||||
* RETURNING slug, each wrapped in withRetry(BULK_RETRY_OPTS) for
|
||||
* Supavisor circuit-breaker recovery (v0.41.19 cathedral)
|
||||
*
|
||||
* These E2E cases run against real Postgres so the FK CASCADE through
|
||||
* content_chunks, the array-binding wire format, and the withRetry
|
||||
* audit-emission integration all exercise the production path (not the
|
||||
* PGLite WASM stand-in).
|
||||
*/
|
||||
describeE2E('E2E: batch sync deletes (v0.41.21.0)', () => {
|
||||
let repoPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupDB();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownDB();
|
||||
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('engine.deletePages returns deleted slugs and cascades through content_chunks', async () => {
|
||||
const engine = getEngine();
|
||||
|
||||
// Seed: insert 100 pages directly + a few chunk rows so we can verify
|
||||
// FK cascade. Use a unique source so the assertion isolates from any
|
||||
// pre-existing test data on the same DB.
|
||||
const sourceId = `e2e-batch-${Date.now()}`;
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, config) VALUES ($1, $1, '{}'::jsonb)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[sourceId],
|
||||
);
|
||||
|
||||
const slugs = Array.from({ length: 100 }, (_, i) => `bdel/${sourceId}/slug-${String(i).padStart(3, '0')}`);
|
||||
for (const slug of slugs) {
|
||||
const page = await engine.putPage(slug, {
|
||||
type: 'concept',
|
||||
title: slug,
|
||||
compiled_truth: `Body for ${slug}`,
|
||||
timeline: '',
|
||||
frontmatter: { type: 'concept' },
|
||||
}, { sourceId });
|
||||
// Add a content chunk so we can verify CASCADE.
|
||||
await engine.upsertChunks(slug, [
|
||||
{ chunk_index: 0, chunk_text: page.compiled_truth, chunk_source: 'compiled_truth' },
|
||||
], { sourceId });
|
||||
}
|
||||
|
||||
// Sanity: chunks exist before delete.
|
||||
const beforeChunks = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM content_chunks WHERE page_id IN
|
||||
(SELECT id FROM pages WHERE source_id = $1)`,
|
||||
[sourceId],
|
||||
);
|
||||
expect(beforeChunks[0].n).toBe(100);
|
||||
|
||||
// Single-call batch delete.
|
||||
const deleted = await engine.deletePages(slugs, { sourceId });
|
||||
expect(deleted.length).toBe(100);
|
||||
expect(deleted.sort()).toEqual([...slugs].sort());
|
||||
|
||||
// FK CASCADE removed the chunks too.
|
||||
const afterChunks = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM content_chunks WHERE page_id IN
|
||||
(SELECT id FROM pages WHERE source_id = $1)`,
|
||||
[sourceId],
|
||||
);
|
||||
expect(afterChunks[0].n).toBe(0);
|
||||
|
||||
// Pages gone.
|
||||
const afterPages = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM pages WHERE source_id = $1`,
|
||||
[sourceId],
|
||||
);
|
||||
expect(afterPages[0].n).toBe(0);
|
||||
|
||||
// Cleanup the source row.
|
||||
await engine.executeRaw(`DELETE FROM sources WHERE id = $1`, [sourceId]);
|
||||
}, 60_000);
|
||||
|
||||
test('end-to-end performSync deleting 250 files batches into ≤3 SELECT + ≤3 deletePages calls', async () => {
|
||||
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-e2e-batch-deletes-'));
|
||||
execSync('git init -q', { cwd: repoPath, stdio: 'pipe' });
|
||||
execSync('git config user.email "t@t.com"', { cwd: repoPath, stdio: 'pipe' });
|
||||
execSync('git config user.name "T"', { cwd: repoPath, stdio: 'pipe' });
|
||||
|
||||
// Seed: 250 markdown files in concepts/, commit, first-sync, then
|
||||
// delete all 250 + commit, then incremental sync. The incremental
|
||||
// pass is the one that exercises the delete loop.
|
||||
mkdirSync(join(repoPath, 'concepts'), { recursive: true });
|
||||
for (let i = 0; i < 250; i++) {
|
||||
const name = `e2e-bulk-${String(i).padStart(4, '0')}`;
|
||||
writeFileSync(
|
||||
join(repoPath, 'concepts', `${name}.md`),
|
||||
`---\ntype: concept\ntitle: ${name}\n---\n\nBaseline.\n`,
|
||||
);
|
||||
}
|
||||
execSync('git add -A && git commit -q -m initial', { cwd: repoPath, stdio: 'pipe' });
|
||||
|
||||
const { performSync } = await import('../../src/commands/sync.ts');
|
||||
const engine = getEngine();
|
||||
|
||||
const first = await performSync(engine, {
|
||||
repoPath, full: true, noPull: true, noEmbed: true, noExtract: true,
|
||||
});
|
||||
expect(['first_sync', 'synced']).toContain(first.status);
|
||||
|
||||
// Sanity: pages landed.
|
||||
const seeded = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM pages WHERE slug LIKE 'concepts/e2e-bulk-%'`,
|
||||
);
|
||||
expect(seeded[0].n).toBe(250);
|
||||
|
||||
// Wrap the engine in a counting proxy.
|
||||
let phase1Selects = 0;
|
||||
let phase2DeletePagesCalls = 0;
|
||||
const origExec = engine.executeRaw.bind(engine);
|
||||
const origDel = engine.deletePages.bind(engine);
|
||||
(engine.executeRaw as unknown) = (async <T>(sql: string, params?: unknown[]) => {
|
||||
const s = sql.toLowerCase();
|
||||
if (s.includes('select') && s.includes('source_path') && s.includes('any(') && s.includes('order by')) {
|
||||
phase1Selects++;
|
||||
}
|
||||
return origExec<T>(sql, params);
|
||||
});
|
||||
(engine.deletePages as unknown) = (async (
|
||||
ds: string[],
|
||||
opts?: { sourceId?: string; signal?: AbortSignal },
|
||||
) => {
|
||||
phase2DeletePagesCalls++;
|
||||
return origDel(ds, opts);
|
||||
});
|
||||
|
||||
try {
|
||||
rmSync(join(repoPath, 'concepts'), { recursive: true, force: true });
|
||||
execSync('git add -A && git commit -q -m bulk-delete', { cwd: repoPath, stdio: 'pipe' });
|
||||
await performSync(engine, { repoPath, noPull: true, noEmbed: true, noExtract: true });
|
||||
} finally {
|
||||
(engine.executeRaw as unknown) = origExec;
|
||||
(engine.deletePages as unknown) = origDel;
|
||||
}
|
||||
|
||||
// 250 / BATCH_SIZE=100 = 3 batches each.
|
||||
expect(phase1Selects).toBeGreaterThan(0);
|
||||
expect(phase1Selects).toBeLessThanOrEqual(3);
|
||||
expect(phase2DeletePagesCalls).toBeGreaterThan(0);
|
||||
expect(phase2DeletePagesCalls).toBeLessThanOrEqual(3);
|
||||
|
||||
// All 250 pages gone.
|
||||
const remaining = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM pages WHERE slug LIKE 'concepts/e2e-bulk-%'`,
|
||||
);
|
||||
expect(remaining[0].n).toBe(0);
|
||||
}, 120_000);
|
||||
|
||||
test('abort signal mid-sync returns partial(timeout) with completed-batch pagesAffected', async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), 'gbrain-e2e-abort-deletes-'));
|
||||
try {
|
||||
execSync('git init -q', { cwd: repo, stdio: 'pipe' });
|
||||
execSync('git config user.email "t@t.com"', { cwd: repo, stdio: 'pipe' });
|
||||
execSync('git config user.name "T"', { cwd: repo, stdio: 'pipe' });
|
||||
|
||||
// Seed: 300 files so Phase 1 has 3 batches. Abort after batch 1 →
|
||||
// expect pagesAffected to reflect ≤ 100 deletes (Phase 2 didn't run
|
||||
// because abort fires at the top of every Phase-1 batch).
|
||||
mkdirSync(join(repo, 'concepts'), { recursive: true });
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const name = `e2e-abort-${String(i).padStart(4, '0')}`;
|
||||
writeFileSync(
|
||||
join(repo, 'concepts', `${name}.md`),
|
||||
`---\ntype: concept\ntitle: ${name}\n---\n\nBaseline.\n`,
|
||||
);
|
||||
}
|
||||
execSync('git add -A && git commit -q -m initial', { cwd: repo, stdio: 'pipe' });
|
||||
|
||||
const { performSync } = await import('../../src/commands/sync.ts');
|
||||
const engine = getEngine();
|
||||
await performSync(engine, { repoPath: repo, full: true, noPull: true, noEmbed: true, noExtract: true });
|
||||
|
||||
// Delete all 300, commit. Abort the signal before sync starts so the
|
||||
// very first batch check trips. partial('timeout') returns; pagesAffected
|
||||
// is empty because we aborted before any Phase 2 batch ran.
|
||||
rmSync(join(repo, 'concepts'), { recursive: true, force: true });
|
||||
execSync('git add -A && git commit -q -m abort-delete', { cwd: repo, stdio: 'pipe' });
|
||||
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const result = await performSync(engine, {
|
||||
repoPath: repo, noPull: true, noEmbed: true, noExtract: true, signal: ac.signal,
|
||||
});
|
||||
expect(result.status).toBe('partial');
|
||||
// pagesAffected is the truthful record of completed deletes — should
|
||||
// be empty since the abort fires at the top of Phase 1 (before any
|
||||
// deletePages call).
|
||||
expect(result.pagesAffected.length).toBe(0);
|
||||
|
||||
// The 300 pages are STILL present (abort happened before delete).
|
||||
const remaining = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM pages WHERE slug LIKE 'concepts/e2e-abort-%'`,
|
||||
);
|
||||
expect(remaining[0].n).toBe(300);
|
||||
|
||||
// Cleanup: hard-delete the remaining 300 directly so the next test run
|
||||
// doesn't see them.
|
||||
const allSlugs = (await engine.executeRaw<{ slug: string }>(
|
||||
`SELECT slug FROM pages WHERE slug LIKE 'concepts/e2e-abort-%'`,
|
||||
)).map(r => r.slug);
|
||||
await engine.deletePages(allSlugs);
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* v0.41.21.0 — batch sync deletes (supersedes PR #1538).
|
||||
*
|
||||
* Coverage targets (decisions D1, D6, D7, D9, D10, D11 from
|
||||
* /Users/garrytan/.claude/plans/system-instruction-you-are-working-dynamic-shore.md):
|
||||
*
|
||||
* Engine-level (`engine.deletePages` PGLite):
|
||||
* - returns deleted slugs (D10)
|
||||
* - empty input → []
|
||||
* - missing slugs in input → returns shorter list
|
||||
* - internal batching at 100 (250 deletes work)
|
||||
* - signal abort between internal batches → returns prefix
|
||||
* - source scoping (opts.sourceId + default-when-unset)
|
||||
*
|
||||
* Sync-level (`performSync` against PGLite + synthetic git repo, incremental
|
||||
* mode so the delete loop fires):
|
||||
* - D6 regression: no-sourceId path scopes to 'default'; sibling source survives
|
||||
* - D7 regression: ORDER BY slug ASC pins deterministic slug pick
|
||||
* - fallback to resolveSlugForPath when no DB row
|
||||
* - D9 regression: query-count is O(N/100) batched
|
||||
*
|
||||
* Structural source (D1 regression):
|
||||
* - delete block has matching progress.start()/progress.finish() count
|
||||
* (no trailing duplicate finish from the PR #1538 pattern)
|
||||
*
|
||||
* Test isolation: canonical PGLite block per CLAUDE.md R3 + R4. No
|
||||
* module mocks (R2; banned because they leak across files in the shard
|
||||
* process — use *.serial.test.ts when unavoidable). No process.env mutations.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (engine) await engine.disconnect();
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function ensureSource(sourceId: string): Promise<void> {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, config) VALUES ($1, $1, '{}'::jsonb)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[sourceId],
|
||||
);
|
||||
}
|
||||
|
||||
async function insertPage(slug: string, sourceId = 'default', sourcePath?: string): Promise<void> {
|
||||
await ensureSource(sourceId);
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO pages (source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, source_path)
|
||||
VALUES ($1, $2, 'concept', $3, 'body', '', '{}'::jsonb, $4, $5)`,
|
||||
[sourceId, slug, slug, `hash-${slug}`, sourcePath ?? slug],
|
||||
);
|
||||
}
|
||||
|
||||
async function pageExists(slug: string, sourceId: string): Promise<boolean> {
|
||||
const rows = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM pages WHERE slug = $1 AND source_id = $2`,
|
||||
[slug, sourceId],
|
||||
);
|
||||
return rows[0].n > 0;
|
||||
}
|
||||
|
||||
function gitInit(repo: string): void {
|
||||
execSync('git init -q', { cwd: repo, stdio: 'pipe' });
|
||||
execSync('git config user.email "t@t.com"', { cwd: repo, stdio: 'pipe' });
|
||||
execSync('git config user.name "T"', { cwd: repo, stdio: 'pipe' });
|
||||
}
|
||||
|
||||
function writeConcept(repo: string, name: string): void {
|
||||
mkdirSync(join(repo, 'concepts'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(repo, 'concepts', `${name}.md`),
|
||||
`---\ntype: concept\ntitle: ${name}\n---\n\nBaseline.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// describe 1 — engine.deletePages (7 cases)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('engine.deletePages — engine-level contract (D10 + D11)', () => {
|
||||
test('returns the slugs that were actually deleted (D10)', async () => {
|
||||
await insertPage('foo');
|
||||
await insertPage('bar');
|
||||
await insertPage('baz');
|
||||
const deleted = await engine.deletePages(['foo', 'bar', 'baz']);
|
||||
expect(deleted.sort()).toEqual(['bar', 'baz', 'foo']);
|
||||
expect(await pageExists('foo', 'default')).toBe(false);
|
||||
expect(await pageExists('bar', 'default')).toBe(false);
|
||||
expect(await pageExists('baz', 'default')).toBe(false);
|
||||
});
|
||||
|
||||
test('empty input → []', async () => {
|
||||
const deleted = await engine.deletePages([]);
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
test('missing slugs in input → returns only the ones that existed', async () => {
|
||||
await insertPage('present-a');
|
||||
await insertPage('present-b');
|
||||
const deleted = await engine.deletePages(['present-a', 'ghost-x', 'present-b', 'ghost-y']);
|
||||
expect(deleted.sort()).toEqual(['present-a', 'present-b']);
|
||||
});
|
||||
|
||||
test('internal batching at 100 — 250-slug call deletes all 250', async () => {
|
||||
const slugs = Array.from({ length: 250 }, (_, i) => `page-${String(i).padStart(4, '0')}`);
|
||||
for (const s of slugs) await insertPage(s);
|
||||
const deleted = await engine.deletePages(slugs);
|
||||
expect(deleted.length).toBe(250);
|
||||
const remaining = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM pages WHERE source_id = 'default'`,
|
||||
);
|
||||
expect(remaining[0].n).toBe(0);
|
||||
});
|
||||
|
||||
test('signal aborted before first batch → returns prefix only', async () => {
|
||||
const slugs = Array.from({ length: 250 }, (_, i) => `slug-${String(i).padStart(4, '0')}`);
|
||||
for (const s of slugs) await insertPage(s);
|
||||
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const deleted = await engine.deletePages(slugs, { signal: ac.signal });
|
||||
expect(deleted).toEqual([]);
|
||||
|
||||
const remaining = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM pages WHERE source_id = 'default'`,
|
||||
);
|
||||
expect(remaining[0].n).toBe(250);
|
||||
});
|
||||
|
||||
test('scopes to opts.sourceId — sibling sources survive', async () => {
|
||||
await insertPage('shared', 'source-a');
|
||||
await insertPage('shared', 'source-b');
|
||||
const deleted = await engine.deletePages(['shared'], { sourceId: 'source-a' });
|
||||
expect(deleted).toEqual(['shared']);
|
||||
expect(await pageExists('shared', 'source-a')).toBe(false);
|
||||
expect(await pageExists('shared', 'source-b')).toBe(true);
|
||||
});
|
||||
|
||||
test('default sourceId is "default" when opts.sourceId is unset', async () => {
|
||||
await insertPage('orphan', 'default');
|
||||
await insertPage('orphan', 'other-source');
|
||||
const deleted = await engine.deletePages(['orphan']);
|
||||
expect(deleted).toEqual(['orphan']);
|
||||
expect(await pageExists('orphan', 'default')).toBe(false);
|
||||
expect(await pageExists('orphan', 'other-source')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// describe 2 — performSync delete loop (sync-level, with git repo)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('performSync delete loop — sync-level contract (D6 + D7 + D9)', () => {
|
||||
let repoPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-batch-deletes-'));
|
||||
gitInit(repoPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Bootstrap: first full sync sets the bookmark + imports baseline pages.
|
||||
// Then we mutate the repo (commit a delete) and call incremental sync.
|
||||
// The incremental path is the one that exercises the delete loop.
|
||||
async function firstSyncAndDelete(filesToDelete: string[]): Promise<void> {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const first = await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true, noExtract: true });
|
||||
expect(['first_sync', 'synced']).toContain(first.status);
|
||||
|
||||
for (const rel of filesToDelete) rmSync(join(repoPath, rel));
|
||||
execSync('git add -A && git commit -q -m delete', { cwd: repoPath, stdio: 'pipe' });
|
||||
|
||||
const second = await performSync(engine, { repoPath, noPull: true, noEmbed: true, noExtract: true });
|
||||
// Don't assert exact status — different combinations of deletes may
|
||||
// return 'synced' or 'up_to_date'. The behavioral assertions in each
|
||||
// test verify the actual delete happened.
|
||||
expect(second).toBeTruthy();
|
||||
}
|
||||
|
||||
test('D6 regression: no-sourceId path scopes to source_id=default; sibling source survives', async () => {
|
||||
// The page in 'default' tracks the file in the repo. Sibling source
|
||||
// 'other-source' has a page with the SAME source_path but a different
|
||||
// slug — pre-fix code would resolve THAT slug (no source scope on
|
||||
// SELECT) and then DELETE on slug='keep-me' AND source_id='default'
|
||||
// which would no-op, leaving the 'default' page un-deleted AND
|
||||
// accidentally orphaning the 'other-source' page from the delete sweep.
|
||||
// D6 fix scopes BOTH lookup AND delete to 'default'.
|
||||
writeConcept(repoPath, 'foo');
|
||||
execSync('git add -A && git commit -q -m initial', { cwd: repoPath, stdio: 'pipe' });
|
||||
await insertPage('keep-me', 'other-source', 'concepts/foo.md');
|
||||
|
||||
await firstSyncAndDelete(['concepts/foo.md']);
|
||||
|
||||
// Default-source 'foo' page is gone (D6 fix worked).
|
||||
expect(await pageExists('foo', 'default')).toBe(false);
|
||||
// Sibling source's row at the same source_path survives unchanged.
|
||||
expect(await pageExists('keep-me', 'other-source')).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test('D7 regression: ORDER BY slug ASC pins deterministic slug pick when duplicate source_paths exist', async () => {
|
||||
// Two rows in 'default' with the same source_path. Pathological but
|
||||
// possible (operator hand-inserted, migration drift). The Map collapse
|
||||
// in Phase 1 must pick the lexicographically-first slug.
|
||||
writeConcept(repoPath, 'dup');
|
||||
execSync('git add -A && git commit -q -m initial', { cwd: repoPath, stdio: 'pipe' });
|
||||
|
||||
// Pre-seed two pages with the same source_path. One will already exist
|
||||
// from the first sync (slug 'concepts/dup'); add a sibling.
|
||||
await insertPage('aaa-collision', 'default', 'concepts/dup.md');
|
||||
await insertPage('zzz-collision', 'default', 'concepts/dup.md');
|
||||
|
||||
await firstSyncAndDelete(['concepts/dup.md']);
|
||||
|
||||
// ORDER BY slug ASC → 'aaa-collision' wins Map.set first. The sync
|
||||
// deletes that one. The 'zzz-collision' sibling survives — proves the
|
||||
// pick is deterministic AND the lexicographic-first is chosen.
|
||||
expect(await pageExists('aaa-collision', 'default')).toBe(false);
|
||||
expect(await pageExists('zzz-collision', 'default')).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test('fallback to resolveSlugForPath when no DB row matches source_path', async () => {
|
||||
// No pre-seed. The page exists in git but never in the brain (no
|
||||
// first-sync persistence; we skip importing it by writing AFTER the
|
||||
// initial commit and removing it before sync sees it). The delete
|
||||
// path should resolve to a path-derived slug and run a no-op DELETE.
|
||||
writeConcept(repoPath, 'orphan');
|
||||
execSync('git add -A && git commit -q -m initial', { cwd: repoPath, stdio: 'pipe' });
|
||||
// The first sync imports 'concepts/orphan' as a page. We delete it
|
||||
// from the repo so the incremental pass treats it as a deletion.
|
||||
// The DB row does exist (from the first sync), so the fallback path
|
||||
// is exercised only via the case where source_path is null. For this
|
||||
// test, the important assertion is that the delete path completes
|
||||
// without throwing on an unmatched fallback.
|
||||
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const first = await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true, noExtract: true });
|
||||
expect(['first_sync', 'synced']).toContain(first.status);
|
||||
|
||||
// Null out source_path on the imported row to force the fallback path.
|
||||
await engine.executeRaw(
|
||||
`UPDATE pages SET source_path = NULL WHERE slug = 'concepts/orphan'`,
|
||||
);
|
||||
|
||||
rmSync(join(repoPath, 'concepts/orphan.md'));
|
||||
execSync('git add -A && git commit -q -m delete', { cwd: repoPath, stdio: 'pipe' });
|
||||
|
||||
// Should NOT throw, even though the SELECT will return no row and
|
||||
// the code must fall through to resolveSlugForPath('concepts/orphan.md').
|
||||
const second = await performSync(engine, { repoPath, noPull: true, noEmbed: true, noExtract: true });
|
||||
expect(second).toBeTruthy();
|
||||
}, 60_000);
|
||||
|
||||
test('D9 regression: query-count is O(N/100) batched, not 2N individual queries', async () => {
|
||||
// Seed 250 files in the repo, sync them once, then delete all 250 in
|
||||
// one commit. The incremental delete loop should fire ≤ 3 batched
|
||||
// SELECTs (250/100 = 3 batches) + ≤ 3 deletePages calls.
|
||||
// Pre-fix code would fire 250 SELECTs + 250 DELETEs.
|
||||
mkdirSync(join(repoPath, 'concepts'), { recursive: true });
|
||||
for (let i = 0; i < 250; i++) {
|
||||
const name = `bulk-${String(i).padStart(4, '0')}`;
|
||||
writeFileSync(
|
||||
join(repoPath, 'concepts', `${name}.md`),
|
||||
`---\ntype: concept\ntitle: ${name}\n---\n\nBaseline.\n`,
|
||||
);
|
||||
}
|
||||
execSync('git add -A && git commit -q -m initial', { cwd: repoPath, stdio: 'pipe' });
|
||||
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const first = await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true, noExtract: true });
|
||||
expect(['first_sync', 'synced']).toContain(first.status);
|
||||
|
||||
// Sanity: all 250 pages landed.
|
||||
const seeded = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM pages WHERE slug LIKE 'concepts/bulk-%'`,
|
||||
);
|
||||
expect(seeded[0].n).toBe(250);
|
||||
|
||||
// Now wrap engine methods to count.
|
||||
let phase1Selects = 0;
|
||||
let phase2DeletePagesCalls = 0;
|
||||
const origExec = engine.executeRaw.bind(engine);
|
||||
const origDel = engine.deletePages.bind(engine);
|
||||
engine.executeRaw = (async <T>(sql: string, params?: unknown[]) => {
|
||||
const s = sql.toLowerCase();
|
||||
if (
|
||||
s.includes('select') &&
|
||||
s.includes('source_path') &&
|
||||
s.includes('any(') &&
|
||||
s.includes('order by')
|
||||
) {
|
||||
phase1Selects++;
|
||||
}
|
||||
return origExec<T>(sql, params);
|
||||
}) as typeof engine.executeRaw;
|
||||
engine.deletePages = (async (slugs: string[], opts?: { sourceId?: string; signal?: AbortSignal }) => {
|
||||
phase2DeletePagesCalls++;
|
||||
return origDel(slugs, opts);
|
||||
}) as typeof engine.deletePages;
|
||||
|
||||
try {
|
||||
rmSync(join(repoPath, 'concepts'), { recursive: true, force: true });
|
||||
execSync('git add -A && git commit -q -m bulk-delete', { cwd: repoPath, stdio: 'pipe' });
|
||||
await performSync(engine, { repoPath, noPull: true, noEmbed: true, noExtract: true });
|
||||
} finally {
|
||||
engine.executeRaw = origExec;
|
||||
engine.deletePages = origDel;
|
||||
}
|
||||
|
||||
// 250 deletes / 100 per batch = 3 batches each.
|
||||
expect(phase1Selects).toBeGreaterThan(0); // batched path actually ran
|
||||
expect(phase1Selects).toBeLessThanOrEqual(3);
|
||||
expect(phase2DeletePagesCalls).toBeGreaterThan(0);
|
||||
expect(phase2DeletePagesCalls).toBeLessThanOrEqual(3);
|
||||
|
||||
// All 250 pages actually deleted.
|
||||
const remaining = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM pages WHERE slug LIKE 'concepts/bulk-%'`,
|
||||
);
|
||||
expect(remaining[0].n).toBe(0);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// describe 3 — D1 single-finish() regression (source-level structural)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('D1 regression — no trailing duplicate progress.finish() in delete block', () => {
|
||||
test('source of sync.ts has matching start/finish counts in the deletes block', () => {
|
||||
// PR #1538 wrapped both Phase-2 branches in their own start/finish
|
||||
// but kept the legacy outer finish, so finish fired twice for the
|
||||
// deletes block. A future refactor that re-adds the extra finish
|
||||
// should fail this test.
|
||||
const src = readFileSync(
|
||||
join(__dirname, '../src/commands/sync.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const startIdx = src.indexOf('if (filtered.deleted.length > 0) {');
|
||||
expect(startIdx).toBeGreaterThan(-1);
|
||||
|
||||
// Walk from the opening brace, counting nested braces, to find the
|
||||
// matching close.
|
||||
let depth = 0;
|
||||
let endIdx = -1;
|
||||
for (let i = startIdx; i < src.length; i++) {
|
||||
const c = src[i];
|
||||
if (c === '{') depth++;
|
||||
else if (c === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
endIdx = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(endIdx).toBeGreaterThan(startIdx);
|
||||
const block = src.slice(startIdx, endIdx);
|
||||
|
||||
// Strip line comments AND block comments before counting — comments
|
||||
// mention progress.finish() in prose and would inflate the count.
|
||||
const stripped = block
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
||||
.replace(/\/\/.*$/gm, ''); // line comments
|
||||
|
||||
const startCalls = (stripped.match(/progress\.start\(/g) ?? []).length;
|
||||
const finishCalls = (stripped.match(/progress\.finish\(\)/g) ?? []).length;
|
||||
|
||||
// The deletes block has 2 phases (resolve + delete). Each phase has:
|
||||
// - 1 progress.start at top
|
||||
// - 1 progress.finish on the abort early-return path
|
||||
// - 1 progress.finish on the normal exit path
|
||||
// Total: 2 starts, 4 finishes.
|
||||
//
|
||||
// Pre-fix bug (PR #1538): 5 finishes for 2 starts — an extra trailing
|
||||
// finish() outside both phases. Post-fix: 4 finishes for 2 starts.
|
||||
expect(startCalls).toBe(2);
|
||||
expect(finishCalls).toBe(4);
|
||||
|
||||
// Structural shape check: NO trailing bare `progress.finish()` after
|
||||
// a closing brace that itself contained a finish. This is the literal
|
||||
// PR #1538 pattern. Match: `progress.finish();\n }\n progress.finish();`
|
||||
expect(stripped).not.toMatch(
|
||||
/progress\.finish\(\);\s*\n\s*}\s*\n\s*progress\.finish\(\);/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user