@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/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 };