File size: 12,555 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// src/generation.ts
//
// Persistent index generations (stage 5): the on-disk layout, the manifest
// codec, and the authoritative inventory of MiniDb's persistent files.
//
// A generation is a self-contained, atomically published checkpoint of every
// piece of DERIVED state MiniDb otherwise rebuilds from scratch on open:
// the store image (key -> value/refs + expiry + dt), the dt / secondary /
// compound index states, and each text index's dictionary + postings + doc
// table. Loading a published generation and replaying only the WAL delta past
// its checkpoint replaces the open-time full rebuild (decode every value,
// tokenize the whole corpus, rewrite every postings file).
//
// Layout (under the db directory):
//
//   db.snapshot                 authoritative data (unchanged)
//   db.wal                      authoritative log (unchanged)
//   db.indexes.json             index-definition sidecars (unchanged,
//   db.compound-indexes.json      still the source of truth for definitions)
//   db.textindexes.json
//   generations/
//     g-000001/                 a published generation (immutable)
//       store                   store image (inline values or disk refs)
//       dt.index                DtIndex columns
//       secondary.index         IndexManager indexes
//       compound.index          CompoundIndexManager indexes
//       text-<name>.dictionary  term -> { off, len, df } into the postings file
//       text-<name>.postings    postings records (native PostingsFile format)
//       text-<name>.docs        docID <-> key table, docLen, delta, tombstones
//       snapshot                hard link to (or copy of) the db.snapshot the
//                               generation's disk refs point into
//       manifest.json           written LAST inside the dir (dir contents are
//                               only meaningful with a parseable manifest)
//     g-000002.tmp-<pid>/       an in-flight build (never referenced by CURRENT)
//   CURRENT                     one line: the published generation id
//
// Crash protocol (publish): build into g-N.tmp-*, write + fsync every file,
// fsync the tmp dir, rename to g-N, fsync generations/, then atomically
// replace CURRENT (tmp + rename) and fsync the db dir. CURRENT therefore only
// ever points at a fully fsynced generation; a crash anywhere earlier strands
// a tmp dir the next writer open sweeps. Old generations are removed lazily,
// keeping the current and previous one (the previous shares the WAL anchor
// when no compaction intervened, so it is a real fallback).
//
// Load protocol: read CURRENT -> parse + validate the manifest (unknown
// format version is a structured fallback, never a deletion) -> verify the
// WAL anchor (dev/ino + size >= checkpoint) and, for disk valueMode, the
// snapshot anchor -> load the store image + every index image whose recorded
// definition hash still matches the live sidecar definitions -> replay WAL
// frames past the checkpoint with the normal per-frame op interpretation.
// ANY validation or I/O failure falls back to the legacy full recovery
// (snapshot + whole WAL + full index rebuild); the fallback never mutates or
// deletes authoritative data. See generation-load.ts / generation-build.ts.
//
// This module absorbs stage 9's transitional persistent-files.ts: it owns the
// name/pattern knowledge for every persistent path. It performs no I/O beyond
// tiny manifest/CURRENT reads and writes.

import { crc32 } from './crc32.js';

// ---- authoritative root file names (absorbed from persistent-files.ts) ----

/** The primary data pair recovery pairs up: the snapshot, then the WAL. */
export const SNAPSHOT_FILE = 'db.snapshot';
export const WAL_FILE = 'db.wal';

/** Index-definition sidecars, rewritten atomically (tmp + rename) on every
 *  definition change. */
export const SECONDARY_INDEXES_FILE = 'db.indexes.json';
export const COMPOUND_INDEXES_FILE = 'db.compound-indexes.json';
export const TEXT_INDEXES_FILE = 'db.textindexes.json';
export const SIDECAR_FILES = [SECONDARY_INDEXES_FILE, COMPOUND_INDEXES_FILE, TEXT_INDEXES_FILE] as const;

/** Per-text-index postings files at the ROOT are the legacy (pre-generation)
 *  location: read-only in-memory-base instances and the generations-disabled
 *  fallback still use them, and a writer deletes a root postings file once a
 *  published generation covers that index. */
export const POSTINGS_PATTERN = /^db\.text-.*\.postings$/;

/** On-disk postings file name for a text index (root location). */
export function rootPostingsFile(name: string): string {
  return `db.text-${sanitizeIndexName(name)}.postings`;
}

// ---- generation layout -----------------------------------------------------

export const GENERATIONS_DIR = 'generations';
export const CURRENT_FILE = 'CURRENT';
export const MANIFEST_FILE = 'manifest.json';
export const STORE_IMAGE_FILE = 'store';
export const DT_INDEX_FILE = 'dt.index';
export const SECONDARY_INDEX_FILE = 'secondary.index';
export const COMPOUND_INDEX_FILE = 'compound.index';
export const GEN_SNAPSHOT_FILE = 'snapshot';

/** Text-index artifact file names inside a generation directory. */
export function textDictionaryFile(name: string): string {
  return `text-${sanitizeIndexName(name)}.dictionary`;
}
export function textPostingsFile(name: string): string {
  return `text-${sanitizeIndexName(name)}.postings`;
}
export function textDocsFile(name: string): string {
  return `text-${sanitizeIndexName(name)}.docs`;
}

/** Index names land in file names; keep the same sanitization the legacy
 *  root postings path used so both locations agree. */
export function sanitizeIndexName(name: string): string {
  return name.replace(/[^a-zA-Z0-9_.-]/g, '_');
}

/** Generation directory id: monotonically increasing, zero-padded so
 *  lexicographic order equals numeric order. */
export function generationId(n: number): string {
  return `g-${String(n).padStart(6, '0')}`;
}

const GEN_ID_PATTERN = /^g-(\d+)$/;

/** Parse a generation directory name into its numeric id, or null. */
export function parseGenerationId(name: string): number | null {
  const m = GEN_ID_PATTERN.exec(name);
  return m ? Number(m[1]) : null;
}

/** In-flight generation build directories (crash-stranded ones are swept by
 *  the next writer open; a live build's tmp is never matched for another
 *  process because only the lock holder builds). */
export const GEN_TMP_PATTERN = /^g-\d+\.tmp-.*$/;

/** Manifest format version. Version 1 is the first persisted layout; an
 *  opener that reads a HIGHER version must not touch the files (a newer
 *  binary wrote them) and falls back to the legacy full recovery. */
export const GENERATION_FORMAT_VERSION = 1;

// ---- manifest --------------------------------------------------------------

/** Per-file integrity record: byte length and crc32 of the whole file. */
export interface ManifestFileInfo {
  bytes: number;
  crc32: number;
}

/** The WAL position the generation's images cover: frames at/after
 *  `walOffset` are NOT included and must be replayed on top. The anchor
 *  (dev/ino) pins the offset to one specific WAL inode; `walSize` is the
 *  size the WAL had when the checkpoint was sealed (>= walOffset). */
export interface GenerationCheckpoint {
  walOffset: number;
  walDev: number;
  walIno: number;
  walSize: number;
  /** Identity of the db.snapshot the generation's disk refs (and its own
   *  `snapshot` member) point into. `linked` records whether the generation's
   *  snapshot is a hard link to that inode (false = a full copy; disk-mode
   *  loads then cannot serve refs through the live db.snapshot path and the
   *  generation is only usable for memory-mode loads). */
  snapshotBytes: number;
  snapshotDev: number;
  snapshotIno: number;
  snapshotLinked: boolean;
}

/** Compatibility + definition-hash metadata. A generation is only loadable
 *  when the codec and value mode match the open options; per-index definition
 *  hashes decide which individual index images are still valid (a definition
 *  change invalidates only that index — it is rebuilt from the loaded store). */
export interface GenerationManifest {
  format: number;
  id: string;
  createdAt: number;
  valueCodec: string;
  /** The payload mode of the store image: 'memory' inlines every value,
   *  'disk' stores { file, off, len } refs (with inline values allowed for
   *  records that were RAM-resident at build time). */
  valueMode: 'memory' | 'disk';
  checkpoint: GenerationCheckpoint;
  /** Definition hash per index name, per index family. */
  indexDefs: {
    secondary: Record<string, string>;
    compound: Record<string, string>;
    text: Record<string, string>;
  };
  /** Integrity record for every file the build wrote (everything except the
   *  manifest itself and the `snapshot` link, which is anchored by dev/ino
   *  and carries per-frame CRCs of its own). */
  files: Record<string, ManifestFileInfo>;
  counts: {
    records: number;
    dtColumns: number;
    secondaryIndexes: number;
    compoundIndexes: number;
    textIndexes: number;
  };
}

/** Canonical definition hash: crc32 of the JSON of the definition with
 *  sorted keys, hex-encoded. Both sides (build and load) derive it from the
 *  SAME persisted definition shape (the sidecar entries), so a sidecar
 *  round-trip never changes it. */
export function indexDefHash(def: unknown): string {
  return crc32(Buffer.from(stableJson(def), 'utf8')).toString(16).padStart(8, '0');
}

function stableJson(v: unknown): string {
  if (v === null || typeof v !== 'object') return JSON.stringify(v) ?? 'null';
  if (Array.isArray(v)) return `[${v.map(stableJson).join(',')}]`;
  const keys = Object.keys(v as Record<string, unknown>).sort();
  return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJson((v as Record<string, unknown>)[k])}`).join(',')}}`;
}

// ---- persistent file inventory (absorbed from persistent-files.ts) ---------

/** The files the cluster reader fingerprint MUST track: a change to any of
 *  them means a cached read-only instance can no longer serve without a
 *  refresh. The WAL comes first — the lock pool's "WAL-only append" fast path
 *  compares every OTHER entry by position (see shardFingerprint). CURRENT is
 *  tracked so a generation switch (compaction publish) refreshes readers even
 *  though the snapshot entry already covers the rotation; both are kept
 *  because a compaction with generation builds disabled rotates the snapshot
 *  without touching CURRENT. */
export const FINGERPRINT_FILES = [WAL_FILE, SNAPSHOT_FILE, CURRENT_FILE, ...SIDECAR_FILES] as const;

/** Is `name` one of MiniDb's persistent top-level entries (a primary data
 *  file, an index-definition sidecar, a legacy postings file, CURRENT, or the
 *  generations directory)? backup/restore filter on this. */
export function isPersistentFile(name: string): boolean {
  return (
    name === SNAPSHOT_FILE ||
    name === WAL_FILE ||
    name === CURRENT_FILE ||
    name === GENERATIONS_DIR ||
    (SIDECAR_FILES as readonly string[]).includes(name) ||
    POSTINGS_PATTERN.test(name)
  );
}

/** Atomic-write temp siblings a crashed previous run may have left behind:
 *  a compaction's snapshot/WAL temps (fixed names), plus sidecar-definition
 *  temps from before sidecar writes gained unique suffixes. Current sidecar
 *  writes use `<file>.tmp-<pid>-<seq>` names, matched by isStaleTmpFile
 *  instead. Only the sole writer may delete them at open — a read-only
 *  opener must never touch a live writer's in-flight temps. */
export const STALE_TMP_FILES: readonly string[] = [SNAPSHOT_FILE, WAL_FILE, ...SIDECAR_FILES].map((f) => `${f}.tmp`);

/** Is `name` a unique-suffixed atomic-write temp (`<file>.tmp-<pid>-<seq>`)
 *  of one of the primary/sidecar/CURRENT files, orphaned by a crash between
 *  the tmp write and the rename? Whitelisted per known file so a LockFile's
 *  `db.lock.tmp-*` — possibly in flight in ANOTHER process right now — is
 *  never matched. Same deletion discipline as STALE_TMP_FILES: only the sole
 *  writer at open. */
export function isStaleTmpFile(name: string): boolean {
  return [SNAPSHOT_FILE, WAL_FILE, CURRENT_FILE, ...SIDECAR_FILES].some((f) => name.startsWith(`${f}.tmp-`));
}

/** A failed postings rebuild orphans `db.text-*.postings.tmp` (its atomic
 *  rename never ran). Postings are pure derived state, so such temps are
 *  always safe for the writer to delete, for any index name. */
export const STALE_POSTINGS_TMP_PATTERN = /^db\.text-.*\.postings\.tmp$/;