@atscript/db-memory 0.1.113
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.
- package/README.md +111 -0
- package/dist/index.cjs +978 -0
- package/dist/index.d.cts +484 -0
- package/dist/index.d.mts +484 -0
- package/dist/index.mjs +972 -0
- package/package.json +55 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
import { BaseDbAdapter, DbError, DbSpace, walkFilter } from "@atscript/db";
|
|
2
|
+
//#region src/lib/memory-filter.ts
|
|
3
|
+
/**
|
|
4
|
+
* Dot-path getter. Splits `path` on `.` and walks plain objects, returning the
|
|
5
|
+
* value at the end of the path or `undefined` if any intermediate segment is
|
|
6
|
+
* missing or is not a plain object.
|
|
7
|
+
*
|
|
8
|
+
* LIMITATION (v1, accepted): this does NOT descend into arrays. If a segment
|
|
9
|
+
* resolves to an array, traversal stops and `undefined` is returned — there is
|
|
10
|
+
* no positional/`$elemMatch`-style indexing. Array-of-object matching is a
|
|
11
|
+
* later concern; the SQL/Mongo adapters flatten differently and we do not want
|
|
12
|
+
* to fake a semantic the store can't back yet.
|
|
13
|
+
*/
|
|
14
|
+
function getPath(row, path) {
|
|
15
|
+
const segments = path.split(".");
|
|
16
|
+
let current = row;
|
|
17
|
+
for (const seg of segments) {
|
|
18
|
+
if (current === null || typeof current !== "object" || Array.isArray(current)) return;
|
|
19
|
+
current = current[seg];
|
|
20
|
+
}
|
|
21
|
+
return current;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Like {@link getPath}, but reports whether the FINAL key EXISTS rather than
|
|
25
|
+
* its value. A key holding `null` counts as present. This is what lets
|
|
26
|
+
* `$exists` distinguish a `null`-valued field (present) from an absent one —
|
|
27
|
+
* a distinction {@link getPath} alone cannot make (both would read back as a
|
|
28
|
+
* nullish value). Same array limitation as {@link getPath}.
|
|
29
|
+
*/
|
|
30
|
+
function hasPath(row, path) {
|
|
31
|
+
const segments = path.split(".");
|
|
32
|
+
const last = segments.pop();
|
|
33
|
+
let current = row;
|
|
34
|
+
for (const seg of segments) {
|
|
35
|
+
if (current === null || typeof current !== "object" || Array.isArray(current)) return false;
|
|
36
|
+
current = current[seg];
|
|
37
|
+
}
|
|
38
|
+
if (current === null || typeof current !== "object" || Array.isArray(current)) return false;
|
|
39
|
+
return Object.prototype.hasOwnProperty.call(current, last);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Deep-equality for leaf values, used by `$eq`/`$ne`/`$in`/`$nin`.
|
|
43
|
+
*
|
|
44
|
+
* - `Date`s compare by their instant (`getTime()`), not identity.
|
|
45
|
+
* - Everything else uses strict `===`. In particular `null === null` is `true`,
|
|
46
|
+
* while `undefined` (how {@link getPath} reports a missing field) is never
|
|
47
|
+
* equal to `null` here.
|
|
48
|
+
*
|
|
49
|
+
* NOTE: this stays STRICT on purpose — it backs `$in`/`$nin` and unique-index
|
|
50
|
+
* tuple equality. The Mongo-like `$eq: null` ⇒ "null OR missing" match is a
|
|
51
|
+
* separate, loose-`==` null branch handled in {@link evalEq} BEFORE it reaches
|
|
52
|
+
* `valuesEqual`, so this function never has to conflate `undefined` with `null`.
|
|
53
|
+
*
|
|
54
|
+
* No structural/object comparison: filter leaves are primitives, so reference
|
|
55
|
+
* equality is the correct floor for anything non-primitive.
|
|
56
|
+
*/
|
|
57
|
+
function valuesEqual(a, b) {
|
|
58
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
59
|
+
return a === b;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Mirror of `mongo-filter.ts`'s `parseRegexString`: normalize a `$regex` value
|
|
63
|
+
* (or a bare `RegExp`) into a `{ pattern, flags }` pair. Accepts a `RegExp`
|
|
64
|
+
* instance, a `/pattern/flags` string literal, or a plain string (treated as
|
|
65
|
+
* the literal pattern with no flags).
|
|
66
|
+
*/
|
|
67
|
+
function parseRegexString(value) {
|
|
68
|
+
if (value instanceof RegExp) return {
|
|
69
|
+
pattern: value.source,
|
|
70
|
+
flags: value.flags
|
|
71
|
+
};
|
|
72
|
+
const str = String(value);
|
|
73
|
+
const match = str.match(/^\/(.+)\/([gimsuy]*)$/);
|
|
74
|
+
if (match) return {
|
|
75
|
+
pattern: match[1],
|
|
76
|
+
flags: match[2]
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
pattern: str,
|
|
80
|
+
flags: ""
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Coerce an arbitrary leaf to a string for regex testing. The `unknown`
|
|
85
|
+
* parameter is load-bearing: it keeps the `String()` coercion behind a typed
|
|
86
|
+
* boundary so `no-base-to-string` does not fire at the call sites (where the
|
|
87
|
+
* value narrows to a non-primitive `{}` and would otherwise trip the rule). Do
|
|
88
|
+
* NOT inline `String(...)` at the call sites — that reintroduces the lint error.
|
|
89
|
+
*/
|
|
90
|
+
function stringifyLeaf(v) {
|
|
91
|
+
return String(v);
|
|
92
|
+
}
|
|
93
|
+
/** `$eq` semantics, factored out so `$ne` can be its exact negation. */
|
|
94
|
+
function evalEq(row, field, value) {
|
|
95
|
+
const fieldValue = getPath(row, field);
|
|
96
|
+
if (value === null) return fieldValue == null;
|
|
97
|
+
if (value instanceof RegExp) return fieldValue != null && value.test(stringifyLeaf(fieldValue));
|
|
98
|
+
return valuesEqual(fieldValue, value);
|
|
99
|
+
}
|
|
100
|
+
/** `$in` membership, factored out so `$nin` can be its exact negation. */
|
|
101
|
+
function evalIn(row, field, value) {
|
|
102
|
+
if (!Array.isArray(value)) return false;
|
|
103
|
+
const fieldValue = getPath(row, field);
|
|
104
|
+
return value.some((element) => valuesEqual(fieldValue, element));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Coerce a leaf to something the JS relational operators can order. `Date`s
|
|
108
|
+
* become their epoch millis; everything else is passed through. Typed as
|
|
109
|
+
* `number` purely so `<`/`>` type-check — at runtime JS still orders strings
|
|
110
|
+
* lexicographically and numbers numerically (see {@link evalRelational}).
|
|
111
|
+
*/
|
|
112
|
+
function toOrdinal(v) {
|
|
113
|
+
return v instanceof Date ? v.getTime() : v;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* `$gt`/`$gte`/`$lt`/`$lte`. A missing or `null` field never matches an
|
|
117
|
+
* ordering comparison. `Date` operands are normalized to epoch millis; all
|
|
118
|
+
* other comparisons use plain JS ordering (numbers numerically, strings
|
|
119
|
+
* lexicographically) — NO collation or locale awareness. This intentionally
|
|
120
|
+
* differs from SQL engines' collated ordering.
|
|
121
|
+
*/
|
|
122
|
+
function evalRelational(row, field, op, value) {
|
|
123
|
+
const fieldValue = getPath(row, field);
|
|
124
|
+
if (fieldValue === void 0 || fieldValue === null) return false;
|
|
125
|
+
const a = toOrdinal(fieldValue);
|
|
126
|
+
const b = toOrdinal(value);
|
|
127
|
+
switch (op) {
|
|
128
|
+
case "$gt": return a > b;
|
|
129
|
+
case "$gte": return a >= b;
|
|
130
|
+
case "$lt": return a < b;
|
|
131
|
+
case "$lte": return a <= b;
|
|
132
|
+
default: return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Visitor that assembles an in-memory {@link Predicate} from a `FilterExpr`.
|
|
137
|
+
*
|
|
138
|
+
* The STRUCTURE (which logical/comparison nodes exist and how they nest) is
|
|
139
|
+
* dictated by the shared {@link walkFilter} walker — the same one the SQL and
|
|
140
|
+
* Mongo adapters use — so structural parity is guaranteed by construction. Only
|
|
141
|
+
* the leaf/composition SEMANTICS below are this adapter's own, JS-native
|
|
142
|
+
* contract.
|
|
143
|
+
*/
|
|
144
|
+
const memoryVisitor = {
|
|
145
|
+
and(children) {
|
|
146
|
+
return (row) => children.every((child) => child(row));
|
|
147
|
+
},
|
|
148
|
+
or(children) {
|
|
149
|
+
return (row) => children.some((child) => child(row));
|
|
150
|
+
},
|
|
151
|
+
not(child) {
|
|
152
|
+
return (row) => !child(row);
|
|
153
|
+
},
|
|
154
|
+
comparison(field, op, value) {
|
|
155
|
+
switch (op) {
|
|
156
|
+
case "$eq": return (row) => evalEq(row, field, value);
|
|
157
|
+
case "$ne": return (row) => !evalEq(row, field, value);
|
|
158
|
+
case "$gt":
|
|
159
|
+
case "$gte":
|
|
160
|
+
case "$lt":
|
|
161
|
+
case "$lte": return (row) => evalRelational(row, field, op, value);
|
|
162
|
+
case "$in": return (row) => evalIn(row, field, value);
|
|
163
|
+
case "$nin": return (row) => !evalIn(row, field, value);
|
|
164
|
+
case "$regex": {
|
|
165
|
+
const { pattern, flags } = parseRegexString(value);
|
|
166
|
+
const regex = new RegExp(pattern, flags);
|
|
167
|
+
return (row) => {
|
|
168
|
+
const fieldValue = getPath(row, field);
|
|
169
|
+
return fieldValue != null && regex.test(stringifyLeaf(fieldValue));
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
case "$exists": return (row) => value === hasPath(row, field);
|
|
173
|
+
default: throw new DbError("INVALID_QUERY", [{
|
|
174
|
+
path: field,
|
|
175
|
+
message: `Unsupported filter operator: ${op}`
|
|
176
|
+
}]);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* Compiles a {@link FilterExpr} into an in-memory row predicate
|
|
182
|
+
* `(row) => boolean`, reusing the shared {@link walkFilter} walker so filter
|
|
183
|
+
* structure matches the SQL/Mongo adapters by construction.
|
|
184
|
+
*
|
|
185
|
+
* An empty/absent filter (for which `walkFilter` returns `undefined`) compiles
|
|
186
|
+
* to a match-everything predicate.
|
|
187
|
+
*/
|
|
188
|
+
function buildMemoryPredicate(filter) {
|
|
189
|
+
return walkFilter(filter, memoryVisitor) ?? (() => true);
|
|
190
|
+
}
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/lib/memory-engine.ts
|
|
193
|
+
/**
|
|
194
|
+
* Pure, store-agnostic core of the in-memory query engine: the `$sort`
|
|
195
|
+
* comparator and `$select` projection, factored out of {@link MemoryAdapter} so
|
|
196
|
+
* there is exactly ONE implementation. Everything here is a pure function over
|
|
197
|
+
* plain `Record<string, unknown>` rows — no adapter/table state — so other
|
|
198
|
+
* consumers (e.g. moost-db's value-help controller) can reuse the SAME engine
|
|
199
|
+
* instead of hand-rolling a second copy. The adapter wires its own PK-derived
|
|
200
|
+
* tie-break / physical-PK fields in as parameters.
|
|
201
|
+
*
|
|
202
|
+
* Dot-path READ (`getPath`) lives in {@link ./memory-filter} and is shared;
|
|
203
|
+
* the dot-path WRITE/DELETE helpers ({@link setPath}/{@link deletePath}) live
|
|
204
|
+
* here because projection (and the adapter's update path) are their only users.
|
|
205
|
+
*/
|
|
206
|
+
/**
|
|
207
|
+
* Total ordering for `$sort`. `null`/`undefined` sort LOW (before any concrete
|
|
208
|
+
* value); `Date`s compare by their instant; numbers numerically; everything
|
|
209
|
+
* else via JS-native `<`/`>` (strings lexicographically) — NO collation or
|
|
210
|
+
* locale awareness (documented divergence from the SQL adapters).
|
|
211
|
+
*/
|
|
212
|
+
function compareLeaves(a, b) {
|
|
213
|
+
const aNil = a === null || a === void 0;
|
|
214
|
+
const bNil = b === null || b === void 0;
|
|
215
|
+
if (aNil && bNil) return 0;
|
|
216
|
+
if (aNil) return -1;
|
|
217
|
+
if (bNil) return 1;
|
|
218
|
+
const av = a instanceof Date ? a.getTime() : a;
|
|
219
|
+
const bv = b instanceof Date ? b.getTime() : b;
|
|
220
|
+
if (typeof av === "number" && typeof bv === "number") return av < bv ? -1 : av > bv ? 1 : 0;
|
|
221
|
+
const as = av;
|
|
222
|
+
const bs = bv;
|
|
223
|
+
return as < bs ? -1 : as > bs ? 1 : 0;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Dot-path setter used by inclusion projection. Creates intermediate plain
|
|
227
|
+
* objects as needed; overwrites a non-object intermediate. Top-level keys and
|
|
228
|
+
* nested dot-paths both work.
|
|
229
|
+
*/
|
|
230
|
+
function setPath(target, path, value) {
|
|
231
|
+
const segments = path.split(".");
|
|
232
|
+
let current = target;
|
|
233
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
234
|
+
const seg = segments[i];
|
|
235
|
+
const next = current[seg];
|
|
236
|
+
if (next === null || typeof next !== "object" || Array.isArray(next)) current[seg] = {};
|
|
237
|
+
current = current[seg];
|
|
238
|
+
}
|
|
239
|
+
current[segments[segments.length - 1]] = value;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Dot-path deleter used by exclusion projection. No-op when any intermediate
|
|
243
|
+
* segment is missing or not a plain object. Top-level keys and nested dot-paths
|
|
244
|
+
* both work.
|
|
245
|
+
*/
|
|
246
|
+
function deletePath(target, path) {
|
|
247
|
+
const segments = path.split(".");
|
|
248
|
+
let current = target;
|
|
249
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
250
|
+
if (current === null || typeof current !== "object" || Array.isArray(current)) return;
|
|
251
|
+
current = current[segments[i]];
|
|
252
|
+
}
|
|
253
|
+
if (current !== null && typeof current === "object" && !Array.isArray(current)) delete current[segments[segments.length - 1]];
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Stable multi-key sort from `$sort`, applied over plain rows.
|
|
257
|
+
*
|
|
258
|
+
* - No `$sort` (or an empty one) → returns the input array UNCHANGED (same
|
|
259
|
+
* reference, insertion order preserved) — the fast path for an unsorted read.
|
|
260
|
+
* - `tieBreak`, when supplied, is the FINAL deterministic tie-break key for a
|
|
261
|
+
* TOTAL order — the adapter injects its {@link MemoryAdapter.pkKey} here so
|
|
262
|
+
* rows with equal sort keys still order deterministically. When ABSENT the
|
|
263
|
+
* sort falls back to preserving input order among equal keys (via each row's
|
|
264
|
+
* original index), so a consumer with no primary key keeps insertion order.
|
|
265
|
+
*
|
|
266
|
+
* NEVER mutates the input array: `.map` decorates into a fresh array and
|
|
267
|
+
* `.toSorted` returns another new sorted array (unlike `.sort`, which reorders
|
|
268
|
+
* in place). The `tieBreak`/index is computed ONCE per row (O(n)), not inside
|
|
269
|
+
* the O(n log n) comparator.
|
|
270
|
+
*/
|
|
271
|
+
function sortRows(rows, $sort, tieBreak) {
|
|
272
|
+
const keys = $sort ? Object.entries($sort) : [];
|
|
273
|
+
if (keys.length === 0) return rows;
|
|
274
|
+
return rows.map((row, index) => ({
|
|
275
|
+
row,
|
|
276
|
+
index,
|
|
277
|
+
tie: tieBreak?.(row)
|
|
278
|
+
})).toSorted((a, b) => {
|
|
279
|
+
for (const [field, dir] of keys) {
|
|
280
|
+
const cmp = compareLeaves(getPath(a.row, field), getPath(b.row, field));
|
|
281
|
+
if (cmp !== 0) return dir === -1 ? -cmp : cmp;
|
|
282
|
+
}
|
|
283
|
+
if (tieBreak) {
|
|
284
|
+
const at = a.tie;
|
|
285
|
+
const bt = b.tie;
|
|
286
|
+
return at < bt ? -1 : at > bt ? 1 : 0;
|
|
287
|
+
}
|
|
288
|
+
return a.index - b.index;
|
|
289
|
+
}).map((decorated) => decorated.row);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Projects a plain row per a `{ path: 0 | 1 }` projection map. Decoupled from
|
|
293
|
+
* `UniquSelect`: the caller passes the resolved projection map (e.g. from
|
|
294
|
+
* `$select.asProjection`) so the engine has no query-layer dependency.
|
|
295
|
+
*
|
|
296
|
+
* - No projection (undefined / empty) → the whole row (cloned per `clone`).
|
|
297
|
+
* - INCLUSION form (first entry is `1`) → a new object with only the selected
|
|
298
|
+
* paths PLUS `opts.pkFields`. Absent fields are omitted; a present-`null`
|
|
299
|
+
* (value === null) is kept.
|
|
300
|
+
* - EXCLUSION form (first entry is `0`) → a clone with those paths removed. This
|
|
301
|
+
* branch ALWAYS clones (it must own a copy to drop paths without mutating the
|
|
302
|
+
* input), so `clone: false` is a no-op here.
|
|
303
|
+
*
|
|
304
|
+
* Top-level and nested dot-paths are supported; exotic Mongo projection quirks
|
|
305
|
+
* (array positional, `$slice`, etc.) are intentionally NOT replicated.
|
|
306
|
+
*/
|
|
307
|
+
function projectRow(row, projection, opts) {
|
|
308
|
+
const clone = opts?.clone ?? false;
|
|
309
|
+
const entries = projection ? Object.entries(projection) : [];
|
|
310
|
+
if (entries.length === 0) return clone ? structuredClone(row) : row;
|
|
311
|
+
if (entries[0][1] === 1) {
|
|
312
|
+
const paths = new Set(entries.filter(([, v]) => v === 1).map(([k]) => k));
|
|
313
|
+
for (const pk of opts?.pkFields ?? []) paths.add(pk);
|
|
314
|
+
const out = {};
|
|
315
|
+
for (const path of paths) {
|
|
316
|
+
const value = getPath(row, path);
|
|
317
|
+
if (value !== void 0) setPath(out, path, value);
|
|
318
|
+
}
|
|
319
|
+
return clone ? structuredClone(out) : out;
|
|
320
|
+
}
|
|
321
|
+
const out = structuredClone(row);
|
|
322
|
+
for (const [path, v] of entries) if (v === 0) deletePath(out, path);
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
//#endregion
|
|
326
|
+
//#region src/lib/memory-adapter.ts
|
|
327
|
+
/**
|
|
328
|
+
* In-memory {@link BaseDbAdapter} implementation.
|
|
329
|
+
*
|
|
330
|
+
* Runs in one of two modes:
|
|
331
|
+
* - STORED mode (default): storage is a plain `Map` living on the adapter
|
|
332
|
+
* instance — adapter instances are 1:1 with a readable (table/view), so the
|
|
333
|
+
* Map is this table's whole store. Documents are kept in their nested PHYSICAL
|
|
334
|
+
* shape (no flattening), which is why {@link supportsNestedObjects} is `true`.
|
|
335
|
+
* Full CRUD surface: inserts, reads, update / replace / delete with
|
|
336
|
+
* optimistic-concurrency CAS.
|
|
337
|
+
* - PROVIDER (read-through) mode: enabled via {@link setProvider} /
|
|
338
|
+
* {@link setMemoryProvider}. Reads are served from a runtime closure
|
|
339
|
+
* recomputed per request (e.g. a Redis/job-manager snapshot) so a
|
|
340
|
+
* runtime-owned entity with NO database can be observed as a READ-ONLY
|
|
341
|
+
* atscript table; all writes are rejected (see {@link _assertWritable}).
|
|
342
|
+
*/
|
|
343
|
+
var MemoryAdapter = class extends BaseDbAdapter {
|
|
344
|
+
/**
|
|
345
|
+
* The table's store, keyed by {@link pkKey}. Values are the stored rows in
|
|
346
|
+
* nested physical shape. An instance field — no `ensureTable` DDL needed.
|
|
347
|
+
*/
|
|
348
|
+
rows = /* @__PURE__ */ new Map();
|
|
349
|
+
/** Unique indexes recorded by {@link syncIndexes}. Enforced on insert. */
|
|
350
|
+
uniqueIndexes = [];
|
|
351
|
+
/** Memoized physical PK field names — stable for the adapter's lifetime. */
|
|
352
|
+
_pkFieldsCache;
|
|
353
|
+
/**
|
|
354
|
+
* Memoized map of PHYSICAL field name → optional `start` for every field
|
|
355
|
+
* carrying `@db.default.increment`. Stable per adapter (see
|
|
356
|
+
* {@link _incrementFields}).
|
|
357
|
+
*/
|
|
358
|
+
_incrementFieldsCache;
|
|
359
|
+
/**
|
|
360
|
+
* Running per-field auto-increment counters (PHYSICAL name → last value
|
|
361
|
+
* handed out). Lives on the adapter INSTANCE, so it resets whenever a new
|
|
362
|
+
* DbSpace/adapter is built — correct for an in-memory store (parity with
|
|
363
|
+
* SQLite `:memory:`, whose sequence also restarts with a fresh DB). Never
|
|
364
|
+
* persisted.
|
|
365
|
+
*/
|
|
366
|
+
_incrementCounters = /* @__PURE__ */ new Map();
|
|
367
|
+
/**
|
|
368
|
+
* Provider (read-through) backing closure. When set, this adapter is
|
|
369
|
+
* PROVIDER-BACKED: reads recompute rows from this closure per request and all
|
|
370
|
+
* writes are rejected (see {@link _assertWritable}). `undefined` ⇒ stored mode.
|
|
371
|
+
*
|
|
372
|
+
* WHY late-binding only (no constructor provider option): a {@link DbSpace}'s
|
|
373
|
+
* zero-arg `TAdapterFactory` builds EVERY table's adapter with the SAME
|
|
374
|
+
* factory — INCLUDING the internal `__atscript_control` sync table. A provider
|
|
375
|
+
* injected at construction would therefore leak onto the control table and
|
|
376
|
+
* break schema sync. Provider mode must target ONE specific table's
|
|
377
|
+
* already-built adapter, so it is only settable AFTER construction via
|
|
378
|
+
* {@link setProvider}.
|
|
379
|
+
*/
|
|
380
|
+
_provider;
|
|
381
|
+
/**
|
|
382
|
+
* The in-memory store keeps documents nested (no flattening), so the generic
|
|
383
|
+
* layer should pass nested objects through as-is.
|
|
384
|
+
*/
|
|
385
|
+
supportsNestedObjects() {
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Parity with the Mongo adapter (`return !fd.encrypted`). The in-memory
|
|
390
|
+
* dot-path filter visitor CAN filter into nested objects AND array
|
|
391
|
+
* (`storage === 'json'`) fields, so JSON storage is NOT a filterability
|
|
392
|
+
* blocker here. The base default vetoes `storage === 'json'` — correct for SQL
|
|
393
|
+
* engines that cannot reach into a raw JSON column, but WRONG for this
|
|
394
|
+
* nested-object-capable adapter, and it would under-report `filterable` to
|
|
395
|
+
* `/meta` for UIs. Overriding fixes that. The `@db.encrypted` veto is
|
|
396
|
+
* core-supplied and absolute (equality/range over ciphertext is meaningless),
|
|
397
|
+
* so it is preserved.
|
|
398
|
+
*
|
|
399
|
+
* NOTE on the capabilities left at their base defaults ON PURPOSE:
|
|
400
|
+
* - `canSortField` — its conservative JSON veto (array sort-by-min/max-element
|
|
401
|
+
* is a footgun for generic UI sort headers) is deliberate, matching Mongo.
|
|
402
|
+
* - `supportsNativePatch` / `supportsNativeRelations` — stay `false`: core
|
|
403
|
+
* decomposes patches into dot-path `$set`s and loads relations app-level.
|
|
404
|
+
*/
|
|
405
|
+
canFilterField(fd) {
|
|
406
|
+
return !fd.encrypted;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Coerces a by-id value to the id field's declared leaf type. The framework
|
|
410
|
+
* calls `adapter.prepareId(value, fieldType)` when building by-id filters, so a
|
|
411
|
+
* URL id like `"21"` reaches this adapter as the STRING `"21"`. Memory does
|
|
412
|
+
* STRICT JS comparison like the Mongo adapter, so the strict `$eq` would then
|
|
413
|
+
* compare `"21" === 21` against a numeric PK and never match — every
|
|
414
|
+
* fetch/patch/delete/replace-by-id of a numeric-PK row would 404. Coercing the
|
|
415
|
+
* id to the field type here fixes that. SQL adapters can rely on the DB to
|
|
416
|
+
* coerce the bound parameter; memory cannot, so it MUST coerce here.
|
|
417
|
+
*
|
|
418
|
+
* Mirrors the Mongo adapter's `prepareId` MINUS its `objectId` branch (an
|
|
419
|
+
* in-memory store has no ObjectId ids): a leaf `designType` of `"number"`
|
|
420
|
+
* coerces via `Number(id)`, everything else via `String(id)`.
|
|
421
|
+
*/
|
|
422
|
+
prepareId(id, _fieldType) {
|
|
423
|
+
const fieldType = _fieldType;
|
|
424
|
+
if (fieldType.type.kind === "") {
|
|
425
|
+
if (fieldType.type.designType === "number") return Number(id);
|
|
426
|
+
}
|
|
427
|
+
return String(id);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Physical names of the primary-key field(s). Single `@meta.id` resolves via
|
|
431
|
+
* {@link AtscriptDbReadable.metaIdPhysical}; a composite key maps each logical
|
|
432
|
+
* PK path through `pathToPhysical` (falling back to the name itself). Memoized
|
|
433
|
+
* because it is stable per adapter (1:1 with a fixed table) yet read on every
|
|
434
|
+
* `pkKey`/projection — recomputing would re-read `this._table.*` and re-allocate.
|
|
435
|
+
*/
|
|
436
|
+
_physicalPkFields() {
|
|
437
|
+
if (this._pkFieldsCache) return this._pkFieldsCache;
|
|
438
|
+
const metaIdPhysical = this._table.metaIdPhysical;
|
|
439
|
+
const fields = metaIdPhysical ? [metaIdPhysical] : this._table.primaryKeys.map((pk) => this._table.pathToPhysical.get(pk) ?? pk);
|
|
440
|
+
this._pkFieldsCache = fields;
|
|
441
|
+
return fields;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* PHYSICAL field name → optional `start` for every `@db.default.increment`
|
|
445
|
+
* field. Discovered lazily from the table's field descriptors — each carries
|
|
446
|
+
* both `physicalName` (the key the stored row uses) and `defaultValue`
|
|
447
|
+
* (sourced from `this._table.defaults`), so this resolves column renames
|
|
448
|
+
* correctly where iterating the logical-keyed `defaults` map would not.
|
|
449
|
+
* Memoized because it is stable per adapter (1:1 with a fixed table), like
|
|
450
|
+
* {@link _physicalPkFields}. An empty map ⇒ the insert fast-path skips all
|
|
451
|
+
* increment work.
|
|
452
|
+
*
|
|
453
|
+
* Mirrors the Mongo adapter's `_incrementFields`
|
|
454
|
+
* (`mongo-adapter.ts` — populated in `onFieldScanned`, keyed by physical
|
|
455
|
+
* name): core NEVER generates `increment` values (its `_applyDefaults`
|
|
456
|
+
* switch has no `increment` case, so the field reaches the adapter absent),
|
|
457
|
+
* whether or not the adapter claims it via `nativeDefaultFns()`. The adapter
|
|
458
|
+
* MUST fill it in. Mongo does not override `nativeDefaultFns()` /
|
|
459
|
+
* `supportsNativeValueDefaults()` for increment, so neither does this adapter.
|
|
460
|
+
*/
|
|
461
|
+
_incrementFields() {
|
|
462
|
+
if (this._incrementFieldsCache) return this._incrementFieldsCache;
|
|
463
|
+
const fields = /* @__PURE__ */ new Map();
|
|
464
|
+
for (const fd of this._table.fieldDescriptors) {
|
|
465
|
+
const def = fd.defaultValue;
|
|
466
|
+
if (def?.kind === "fn" && def.fn === "increment") fields.set(fd.physicalName, def.start);
|
|
467
|
+
}
|
|
468
|
+
this._incrementFieldsCache = fields;
|
|
469
|
+
return fields;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Assigns `@db.default.increment` values onto `row` (PHYSICAL shape) IN
|
|
473
|
+
* PLACE — called from {@link _insertRow} BEFORE `pkKey`/uniqueness/inserted-id
|
|
474
|
+
* are computed so an increment PRIMARY KEY produces a real `insertedId` and
|
|
475
|
+
* stores under a real key. The memory analogue of the Mongo adapter's
|
|
476
|
+
* insert-time increment (Mongo uses an atomic `__atscript_counters`
|
|
477
|
+
* collection; an in-memory store just keeps the counter on the instance):
|
|
478
|
+
*
|
|
479
|
+
* - No value for the field → assign the next counter value. First use starts
|
|
480
|
+
* at `start ?? 1` (`max(counter, (start ?? 1) - 1) + 1`); thereafter it is
|
|
481
|
+
* the previous value + 1. Sequential across an `insertMany` batch because
|
|
482
|
+
* {@link _insertRow} runs per item in `insertedIds` order against the shared
|
|
483
|
+
* counter.
|
|
484
|
+
* - Explicit value present → keep it, but advance the counter to
|
|
485
|
+
* `max(counter, value)` so a later auto value can never collide with it.
|
|
486
|
+
*/
|
|
487
|
+
_applyIncrements(row) {
|
|
488
|
+
const fields = this._incrementFields();
|
|
489
|
+
if (fields.size === 0) return;
|
|
490
|
+
for (const [physical, start] of fields) {
|
|
491
|
+
const floor = (start ?? 1) - 1;
|
|
492
|
+
const base = this._incrementCounters.get(physical) ?? floor;
|
|
493
|
+
const current = row[physical];
|
|
494
|
+
if (current === void 0 || current === null) {
|
|
495
|
+
const next = base + 1;
|
|
496
|
+
row[physical] = next;
|
|
497
|
+
this._incrementCounters.set(physical, next);
|
|
498
|
+
} else if (typeof current === "number") this._incrementCounters.set(physical, Math.max(base, current));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Builds the duplicate-primary-key {@link DbError}. The reported `path` is the
|
|
503
|
+
* physical `@meta.id` name, falling back to the first (logical) primary key,
|
|
504
|
+
* then `""`. Centralized so the insert and re-key paths raise an identical
|
|
505
|
+
* CONFLICT.
|
|
506
|
+
*/
|
|
507
|
+
_pkConflict() {
|
|
508
|
+
return new DbError("CONFLICT", [{
|
|
509
|
+
path: this._table.metaIdPhysical ?? this._table.primaryKeys[0] ?? "",
|
|
510
|
+
message: "Duplicate primary key"
|
|
511
|
+
}]);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Derives the storage key from a row's PRIMARY KEY value(s), read by PHYSICAL
|
|
515
|
+
* name. Encoded as `JSON.stringify` of the ordered PK values so it is
|
|
516
|
+
* collision-proof across both value shapes and types — `['a','b:c']` and
|
|
517
|
+
* `['a:b','c']` differ, and `1` differs from `'1'`.
|
|
518
|
+
*/
|
|
519
|
+
pkKey(row) {
|
|
520
|
+
const values = this._physicalPkFields().map((field) => getPath(row, field));
|
|
521
|
+
return JSON.stringify(values);
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Builds a DEFINED inserted-id for a table with NO single `@meta.id` — a
|
|
525
|
+
* composite (or single non-meta) primary key, where
|
|
526
|
+
* {@link BaseDbAdapter._resolveInsertedId} would otherwise fall back to
|
|
527
|
+
* `undefined` (memory has no rowid/`_id` to hand back). Returns an object
|
|
528
|
+
* mapping each PRIMARY KEY PHYSICAL field name → its value in the stored row
|
|
529
|
+
* (e.g. `{ part1: "a", part2: "b" }`), read by physical name via
|
|
530
|
+
* {@link getPath} so it matches how {@link pkKey} derives the storage key and
|
|
531
|
+
* honours column renames. A single-field non-meta PK yields the one-key object
|
|
532
|
+
* form for consistency (documented shape).
|
|
533
|
+
*
|
|
534
|
+
* Passed as the `dbGeneratedId` fallback in {@link _insertRow} ONLY when
|
|
535
|
+
* {@link AtscriptDbReadable.metaIdPhysical} is null; single-`@meta.id` tables
|
|
536
|
+
* keep an `undefined` fallback, so their scalar `insertedId`
|
|
537
|
+
* (`row[metaIdPhysical]`) is byte-identical to before.
|
|
538
|
+
*/
|
|
539
|
+
_compositeInsertedId(row) {
|
|
540
|
+
const id = {};
|
|
541
|
+
for (const field of this._physicalPkFields()) id[field] = getPath(row, field);
|
|
542
|
+
return id;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Switches this adapter into PROVIDER-BACKED mode: `fn` is invoked on every
|
|
546
|
+
* read to recompute the table's rows (e.g. a Redis/job-manager snapshot), so a
|
|
547
|
+
* runtime-owned entity with NO database can be observed as a read-only
|
|
548
|
+
* atscript table (and still carry `@DbAction`s). Rows are recomputed per read
|
|
549
|
+
* (no caching); once set, the table is READ-ONLY — all writes throw (see
|
|
550
|
+
* {@link _assertWritable}).
|
|
551
|
+
*
|
|
552
|
+
* Late-binding by design: set AFTER construction only, never via the
|
|
553
|
+
* constructor — see the {@link _provider} field comment for why a constructor
|
|
554
|
+
* option would leak onto the shared control-table adapter and break sync.
|
|
555
|
+
*/
|
|
556
|
+
setProvider(fn) {
|
|
557
|
+
this._provider = fn;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Write guard for provider-backed (read-only) mode. Called first in every one
|
|
561
|
+
* of the 8 write methods so all mutation entry points reject identically. Uses
|
|
562
|
+
* `INVALID_QUERY` (moost-db's validation interceptor maps it to HTTP 400, NOT
|
|
563
|
+
* 500 — the same choice `aggregate()` makes) since there is no dedicated
|
|
564
|
+
* read-only error code.
|
|
565
|
+
*/
|
|
566
|
+
_assertWritable() {
|
|
567
|
+
if (this._provider) throw new DbError("INVALID_QUERY", [{
|
|
568
|
+
path: "",
|
|
569
|
+
message: `Table "${this._table.tableName}" is provider-backed (read-only); writes are not supported`
|
|
570
|
+
}]);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Snapshot seam for reads. Returns the current rows.
|
|
574
|
+
*
|
|
575
|
+
* - Stored mode reads the instance Map directly (insertion order preserved).
|
|
576
|
+
* - Provider (read-through) mode calls {@link _provider} to recompute a fresh
|
|
577
|
+
* snapshot per read.
|
|
578
|
+
*
|
|
579
|
+
* Does NOT clone — cloning happens only on OUTPUT (see {@link _projectAndClone})
|
|
580
|
+
* so the store stays authoritative and cheap. That same clone-on-output path
|
|
581
|
+
* ALSO covers provider rows: every value handed back to a caller is a
|
|
582
|
+
* `structuredClone`, so a provider that returns objects it still holds is
|
|
583
|
+
* protected from mutation by `reconstructFromRead`/callers.
|
|
584
|
+
*
|
|
585
|
+
* A single logical read invokes the provider EXACTLY ONCE: `findMany`
|
|
586
|
+
* delegates to `findManyWithCount` (one `_filteredRows` ⇒ one `_loadRows`),
|
|
587
|
+
* and `findOne`/`count` each call `_filteredRows` once — so recompute-per-read
|
|
588
|
+
* AND single-snapshot-per-`findManyWithCount` both fall out for free.
|
|
589
|
+
*/
|
|
590
|
+
_loadRows() {
|
|
591
|
+
if (this._provider) return this._provider();
|
|
592
|
+
return [...this.rows.values()];
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Clones the payload in, applies the version default, generates any
|
|
596
|
+
* `@db.default.increment` values, enforces PK + unique constraints, stores
|
|
597
|
+
* the row, and returns the resolved inserted id. Clone-in (`structuredClone`)
|
|
598
|
+
* is what makes post-insert mutation of the caller's object never leak into
|
|
599
|
+
* the store. Called once per item by both `insertOne` and `insertMany`, so
|
|
600
|
+
* increment values advance sequentially across a batch.
|
|
601
|
+
*/
|
|
602
|
+
_insertRow(data) {
|
|
603
|
+
const row = structuredClone(data);
|
|
604
|
+
const versionColumn = this._table.versionColumn;
|
|
605
|
+
if (versionColumn !== void 0 && !(versionColumn in row)) row[versionColumn] = 0;
|
|
606
|
+
this._applyIncrements(row);
|
|
607
|
+
const key = this.pkKey(row);
|
|
608
|
+
if (this.rows.has(key)) throw this._pkConflict();
|
|
609
|
+
this._enforceUniqueIndexes(row);
|
|
610
|
+
this.rows.set(key, row);
|
|
611
|
+
const fallback = this._table.metaIdPhysical ? void 0 : this._compositeInsertedId(row);
|
|
612
|
+
return this._resolveInsertedId(row, fallback);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Enforces every recorded unique index against the current store. A row is
|
|
616
|
+
* exempted from an index (present-only semantics) when ANY of that index's
|
|
617
|
+
* optional fields is absent/`null`. Otherwise a stored row with an equal
|
|
618
|
+
* tuple → `CONFLICT`.
|
|
619
|
+
*
|
|
620
|
+
* `excludeKey` (when given) skips the row stored under that {@link pkKey} — so
|
|
621
|
+
* a row updating its own unique value does not false-conflict with itself.
|
|
622
|
+
*/
|
|
623
|
+
_enforceUniqueIndexes(row, excludeKey) {
|
|
624
|
+
for (const index of this.uniqueIndexes) {
|
|
625
|
+
const tuple = [];
|
|
626
|
+
let skip = false;
|
|
627
|
+
for (const field of index.fields) {
|
|
628
|
+
const value = getPath(row, field);
|
|
629
|
+
if (index.optionalFields.has(field) && (value === null || value === void 0)) {
|
|
630
|
+
skip = true;
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
tuple.push(value);
|
|
634
|
+
}
|
|
635
|
+
if (skip) continue;
|
|
636
|
+
for (const [existingKey, existing] of this.rows) {
|
|
637
|
+
if (excludeKey !== void 0 && existingKey === excludeKey) continue;
|
|
638
|
+
let equal = true;
|
|
639
|
+
for (let i = 0; i < index.fields.length; i++) if (!valuesEqual(getPath(existing, index.fields[i]), tuple[i])) {
|
|
640
|
+
equal = false;
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
if (equal) throw new DbError("CONFLICT", [{
|
|
644
|
+
path: index.fields[0] ?? index.name,
|
|
645
|
+
message: `Duplicate value for unique index "${index.name}"`
|
|
646
|
+
}]);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
async insertOne(data) {
|
|
651
|
+
this._assertWritable();
|
|
652
|
+
return { insertedId: this._insertRow(data) };
|
|
653
|
+
}
|
|
654
|
+
async insertMany(data) {
|
|
655
|
+
this._assertWritable();
|
|
656
|
+
const insertedIds = data.map((item) => this._insertRow(item));
|
|
657
|
+
return {
|
|
658
|
+
insertedCount: data.length,
|
|
659
|
+
insertedIds
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Selects the stored rows a write should touch, as `{ key, row }` pairs so
|
|
664
|
+
* callers can mutate in place and re-key. Stored mode scans the instance Map
|
|
665
|
+
* directly (writes are authoritative against the store, unlike reads which go
|
|
666
|
+
* through the {@link _loadRows} snapshot seam).
|
|
667
|
+
*
|
|
668
|
+
* A defined `expectedVersion` layers an OCC (compare-and-set) predicate on top
|
|
669
|
+
* of `filter`: a row matches only when `row[versionColumn] === expectedVersion`.
|
|
670
|
+
* A version MISMATCH is NOT an error — it simply yields zero matches, so the
|
|
671
|
+
* caller reports `matchedCount: 0`. Supplying `expectedVersion` for a table
|
|
672
|
+
* that has no version column is a misconfiguration and throws (mirrors the
|
|
673
|
+
* Mongo adapter's `_buildCasFilter`).
|
|
674
|
+
*
|
|
675
|
+
* When `many` is `false` at most the first match is returned.
|
|
676
|
+
*/
|
|
677
|
+
_selectForWrite(filter, expectedVersion, many) {
|
|
678
|
+
const versionColumn = this._table.versionColumn;
|
|
679
|
+
if (expectedVersion !== void 0 && versionColumn === void 0) throw new Error("expectedVersion requires a versioned table");
|
|
680
|
+
const match = buildMemoryPredicate(filter);
|
|
681
|
+
const matched = [];
|
|
682
|
+
for (const [key, row] of this.rows) {
|
|
683
|
+
if (!match(row)) continue;
|
|
684
|
+
if (expectedVersion !== void 0 && row[versionColumn] !== expectedVersion) continue;
|
|
685
|
+
matched.push({
|
|
686
|
+
key,
|
|
687
|
+
row
|
|
688
|
+
});
|
|
689
|
+
if (!many) break;
|
|
690
|
+
}
|
|
691
|
+
return matched;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Sets `target`'s version column to `oldRow`'s version + 1, coercing a missing
|
|
695
|
+
* old version to `0`. No-op on an unversioned table. The OLD version is read
|
|
696
|
+
* from a pristine `oldRow` (not `target`) so the result is always
|
|
697
|
+
* `oldVersion + 1` regardless of what a patch/replace payload wrote onto
|
|
698
|
+
* `target`'s version column — the memory analogue of Mongo forcing
|
|
699
|
+
* `$inc: { version: 1 }` last. Shared by {@link _commitUpdate} (merge path) and
|
|
700
|
+
* {@link replaceOne} (full-replace path).
|
|
701
|
+
*/
|
|
702
|
+
_bumpVersion(target, oldRow) {
|
|
703
|
+
const versionColumn = this._table.versionColumn;
|
|
704
|
+
if (versionColumn !== void 0) target[versionColumn] = (oldRow[versionColumn] ?? 0) + 1;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Applies a merge-style update to `row` IN PLACE — the memory analogue of
|
|
708
|
+
* `buildMongoUpdateDoc`:
|
|
709
|
+
*
|
|
710
|
+
* - `$set` (`data`): each key is set onto the row via {@link setPath}. Keys are
|
|
711
|
+
* DOT-PATHS (the table layer decomposes nested patches into `"profile.city"`
|
|
712
|
+
* because this adapter reports no {@link supportsNativePatch}), so they must
|
|
713
|
+
* nest into the stored document — MERGING siblings — exactly like Mongo's
|
|
714
|
+
* `$set: { "profile.city": v }`, not create a literal dotted key. Top-level
|
|
715
|
+
* (dot-free) keys behave as a plain assignment. `data` is `structuredClone`d
|
|
716
|
+
* first so nested subtrees from the caller never alias into the store.
|
|
717
|
+
* - `ops.inc` / `ops.mul`: numeric increment / multiply on the (dot-path)
|
|
718
|
+
* target, coercing a missing or non-numeric current value to `0` (parity with
|
|
719
|
+
* Mongo's `$inc`/`$mul`).
|
|
720
|
+
*
|
|
721
|
+
* Does NOT bump the version column — {@link _commitUpdate} does that LAST via
|
|
722
|
+
* {@link _bumpVersion} (after this merge, reading the pristine old row), so the
|
|
723
|
+
* bump always wins over whatever `data`/`inc` wrote and lands on `oldVersion + 1`.
|
|
724
|
+
*/
|
|
725
|
+
_applyUpdate(row, data, ops) {
|
|
726
|
+
const patch = structuredClone(data);
|
|
727
|
+
for (const k of Object.keys(patch)) setPath(row, k, patch[k]);
|
|
728
|
+
if (ops?.inc) for (const [col, n] of Object.entries(ops.inc)) setPath(row, col, (Number(getPath(row, col)) || 0) + n);
|
|
729
|
+
if (ops?.mul) for (const [col, n] of Object.entries(ops.mul)) setPath(row, col, (Number(getPath(row, col)) || 0) * n);
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Places `next` into the store under its (possibly changed) {@link pkKey},
|
|
733
|
+
* re-keying when a mutation/replace moved the primary key. A collision on the
|
|
734
|
+
* NEW key (some other row already owns it) throws `CONFLICT`. Throws BEFORE
|
|
735
|
+
* touching the Map so a failed re-key leaves the store unchanged.
|
|
736
|
+
*/
|
|
737
|
+
_commitRow(oldKey, next) {
|
|
738
|
+
const newKey = this.pkKey(next);
|
|
739
|
+
if (newKey !== oldKey && this.rows.has(newKey)) throw this._pkConflict();
|
|
740
|
+
if (newKey !== oldKey) this.rows.delete(oldKey);
|
|
741
|
+
this.rows.set(newKey, next);
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Update-then-commit for a single matched row: clones the pristine `row`,
|
|
745
|
+
* applies the merge update, bumps the version LAST (read from the pristine old
|
|
746
|
+
* `row`), enforces unique indexes on the result EXCLUDING the row's own key (so
|
|
747
|
+
* a row keeping/rewriting its own unique value never self-conflicts), then
|
|
748
|
+
* commits. Nothing is written to the store until both the unique and PK checks
|
|
749
|
+
* pass, so a conflict leaves the store untouched.
|
|
750
|
+
*/
|
|
751
|
+
_commitUpdate(oldKey, row, data, ops) {
|
|
752
|
+
const next = structuredClone(row);
|
|
753
|
+
this._applyUpdate(next, data, ops);
|
|
754
|
+
this._bumpVersion(next, row);
|
|
755
|
+
this._enforceUniqueIndexes(next, oldKey);
|
|
756
|
+
this._commitRow(oldKey, next);
|
|
757
|
+
}
|
|
758
|
+
async replaceOne(filter, data, expectedVersion) {
|
|
759
|
+
this._assertWritable();
|
|
760
|
+
const matched = this._selectForWrite(filter, expectedVersion, false);
|
|
761
|
+
if (matched.length === 0) return {
|
|
762
|
+
matchedCount: 0,
|
|
763
|
+
modifiedCount: 0
|
|
764
|
+
};
|
|
765
|
+
const { key, row } = matched[0];
|
|
766
|
+
const next = structuredClone(data);
|
|
767
|
+
this._bumpVersion(next, row);
|
|
768
|
+
this._enforceUniqueIndexes(next, key);
|
|
769
|
+
this._commitRow(key, next);
|
|
770
|
+
return {
|
|
771
|
+
matchedCount: 1,
|
|
772
|
+
modifiedCount: 1
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
async updateOne(filter, data, ops, expectedVersion) {
|
|
776
|
+
this._assertWritable();
|
|
777
|
+
const matched = this._selectForWrite(filter, expectedVersion, false);
|
|
778
|
+
if (matched.length === 0) return {
|
|
779
|
+
matchedCount: 0,
|
|
780
|
+
modifiedCount: 0
|
|
781
|
+
};
|
|
782
|
+
const { key, row } = matched[0];
|
|
783
|
+
this._commitUpdate(key, row, data, ops);
|
|
784
|
+
return {
|
|
785
|
+
matchedCount: 1,
|
|
786
|
+
modifiedCount: 1
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
async deleteOne(filter) {
|
|
790
|
+
this._assertWritable();
|
|
791
|
+
const matched = this._selectForWrite(filter, void 0, false);
|
|
792
|
+
if (matched.length === 0) return { deletedCount: 0 };
|
|
793
|
+
this.rows.delete(matched[0].key);
|
|
794
|
+
return { deletedCount: 1 };
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Stable multi-key comparator from `$sort`, with a final tie-break on
|
|
798
|
+
* {@link pkKey} for a deterministic total order. Returns the input unchanged
|
|
799
|
+
* (insertion order) when there is no `$sort`. Delegates to the shared pure
|
|
800
|
+
* {@link sortRows}, injecting {@link pkKey} as the total-order tie-break.
|
|
801
|
+
*/
|
|
802
|
+
_sortRows(rows, $sort) {
|
|
803
|
+
return sortRows(rows, $sort, (r) => this.pkKey(r));
|
|
804
|
+
}
|
|
805
|
+
/** Applies `$skip` then `$limit` (both optional) via a single slice. */
|
|
806
|
+
_paginate(rows, skip, limit) {
|
|
807
|
+
const start = skip ?? 0;
|
|
808
|
+
const end = limit === void 0 ? void 0 : start + limit;
|
|
809
|
+
if (start === 0 && end === void 0) return rows;
|
|
810
|
+
return rows.slice(start, end);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Projects a stored row per `$select` and returns a fresh, deep-cloned object
|
|
814
|
+
* so the store can never be mutated through a returned value.
|
|
815
|
+
*
|
|
816
|
+
* - No projection → a full clone.
|
|
817
|
+
* - INCLUSION form (`{ field: 1 }`) → a new object with only the selected
|
|
818
|
+
* paths PLUS the primary-key field(s) (mirrors Mongo including `_id`).
|
|
819
|
+
* - EXCLUSION form (`{ field: 0 }`) → a clone with those paths removed.
|
|
820
|
+
*
|
|
821
|
+
* Top-level and nested dot-paths are supported; exotic Mongo projection
|
|
822
|
+
* quirks (array positional, `$slice`, etc.) are intentionally NOT replicated.
|
|
823
|
+
*/
|
|
824
|
+
_projectAndClone(row, $select) {
|
|
825
|
+
return projectRow(row, $select?.asProjection, {
|
|
826
|
+
pkFields: this._physicalPkFields(),
|
|
827
|
+
clone: true
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Reads the pagination/sort/projection controls with their intended types.
|
|
832
|
+
* `DbControls` carries a `[key: `$${string}`]: unknown` index signature, and
|
|
833
|
+
* `Omit`-ing `$select` from `UniqueryControls` widens `$sort`/`$skip`/`$limit`
|
|
834
|
+
* back to `unknown` — so the casts here restore the declared shapes at a
|
|
835
|
+
* single, documented boundary. `$select` keeps its explicit `UniquSelect` type.
|
|
836
|
+
*/
|
|
837
|
+
_readControls(controls) {
|
|
838
|
+
return {
|
|
839
|
+
$sort: controls.$sort,
|
|
840
|
+
$skip: controls.$skip,
|
|
841
|
+
$limit: controls.$limit,
|
|
842
|
+
$select: controls.$select
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* The single "load a snapshot, apply the filter predicate" step every read
|
|
847
|
+
* shares. Goes through the {@link _loadRows} seam exactly ONCE per call, so a
|
|
848
|
+
* reader (and provider read-through mode) has one place that materializes the
|
|
849
|
+
* working set — one provider invocation per logical read.
|
|
850
|
+
*/
|
|
851
|
+
async _filteredRows(query) {
|
|
852
|
+
const match = buildMemoryPredicate(query.filter);
|
|
853
|
+
return (await this._loadRows()).filter(match);
|
|
854
|
+
}
|
|
855
|
+
async findOne(query) {
|
|
856
|
+
const filtered = await this._filteredRows(query);
|
|
857
|
+
const { $sort, $skip, $select } = this._readControls(query.controls ?? {});
|
|
858
|
+
const row = this._sortRows(filtered, $sort)[$skip ?? 0];
|
|
859
|
+
return row ? this._projectAndClone(row, $select) : null;
|
|
860
|
+
}
|
|
861
|
+
async findMany(query) {
|
|
862
|
+
return (await this.findManyWithCount(query)).data;
|
|
863
|
+
}
|
|
864
|
+
async count(query) {
|
|
865
|
+
return (await this._filteredRows(query)).length;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Overridden so the filtered snapshot is computed ONCE — the base default
|
|
869
|
+
* runs `findMany` and `count` separately (two `_loadRows` snapshots). A
|
|
870
|
+
* single snapshot is both cheaper here and the correct semantics for
|
|
871
|
+
* provider (read-through) mode, where two separate reads could otherwise
|
|
872
|
+
* observe different snapshots and make count/data disagree.
|
|
873
|
+
*/
|
|
874
|
+
async findManyWithCount(query) {
|
|
875
|
+
const filtered = await this._filteredRows(query);
|
|
876
|
+
const { $sort, $skip, $limit, $select } = this._readControls(query.controls ?? {});
|
|
877
|
+
const sorted = this._sortRows(filtered, $sort);
|
|
878
|
+
return {
|
|
879
|
+
data: this._paginate(sorted, $skip, $limit).map((row) => this._projectAndClone(row, $select)),
|
|
880
|
+
count: filtered.length
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Aggregation (`$groupBy`) is a documented v1 non-goal for the in-memory
|
|
885
|
+
* adapter. The inherited base default throws a PLAIN `Error`, which a readable
|
|
886
|
+
* REST controller would surface as an unhandled HTTP 500 when a `?$groupBy=`
|
|
887
|
+
* query routes here. Throwing a typed {@link DbError} with `INVALID_QUERY`
|
|
888
|
+
* instead converts that into a clean client error — moost-db's validation
|
|
889
|
+
* interceptor maps `INVALID_QUERY` → HTTP 400.
|
|
890
|
+
*/
|
|
891
|
+
async aggregate(_query) {
|
|
892
|
+
throw new DbError("INVALID_QUERY", [{
|
|
893
|
+
path: "",
|
|
894
|
+
message: "Aggregation ($groupBy) is not supported by the in-memory adapter"
|
|
895
|
+
}]);
|
|
896
|
+
}
|
|
897
|
+
async updateMany(filter, data, ops) {
|
|
898
|
+
this._assertWritable();
|
|
899
|
+
const matched = this._selectForWrite(filter, void 0, true);
|
|
900
|
+
for (const { key, row } of matched) this._commitUpdate(key, row, data, ops);
|
|
901
|
+
return {
|
|
902
|
+
matchedCount: matched.length,
|
|
903
|
+
modifiedCount: matched.length
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
async replaceMany(filter, data) {
|
|
907
|
+
this._assertWritable();
|
|
908
|
+
const matched = this._selectForWrite(filter, void 0, true);
|
|
909
|
+
for (const { key, row } of matched) this._commitUpdate(key, row, data);
|
|
910
|
+
return {
|
|
911
|
+
matchedCount: matched.length,
|
|
912
|
+
modifiedCount: matched.length
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
async deleteMany(filter) {
|
|
916
|
+
this._assertWritable();
|
|
917
|
+
const matched = this._selectForWrite(filter, void 0, true);
|
|
918
|
+
for (const { key } of matched) this.rows.delete(key);
|
|
919
|
+
return { deletedCount: matched.length };
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Records the model's `unique` indexes for insert-time enforcement. Idempotent
|
|
923
|
+
* (replaces the recorded set). Non-unique index types are ignored — an
|
|
924
|
+
* in-memory scan needs no plain/fulltext/geo index to answer queries.
|
|
925
|
+
*/
|
|
926
|
+
async syncIndexes() {
|
|
927
|
+
this.uniqueIndexes = [];
|
|
928
|
+
for (const index of this._table.indexes.values()) {
|
|
929
|
+
if (index.type !== "unique") continue;
|
|
930
|
+
this.uniqueIndexes.push({
|
|
931
|
+
name: index.name,
|
|
932
|
+
fields: index.fields.map((f) => f.name),
|
|
933
|
+
optionalFields: new Set(index.fields.filter((f) => f.optional).map((f) => f.name))
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* No-op: the store is the instance-level {@link rows} Map, which already
|
|
939
|
+
* exists. Safe to call repeatedly.
|
|
940
|
+
*/
|
|
941
|
+
async ensureTable() {}
|
|
942
|
+
};
|
|
943
|
+
/**
|
|
944
|
+
* Ergonomic late-binding entry point for provider (read-through) mode. Resolves
|
|
945
|
+
* the ALREADY-BUILT {@link MemoryAdapter} backing `type` on `space` (reached
|
|
946
|
+
* after `getTable`/`syncSchema` has constructed it via `space.getAdapter`, which
|
|
947
|
+
* exists in core — so this helper needs NO core change) and installs `fn` as its
|
|
948
|
+
* provider, making that one table read-only and recomputed per read.
|
|
949
|
+
*
|
|
950
|
+
* Throws if the resolved adapter is not a {@link MemoryAdapter} (i.e. the space
|
|
951
|
+
* is backed by a different engine) so a misuse fails loudly, not silently.
|
|
952
|
+
*/
|
|
953
|
+
function setMemoryProvider(space, type, fn) {
|
|
954
|
+
const adapter = space.getAdapter(type);
|
|
955
|
+
if (!(adapter instanceof MemoryAdapter)) throw new Error("setMemoryProvider: table is not backed by MemoryAdapter");
|
|
956
|
+
adapter.setProvider(fn);
|
|
957
|
+
}
|
|
958
|
+
//#endregion
|
|
959
|
+
//#region src/lib/index.ts
|
|
960
|
+
/**
|
|
961
|
+
* Creates a {@link DbSpace} backed by an in-memory {@link MemoryAdapter}.
|
|
962
|
+
*
|
|
963
|
+
* Tables default to STORED mode (an instance-level `Map`). To make one table
|
|
964
|
+
* read-only and read-through from a runtime closure, call `setMemoryProvider`
|
|
965
|
+
* (re-exported from {@link ./memory-adapter}) on the space AFTER the table's
|
|
966
|
+
* adapter has been built.
|
|
967
|
+
*/
|
|
968
|
+
function createAdapter() {
|
|
969
|
+
return new DbSpace(() => new MemoryAdapter());
|
|
970
|
+
}
|
|
971
|
+
//#endregion
|
|
972
|
+
export { MemoryAdapter, buildMemoryPredicate, createAdapter, projectRow, setMemoryProvider, sortRows };
|