Compare commits

...
Author SHA1 Message Date
Garry Tan c15f3f2dd3 Merge remote-tracking branch 'origin/master' into garrytan/copenhagen-v6
Catches up to v0.41.23.0 (#1541). VERSION + package.json kept at
0.41.25.0 (ours, higher semver); CHANGELOG carries both entries with
v0.41.25.0 on top. llms-full.txt regenerated for the merged CHANGELOG.
No code conflicts in src/ — master's extract operator surfaces wave
(#1541) touches different files than this branch's batch-deletes
work in sync.ts.
2026-05-27 08:28:01 -07:00
0da4feffab v0.41.25.0 perf: batch sync deletes (supersedes #1538)
Bulk deletes no longer jam sync for two days. A single commit deleting
73K files used to take ~5 hours (146K individual DB round-trips) and
time out the sync cron every run. This release batches both halves:
~2 minutes on the same scenario, with the per-batch shape inheriting
the v0.41.19 Supavisor retry cathedral so transient connection blips
don't lose work.

Also closes a latent silent-no-op bug class: on brains that didn't pass
`--source`, the old per-file resolver could pull a slug from the wrong
source and then issue a delete against `source_id='default'`, which
would no-op. Files stayed in the brain after the file was gone from
disk. The new path scopes lookup AND delete to the same source.

Productionized from community PR #1538 (@garrytan-agents) per the
CLAUDE.md community-PR-wave workflow. 12 review-driven improvements
layered on top via /plan-eng-review + codex outside voice:

  D1  drop duplicate progress.finish() from PR #1538
  D2  batch BOTH sourceId-set and no-sourceId Phase-1 lookups
  D3  BATCH_SIZE=100 matches v0.12.1 addLinksBatch precedent
  D4  full test coverage: 12 unit + 3 E2E + D1/D9 regressions
  D5  codex outside-voice review (12 findings absorbed)
  D6  scope no-sourceId path to source_id='default' (kills latent bug)
  D7  ORDER BY slug ASC for deterministic duplicate-source_path collapse
  D8  wire deletePages through withRetry(BULK_RETRY_OPTS) + audit
  D9  query-count regression test (the actual perf regression test)
  D10 deletePages returns Promise<string[]> not Promise<number>
  D11 implement on both engines, non-optional interface (no fallback)
  D12 CHANGELOG scoped honestly to delete hotspot

Engine surface:
  BrainEngine.deletePages(slugs, opts?): Promise<string[]>
  PostgresEngine: withRetry wrap, audit 'deletePages', BATCH_SIZE=100
  PGLiteEngine:   raw DELETE ... WHERE slug = ANY($1::text[])
  BATCH_AUDIT_SITES enum + retry.test.ts regression pin updated

Sync rewrite: src/commands/sync.ts delete loop replaced with two-phase
batched path. New sync.deletes.resolve progress event (Phase 1 lookup),
existing sync.deletes covers Phase 2. Per-batch --timeout abort checks
preserve the v0.41.13.0 D-V4-2 contract.

Tests:
  test/sync-batch-deletes.test.ts (NEW, 12 PGLite cases): engine-level
    contract + sync-level integration (D6/D7/D9 regressions) + D1
    structural source-text regression
  test/e2e/sync.test.ts (extended, 3 real-Postgres cases): FK cascade
    through content_chunks, 250-file batch-shape verification, abort
    mid-sync returns partial(timeout) cleanly
  test/core/retry.test.ts BATCH_AUDIT_SITES regression-pin extended

Docs:
  docs/ENGINES.md documents deletePages alongside deletePage
  docs/progress-events.md documents sync.deletes.resolve

Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:25:16 -07:00
15 changed files with 940 additions and 19 deletions
+116
View File
@@ -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
+8
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
0.41.23.0
0.41.25.0
+13 -2
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -140,5 +140,5 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.41.23.0"
"version": "0.41.25.0"
}
+72 -12
View File
@@ -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).
+19
View File
@@ -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).
+24
View File
@@ -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.
+32
View File
@@ -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;
+1
View File
@@ -77,6 +77,7 @@ export const BATCH_AUDIT_SITES = [
'addLinksBatch',
'addTimelineEntriesBatch',
'upsertChunks',
'deletePages',
// extract.ts per-site labels.
'extract.links_inc',
'extract.timeline_inc',
+2
View File
@@ -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',
+223
View File
@@ -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);
});
+411
View File
@@ -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\(\);/,
);
});
});