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
Mapwith full CRUD — a fast in-memoryDbSpacefor tests and trivial in-app tables.
Installation
pnpm add @atscript/db-memoryThere 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:
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:
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-onlyBuilding 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 typedDbError— 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 backsfindManyand the atscript-ui/pagescount) 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:
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", aDate≠ its ISO string;Dates compare by instant). Anullor missing key part relates to nothing. buildMemoryPredicatealone cannot evaluate a predicate (it has no access to the related tables) and throwsREL_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
createand 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 reportedin-syncwith 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.derivedfield 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 isnull).
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 SQLLIKE. - Null model — Mongo-like.
{ f: null }/$eq: nullmatches both an explicitnulland a missing field;$nematches only concrete, present values. A$gt/$gte/$lt/$lteagainstnullmatches no row, as on SQL and MongoDB (since 0.1.168; before,nullcompared as0, 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 holdingnullcounted 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
$sortkey are ordered by the primary key, compared by value, in the direction of the last$sortkey. 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, so10came before9. A read without$sortreturns 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;$eqand sort are code-point / JS-native. - No sort on array /
@db.jsoncolumns —$sorton 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/$ninagainst an array field matches when any element equals it ({ labels: "bug" }matches["bug", "ui"];$ne/$ninare the negation;nullmatches a missing field, anulland an array holding anull). Dot-paths do not descend into arrays and$elemMatchis not provided. - Non-atomic stored batch writes —
insertMany/updateMany/replaceMany/deleteManyapply sequentially with no rollback; a mid-batch conflict leaves earlier items written. Single writes are safe. - No transactions —
withTransactionruns the callback but nothing rolls back on a throw. Rollback behaviour (includingmoost-dbwrite 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 outsidewithTransactionstill throwsINVALID_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()isfalse), not natively. - No FTS / vector / geo /
$search— unsupported natively.$searchover HTTP falls back to the@db.column.searchablesubstring match, which includes integer fields ($regexon an integer matches its decimal text).@db.index.fulltexthas 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 tablein-sync.@db.sync.methodhas no effect.@db.table.renamedis 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 aFilterExprinto a JS-native(row) => booleanpredicate. A filter with relational predicates needs the adapter (it reads the related tables) — the function throwsREL_FILTER_NOT_SUPPORTEDon one.sortRows(rows, $sort, tieBreak?, topK?, nulls?)— stable multi-key$sortwith the adapter's leaf ordering (null-low unlessnulls— a$nullsmap, since 0.1.153 — places a key's nil values'first'/'last';Date-by-instant, no collation); an optionaltieBreakyields a deterministic total order.topK(since 0.1.151) asks for the firsttopKrows of that order only (e.g.$skip + $limit) — the same rows the full sort puts first, picked without sorting everything whentopKis small.projectRow(row, projection, opts?)— dot-path$selectinclusion/exclusion projection over one row, with an optional deep-clone.opts.pkFieldsadds the named fields to an inclusion projection. The adapter itself does not pass it: since 0.1.145 an inclusion$selectreturns exactly the selected fields, as on the SQL adapters. Earlier versions also returned the primary key.
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