@dzhechkov/memory 0.2.19 → 0.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,8 @@ import { mkdirSync } from 'node:fs';
13
13
  import { dirname } from 'node:path';
14
14
 
15
15
  import type { MemoryBackend, MemoryQuery, MemoryRecord } from './backend.js';
16
+ import { openSqliteReadOnly, SqliteReadOnlyStore } from './sqlite-readonly.js';
17
+ import type { OpenReadOnlyOptions, ReadOnlyStore } from './sqlite-readonly.js';
16
18
 
17
19
  const require = createRequire(import.meta.url);
18
20
 
@@ -117,14 +119,14 @@ const FTS5_SQL = `
117
119
  `;
118
120
 
119
121
  /** FTS5 query — matching records with their relevance rank (lower = better). */
120
- const FTS5_SEARCH_SQL = `
122
+ export const FTS5_SEARCH_SQL = `
121
123
  SELECT mr.*, fts.rank AS _rank FROM memory_fts fts
122
124
  JOIN memory_records mr ON mr.rowid = fts.rowid
123
125
  WHERE memory_fts MATCH ?
124
126
  ORDER BY fts.rank
125
127
  `;
126
128
 
127
- const FTS5_SEARCH_SKILL_SQL = `
129
+ export const FTS5_SEARCH_SKILL_SQL = `
128
130
  SELECT mr.*, fts.rank AS _rank FROM memory_fts fts
129
131
  JOIN memory_records mr ON mr.rowid = fts.rowid
130
132
  WHERE memory_fts MATCH ? AND mr.skill_id = ?
@@ -138,9 +140,94 @@ const UPSERT_SQL = `
138
140
 
139
141
  const DELETE_SQL = 'DELETE FROM memory_records WHERE id = ?';
140
142
 
141
- const ALL_SQL = 'SELECT * FROM memory_records';
142
- const COUNT_SQL = 'SELECT COUNT(*) as cnt FROM memory_records';
143
- const BY_SKILL_SQL = 'SELECT * FROM memory_records WHERE skill_id = ?';
143
+ export const ALL_SQL = 'SELECT * FROM memory_records';
144
+ export const COUNT_SQL = 'SELECT COUNT(*) as cnt FROM memory_records';
145
+ export const BY_SKILL_SQL = 'SELECT * FROM memory_records WHERE skill_id = ?';
146
+
147
+ /**
148
+ * The FTS5-ranked / keyword-overlap search decision, extracted from `querySync` so a
149
+ * read-only backend (which prepares the same statements but never runs `INIT_SQL`/`FTS5_SQL`)
150
+ * can share it byte-for-byte instead of forking its own copy. A forked copy is exactly the
151
+ * class of bug documented above (lines 24-29): a reader whose ranking diverges from the
152
+ * writer's silently regresses recall. Behaviorally IDENTICAL to the body it replaced — same
153
+ * sort order, same `stemOf` prefixes, same `relevance > 0` filter, same `terms.length > 0`
154
+ * branch. `@internal` — exported only so `sqlite-readonly.ts` can call it; not part of the
155
+ * package's public surface (see `index.ts`, which does not re-export it).
156
+ *
157
+ * @internal
158
+ */
159
+ export function searchPreparedRecords(
160
+ stmts: { fts?: any; ftsSkill?: any; all: any; bySkill: any },
161
+ hasFts5: boolean,
162
+ query: MemoryQuery,
163
+ ): MemoryRecord[] {
164
+ const limit = query.limit ?? DEFAULT_LIMIT;
165
+
166
+ // FTS5 path — use SQLite full-text search when available and text query provided
167
+ if (hasFts5 && query.text !== undefined && query.text.trim().length > 0) {
168
+ try {
169
+ // FTS5 query syntax: simple terms joined by spaces (implicit AND → OR with ranking)
170
+ // Each token also contributes its prefix-stem as `stem*` (see stemOf): the exact word and
171
+ // its inflections all match, and a row holding the exact token matches BOTH disjuncts, so
172
+ // bm25 ranks it at or above a prefix-only row. Tokens are \p{L}\p{N}-only — safe to
173
+ // interpolate; the star is appended HERE, never taken from user text.
174
+ const ftsQuery = tokenize(query.text)
175
+ .flatMap((t) => {
176
+ const stem = stemOf(t);
177
+ return stem === null ? [t] : [t, stem + '*'];
178
+ })
179
+ .join(' OR ');
180
+ if (ftsQuery.length > 0) {
181
+ let rows: any[];
182
+ if (query.skillId !== undefined) {
183
+ rows = stmts.ftsSkill!.all(ftsQuery, query.skillId);
184
+ } else {
185
+ rows = stmts.fts!.all(ftsQuery);
186
+ }
187
+ // Rank by FTS5 relevance FIRST (lower rank = better match), with score
188
+ // then timestamp as a true tiebreak between equally-relevant rows. Score
189
+ // must NOT be primary — that would discard FTS5's relevance signal and
190
+ // diverge from the JSON backend (which also ranks relevance-primary).
191
+ const ranked = rows
192
+ .map((row) => ({ record: rowToRecord(row), rank: row._rank as number }))
193
+ .sort(
194
+ (a, b) =>
195
+ a.rank - b.rank ||
196
+ b.record.score - a.record.score ||
197
+ b.record.timestamp.localeCompare(a.record.timestamp),
198
+ );
199
+ return ranked.slice(0, limit).map((entry) => entry.record);
200
+ }
201
+ } catch {
202
+ // FTS5 query failed (e.g., special chars) — fall through to keyword approach
203
+ }
204
+ }
205
+
206
+ // Keyword overlap fallback
207
+ const terms = query.text !== undefined ? tokenize(query.text) : [];
208
+ let rows: any[];
209
+ if (query.skillId !== undefined) {
210
+ rows = stmts.bySkill.all(query.skillId);
211
+ } else {
212
+ rows = stmts.all.all();
213
+ }
214
+
215
+ const records = rows.map(rowToRecord);
216
+ const ranked = records
217
+ .map((record) => ({ record, relevance: relevanceOf(record, terms) }))
218
+ .sort(
219
+ (a, b) =>
220
+ b.relevance - a.relevance ||
221
+ b.record.score - a.record.score ||
222
+ b.record.timestamp.localeCompare(a.record.timestamp),
223
+ );
224
+ // The SAME guard the JSON backend applies, so a store's answers never depend on which backend is
225
+ // installed. This is the keyword FALLBACK; the FTS5 path above already returns zero honestly and
226
+ // is untouched. With no usable terms there was nothing to match on, so the store still comes back
227
+ // ranked by confidence — that distinction is the whole decision (ADR-001).
228
+ const filtered = terms.length > 0 ? ranked.filter((entry) => entry.relevance > 0) : ranked;
229
+ return filtered.slice(0, limit).map((entry) => entry.record);
230
+ }
144
231
 
145
232
  /** Options for SqliteBackend. */
146
233
  export interface SqliteBackendOptions {
@@ -204,6 +291,32 @@ export class SqliteBackend implements MemoryBackend {
204
291
  return new SqliteBackend(db);
205
292
  }
206
293
 
294
+ /**
295
+ * Open a SQLite database for READING ONLY (ADR-001, Решение 2). Never runs `INIT_SQL`,
296
+ * `FTS5_SQL`, or the FTS rebuild — the writer's `constructor` above stays untouched byte
297
+ * for byte. Presence of the FTS5 table is discovered by reading `sqlite_master`, not by
298
+ * attempting to (re)create it. `put`/`putMany`/`remove`/`removeSync` on the returned store
299
+ * throw `read-only backend: <method> is not available`.
300
+ */
301
+ static openReadOnly(filePath: string, opts?: OpenReadOnlyOptions): ReadOnlyStore {
302
+ const handle = openSqliteReadOnly(filePath, opts);
303
+ try {
304
+ return new SqliteReadOnlyStore(handle);
305
+ } catch (err) {
306
+ // The constructor prepares statements (`sqlite_master` lookup, ALL/COUNT/BY_SKILL) against
307
+ // an ALREADY-OPEN handle — if any of that throws (e.g. a valid SQLite file missing
308
+ // `memory_records`), the handle must not leak: close the connection and remove a tmp-copy
309
+ // before rethrowing (fix round 1, HIGH #2).
310
+ try {
311
+ handle.db.close();
312
+ } catch {
313
+ // already failing on the caller's side — a close failure here must not mask the real cause
314
+ }
315
+ handle.cleanup();
316
+ throw err;
317
+ }
318
+ }
319
+
207
320
  put(record: MemoryRecord): Promise<void> {
208
321
  this.upsertStmt.run(
209
322
  record.id,
@@ -227,72 +340,11 @@ export class SqliteBackend implements MemoryBackend {
227
340
  * path (a recommender / `dz recall`) can query the store without an async ripple.
228
341
  */
229
342
  querySync(query: MemoryQuery): MemoryRecord[] {
230
- const limit = query.limit ?? DEFAULT_LIMIT;
231
-
232
- // FTS5 path — use SQLite full-text search when available and text query provided
233
- if (this.hasFts5 && query.text !== undefined && query.text.trim().length > 0) {
234
- try {
235
- // FTS5 query syntax: simple terms joined by spaces (implicit AND → OR with ranking)
236
- // Each token also contributes its prefix-stem as `stem*` (see stemOf): the exact word and
237
- // its inflections all match, and a row holding the exact token matches BOTH disjuncts, so
238
- // bm25 ranks it at or above a prefix-only row. Tokens are \p{L}\p{N}-only — safe to
239
- // interpolate; the star is appended HERE, never taken from user text.
240
- const ftsQuery = tokenize(query.text)
241
- .flatMap((t) => {
242
- const stem = stemOf(t);
243
- return stem === null ? [t] : [t, stem + '*'];
244
- })
245
- .join(' OR ');
246
- if (ftsQuery.length > 0) {
247
- let rows: any[];
248
- if (query.skillId !== undefined) {
249
- rows = this.ftsSearchSkillStmt!.all(ftsQuery, query.skillId);
250
- } else {
251
- rows = this.ftsSearchStmt!.all(ftsQuery);
252
- }
253
- // Rank by FTS5 relevance FIRST (lower rank = better match), with score
254
- // then timestamp as a true tiebreak between equally-relevant rows. Score
255
- // must NOT be primary — that would discard FTS5's relevance signal and
256
- // diverge from the JSON backend (which also ranks relevance-primary).
257
- const ranked = rows
258
- .map((row) => ({ record: rowToRecord(row), rank: row._rank as number }))
259
- .sort(
260
- (a, b) =>
261
- a.rank - b.rank ||
262
- b.record.score - a.record.score ||
263
- b.record.timestamp.localeCompare(a.record.timestamp),
264
- );
265
- return ranked.slice(0, limit).map((entry) => entry.record);
266
- }
267
- } catch {
268
- // FTS5 query failed (e.g., special chars) — fall through to keyword approach
269
- }
270
- }
271
-
272
- // Keyword overlap fallback
273
- const terms = query.text !== undefined ? tokenize(query.text) : [];
274
- let rows: any[];
275
- if (query.skillId !== undefined) {
276
- rows = this.bySkillStmt.all(query.skillId);
277
- } else {
278
- rows = this.allStmt.all();
279
- }
280
-
281
- const records = rows.map(rowToRecord);
282
- const ranked = records
283
- .map((record) => ({ record, relevance: relevanceOf(record, terms) }))
284
- .sort(
285
- (a, b) =>
286
- b.relevance - a.relevance ||
287
- b.record.score - a.record.score ||
288
- b.record.timestamp.localeCompare(a.record.timestamp),
289
- );
290
- // The SAME guard the JSON backend applies, so a store's answers never depend on which backend is
291
- // installed. This is the keyword FALLBACK; the FTS5 path above already returns zero honestly and
292
- // is untouched. With no usable terms there was nothing to match on, so the store still comes back
293
- // ranked by confidence — that distinction is the whole decision (ADR-001).
294
- const filtered = terms.length > 0 ? ranked.filter((entry) => entry.relevance > 0) : ranked;
295
- return filtered.slice(0, limit).map((entry) => entry.record);
343
+ return searchPreparedRecords(
344
+ { fts: this.ftsSearchStmt, ftsSkill: this.ftsSearchSkillStmt, all: this.allStmt, bySkill: this.bySkillStmt },
345
+ this.hasFts5,
346
+ query,
347
+ );
296
348
  }
297
349
 
298
350
  all(): Promise<MemoryRecord[]> {
@@ -343,7 +395,7 @@ export class SqliteBackend implements MemoryBackend {
343
395
  }
344
396
 
345
397
  /** Convert a raw SQLite row to a MemoryRecord. */
346
- function rowToRecord(row: any): MemoryRecord {
398
+ export function rowToRecord(row: any): MemoryRecord {
347
399
  return {
348
400
  id: row.id,
349
401
  skillId: row.skill_id,
@@ -0,0 +1,244 @@
1
+ /**
2
+ * A single, shared opener for READ-ONLY SQLite access (ADR-001,
3
+ * `features/store-readonly-reads/03_adr/001-read-opener-ladder-in-place-then-tmp-copy.md`).
4
+ *
5
+ * `{ readonly: true }` alone does not read a WAL database on a directory that cannot be written
6
+ * to: the wal-mode reader still needs to create (or find) `-shm`/`-wal` next to the file, and on
7
+ * a read-only-mounted directory that create fails with `unable to open database file` — MEASURED
8
+ * 2026-09-12 against better-sqlite3 11.10.0 / SQLite 3.49.2 (`chattr +i` on a `/var/tmp`
9
+ * directory holding a checkpointed WAL database). `openSqliteReadOnly` climbs a three-step ladder
10
+ * instead of assuming step 1 always works:
11
+ *
12
+ * 1. **In place.** `{ readonly: true, fileMustExist: true }`, then a real read (the WAL error
13
+ * surfaces on the first query, not on open) — this is what a live writer on the same host
14
+ * gets: it sees uncheckpointed rows too.
15
+ * 2. **Copy.** Only on `SQLITE_CANTOPEN` / "unable to open database file": copy the database
16
+ * (+ its `-wal`, if any — never `-shm`, which is derived and recreated) into a fresh
17
+ * `mkdtemp` directory and open THAT read-only.
18
+ * 3. **Honest failure.** Any other error, or step 2 itself failing, throws — naming the path
19
+ * and the original cause. Never a swallowed `{ hits: [] }`.
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+
24
+ import { createRequire } from 'node:module';
25
+ import { existsSync, copyFileSync, mkdtempSync, rmSync } from 'node:fs';
26
+ import { tmpdir } from 'node:os';
27
+ import { basename, join } from 'node:path';
28
+
29
+ import type { MemoryQuery, MemoryRecord } from './backend.js';
30
+ import {
31
+ searchPreparedRecords,
32
+ FTS5_SEARCH_SQL,
33
+ FTS5_SEARCH_SKILL_SQL,
34
+ ALL_SQL,
35
+ COUNT_SQL,
36
+ BY_SKILL_SQL,
37
+ rowToRecord,
38
+ } from './sqlite-backend.js';
39
+
40
+ const require = createRequire(import.meta.url);
41
+
42
+ /** Constructor shape `openSqliteReadOnly` needs from `better-sqlite3` (or a caller-supplied one). */
43
+ type DatabaseCtor = new (p: string, o?: { readonly?: boolean; fileMustExist?: boolean }) => any;
44
+
45
+ /** The handle returned by {@link openSqliteReadOnly}. */
46
+ export interface ReadOnlyHandle {
47
+ /** better-sqlite3 `Database`, opened read-only. */
48
+ readonly db: any;
49
+ /** Which rung of the ladder answered: the file itself, or a temporary copy of it. */
50
+ readonly source: 'in-place' | 'tmp-copy';
51
+ /** Removes the temporary copy's directory. No-op for `'in-place'`. Idempotent — safe to call more than once. */
52
+ readonly cleanup: () => void;
53
+ }
54
+
55
+ /** Options for {@link openSqliteReadOnly}. */
56
+ export interface OpenReadOnlyOptions {
57
+ /**
58
+ * The `better-sqlite3` constructor to use. Callers that resolve their own copy of the native
59
+ * module (`book-kb.ts`, `agentdb-index.ts` — both resolve from the TARGET project, not from
60
+ * this package) MUST pass it here rather than let this opener resolve its own: a silently
61
+ * different native-module instance is the same class of bug as "the tool in PATH decides the
62
+ * verdict". Omitted only by callers that are fine with the memory package's own resolution
63
+ * (`SqliteBackend.openReadOnly`, which resolves via `createRequire(import.meta.url)` — the same
64
+ * resolution `SqliteBackend.open` already uses).
65
+ */
66
+ readonly Database?: DatabaseCtor;
67
+ }
68
+
69
+ function defaultDatabaseCtor(): DatabaseCtor {
70
+ // Dynamic require — better-sqlite3 must be available at runtime, same discipline as
71
+ // `SqliteBackend.open` (sqlite-backend.ts).
72
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
73
+ return require('better-sqlite3');
74
+ }
75
+
76
+ function errorMessage(err: unknown): string {
77
+ return err instanceof Error ? err.message : String(err);
78
+ }
79
+
80
+ /** Step-1 → step-2 trigger: ONLY a can't-open-the-file failure retries as a copy. */
81
+ function isCantOpenError(err: unknown): boolean {
82
+ const code = (err as { code?: string } | undefined)?.code;
83
+ const msg = errorMessage(err);
84
+ return code === 'SQLITE_CANTOPEN' || /unable to open database file/i.test(msg);
85
+ }
86
+
87
+ /** A cheap, real read — the WAL "can't create -shm/-wal here" failure surfaces on a query, not on open. */
88
+ function probe(db: any): void {
89
+ db.prepare('SELECT count(*) AS n FROM sqlite_master').get();
90
+ }
91
+
92
+ function closeQuietly(db: any): void {
93
+ try {
94
+ db.close();
95
+ } catch {
96
+ // already failing on the caller's side — a close failure here must not mask the real cause
97
+ }
98
+ }
99
+
100
+ function removeQuietly(dir: string): void {
101
+ try {
102
+ rmSync(dir, { recursive: true, force: true });
103
+ } catch {
104
+ // best-effort — the honest-failure path below still reports the real cause
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Open `filePath` for reading only, climbing the ladder described above.
110
+ *
111
+ * Deliberately does NOT: `mkdirSync` any directory, run any DDL/DML, set `journal_mode` or
112
+ * `synchronous`, or check `existsSync` itself (FR-5 keeps that check with the caller, before this
113
+ * is called — `fileMustExist: true` below is only the second line of defence against a race).
114
+ */
115
+ export function openSqliteReadOnly(filePath: string, opts?: OpenReadOnlyOptions): ReadOnlyHandle {
116
+ const Database = opts?.Database ?? defaultDatabaseCtor();
117
+
118
+ // Step 1 — in place.
119
+ let inPlaceDb: any | undefined;
120
+ try {
121
+ inPlaceDb = new Database(filePath, { readonly: true, fileMustExist: true });
122
+ probe(inPlaceDb);
123
+ return { db: inPlaceDb, source: 'in-place', cleanup: () => {} };
124
+ } catch (stepOneErr) {
125
+ if (inPlaceDb !== undefined) closeQuietly(inPlaceDb);
126
+ if (!isCantOpenError(stepOneErr)) {
127
+ throw new Error(`failed to open ${filePath} for reading: ${errorMessage(stepOneErr)}`);
128
+ }
129
+
130
+ // Step 2 — copy into a fresh tmp directory.
131
+ let tmpDir: string | undefined;
132
+ let copyDb: any | undefined;
133
+ try {
134
+ tmpDir = mkdtempSync(join(tmpdir(), 'dz-ro-'));
135
+ const dest = join(tmpDir, basename(filePath));
136
+ copyFileSync(filePath, dest);
137
+ const walSource = `${filePath}-wal`;
138
+ if (existsSync(walSource)) {
139
+ copyFileSync(walSource, `${dest}-wal`);
140
+ }
141
+ // `-shm` is intentionally NOT copied — it is derived and SQLite recreates it; a stale one
142
+ // next to a fresh copy is worse than none (ADR-001, Решение 1, ступень 2).
143
+ copyDb = new Database(dest, { readonly: true, fileMustExist: true });
144
+ probe(copyDb);
145
+ const dir = tmpDir;
146
+ return {
147
+ db: copyDb,
148
+ source: 'tmp-copy',
149
+ cleanup: () => removeQuietly(dir),
150
+ };
151
+ } catch (stepTwoErr) {
152
+ // `copyDb` may have opened successfully and THEN failed in `probe()` — an unclosed handle
153
+ // here leaks a file descriptor and, on a platform that refuses to delete an open file,
154
+ // leaves the tmp directory behind too. Close before removing (fix round 1, HIGH #1).
155
+ if (copyDb !== undefined) closeQuietly(copyDb);
156
+ if (tmpDir !== undefined) removeQuietly(tmpDir);
157
+ throw new Error(`failed to open ${filePath} for reading: ${errorMessage(stepTwoErr)}`);
158
+ }
159
+ }
160
+ }
161
+
162
+ /** The read-only surface a caller gets back from {@link openSqliteReadOnly} + the search logic. */
163
+ export interface ReadOnlyStore {
164
+ querySync(query: MemoryQuery): MemoryRecord[];
165
+ allSync(): MemoryRecord[];
166
+ countSync(): number;
167
+ close(): void;
168
+ }
169
+
170
+ /** `sqlite_master` lookup used to discover FTS5 presence WITHOUT attempting to (re)create it. */
171
+ const FTS5_TABLE_PRESENT_SQL = `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'memory_fts'`;
172
+
173
+ /**
174
+ * `ReadOnlyStore` over a {@link ReadOnlyHandle}. Prepares the SAME statement text
175
+ * `SqliteBackend`'s writer constructor prepares (imported, not forked) and shares its search
176
+ * logic via `searchPreparedRecords` — so a reader can never rank differently than the writer.
177
+ * Runs NO `INIT_SQL`, NO `FTS5_SQL`, NO FTS rebuild; discovers FTS5 by reading `sqlite_master`.
178
+ * Mutating methods throw — this store has no `put`/`remove` sibling by construction, not by
179
+ * convention.
180
+ */
181
+ export class SqliteReadOnlyStore implements ReadOnlyStore {
182
+ private readonly handle: ReadOnlyHandle;
183
+ private readonly hasFts5: boolean;
184
+ private readonly ftsSearchStmt: any | undefined;
185
+ private readonly ftsSearchSkillStmt: any | undefined;
186
+ private readonly allStmt: any;
187
+ private readonly countStmt: any;
188
+ private readonly bySkillStmt: any;
189
+
190
+ constructor(handle: ReadOnlyHandle) {
191
+ this.handle = handle;
192
+ const db = handle.db;
193
+ const ftsRow = db.prepare(FTS5_TABLE_PRESENT_SQL).get() as { name?: string } | undefined;
194
+ this.hasFts5 = ftsRow?.name === 'memory_fts';
195
+ if (this.hasFts5) {
196
+ this.ftsSearchStmt = db.prepare(FTS5_SEARCH_SQL);
197
+ this.ftsSearchSkillStmt = db.prepare(FTS5_SEARCH_SKILL_SQL);
198
+ }
199
+ this.allStmt = db.prepare(ALL_SQL);
200
+ this.countStmt = db.prepare(COUNT_SQL);
201
+ this.bySkillStmt = db.prepare(BY_SKILL_SQL);
202
+ }
203
+
204
+ querySync(query: MemoryQuery): MemoryRecord[] {
205
+ return searchPreparedRecords(
206
+ { fts: this.ftsSearchStmt, ftsSkill: this.ftsSearchSkillStmt, all: this.allStmt, bySkill: this.bySkillStmt },
207
+ this.hasFts5,
208
+ query,
209
+ );
210
+ }
211
+
212
+ allSync(): MemoryRecord[] {
213
+ return this.allStmt.all().map(rowToRecord);
214
+ }
215
+
216
+ countSync(): number {
217
+ return this.countStmt.get().cnt as number;
218
+ }
219
+
220
+ /** `db.close()` first, THEN `handle.cleanup()` in `finally` — the tmp-copy must be removed even if `close()` throws. */
221
+ close(): void {
222
+ try {
223
+ this.handle.db.close();
224
+ } finally {
225
+ this.handle.cleanup();
226
+ }
227
+ }
228
+
229
+ put(): never {
230
+ throw new Error('read-only backend: put is not available');
231
+ }
232
+
233
+ putMany(): never {
234
+ throw new Error('read-only backend: putMany is not available');
235
+ }
236
+
237
+ remove(): never {
238
+ throw new Error('read-only backend: remove is not available');
239
+ }
240
+
241
+ removeSync(): never {
242
+ throw new Error('read-only backend: removeSync is not available');
243
+ }
244
+ }