mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(sync): contention-free page-generation clock — sequence swap The page-generation clock backed the query-cache Layer-1 bookmark via a FOR EACH STATEMENT trigger running `UPDATE page_generation_clock SET value=value+1 WHERE id=1`. That took a transaction-length RowExclusiveLock on one tuple, so every concurrent page writer serialized on the prior writer's COMMIT — sync ran at ~0.8 cores regardless of worker count. Swap to a SEQUENCE bumped by nextval() (a microsecond LWLock, never a row lock). The clock's only contract is monotonic advancement on any page INSERT/UPDATE/DELETE; last_value is non-transactional, so rolled-back or concurrent-uncommitted writers only OVER-invalidate the cache (lose a hit), never serve stale. - migration v118: CREATE SEQUENCE + load-bearing 2-arg setval (is_called= true, floor 1, seeded >= old clock and MAX(generation)) + repoint the trigger function body + DELETE query_cache so no old-clock bookmark survives the swap. v107 left immutable. - query-cache-gate.ts: 3 readers -> SELECT last_value FROM page_generation_clock_seq. - schema.sql + pglite-schema.ts (+ regenerated schema-embedded.ts) ship the sequence on fresh install; table + trigger names retained. - tests: clockValue reads last_value; mechanism proof (trigger fn uses nextval not the row UPDATE); rollback-advances-clock safety pin; real PGLite sequence round-trip (is_called gotcha); shape test requires _seq. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): op_checkpoints array-shape guard — CHECK + repair + defensive loader completed_keys is JSONB and the checkpoint loader runs jsonb_array_elements_text over it. A non-array (scalar) value makes that throw "cannot extract elements from a scalar", which takes down the whole UNION load — including the valid op_checkpoint_paths child rows — and loses all checkpoint progress for that key. No current writer produces a scalar, but an older binary / external script / future bug could. Make the corruption class structurally impossible and self-healing: - migration v119: LOCK TABLE (so an out-of-band scalar can't land between repair and constrain; no-op on single-connection PGLite), repair any pre-existing scalar to '[]' (op_checkpoint_paths child rows are the append-only source of truth, so the reset loses nothing), then add the named CHECK (jsonb_typeof(completed_keys) = 'array') via a pg_constraint IF NOT EXISTS guard. A DB-enforced always-on guard — the correct pattern vs a migration verify-hook, which never runs on already-stamped brains. - schema.sql + pglite-schema.ts (+ regenerated schema-embedded.ts) ship the same NAMED inline CHECK so fresh installs match migrated brains and v119 skips the duplicate. - op-checkpoint.ts loader: gate the legacy arm on jsonb_typeof = 'array' so a scalar parent is skipped (children still load) instead of throwing the whole union, and log a specific corruption warning when one is seen. - tests: CHECK rejects a scalar (exactly one constraint, no blob+migration dupe); loader survives a scalar parent and returns the children; v119 repair converts a scalar to '[]'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(doctor): report actively-running sync via live lock, not stale freshness A slow source that makes partial progress every cycle but never fully completes used to read as permanently "stale" / "never synced" because last_sync_at only advances on a full successful sync. The naive fix (treat recent checkpoint banking as "in progress") is unsafe: a blocked sync banks the good files then writes no anchor, so banking can't tell in-progress from wedged. Use the only honest signal: a LIVE, non-expired per-source sync lock (inspectLock + syncLockId against gbrain_cycle_locks). Every non-skipLock sync holds it and refreshes it; a blocked/failed sync's process has exited (no lock row) and a wedged holder stops refreshing (TTL lapses), so either correctly falls through to the stale path and is NEVER masked. An actively-syncing source (including a never-synced source doing its first sync) counts as synced_recently, preserving the pinned 3-bucket invariant. The lock lookup reuses doctor's existing dynamic db-lock import and swallows any throw (stub engine, pre-lock-table brain) to false, so it can only ADD an in-progress verdict, never suppress a real stale one. Tests (real PGLiteEngine + real lock rows): stale+no-lock -> fail; stale+live-lock -> ok; never-synced+live-lock -> ok; never-synced+no-lock -> fail; expired-TTL lock -> fail (wedged not masked); blocked source with banked checkpoint rows but no lock -> still fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): honest --force-break-lock diagnostic when no lock is held --force-break-lock used to emit the same terse "Lock ... is not held (nothing to break)" line and exit 0 even when a sync was genuinely wedged, sending the operator down a dead end — the wedge was not a held lock. Keep rc=0 (breaking a non-existent lock is idempotently successful; flipping the exit code would break automation), but under --force say plainly that nothing was broken and point at the real next step (gbrain sync / gbrain doctor) plus a `wedge_hint` field in --json output. The non-force path is byte-for-byte unchanged. runBreakLock is exported for the test. Tests: force+no-lock -> wedge_hint JSON + human hint, rc 0; non-force+no-lock -> unchanged terse line, no hint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(doctor): surface the in-progress sync holder in the freshness message Plan-completion follow-up to the BUG 4 live-lock signal: when a source is actively syncing, name the holder (pid + host) in the check message instead of silently folding it into synced_recently. The note is appended only when something is in progress, so steady-state messages stay byte-for-byte unchanged (the pinned exact-message + 3-bucket-invariant tests still pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): pre-landing review fixes — monotonic clock seed, scoped CHECK guard Adversarial (codex) review of the implementation diff caught three: - P1 (correctness): the fresh-schema setval was not monotonic. initSchema replays the schema blob, and the unconditional setval(MAX(generation)) could move page_generation_clock_seq.last_value BACKWARD on an already-upgraded brain, letting a stored query_cache bookmark serve stale rows. Seed via GREATEST over the sequence's OWN last_value (+ old table value + MAX(generation)) in all 3 fresh schemas and migration v118, so a replay is idempotent — mirrors the old table's ON CONFLICT DO NOTHING. Pinned by a new monotonic regression test. - P2: v119's CHECK-exists guard keyed on conname only (not globally unique). Scope it to conrelid = 'op_checkpoints'::regclass. - P3: in-progress note ran into the prior sentence in fail/warn doctor messages; separate it with '. '. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: make Anthropic/ZE no-key tests hermetic against a dev config key These "no key" tests cleared only ANTHROPIC_API_KEY / ZEROENTROPY_API_KEY from the env, but hasAnthropicKey() and checkZeEmbeddingHealth() also read the key from ~/.gbrain/config.json. On a dev machine whose real config holds a key, the no-key assertions flipped and the tests failed locally (they passed only in key-less CI). Add a shared with-env emptyHome() helper and point GBRAIN_HOME at an empty dir in every no-key path so loadConfig finds nothing — matching the already-hermetic anthropic-key / gateway-probe tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.44.1.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(key-files): sync doctor + op-checkpoint entries to v0.44.1.0 truth checkSyncFreshness now reports an actively-running sync via the live per-source lock (names holder pid+host, counts as synced_recently) instead of flagging it stale; loadOpCheckpoint gates the legacy union arm on jsonb_typeof = 'array' so a scalar parent can't take down the whole load, and migration v119's CHECK constraint makes the corruption class structurally impossible. Reference docs describe current behavior only — both entries updated in place, no release-clause appends. Guard + llms freshness test green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-version to v0.42.51.0 (natural next-off-master) Maintainer override of the queue allocator's leap to 0.44.1.0 (it jumped past in-flight sibling PR claims at 0.42.50/0.43.0/0.44.0). Take the natural next slot in the 0.42.x line above the immediate sibling claim (0.42.50.0); a merge re-bump resolves any collision if a cathedral PR lands first. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(e2e): bound + retry the OpenClaw install so a transient npm hang can't burn the Tier 2 budget The Tier 2 (LLM Skills) job failed at 30m16s — the `npm install -g openclaw@2026.4.9` step hung on a transient npm/registry stall (orphan `npm install openclaw` was still running at cancel time) and consumed the entire 30m job budget that v0.42.50.0 (#2254) introduced. The install normally finishes in under a minute (Tier 2 is ~4m end to end on master), so this is flaky-install infra, not a test failure. Wrap the install in `timeout 120` + a 3-attempt retry loop with an 8-minute step backstop: a hung attempt is killed in 2 min and retried instead of eating the whole job. Same bound-the-hang philosophy as #2254's job timeouts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
316 lines
14 KiB
TypeScript
316 lines
14 KiB
TypeScript
/**
|
|
* v0.41.19.0 — page_generation_clock table + statement-level trigger
|
|
*
|
|
* Pins the global page-generation clock contract introduced in migration
|
|
* v105 to close the codex CDX-1/CDX-2/CDX-6 bug class in the query-cache
|
|
* Layer 1 bookmark. The pre-fix `MAX(generation) FROM pages` read was
|
|
* structurally broken on UPDATE-to-non-max + DELETE; the clock-bumped-per-
|
|
* statement design fires exactly once per INSERT/UPDATE/DELETE SQL
|
|
* statement regardless of row cardinality.
|
|
*
|
|
* Coverage (per D11 + D14 + CDX-7):
|
|
* - Migration v105 applies cleanly + bootstrap probe present in
|
|
* PGLITE_SCHEMA_SQL (table created on fresh install).
|
|
* - Statement-level trigger fires once per INSERT statement.
|
|
* - Statement-level trigger fires once per UPDATE statement.
|
|
* - Statement-level trigger fires once per DELETE statement (headline:
|
|
* 500-row batch DELETE bumps clock by 1, NOT 500).
|
|
* - UPDATE-to-non-max-page bumps the clock (CDX-2 regression pin).
|
|
* - DELETE-of-non-max-page bumps the clock (CDX-1 regression pin).
|
|
* - D14: end-to-end query-cache invalidation after batch DELETE.
|
|
* - CDX-6/D20: empty-result cache + INSERT matching page → cache invalidates.
|
|
* - CDX-7: cache a query, UPDATE non-max page → cache invalidates.
|
|
*/
|
|
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
import { DELETE_BATCH_SIZE } from '../src/core/engine-constants.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
});
|
|
|
|
async function clockValue(): Promise<number> {
|
|
// v0.42.x: the Layer-1 bookmark moved from the locked single-row
|
|
// page_generation_clock table to a contention-free SEQUENCE bumped by
|
|
// nextval() in the statement trigger. Read last_value.
|
|
const rows = await engine.executeRaw<{ value: number }>(
|
|
`SELECT last_value AS value FROM page_generation_clock_seq`,
|
|
);
|
|
return Number(rows[0]?.value ?? -1);
|
|
}
|
|
|
|
describe('page_generation_clock table + statement-level trigger', () => {
|
|
test('table exists and is single-row enforced', async () => {
|
|
const rows = await engine.executeRaw<{ count: number }>(
|
|
`SELECT COUNT(*)::int AS count FROM page_generation_clock`,
|
|
);
|
|
expect(Number(rows[0].count)).toBe(1);
|
|
|
|
// CHECK (id = 1) prevents a second row.
|
|
let threw = false;
|
|
try {
|
|
await engine.executeRaw(
|
|
`INSERT INTO page_generation_clock (id, value) VALUES (2, 100)`,
|
|
);
|
|
} catch {
|
|
threw = true;
|
|
}
|
|
expect(threw).toBe(true);
|
|
});
|
|
|
|
test('seed: clock sequence starts at >= 1 with is_called=true', async () => {
|
|
// v0.42.x: the sequence is seeded via setval(GREATEST(1, MAX(generation)))
|
|
// at initSchema with is_called=true (2-arg setval), so the FIRST write's
|
|
// nextval strictly exceeds the seed. Without is_called=true a fresh
|
|
// sequence's first nextval returns the start value and last_value would not
|
|
// visibly advance — that would let a fresh install serve a stale cache row.
|
|
// resetPgliteState does NOT reset the sequence (sequences aren't pg_tables),
|
|
// so last_value only ever increases — monotonic, never decrease-on-truncate.
|
|
const v = await clockValue();
|
|
expect(v).toBeGreaterThanOrEqual(1);
|
|
const meta = await engine.executeRaw<{ is_called: boolean }>(
|
|
`SELECT is_called FROM page_generation_clock_seq`,
|
|
);
|
|
expect(meta[0].is_called).toBe(true);
|
|
});
|
|
|
|
test('INSERT bumps clock by exactly 1 (single-row insert via raw SQL)', async () => {
|
|
// NOTE: must use raw INSERT (without ON CONFLICT). Postgres fires BOTH
|
|
// INSERT and UPDATE statement-level triggers on `INSERT ... ON CONFLICT
|
|
// DO UPDATE` regardless of which branch ran, so engine.putPage (which
|
|
// uses ON CONFLICT DO UPDATE) bumps the clock by 2, not 1. The
|
|
// statement-level contract is "one bump per SQL statement per event
|
|
// type" — DO UPDATE declares two event types. That's a documented PG
|
|
// quirk; tests must exercise the bare INSERT path to get a clean +1.
|
|
const before = await clockValue();
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (source_id, slug, type, title, compiled_truth, timeline, frontmatter)
|
|
VALUES ('default', 'test/single-insert', 'note', 't', 'body', '', '{}'::jsonb)`,
|
|
);
|
|
const after = await clockValue();
|
|
expect(after).toBe(before + 1);
|
|
});
|
|
|
|
test('UPDATE bumps clock by exactly 1 (single-statement, raw SQL)', async () => {
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (source_id, slug, type, title, compiled_truth, timeline, frontmatter)
|
|
VALUES ('default', 'test/update-target', 'note', 't', 'v1', '', '{}'::jsonb)`,
|
|
);
|
|
const before = await clockValue();
|
|
await engine.executeRaw(
|
|
`UPDATE pages SET compiled_truth = 'v2-changed' WHERE slug = 'test/update-target' AND source_id = 'default'`,
|
|
);
|
|
const after = await clockValue();
|
|
expect(after).toBe(before + 1);
|
|
});
|
|
|
|
test('upsert via putPage bumps clock by 2 (INSERT...ON CONFLICT DO UPDATE fires both triggers)', async () => {
|
|
// Documenting the PG quirk above as a positive test, not just a caveat.
|
|
// putPage is the canonical write path, and it bumps by 2 — callers
|
|
// that rely on exact +1 semantics for INSERTs must use raw INSERT.
|
|
const before = await clockValue();
|
|
await engine.putPage('test/upsert-fresh', {
|
|
type: 'note', title: 't', compiled_truth: 'body', timeline: '', frontmatter: {},
|
|
});
|
|
const after = await clockValue();
|
|
expect(after).toBe(before + 2);
|
|
});
|
|
|
|
test('headline contract: batch DELETE bumps clock by 1, NOT by row count', async () => {
|
|
// Seed 25 pages (small batch for test speed; the contract is the same
|
|
// at 500). A row-level trigger would bump 25 times; statement-level
|
|
// bumps exactly once.
|
|
const slugs: string[] = [];
|
|
for (let i = 0; i < 25; i++) {
|
|
const s = `test/bulk-${i}`;
|
|
slugs.push(s);
|
|
await engine.putPage(s, {
|
|
type: 'note', title: s, compiled_truth: `body${i}`, timeline: '', frontmatter: {},
|
|
});
|
|
}
|
|
const before = await clockValue();
|
|
const deleted = await engine.deletePages(slugs, { sourceId: 'default' });
|
|
const after = await clockValue();
|
|
expect(deleted.length).toBe(25);
|
|
expect(after).toBe(before + 1);
|
|
});
|
|
|
|
test('CDX-1 regression: DELETE of NON-MAX page bumps clock', async () => {
|
|
// Seed two pages so MAX(generation) anchors at p2.
|
|
await engine.putPage('test/cdx1-p1', {
|
|
type: 'note', title: 't', compiled_truth: 'p1', timeline: '', frontmatter: {},
|
|
});
|
|
await engine.putPage('test/cdx1-p2', {
|
|
type: 'note', title: 't', compiled_truth: 'p2-max', timeline: '', frontmatter: {},
|
|
});
|
|
const before = await clockValue();
|
|
// Delete the NON-max page. Pre-fix, MAX(generation) didn't change so
|
|
// the bookmark sat. Post-fix, the clock bumps via the statement trigger.
|
|
await engine.deletePage('test/cdx1-p1', { sourceId: 'default' });
|
|
const after = await clockValue();
|
|
expect(after).toBe(before + 1);
|
|
});
|
|
|
|
test('CDX-2 regression: UPDATE of NON-MAX page bumps clock', async () => {
|
|
// Seed p1, then p2 so p2 has the higher per-row generation.
|
|
await engine.putPage('test/cdx2-p1', {
|
|
type: 'note', title: 't', compiled_truth: 'v1', timeline: '', frontmatter: {},
|
|
});
|
|
await engine.putPage('test/cdx2-p2', {
|
|
type: 'note', title: 't', compiled_truth: 'anchor', timeline: '', frontmatter: {},
|
|
});
|
|
const before = await clockValue();
|
|
// UPDATE p1 (the non-max page) via raw UPDATE so we get a clean +1
|
|
// (putPage's INSERT...ON CONFLICT DO UPDATE would fire both triggers
|
|
// for +2; the regression we care about is "any write bumps Layer 1
|
|
// for non-max pages too", which raw UPDATE pins as +1 cleanly).
|
|
await engine.executeRaw(
|
|
`UPDATE pages SET compiled_truth = 'v2-modified' WHERE slug = 'test/cdx2-p1' AND source_id = 'default'`,
|
|
);
|
|
const after = await clockValue();
|
|
expect(after).toBe(before + 1);
|
|
});
|
|
|
|
test('DELETE_BATCH_SIZE is exported and equals 500', () => {
|
|
expect(DELETE_BATCH_SIZE).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('query-cache integration (D14 + CDX-6 + CDX-7 end-to-end)', () => {
|
|
// These tests exercise the buildPageGenerationsSnapshot + the
|
|
// CACHE_GATE_WHERE_CLAUSE path via direct SQL on query_cache. They
|
|
// complement test/e2e/cache-gate-pglite.test.ts which uses the real
|
|
// query-cache.ts wrapper.
|
|
|
|
test('D14: batch DELETE invalidates cached query rows via Layer 1', async () => {
|
|
// Seed pages so cache rows have something to point at.
|
|
await engine.putPage('test/d14-anchor', {
|
|
type: 'note', title: 't', compiled_truth: 'a', timeline: '', frontmatter: {},
|
|
});
|
|
const seeded: string[] = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const s = `test/d14-${i}`;
|
|
seeded.push(s);
|
|
await engine.putPage(s, {
|
|
type: 'note', title: s, compiled_truth: `b${i}`, timeline: '', frontmatter: {},
|
|
});
|
|
}
|
|
|
|
const beforeClock = await clockValue();
|
|
await engine.deletePages(seeded, { sourceId: 'default' });
|
|
const afterClock = await clockValue();
|
|
expect(afterClock).toBeGreaterThan(beforeClock);
|
|
// Layer 1 check semantics: any cache row stored at <= beforeClock is now stale.
|
|
expect(afterClock > beforeClock).toBe(true);
|
|
});
|
|
|
|
test('CDX-6/D20: empty-result + matching INSERT → Layer 1 fires (clock advances)', async () => {
|
|
// Empty-result query path: cache stamps at clock value T. INSERT a
|
|
// matching page → clock advances via statement trigger. Layer 1
|
|
// detects the advance. Pre-v0.41.19.0 the empty {} snapshot served
|
|
// vacuously via Layer 2 — that was CDX-6. Use raw INSERT so the
|
|
// bump is exactly +1 (putPage's INSERT...ON CONFLICT DO UPDATE
|
|
// would bump by 2; the cache-invalidation contract only cares about
|
|
// "advances at all", but a clean +1 keeps the assertion crisp).
|
|
const beforeClock = await clockValue();
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (source_id, slug, type, title, compiled_truth, timeline, frontmatter)
|
|
VALUES ('default', 'test/cdx6-matching-page', 'note', 't', 'matches query', '', '{}'::jsonb)`,
|
|
);
|
|
const afterClock = await clockValue();
|
|
expect(afterClock).toBe(beforeClock + 1);
|
|
});
|
|
});
|
|
|
|
describe('v0.42.x sequence-backed clock (BUG 1: contention removal)', () => {
|
|
test('mechanism: trigger function uses nextval, NOT a locked row UPDATE', async () => {
|
|
// The contention source was `UPDATE page_generation_clock SET value=value+1
|
|
// WHERE id=1` (a transaction-length RowExclusiveLock on one tuple). Prove at
|
|
// the schema level that it is gone and replaced by nextval (a microsecond
|
|
// LWLock). This is the deterministic, PGLite-runnable contention proof.
|
|
const rows = await engine.executeRaw<{ src: string }>(
|
|
`SELECT prosrc AS src FROM pg_proc WHERE proname = 'bump_page_generation_clock_fn'`,
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].src).toContain("nextval('page_generation_clock_seq')");
|
|
expect(rows[0].src).not.toContain('UPDATE page_generation_clock');
|
|
});
|
|
|
|
test('rollback still advances the sequence (over-invalidation is the SAFE direction)', async () => {
|
|
const before = await clockValue();
|
|
// Aborted import: the statement trigger fires nextval (sequences are
|
|
// non-transactional), so last_value advances even though the page never
|
|
// commits. A cache row stamped before this now fails Layer 1 and
|
|
// re-validates — a LOST HIT, never a stale serve.
|
|
try {
|
|
await engine.transaction(async (tx) => {
|
|
await tx.executeRaw(
|
|
`INSERT INTO pages (source_id, slug, type, title, compiled_truth, timeline, frontmatter)
|
|
VALUES ('default', 'test/rollback-page', 'note', 't', 'body', '', '{}'::jsonb)`,
|
|
);
|
|
throw new Error('abort');
|
|
});
|
|
} catch {
|
|
/* expected */
|
|
}
|
|
const after = await clockValue();
|
|
expect(after).toBeGreaterThan(before);
|
|
// The page itself did NOT persist — rollback worked.
|
|
const pages = await engine.executeRaw<{ n: number }>(
|
|
`SELECT COUNT(*)::int AS n FROM pages WHERE slug = 'test/rollback-page' AND source_id = 'default'`,
|
|
);
|
|
expect(Number(pages[0].n)).toBe(0);
|
|
});
|
|
|
|
test('PGLite supports sequences: CREATE / nextval / setval / last_value round-trip', async () => {
|
|
// codex flagged: no existing sequence usage in the repo — prove (not assert)
|
|
// that PGLite's WASM Postgres supports the constructs migration v118 relies
|
|
// on, including the is_called gotcha the load-bearing setval guards against.
|
|
await engine.executeRaw(`CREATE SEQUENCE IF NOT EXISTS test_probe_seq`);
|
|
// Fresh sequence (is_called=false): first nextval returns the START value 1,
|
|
// and last_value does NOT visibly advance past it — the exact trap.
|
|
const n1 = await engine.executeRaw<{ v: number }>(`SELECT nextval('test_probe_seq') AS v`);
|
|
expect(Number(n1[0].v)).toBe(1);
|
|
const n2 = await engine.executeRaw<{ v: number }>(`SELECT nextval('test_probe_seq') AS v`);
|
|
expect(Number(n2[0].v)).toBe(2);
|
|
// 2-arg setval → last_value=N, is_called=true; the next nextval = N+1.
|
|
await engine.executeRaw(`SELECT setval('test_probe_seq', 100)`);
|
|
const lv = await engine.executeRaw<{ v: number }>(`SELECT last_value AS v FROM test_probe_seq`);
|
|
expect(Number(lv[0].v)).toBe(100);
|
|
const n3 = await engine.executeRaw<{ v: number }>(`SELECT nextval('test_probe_seq') AS v`);
|
|
expect(Number(n3[0].v)).toBe(101);
|
|
await engine.executeRaw(`DROP SEQUENCE test_probe_seq`);
|
|
});
|
|
|
|
test('re-seeding is monotonic: the GREATEST guard never moves last_value backward', async () => {
|
|
// Regression for the codex P1: initSchema replays the schema blob, whose
|
|
// setval must NOT reset the clock below its current value — a backward move
|
|
// would let a stored query_cache bookmark serve stale rows. Push the
|
|
// sequence high, then run the EXACT monotonic seed the blob + v118 use.
|
|
await engine.executeRaw(`SELECT setval('page_generation_clock_seq', 999999)`);
|
|
await engine.executeRaw(
|
|
`SELECT setval('page_generation_clock_seq', GREATEST(
|
|
1,
|
|
COALESCE((SELECT last_value FROM page_generation_clock_seq), 0),
|
|
COALESCE((SELECT value FROM page_generation_clock WHERE id = 1), 0),
|
|
COALESCE((SELECT MAX(generation) FROM pages), 0)
|
|
))`,
|
|
);
|
|
expect(await clockValue()).toBeGreaterThanOrEqual(999999);
|
|
});
|
|
});
|