Skip to content

Memory ​

The memory adapter (@atscript/db-memory) is an in-memory BaseDbAdapter whose backing store is plain JavaScript Maps — no engine, no I/O, and no persistence. It has no driver dependency, no compiler plugin, and no adapter-specific annotations: it speaks only the portable @db.* layer, so any .as model that runs on SQLite runs here unchanged. Because it is decoupled from any datastore, its primary job is to expose a runtime-owned surface — data that lives in Redis, an in-code registry, or a computed snapshot — as a real atscript table without giving it a database.

It runs in two modes:

  • Provider-backed (read-only) — the primary use case. A table's rows come from a closure you register with setMemoryProvider. Writes throw.
  • Stored (read-write) — a secondary convenience. Rows live in a Map with full CRUD — a fast in-memory DbSpace for tests and trivial in-app tables.

Installation ​

bash
pnpm add @atscript/db-memory

There is no driver to install and no plugin to register. @atscript/core, @atscript/db, and @atscript/typescript are peer dependencies.

When to use it ​

Provider-backed tables are the reason this adapter exists. When you have a runtime-owned entity that must not acquire a database — a "scheduled jobs" list zipped together from Redis keys and an in-code job registry, a job-runs snapshot, any computed set — the memory adapter lets you observe it as a real atscript table. It renders in the atscript-ui table UI, carries @DbAction row actions and @InputForms, and participates in /meta filterability, all while the underlying data stays wherever it actually lives.

Stored mode is a convenience. It is the role an in-memory SQLite plays — a drop-in DbSpace with full CRUD — minus the native module and the SQL round-trip. Reach for it in tests and for trivial in-app tables. It is not a production datastore (see Limitations).

Setup ​

Both modes start from createAdapter(), which builds a DbSpace backed by a fresh MemoryAdapter per table, then run schema sync to record indexes and provision the in-memory control table.

The space is the database: its tables are keyed by table name and shared by every adapter the space builds, and a new space starts empty. Since 0.1.137 — before, each compiled type had its own store, even when two types named the same table.

Stored (read-write) — the drop-in in-memory space for tests and trivial tables:

typescript
import { createAdapter } from "@atscript/db-memory";
import { syncSchema } from "@atscript/db/sync";
import { User } from "./user.as.js";

const db = createAdapter(); // new DbSpace(() => new MemoryAdapter())
await syncSchema(db, [User]);

const users = db.getTable(User);
await users.insertOne({ id: "u1", name: "Ada" });

Provider-backed (read-only) — a runtime-owned surface that still carries @DbActions:

typescript
import { createAdapter, setMemoryProvider } from "@atscript/db-memory";
import { syncSchema } from "@atscript/db/sync";
import { Job } from "./job.as.js";

const jobsDb = createAdapter();
await syncSchema(jobsDb, [Job]);

// Rows come from a runtime closure. Late-bound AFTER syncSchema.
setMemoryProvider(jobsDb, Job, () => loadJobRows());

const jobs = jobsDb.getTable(Job); // read-only

Building the space by hand

createAdapter() is shorthand for new DbSpace(() => new MemoryAdapter()). Construct it directly if you need to — MemoryAdapter takes no arguments.

Provider-backed tables ​

setMemoryProvider(space, Type, fn) switches one table into provider (read-through) mode. The closure fn — a MemoryProviderFn, () => Row[] | Promise<Row[]> — is invoked on every read to recompute a fresh snapshot, so the table always reflects the current runtime state.

Late-bound by design

Call setMemoryProvider after syncSchema / getTable has built the table's adapter — never before. A DbSpace builds every table's adapter (including the internal control table) with the same zero-arg factory, so a provider injected at construction would leak onto the control table and break sync. Binding it after construction targets one specific table. The closure can therefore also close over dependencies (a Redis client, a job registry) that don't exist at factory time.

Key behaviors:

  • Read-only. Every write (insertOne, updateMany, deleteOne, …) throws a typed DbError — the table cannot be mutated through the adapter.
  • Recompute per request. There is no caching; each logical read calls the provider afresh.
  • One invocation per logical read. A single findManyWithCount (which backs findMany and the atscript-ui /pages count) invokes the provider exactly once, so the count and the returned rows always come from the same snapshot.

Because the readable is built uniformly, @DbAction discovery, ?$actions=true gating, disabled predicates, @InputForm, and the atscript-ui table / /meta surface all work against a provider-backed table with no adapter-side action code. See Actions for the action flow.

Adapter-Specific Annotations ​

The memory adapter has no adapter-specific annotations. All generic @db.* annotations work as documented in the Annotations Reference — there is no @db.memory.* namespace and no plugin to register.

Features ​

Nested Objects ​

Documents are stored in their nested physical shape (no flattening), so supportsNestedObjects() is true. Filter, sort, project, and patch with dot-notation exactly as you would on MongoDB:

typescript
await contacts.insertOne({ id: 1, address: { city: "Portland", zip: "97201" } });

await contacts.findMany({
  filter: { "address.city": "Portland" },
  controls: { $sort: { "address.zip": 1 } },
});

Dotted paths into @db.json objects and arrays are listed in /meta.fields and accepted by filters, $sort, $select (SQL adapters reject them). The array / @db.json column itself is filterable but never sortable — since 0.1.128 $sort=tags is rejected with INVALID_QUERY / 400 instead of ordering by the stringified array, and /meta says sortable: false. Navigation descendants are not listed in /meta.fields (since 0.1.128) — load relations with $with.

Optimistic Concurrency ​

Versioned writes support compare-and-set via $cas / expectedVersion. A stale version yields matchedCount: 0 (no throw), and the version column auto-bumps on every versioned write that touches a non-exempt field (@db.column.version.exempt) — including the PK-only versioned touch. See Optimistic Concurrency.

Field Operations & Defaults ​

$inc, $dec, and $mul field operators are applied over dot-paths. @db.default.increment (a per-table counter that restarts with each fresh space, like SQLite :memory:, and when the table is dropped), @db.default.now, and static defaults are all honored at insert time.

Unique & Primary-Key Enforcement ​

Unique indexes (recorded from the model at sync time) and primary keys are enforced on every write; a collision raises DbError('CONFLICT'). Present-only (partial) semantics apply when an optional index member is absent — matching SQL's NULLS DISTINCT.

A unique index that the rows already stored break is not recorded (since 0.1.168): sync reports the table as an error entry naming the index and withholds the schema hash, the way CREATE UNIQUE INDEX fails over duplicates on SQL. Fix the rows — for example in a beforeConstraints data step — and the next sync records it. Before 0.1.168 the index was recorded over the duplicates.

Single-Snapshot Count ​

findManyWithCount computes the data page and the total count from one filtered snapshot, so the count never disagrees with the page — important for provider-backed reads where two separate reads could otherwise observe different snapshots.

Grouped queries ​

Grouped queries — $groupBy, count / countDistinct / sum / avg / min / max, calendar buckets, $having, $sort, $skip / $limit and $count — work since 0.1.132, in stored and provider mode (before, $groupBy threw INVALID_QUERY). Groups are computed in process over one snapshot of the rows with the SQL semantics described in Aggregate functions: null and missing values form one group, and sum / avg over no values are null. Calendar-bucket labels use the Node.js runtime's time zone data.

Arithmetic and first / last are evaluated in JavaScript.

There is no index-backed grouping: every grouped query scans the matching rows, which suits the small sets this adapter targets.

Fields renamed with @db.column are honored in $select and $sort since 0.1.132; earlier versions dropped them from selected rows and ignored them in $sort.

Relational predicates ​

$some / $none filters (since 0.1.147) work in reads — stored and provider-backed tables alike — and in every mutation filter.

  • Cost. An operation whose filter holds predicates loads each related table (and junction) once — a provider-backed table's provider runs once per operation — and builds a key set from it; then every candidate row is a set lookup. Expect time proportional to the related tables' sizes plus the source size. There are no indexes. A filter without predicates costs nothing extra.
  • Snapshots. A read sees one consistent snapshot per table, shared with the outer scan when a relation points back to its own table.
  • Writes evaluate the predicates against the related rows as they were just before the write. Loading those rows is asynchronous, so a write whose filter holds a predicate reads the table only after that await: concurrent writes can interleave between them, with read-committed-like results (a $cas / version check is still evaluated synchronously with the write itself). The evaluation and the write are not atomic, like the adapter's other batch writes.
  • Key comparison follows the adapter's equality everywhere else: a related key matches only a value of the same type (5 ≠ "5", 5n ≠ "5n", a Date ≠ its ISO string; Dates compare by instant). A null or missing key part relates to nothing.
  • buildMemoryPredicate alone cannot evaluate a predicate (it has no access to the related tables) and throws REL_FILTER_NOT_SUPPORTED — see Utilities.

Foreign Keys ​

There is no native FK enforcement; supportsNativeForeignKeys() is false. Cascade and set-null run through the generic layer's application-level logic (via the adapter's updateMany / deleteMany), driven by @db.rel.onDelete / @db.rel.onUpdate. When a deleteMany / deleteOne filter holds a relational predicate, the matching rows are pinned by primary key first; the cascade and the delete then both act on those keys, so a filter that reads the cascaded children ({ issues: { $some: … } }) still deletes the parent. See Referential Actions.

Schema Sync ​

Schema sync provisions an in-memory __atscript_control table, takes the distributed lock, creates and drops tables, and records unique indexes for insert-time enforcement. See Schema Sync.

  • Create — a table that does not exist yet is reported create. A write to a table that was never synced creates it too.
  • Drop (since 0.1.137) — a model removed from the schema has its table dropped: rows, unique indexes and increment counter. Added back, it is reported create and starts empty. Removed views are dropped the same way. Before 0.1.137, sync reported the drop but the rows stayed, and a model added back was reported in-sync with its old rows.
  • Pre-flight (since 0.1.138) — sync refuses a managed view declared where a table sits, a table declared where a view sits, and a foreign key whose target is neither in the inventory nor already in the space. Include FK targets in the same sync, or sync them first on the same DbSpace — a new space starts empty.
  • Columns — sync does not diff or migrate columns. See Limitations.
  • Derived columns (since 0.1.141) — a @db.column.derived field stores nothing: queries and unique indexes address its source path (payload.customer.id), reads copy the value as stored (no type guard; a missing leaf is null).

Comparison semantics ​

Leaf-comparison semantics are JS-native and documented — deliberately not claimed identical to the SQL engines. Call these out when moving a model between adapters:

  • Regex — a JS RegExp, honoring /pat/flags. Not translated to SQL LIKE.
  • Null model — Mongo-like. { f: null } / $eq: null matches both an explicit null and a missing field; $ne matches only concrete, present values. A $gt / $gte / $lt / $lte against null matches no row, as on SQL and MongoDB (since 0.1.168; before, null compared as 0, so { amount: { $gt: null } } matched every positive amount).
  • $exists — "holds a value", the same on every adapter: $exists: true ⇔ $ne: null, $exists: false ⇔ $eq: null. Before 0.1.132 a key holding null counted as present; it now counts as absent. See Existence.
  • Ordering — JS-native (< / >); strings compare by code point. There is no collation or locale awareness.
  • Ties and unsorted reads — rows that tie on every $sort key are ordered by the primary key, compared by value, in the direction of the last $sort key. This is the same tie-breaker every adapter gets, and it also applies to a direct adapter call. Before 0.1.153 ties always went by the primary key ascending, compared as text, so 10 came before 9. A read without $sort returns rows in insertion order (stored mode).

Conflict-ignoring inserts ​

insertMany(rows, { onConflict: "ignore" }) (CRUD) tries each row and turns a primary-key / unique-index CONFLICT into a skipped slot. Memory has no transactions, so inside withTransaction a skipped row simply does not throw and nothing rolls back. lockConflicts (CRUD) is accepted and has no effect. Closing the space drops its in-memory tables (Closing the space).

Limitations ​

Deliberate v1 trade-offs — matching a real engine here is hard or unnecessary for the small computed surfaces this adapter targets. All are documented, none silent:

  • JS-native regex / null semantics — not byte-identical to any SQL engine (see Comparison semantics).
  • No collation — @db.column.collate (nocase / unicode) is not honored; $eq and sort are code-point / JS-native.
  • No sort on array / @db.json columns — $sort on them is rejected (400 / INVALID_QUERY, since 0.1.128); sort by a nested leaf (address.zip) instead.
  • Array fields: scalar containment only — like Mongo, a scalar $eq / $in / $ne / $nin against an array field matches when any element equals it ({ labels: "bug" } matches ["bug", "ui"]; $ne / $nin are the negation; null matches a missing field, a null and an array holding a null). Dot-paths do not descend into arrays and $elemMatch is not provided.
  • Non-atomic stored batch writes — insertMany / updateMany / replaceMany / deleteMany apply sequentially with no rollback; a mid-batch conflict leaves earlier items written. Single writes are safe.
  • No transactions — withTransaction runs the callback but nothing rolls back on a throw. Rollback behaviour (including moost-db write guards) is not observable on this adapter; assert it on SQLite :memory: or with adapter spies. See Transactions.
  • Locking reads and isolation are accepted and ignored (since 0.1.162). Inside withTransaction, { lock } and { isolation } do nothing. A lock outside withTransaction still throws INVALID_QUERY, so a forgotten transaction fails in tests too. See Locking reads.
  • Provider tables are read-only — writes throw, and there is no cross-request pagination stability (page 1 and page 2 are separate requests over separate snapshots).
  • Relations $with — resolved by core's app-level batch loading (supportsNativeRelations() is false), not natively.
  • No FTS / vector / geo / $search — unsupported natively. $search over HTTP falls back to the @db.column.searchable substring match, which includes integer fields ($regex on an integer matches its decimal text). @db.index.fulltext has no effect. No DB views.
  • Table-level schema sync only — sync creates and drops tables but does not diff columns: after a field is renamed, removed or changes type, stored rows keep their old shape and plan() reports the table in-sync. @db.sync.method has no effect. @db.table.renamed is not applied: the table is created empty under its new name, and the old rows are not moved.
  • In-process only — nothing is persisted or shared across processes. Not a production datastore.

Utilities ​

The package exports its query engine as three pure functions — the same engine the adapter runs internally, so a raw-driver or custom controller can apply identical filter/sort/projection semantics outside the standard CRUD flow:

  • buildMemoryPredicate(filter) — compiles a FilterExpr into a JS-native (row) => boolean predicate. A filter with relational predicates needs the adapter (it reads the related tables) — the function throws REL_FILTER_NOT_SUPPORTED on one.
  • sortRows(rows, $sort, tieBreak?, topK?, nulls?) — stable multi-key $sort with the adapter's leaf ordering (null-low unless nulls — a $nulls map, since 0.1.153 — places a key's nil values 'first' / 'last'; Date-by-instant, no collation); an optional tieBreak yields a deterministic total order. topK (since 0.1.151) asks for the first topK rows of that order only (e.g. $skip + $limit) — the same rows the full sort puts first, picked without sorting everything when topK is small.
  • projectRow(row, projection, opts?) — dot-path $select inclusion/exclusion projection over one row, with an optional deep-clone. opts.pkFields adds the named fields to an inclusion projection. The adapter itself does not pass it: since 0.1.145 an inclusion $select returns exactly the selected fields, as on the SQL adapters. Earlier versions also returned the primary key.
typescript
import { buildMemoryPredicate, sortRows, projectRow } from "@atscript/db-memory";

const match = buildMemoryPredicate({ status: "active", age: { $gte: 18 } });
const active = sortRows(rows.filter(match), { age: -1 });

This same trio backs @atscript/moost-db's AsJsonValueHelpController — static value-help dictionaries and in-memory tables share one filter/sort/projection engine.

Next Steps ​

  • SQLite — zero-config file/:memory: adapter for development and testing
  • MongoDB — document-oriented adapter with native nested objects and Atlas Search
  • Adapter Overview — feature comparison across all adapters

Released under the MIT License.