File size: 10,774 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
// src/index-admin.ts
//
// MiniDb's secondary + compound index administration as a facet: the
// create/drop/list/find API and the definition sidecar loads. The persist
// seams stay on the owner (injected as callbacks β€” tests stub them on the
// MiniDb instance, same discipline as the text registry's); the definition
// content decisions (live Β± staged) live here.

import fs from 'node:fs/promises';
import { fromKStr } from './value-codec.js';
import { REBUILD_YIELD_DOCS } from './generation-builder.js';
import { yieldToLoop } from './text-index/tokenize.js';
import type { Store } from './store.js';
import type { IndexManager, IndexDef, IndexInfo } from './index-manager.js';
import type { CompoundIndexManager, CompoundIndexDef, CompoundIndexInfo } from './compound-index.js';
import type { DtIndex } from './dt-index.js';
import type { TextRegistry } from './text-registry.js';
import type { TextIndexBuild } from './text-index/index.js';
import type { RangeOptions } from './skiplist.js';
import type { ValueCodecName } from './types.js';

/** The owner-injected surface the admin facet needs (see the header). */
export interface IndexAdminDeps<V> {
  indexes: IndexManager;
  compound: CompoundIndexManager;
  dt: DtIndex;
  textRegistry: TextRegistry<V>;
  store: () => Store;
  codecName: () => ValueCodecName;
  ensureOpen: () => void;
  ensureWritable: () => void;
  decode: (b: Buffer | undefined) => V | undefined;
  indexable: (v: unknown) => v is Record<string, unknown>;
  /** The owner's stats object (rebuild duration/decode counters). */
  stats: {
    indexRebuildDecoded: number;
    indexRebuildDurationMs: number;
    textRebuildDurationMs: number;
  };
  /** Live records (decoded values), for staged compound rebuilds. */
  liveRecords: () => Generator<{ key: Buffer; value: V | undefined; dt: Record<string, number> | null }>;
  /** Live records with untyped decoded values, for staged secondary rebuilds. */
  liveRecordsRaw: () => Generator<{ key: Buffer; value: unknown }>;
  /** Per-sidecar mutation chains (staged β†’ persist β†’ publish serialization). */
  secondaryDefChain: <T>(fn: () => Promise<T>) => Promise<T>;
  compoundDefChain: <T>(fn: () => Promise<T>) => Promise<T>;
  /** The definition-sidecar write seams (kept on the owner so tests can stub
   *  them on the MiniDb instance); the facet decides WHEN and WHAT to persist. */
  persistIndexDefinitions: (defs: IndexInfo[]) => Promise<void>;
  persistCompoundIndexDefinitions: (defs: CompoundIndexInfo[]) => Promise<void>;
}

export class IndexAdmin<V> {
  constructor(private readonly deps: IndexAdminDeps<V>) {}

  /** One Store walk feeds every staged builder (the open-time full rebuild).
   *  dt comes from record metadata (never decoded); the value is decoded at
   *  most once per record and only when a value-derived index (secondary /
   *  compound / text) actually exists β€” an index-less open performs a
   *  metadata-only walk. Text indexes whose base build is DEFERRED (the
   *  bounded background build, see MiniDb.deferOpenTextBuilds) are skipped
   *  via `skipTextIndex`: they get neither a staged builder nor feeding. */
  async rebuildAllIndexes(opts: { skipTextIndex?: (name: string) => boolean } = {}): Promise<void> {
    const dtB = this.deps.dt.beginRebuild();
    const secB = this.deps.indexes.indexes.size ? this.deps.indexes.beginRebuild() : null;
    const cmpB = this.deps.compound.indexes.size ? this.deps.compound.beginRebuild() : null;
    const textBs: { b: TextIndexBuild }[] = [];
    for (const [name, ti] of this.deps.textRegistry.text) {
      if (opts.skipTextIndex?.(name)) continue;
      textBs.push({ b: ti.beginBuild() });
    }
    const needValues = secB !== null || cmpB !== null || textBs.length > 0;

    const t0 = performance.now();
    let docsSinceYield = 0;
    try {
      for (const rec of this.deps.store().rawRecords()) {
        // Yield periodically so a huge open-time rebuild never hard-blocks the
        // event loop; safe because open() awaits this before publishing the
        // db or starting the background compaction.
        if (++docsSinceYield >= REBUILD_YIELD_DOCS) {
          docsSinceYield = 0;
          await yieldToLoop();
        }
        dtB.add(rec.kstr, rec.dt);
        if (!needValues) continue;
        const value = this.deps.decode(rec.readValue());
        this.deps.stats.indexRebuildDecoded++;
        secB?.add(rec.kstr, value);
        cmpB?.add(rec.kstr, value, rec.dt);
        if (this.deps.indexable(value)) for (const { b } of textBs) b.add(rec.kstr, value);
      }
    } catch (e) {
      for (const { b } of textBs) b.abort();
      throw e;
    }
    this.deps.stats.indexRebuildDurationMs += performance.now() - t0;

    // Commit the fallible builders first (text postings do file I/O); the
    // in-memory swaps cannot fail. A text commit failure leaves every
    // not-yet-committed builder on its previous state.
    const t1 = performance.now();
    try {
      for (const { b } of textBs) await b.commit();
    } catch (e) {
      for (const { b } of textBs) b.abort();
      throw e;
    }
    this.deps.stats.textRebuildDurationMs += performance.now() - t1;
    secB?.commit();
    cmpB?.commit();
    dtB.commit();
  }

  async loadIndexDefinitions(indexPath: string): Promise<void> {
    try {
      const raw = await fs.readFile(indexPath, 'utf8');
      for (const d of JSON.parse(raw) as (IndexInfo & IndexDef)[]) this.deps.indexes.create(d.name, d);
    } catch (e) {
      if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
    }
  }

  async loadCompoundIndexDefinitions(compoundIndexPath: string): Promise<void> {
    try {
      const raw = await fs.readFile(compoundIndexPath, 'utf8');
      for (const d of JSON.parse(raw) as (CompoundIndexInfo & { name: string })[]) {
        this.deps.compound.create(d.name, { groupBy: d.groupBy, orderBy: d.orderBy, orderType: d.orderType });
      }
    } catch (e) {
      if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
    }
  }

  async createIndex(name: string, opts: IndexDef): Promise<void> {
    this.deps.ensureOpen();
    this.deps.ensureWritable();
    if (this.deps.codecName() !== 'json') throw new Error('secondary indexes require valueCodec: "json"');
    // Serialized staged β†’ persist β†’ publish (see secondaryDefChain): the
    // definition is staged off to the side, rebuilt there, persisted as part
    // of the sidecar content, and only then published into the live registry.
    // Any failure discards the staged index β€” the live registry and the
    // sidecar keep their previous state, so a retry cannot hit a phantom
    // "already exists".
    await this.deps.secondaryDefChain(async () => {
      this.deps.indexes.stage(name, opts);
      try {
        this.deps.indexes.rebuildStaged(name, this.deps.liveRecordsRaw());
        // A unique index must not be created over data that already violates it.
        this.deps.indexes.assertUniqueValid(name);
        await this.deps.persistIndexDefinitions([...this.deps.indexes.list(), this.deps.indexes.stagedInfo(name)]);
      } catch (e) {
        this.deps.indexes.discardStaged(name);
        throw e;
      }
      this.deps.indexes.publish(name);
    });
  }

  async dropIndex(name: string): Promise<boolean> {
    this.deps.ensureOpen();
    this.deps.ensureWritable();
    return this.deps.secondaryDefChain(async () => {
      // Persist FIRST (content without the definition), remove from the live
      // registry only after the sidecar is durable: a persist failure leaves
      // the index fully usable instead of diverging memory from disk (which a
      // reopen would have resurrected).
      await this.deps.persistIndexDefinitions(this.deps.indexes.list().filter((i) => i.name !== name));
      return this.deps.indexes.drop(name);
    });
  }

  listIndexes(): IndexInfo[] {
    return this.deps.indexes.list();
  }

  findEq(name: string, value: unknown): { key: string; value: V | undefined }[] {
    this.deps.ensureOpen();
    return this.deps.indexes
      .findEq(name, value)
      .map((pk) => ({ key: fromKStr(pk), value: this.deps.decode(this.deps.store().get(pk)) }))
      .filter((r): r is { key: string; value: V } => r.value !== undefined);
  }

  findRange(name: string, opts: Parameters<IndexManager['findRange']>[1]): { key: string; value: V | undefined; field: number }[] {
    this.deps.ensureOpen();
    return this.deps.indexes
      .findRange(name, opts)
      .map(({ pk, value }) => ({ key: fromKStr(pk), value: this.deps.decode(this.deps.store().get(pk)), field: value }))
      .filter((r): r is { key: string; value: V; field: number } => r.value !== undefined);
  }

  async createCompoundIndex(name: string, def: CompoundIndexDef): Promise<void> {
    this.deps.ensureOpen();
    this.deps.ensureWritable();
    if (this.deps.codecName() !== 'json') throw new Error('compound indexes require valueCodec: "json"');
    // Serialized staged β†’ persist β†’ publish, the same discipline as
    // createIndex (see compoundDefChain).
    await this.deps.compoundDefChain(async () => {
      this.deps.compound.stage(name, def);
      try {
        this.deps.compound.rebuildStaged(name, this.deps.liveRecords());
        await this.deps.persistCompoundIndexDefinitions([...this.deps.compound.list(), this.deps.compound.stagedInfo(name)]);
      } catch (e) {
        this.deps.compound.discardStaged(name);
        throw e;
      }
      this.deps.compound.publish(name);
    });
  }

  async dropCompoundIndex(name: string): Promise<boolean> {
    this.deps.ensureOpen();
    this.deps.ensureWritable();
    return this.deps.compoundDefChain(async () => {
      // Persist FIRST (content without the definition), remove from live only
      // after the sidecar is durable (see dropIndex).
      await this.deps.persistCompoundIndexDefinitions(this.deps.compound.list().filter((i) => i.name !== name));
      return this.deps.compound.drop(name);
    });
  }

  listCompoundIndexes(): CompoundIndexInfo[] {
    return this.deps.compound.list();
  }

  /**
   * Ordered range within a group, e.g. "sessions in workspace X ordered by
   * updatedAt". O(log N + limit) β€” no full sort.
   */
  compoundRange(
    name: string,
    groupValue: unknown,
    opts: RangeOptions<unknown> & { limit?: number } = {},
  ): { key: string; value: V | undefined; orderValue: unknown }[] {
    this.deps.ensureOpen();
    return this.deps.compound
      .range(name, groupValue, opts)
      .map(({ key, orderValue }) => ({
        key: fromKStr(key),
        value: this.deps.decode(this.deps.store().get(key)),
        orderValue,
      }))
      .filter((r): r is { key: string; value: V; orderValue: unknown } => r.value !== undefined);
  }
}