-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathschema.sql
More file actions
561 lines (465 loc) · 23.8 KB
/
Copy pathschema.sql
File metadata and controls
561 lines (465 loc) · 23.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
-- =============================================================================
-- Transcripta - database schema
-- =============================================================================
-- PostgreSQL 17.5
--
-- THIS IS THE SOURCE OF TRUTH FOR THE SCHEMA, but not the way to apply it.
-- The project uses Knex migrations: apps/backend/src/db/migrations/*.ts.
-- Every block below is ported into a migration - plain tables through
-- knex.schema.createTable, partial indexes / views / triggers through knex.raw.
-- Order when the schema changes: edit this file -> write the migration -> never the reverse.
--
-- Quick check on a clean database:
-- psql -f docs/schema/schema.sql
-- psql -f docs/schema/seed.sql
--
-- 9 tables: 8 are ours, users comes from the template (see below).
-- A deliberately minimal schema for a 6-week project.
--
-- Conventions:
-- * time is always timestamptz (never timestamp)
-- * money is numeric (not float: 0.1 + 0.2 <> 0.3)
-- * created_at AND updated_at exist in EVERY table, even an immutable one.
-- This is not style but a requirement: the template's AbstractModel.$beforeInsert
-- writes updatedAt on every insert, and without the column INSERT fails with
-- 'column "updated_at" ... does not exist'.
-- * primary keys are serial (int4), NOT bigserial: the pg driver returns int8
-- as a string, while the base model declares id as number.
--
-- The public schema, no separate namespace: the template's migrations write into
-- public, and keeping some tables aside would mean fiddling with search_path
-- in every Knex connection.
-- =============================================================================
-- =============================================================================
-- Enums
-- =============================================================================
CREATE TYPE document_status AS ENUM (
'draft', -- created, the file is not uploaded yet
'ingesting', -- splitting into pages
'ready', -- pages are ready
'processing', -- transcription in progress
'paused', -- the user stopped it
'budget_stop', -- the budget is exhausted
'done', -- every page has been verified
'failed'
);
CREATE TYPE page_status AS ENUM (
'pending', -- the page exists, not queued yet
'queued', -- in the queue
'transcribing', -- the worker picked it up
'transcribed', -- the machine read it, the human has not
'confirmed', -- the human confirmed without changes
'corrected', -- the human corrected it
'skipped', -- the human skipped it
'blank', -- empty page, the LLM was never called
'failed'
);
-- NOTE: only 'confirmed' and 'corrected' ever feed the context.
-- This is the same constant as CONTEXT_ELIGIBLE in the code. See docs/03-core-logic.md
CREATE TYPE lexicon_kind AS ENUM (
'person_name', 'surname', 'place', 'term', 'formula', 'abbreviation', 'other'
);
CREATE TYPE export_format AS ENUM ('json', 'csv', 'txt');
-- =============================================================================
-- 1. Users - NOT OUR TABLE
-- =============================================================================
-- users is already created by the template's migration
-- (apps/backend/src/db/migrations/20240127205704_add_users_table.ts),
-- and the ready-made auth module depends on it. We do not rewrite it.
--
-- What that means for the rest of the schema:
-- * id is integer (table.increments), not uuid;
-- * the template's base model (abstract.model.ts) declares `public id!: number`,
-- so ALL our tables are integer too - otherwise domain models could not
-- extend AbstractModel together with its timestamp hooks;
-- * consequence for the API: ids in URLs are sequential and guessable, so the
-- document ownership check is mandatory on EVERY route, not merely desirable;
-- * the password is stored as a password_hash + password_salt pair;
-- * its created_at / updated_at are timestamp without a zone, unlike
-- our tables. The discrepancy is known; touching someone else's migration costs more.
--
-- The CREATE below is needed ONLY to run this file on a clean database
-- (schema check, seed). In the project itself the template's migration does it.
CREATE TABLE IF NOT EXISTS users (
id serial PRIMARY KEY,
email text NOT NULL UNIQUE,
password_hash text NOT NULL,
password_salt text NOT NULL,
created_at timestamp NOT NULL DEFAULT now(),
updated_at timestamp NOT NULL DEFAULT now()
);
-- The template's schema has no administrator flag.
-- Added by a separate migration when access to other users' documents is needed.
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin boolean NOT NULL DEFAULT false;
-- =============================================================================
-- 2. Presets
-- =============================================================================
-- A preset = transcription settings for a document type.
--
-- The row is NEVER UPDATED. Changing a preset = a new row with version + 1.
-- Reason: a transcription references a specific version. Without that it is
-- impossible to explain why page 10 looks different from page 400.
-- A version family gets its number from a sequence of its own.
-- Through a separate sequence and NOT through `UPDATE ... SET family_id = id`
-- after the insert: the preset_immutable trigger below forbids any UPDATE of
-- this table, so filling the value in afterwards is impossible.
CREATE SEQUENCE preset_family_seq;
CREATE TABLE preset (
id serial PRIMARY KEY,
-- Shared by all versions of one preset.
-- New preset: pass nothing, DEFAULT takes the next number.
-- New version: pass the existing version's family_id explicitly.
family_id integer NOT NULL DEFAULT nextval('preset_family_seq'),
version integer NOT NULL DEFAULT 1,
owner_id integer NOT NULL REFERENCES users(id),
name text NOT NULL,
description text NOT NULL DEFAULT '',
is_public boolean NOT NULL DEFAULT false,
-- Instructions for the model. They go into the USER MESSAGE,
-- never into the system one - anyone can write a preset.
instructions text NOT NULL,
-- JSON Schema of the expected output. Fields with "x-entity-kind"
-- automatically flow into the lexicon.
output_schema jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Seed glossary: [{ kind, value, note }]
-- This is what saves the first pages of a document.
seed_glossary jsonb NOT NULL DEFAULT '[]'::jsonb,
-- Settings: { provider, model, temperature, dpi, maxContextTokens, ... }
settings jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
-- Never changes (the row is immutable), but the column is mandatory:
-- AbstractModel.$beforeInsert writes updatedAt on EVERY insert.
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT preset_version_unique UNIQUE (family_id, version),
CONSTRAINT preset_version_positive CHECK (version > 0)
);
CREATE INDEX preset_family_idx ON preset (family_id, version DESC);
CREATE INDEX preset_owner_idx ON preset (owner_id);
CREATE INDEX preset_public_idx ON preset (is_public) WHERE is_public;
-- =============================================================================
-- 3. Documents
-- =============================================================================
CREATE TABLE document (
id serial PRIMARY KEY,
owner_id integer NOT NULL REFERENCES users(id),
preset_id integer NOT NULL REFERENCES preset(id),
title text NOT NULL,
status document_status NOT NULL DEFAULT 'draft',
-- The file in S3
source_key text,
source_name text,
source_bytes bigint,
page_count integer NOT NULL DEFAULT 0,
cursor_page_no integer NOT NULL DEFAULT 1, -- where the verifier is now
-- Budget. numeric, because it is money.
budget_usd numeric(10,4) NOT NULL DEFAULT 10.0000,
spent_usd numeric(12,6) NOT NULL DEFAULT 0,
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT document_pages_nonneg CHECK (page_count >= 0),
CONSTRAINT document_budget_nonneg CHECK (budget_usd >= 0 AND spent_usd >= 0)
);
CREATE INDEX document_owner_idx ON document (owner_id, created_at DESC);
CREATE INDEX document_status_idx ON document (status);
-- =============================================================================
-- 4. Pages
-- =============================================================================
CREATE TABLE page (
id serial PRIMARY KEY,
document_id integer NOT NULL REFERENCES document(id) ON DELETE CASCADE,
page_no integer NOT NULL,
image_key text, -- normalised image for the LLM
thumb_key text, -- thumbnail for the strip in the UI
image_sha256 text, -- cache key: same image = same result
status page_status NOT NULL DEFAULT 'pending',
-- Who verified it and when. Edit history lives in page_event.
verified_by integer REFERENCES users(id),
verified_at timestamptz,
attempts integer NOT NULL DEFAULT 0,
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT page_unique_in_document UNIQUE (document_id, page_no),
CONSTRAINT page_no_positive CHECK (page_no >= 1)
);
-- The main index: next page for the verifier, filling the window
CREATE INDEX page_doc_no_idx ON page (document_id, page_no);
CREATE INDEX page_status_idx ON page (document_id, status);
-- For finding stuck pages
CREATE INDEX page_stuck_idx ON page (updated_at) WHERE status IN ('queued', 'transcribing');
-- =============================================================================
-- 5. Transcriptions
-- =============================================================================
-- A page can have several transcriptions (re-runs).
-- The current one is marked is_current - a partial UNIQUE guarantees there is one.
CREATE TABLE transcription (
id serial PRIMARY KEY,
page_id integer NOT NULL REFERENCES page(id) ON DELETE CASCADE,
document_id integer NOT NULL REFERENCES document(id) ON DELETE CASCADE,
preset_id integer NOT NULL REFERENCES preset(id),
-- What the model read
text text NOT NULL DEFAULT '',
structured jsonb, -- follows the preset's output_schema
-- The human's corrections (NULL if confirmed unchanged)
edited_text text,
edited_structured jsonb,
-- WHICH CONTEXT WENT INTO THIS CALL.
-- Without it there is no way to find the pages affected by a wrong word
-- in the lexicon, and the only option left is recomputing the whole document.
-- Format: { pageIds: [...], lexiconIds: [...], hash: "...", tokens: 1840 }
context_used jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Exact user prompt sent with the image for debug (#153).
-- Usually buildUserPrompt (preset + context + schema); if the final call
-- was a repair, includes the repair suffix. Paired with raw_response.
prompt text NOT NULL DEFAULT '',
-- Raw model text before validation / code-fence strip (#153).
-- Empty for cache hits (no model call on this run) and pre-feature rows.
-- On validation failure after repair: still stored; text stays empty.
raw_response text NOT NULL DEFAULT '',
-- What it cost. In the same table to avoid extra joins.
provider text,
model text,
input_tokens integer NOT NULL DEFAULT 0,
output_tokens integer NOT NULL DEFAULT 0,
cost_usd numeric(12,6) NOT NULL DEFAULT 0,
latency_ms integer NOT NULL DEFAULT 0,
from_cache boolean NOT NULL DEFAULT false,
is_current boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Guarantee: exactly one current transcription per page
CREATE UNIQUE INDEX transcription_one_current
ON transcription (page_id) WHERE is_current;
CREATE INDEX transcription_page_idx ON transcription (page_id, created_at DESC);
CREATE INDEX transcription_doc_idx ON transcription (document_id);
-- For finding pages that used a specific lexicon word
CREATE INDEX transcription_context_idx ON transcription USING GIN (context_used);
COMMENT ON COLUMN transcription.context_used IS
'A snapshot of the context. Main query: find pages whose context contained '
'a wrong word - context_used -> ''lexiconIds'' @> to_jsonb(id)';
-- =============================================================================
-- 6. Document lexicon
-- =============================================================================
-- Filled from confirmed pages. Read while building the context.
CREATE TABLE lexicon_entry (
id serial PRIMARY KEY,
document_id integer NOT NULL REFERENCES document(id) ON DELETE CASCADE,
kind lexicon_kind NOT NULL,
value_normalized text NOT NULL, -- for deduplication: "ivanenko"
value_display text NOT NULL, -- for the prompt: "Ivanenko"
freq integer NOT NULL DEFAULT 1,
-- The threshold for entering the context is counted ON THIS field, not on freq.
-- A surname 30 times on one page is a weaker signal than
-- a surname once on each of three pages.
distinct_pages integer NOT NULL DEFAULT 1,
first_page_no integer NOT NULL,
last_page_no integer NOT NULL,
-- The "this word is wrong" mark. Such words never feed the context.
invalidated_at timestamptz,
invalid_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT lexicon_unique UNIQUE (document_id, kind, value_normalized),
CONSTRAINT lexicon_freq_positive CHECK (freq >= 1)
);
-- The main context-building query: top-K live words
CREATE INDEX lexicon_topk_idx
ON lexicon_entry (document_id, distinct_pages DESC, freq DESC)
WHERE invalidated_at IS NULL;
-- =============================================================================
-- 7. Event history
-- =============================================================================
-- One table for both the audit trail and the verification history.
-- Append-only: rows are never updated.
CREATE TABLE page_event (
id serial PRIMARY KEY,
document_id integer NOT NULL REFERENCES document(id) ON DELETE CASCADE,
page_id integer REFERENCES page(id) ON DELETE CASCADE,
-- Which transcription the human acted against. NULL for system events
-- (transcribed, failed). For human actions it is what makes a replay
-- detectable - see the unique index below.
transcription_id integer REFERENCES transcription(id) ON DELETE CASCADE,
-- confirm | correct | skip | reprocess | transcribed | failed | ...
event text NOT NULL,
actor_id integer REFERENCES users(id),
-- For edits: { before, after, editDistance }
-- For errors: { error, attempt }
details jsonb NOT NULL DEFAULT '{}'::jsonb,
-- How long the human spent on the page. The headline product metric.
duration_ms integer,
created_at timestamptz NOT NULL DEFAULT now(),
-- append-only, but the column is required by the base model's $beforeInsert
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Idempotency of verification. The offline queue in the browser may resend the
-- same action: the request went through but the response was lost. Without this
-- index a replay would increment the lexicon counters a second time, and
-- distinct_pages is the threshold for entering the context - a word would reach
-- the prompt without having earned it.
-- Partial: only human actions are unique. transcribed/failed repeat on re-runs.
CREATE UNIQUE INDEX page_event_once
ON page_event (page_id, transcription_id, event)
WHERE event IN ('confirm', 'correct', 'skip');
CREATE INDEX page_event_page_idx ON page_event (page_id, created_at DESC);
CREATE INDEX page_event_doc_idx ON page_event (document_id, created_at DESC);
-- =============================================================================
-- 8. Transcription cache
-- =============================================================================
-- In Postgres, not Redis: a cache that vanishes on restart does not do
-- its main job - making a re-run free.
CREATE TABLE transcription_cache (
cache_key text PRIMARY KEY, -- sha256(image_sha|preset_id|model|context_hash)
text text NOT NULL,
structured jsonb,
input_tokens integer NOT NULL DEFAULT 0,
output_tokens integer NOT NULL DEFAULT 0,
cost_usd numeric(12,6) NOT NULL DEFAULT 0,
hit_count integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
last_hit_at timestamptz
);
-- =============================================================================
-- 9. Exports
-- =============================================================================
CREATE TABLE document_export (
id serial PRIMARY KEY,
document_id integer NOT NULL REFERENCES document(id) ON DELETE CASCADE,
format export_format NOT NULL,
status text NOT NULL DEFAULT 'queued', -- queued | ready | failed
object_key text,
requested_by integer NOT NULL REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
error_message text
);
CREATE INDEX document_export_doc_idx ON document_export (document_id, created_at DESC);
-- =============================================================================
-- Ready-made queries as VIEWs
-- =============================================================================
-- Progress is requested on every verification step - let the DB compute it.
CREATE VIEW document_progress AS
SELECT
d.id AS document_id,
d.title,
d.status,
d.page_count,
d.cursor_page_no,
d.budget_usd,
d.spent_usd,
-- ::int and ::float8 are mandatory. count() returns bigint, round() numeric,
-- and the pg driver turns both into STRINGS. Without casting the frontend gets
-- "300" instead of 300 and "15.3" instead of 15.3.
count(p.*)::int AS pages_total,
count(p.*) FILTER (WHERE p.status IN ('confirmed','corrected'))::int AS pages_verified,
count(p.*) FILTER (WHERE p.status = 'transcribed')::int AS pages_ready_to_check,
count(p.*) FILTER (WHERE p.status IN ('queued','transcribing'))::int AS pages_in_work,
count(p.*) FILTER (WHERE p.status = 'pending')::int AS pages_pending,
count(p.*) FILTER (WHERE p.status = 'failed')::int AS pages_failed,
count(p.*) FILTER (WHERE p.status = 'blank')::int AS pages_blank,
count(p.*) FILTER (WHERE p.status = 'skipped')::int AS pages_skipped,
-- Two different percentages, and confusing them makes the UI lie.
-- verified_pct - how much a HUMAN actually read.
-- closed_pct - how much is finished. blank/skipped/failed are closed but
-- not verified, so a done document can sit at verified 96%.
CASE WHEN count(p.*) > 0
THEN round(count(p.*) FILTER (WHERE p.status IN ('confirmed','corrected'))::numeric
/ count(p.*) * 100, 1)::float8
ELSE 0 END AS verified_pct,
CASE WHEN count(p.*) > 0
THEN round(count(p.*) FILTER (WHERE p.status IN
('confirmed','corrected','skipped','blank','failed'))::numeric
/ count(p.*) * 100, 1)::float8
ELSE 0 END AS closed_pct
FROM document d
LEFT JOIN page p ON p.document_id = d.id
GROUP BY d.id;
-- What the document cost and how much of it the context ate.
CREATE VIEW document_cost AS
SELECT
document_id,
count(*)::int AS calls,
sum(cost_usd) AS total_cost_usd, -- numeric: stays a string, it is money
sum(input_tokens)::int AS input_tokens,
sum(output_tokens)::int AS output_tokens,
round(avg(latency_ms))::int AS avg_latency_ms,
count(*) FILTER (WHERE from_cache)::int AS cache_hits
FROM transcription
GROUP BY document_id;
-- Verification speed - the headline product metric.
CREATE VIEW verification_speed AS
SELECT
document_id,
actor_id,
count(*)::int AS pages,
round(avg(duration_ms))::int AS avg_ms,
percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms) AS median_ms,
-- Share of pages confirmed without edits. The cheapest indicator of
-- model quality: it needs no ground truth and runs on live data.
round(count(*) FILTER (WHERE event = 'confirm')::numeric
/ nullif(count(*) FILTER (WHERE event IN ('confirm','correct')), 0), 3)::float8 AS clean_rate
FROM page_event
WHERE event IN ('confirm', 'correct') AND duration_ms IS NOT NULL
GROUP BY document_id, actor_id;
-- =============================================================================
-- Triggers
-- =============================================================================
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
CREATE TRIGGER document_touch BEFORE UPDATE ON document
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER page_touch BEFORE UPDATE ON page
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER lexicon_touch BEFORE UPDATE ON lexicon_entry
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER transcription_touch BEFORE UPDATE ON transcription
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER export_touch BEFORE UPDATE ON document_export
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER cache_touch BEFORE UPDATE ON transcription_cache
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
-- Presets and the event history are never updated.
-- Discipline enforced by the database, not only by agreement in code review.
CREATE OR REPLACE FUNCTION forbid_update() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION '% table is not updatable, create a new row', TG_TABLE_NAME;
END;
$$;
CREATE TRIGGER preset_immutable BEFORE UPDATE ON preset
FOR EACH ROW EXECUTE FUNCTION forbid_update();
CREATE TRIGGER page_event_immutable BEFORE UPDATE ON page_event
FOR EACH ROW EXECUTE FUNCTION forbid_update();
-- =============================================================================
-- How this maps onto Knex migrations
-- =============================================================================
-- Not everything in the schema can be expressed with the builder. The split:
--
-- knex.schema.createTable plain columns, PK, FK, UNIQUE, ordinary indexes
-- knex.raw CREATE TYPE, partial indexes (WHERE ...),
-- GIN, views, functions, triggers, COMMENT ON
--
-- Enums are conveniently declared as native ones:
-- table.enu('status', null, {
-- useNative: true, existingType: true, enumName: 'page_status'
-- })
-- the CREATE TYPE itself is a separate knex.raw BEFORE createTable.
--
-- Migration order matters: types -> tables -> indexes -> views -> triggers.
-- In down() everything is removed in reverse order, otherwise DROP TYPE fails
-- because of dependent columns.
--
-- What must NOT be lost in the port, because it is what makes the schema right:
-- * transcription_one_current - partial UNIQUE, the "exactly one current" guarantee
-- * lexicon_topk_idx - partial index, the main context query
-- * transcription_context_idx - GIN, finding affected pages
-- * preset_immutable - forbids UPDATE on a preset
-- * page_event_immutable - append-only history