Skip to content

Upgrading ​

Changes that need action or attention when you upgrade. Each entry links to the page that documents the current behavior.

0.1.175 ​

Features ​

  • Unranked text search: $rank: false. A search with controls.$rank: false ($rank=false over HTTP) computes no relevance score on any adapter; rows come in $sort order, or primary-key order without one. Use it instead of a placeholder $sort when your app orders the matches itself. vectorSearch() and geoSearch() reject it with INVALID_QUERY. $rank is now in the crud.query / crud.pages control lists of /meta. See Unranked search.
  • SQLite: a tokenizer per language. The ftsTokenizers adapter option gives a @db.index.fulltext.language its FTS5 tokenizer, such as a Snowball stemmer loaded with loadExtensions. See Tokenizer per language.
  • SQLite: FTS5 prefix indexes. The ftsPrefix adapter option ([2, 3]) builds FTS5 prefix indexes for faster word* searches. Setting or changing it recreates the FTS5 tables on the next schema sync; without it nothing changes. See Prefix indexes. Serving a prebuilt read-only SQLite file in a second space is now documented: Read-only database files.
  • Data steps: notes and continue-on-failure. A data step's run can return { note }; the note is stored with the step's record and reported in result.dataSteps.notes. A step with onError: "warn" that fails is logged, listed in result.dataSteps.failed, rolled back and left pending, and the next step runs. See Step notes and Continue after a failed step.
  • rewriteRowsStep(): rewrite stored rows after a type change. A data step from @atscript/db/sync that maps the stored rows of a table, or one field, in primary-key batches and writes the changed ones. Its id carries a hash of the field's validation rules, so changing a limit runs it again; map must be idempotent. See Rewrite stored rows.
  • PostgreSQL: a text search configuration per language. The textSearchConfigs adapter option maps a language to a configuration your app created. See Text search configuration per language.

Changes ​

  • Every moost-db error is coded. Each 400, 403 and 409 a moost-db controller answers now carries code (and params) on the envelope and on its entry: UNKNOWN_FIELD, UNKNOWN_RELATION, FIELD_NOT_PERMITTED, FIELD_WRITE_ONLY, EXPECTED_OBJECT, INVALID_QUERY, ACTION_FORBIDDEN, VERSION_MISMATCH and others. A translator registered with setTranslator looks them up by code instead of by English text; the English message is unchanged. The 409 version mismatch keeps kind: "version_mismatch". Errors that were a bare { message } (a refusal returned by your validateControls or validateInsights override — its entry is custom with no top-level code, so a translator still finds the text by itself — a mixed $select, $with with $groupBy, a geo control, the one endpoint, the 403 of an action) are now the usual envelope with errors. The core's query errors (DbError INVALID_QUERY) code their entries the same way, and an unknown $with relation in a direct table call is now a DbError INVALID_QUERY (it was a plain Error): a request that reaches it answers 400 where it answered 500, and its message names only the requested relation, not the relations the table has. See Request error codes.
  • badRequest() takes a code. Its signature is now badRequest(path, message, code, { params?, top? }); the top-level message moved into the options. Code that called badRequest(path, message) no longer compiles: pass a code ("INVALID_QUERY", or one of your own registered with registerMessages). For a hidden field use the new unknownFieldError(path).
  • Arithmetic with more than two arguments is refused. A hand-built expression node such as { $op: "+", $args: ["a", "b", "c"] } is now INVALID_QUERY on every adapter; before, a direct call dropped the third term silently (MongoDB summed it). Nest longer chains, or build them with arithAdd() / arithMul() from @uniqu/core 0.2.2. See Aggregation.
  • ARRAY_OP_ON_JSON. An array patch operator on a @db.json field now fails with this code (server and db-client preflight) instead of TYPE_ARRAY "Expected array". Send the whole array. See JSON fields.
  • Contention errors are retryable DbErrors. A deadlock, lock timeout or serialization failure raised at COMMIT, or by a plain read, now surfaces as a DbError with code DEADLOCK, LOCK_TIMEOUT or SERIALIZATION_FAILURE (retryable is true) on PostgreSQL and MySQL, as it already did on writes; SQLite maps SQLITE_BUSY at COMMIT to LOCK_TIMEOUT, and MongoDB maps a transaction that ran out of its transient-error retries to SERIALIZATION_FAILURE. Before, the raw driver error escaped (40001, ER_LOCK_DEADLOCK, a MongoServerError), so a retry loop keyed on retryable never retried. The driver error is kept as the DbError's cause. Code that matched the raw driver error, such as error.code === "40001", should check error.retryable or the DbError code instead.
  • A COMMIT that fails on the client discards the connection. A PostgreSQL or MySQL COMMIT that fails without a server-reported error (a query timeout, a lost socket) closes the connection instead of returning it to the pool with the transaction possibly open. TPgConnection has an optional destroy() for this; a custom driver without it gets a best-effort ROLLBACK, then release(). SQLite keeps its transaction gate closed until the rollback after a failed COMMIT.
  • A translated schema always carries $i18n. A translated /meta type or /meta/form/:name schema now has $i18n even when there is nothing to serve ({}), so a client can tell it was already translated and does not translate it again. Before, a schema whose texts were translated but which had no templates to serve came without $i18n. An untranslated schema still has none. See Translations.

Custom adapters ​

  • controls.$rank === false reaches search() / searchWithCount(): compute no score and order by $sort, else the primary key. The core rejects it before vectorSearch() / geoSearch().
  • An adapter whose options change how a fulltext index is built can implement fulltextEngineConfig(index); the value becomes part of the table's schema snapshot, so changing it re-runs schema sync. See Creating adapters.
  • A native loadRelations throws unknownWithRelation(name) for a $with name the table lacks, and an expression renderer of your own guards each node with assertExprArity() / assertExprTreeArity() (all from @atscript/db). See Native relation loading and View capabilities.
  • Map your driver's contention errors (deadlock, lock timeout, serialization failure) to the retryable DbError codes on every statement and at COMMIT, keeping the driver error as cause (new DbError(code, errors, message, { cause })). See Transaction Support.

0.1.174 ​

Fixes ​

  • result.dataSteps.pending lists only unrecorded steps. It now holds the passed steps that are still not recorded after the run, the ones a later sync runs, as plan()'s pendingDataSteps does. An 'up-to-date' run reports pending: [], and a run that resumes after a failed step no longer lists the steps an earlier run recorded. A 'synced-by-peer' result now carries dataSteps ({ ran: [], pending: [] }) too.
  • A data step is never recorded twice. Its record is now insert-only. On adapters with transactions it is written first, in the step's own transaction, so a step that another sync recorded meanwhile (one that took over an expired lock) is not run again; it is logged and counted as settled (neither ran nor pending). Before, the record was overwritten and the step ran a second time. Without transactions (the in-memory adapter, MongoDB without a replica set) the record is checked right before the step runs. Before, pending listed every step the call did not run itself, so a check for pending.length === 0 failed on every sync after the first. See Data steps.
  • MongoDB: a duplicate key is always a CONFLICT. The adapter now recognises a duplicate-key error by its code on every write path, whichever driver error carries it (MongoServerError, or MongoBulkWriteError from an insert, with the code on the error or only on its write errors). Before, it checked the error's class. When the MongoClient came from another installation of the mongodb package than the adapter's own (the app's own dependency, a linked workspace), the driver's raw MongoBulkWriteError escaped instead of DbError("CONFLICT"): a write answered 500 instead of 409, and two instances racing for the schema-sync lock failed with E11000 duplicate key error … { _id: "sync_lock" } instead of one of them waiting. A write-concern failure is still thrown as it is. See Distributed Locking.
  • Schema sync recognises its own lock row. When the lock insert conflicts with a row that holds exactly what this attempt wrote (its podId, lockedAt and expiresAt, as a write retried after its acknowledgement was lost leaves it), the run holds the lock. Before, it waited for its own lock until lockTtlMs expired. A lock that an earlier run left under the same podId still blocks. Outside a transaction a data step's record is checked the same way, and it now holds the recording run's podId.
  • MySQL match "all" splits words at punctuation. The search text is split into words as InnoDB's full-text parser splits it: at every character that is not one of MySQL's word characters (letters, numbers, combining marks and _ of the Basic Multilingual Plane, as of Unicode 5.0). A short word or stop word next to punctuation (the,, ox., about:) now stays optional like a bare one. Before, it became a required term the server does not index, and the search returned nothing. A phrase made only of short and stop words is optional too. A word* prefix is required whatever its length (before, a prefix shorter than 3 characters or matching a stop word was optional). See FULLTEXT Indexes.

0.1.173 ​

Features ​

  • table.normalizePayload(payload, mode) returns a payload (one row or an array of rows) as a write of mode stores it — the type's @normalize.* applied, copy-on-change, nothing validated. See Normalization.
  • notFoundError() and MOOST_DB_MESSAGES from @atscript/moost-db: the 404 every moost-db endpoint answers, and its message catalog (registered with moost on import). See Not found.

Breaking & behaviour changes ​

  • One coded 404 body. Every moost-db 404 answers { "message": "Not found", "statusCode": 404, "code": "NOT_FOUND", "error": "Not Found" }. That covers /one, DELETE, a versioned write on a missing row, 'row' actions, value-help /one, /meta/form/:name and delegated actions. Before, most 404s had an empty message (a translator received the id ""), a 'row' action answered "Row not found for action identifier", and an unknown or refused form or delegated action answered Unknown form "<name>" / Unknown action "<name>". Branch on the status or on code, not on those texts. A permission layer that refuses a row itself should throw notFoundError(), so its answer matches a missing row byte for byte. See Not found.
  • onWrite receives the normalized body. The body is passed in with the type's @normalize.* applied, as guardWrite and checkWrite already saw it. Before, the hook got the raw request body. The request's body object itself is not changed. The controller calls the bound table's normalizePayload(payload, mode) for it, so a custom stand-in for AtscriptDbTable (a wrapper or a test double) must implement that method too. See onWrite.
  • Column defaults are normalized. A @db.default on a field with @normalize.* now becomes the normalized value in the column DEFAULT that schema sync writes, and in the value MongoDB fills existing documents with when the field is added. An insert through the table already stored the normalized value. On such tables the next schema sync changes the column's default: MySQL and PostgreSQL alter it in place, SQLite rebuilds the table, and --safe leaves it pending. Rows already stored are not rewritten. A literal the normalizers leave unchanged keeps its schema hash. See Static Defaults.
  • Safe mode: a type change waits for a removed table that references the column. A run without --safe drops the removed table first, then changes the type. A --safe run keeps that table, so the type change is now skipped and pending (skipped includes 'retype', typeChanges[].waitsFor names the table) until a run without --safe. Before, MySQL and PostgreSQL ran the change into the kept foreign key and the sync failed with the engine's error when the types no longer matched; SQLite applied the change under the kept constraint. See Safe Mode.

Fixes ​

  • validateRows keeps codes. Each problem now carries the code and params that the same write's error carries: the validator's codes (custom for your own annotation text), DEPTH_EXCEEDED, and FK_MISSING for a missing foreign-key target. Translators and HTTP answers built from them are rendered like a write's errors. See Validation.
  • Stored keys are compared as stored. Relation loading ($with), application-level cascades and RESTRICT checks, and a row addressed by the primary key the table just read no longer normalize those keys. A row stored before its key field gained @normalize.* ("ABC") is loaded, cascaded and deleted with the rows that reference it. Before, they looked for "abc" and missed it. Lookups by what a caller sends are still normalized. See Lookups.

0.1.170 ​

Requires @uniqu/core / @uniqu/url 0.2.1 and @atscript/core / @atscript/typescript 0.2.7.

Features ​

  • Translations. With moost's setTranslator, /meta, its forms, error bodies and disabled reasons are served in the request's language (an ETag per language; opt out per controller with metaTranslation()), and the db-client gets a locale option. See Translations (i18n).
  • @db.default takes a number or a boolean. @db.default 0, @db.default true, @db.default 9.99 next to @db.default 'active'; a literal that does not fit the field's type is a compile error. The string form (@db.default '0') keeps working and gives the same column default and schema hash. See Static Defaults.
  • MySQL engine, character set and ON UPDATE precision. @db.mysql.engine and @db.mysql.charset take any name the server provides (the editor completes the common ones; a name the server lacks fails the sync), and @db.mysql.onUpdate "CURRENT_TIMESTAMP(3)" is accepted. See MySQL-specific annotations.
  • @normalize.* on writes and lookups. Writes store the values the type's @normalize.trim / @normalize.lowercase / @normalize.uppercase produce, equality filters and ids normalize their operand, and the db-client preflight sends the normalized data (see the behaviour change below). New exports: normalizeWritePayload() and dbValidatorOptions() from @atscript/db and @atscript/db/validator, ClientValidator.normalize() from the client. See Normalization.
  • Action-form texts on the client. client.getActionFormTexts(name) returns the $i18n an action form was served with, from the same cached response as getActionForm(). See Form-schema discovery.
  • Any Atlas Search analyzer. The analyzer of the @db.mongo.search.* annotations is no longer limited to 14 names; the editor completes the built-in ones. See Supported Analyzers.

Breaking & behaviour changes ​

  • Stored values and equality lookups are normalized. A field (or a type alias it uses) with @normalize.* is stored trimmed / lowercased / uppercased by every write, HTTP and db-client writes included. A unique key collides after normalization (" A@x " and "a@x" are one key), and @expect.* checks the normalized value. Equality filters and ids normalize their operand; $regex, $search, ranges, aggregate conditions and $having compare as given. See Normalization and Lookups.

    Rows stored before the upgrade are not rewritten, and that matters once a field gains @normalize.*:

    • A row stored as "ABC" (not normalized) is no longer found by its old exact value through a normalized lookup: findById("ABC"), ?code=ABC, GET /one/ABC look for "abc". A patch, replace or touchMany addressed by that key matches 0 rows (PATCH answers 404).

    • Keys, unique keys and foreign keys inherit the normalizers: a reference field (parentCode: Parent.code) carries the target field's @normalize.*. New foreign-key values and @db.rel.via links to a legacy key are normalized and no longer match it — an FK violation on engines with native foreign keys, a missing link elsewhere — and relation loading ($with) and application-level cascades look the legacy key up by its normalized form.

    • Rewrite the stored rows once, through the same normalization, before relying on lookups — a data step (or the same loop in a one-off script). It writes through the adapter, so the old key still addresses the row:

      typescript
      import { normalizeWritePayload } from "@atscript/db";
      
      await syncSchema(db, types, {
        dataSteps: [
          {
            id: "2026-11-normalize-users",
            async run(space) {
              const adapter = space.getAdapter(User); // raw: no lookup normalization
              for (const row of await adapter.findMany({ filter: {}, controls: {} })) {
                const fixed = normalizeWritePayload(User, row, "replace");
                if (fixed !== row) await adapter.updateOne({ id: row.id }, { email: fixed.email });
              }
            },
          },
        ],
      });

      Rewrite a referenced key and the foreign keys pointing at it in the same step (children first on engines that enforce the reference, or update the parent with the children in one transaction), and merge rows whose keys become equal first — a phase: "beforeConstraints" step runs before unique indexes are created. Columns renamed with @db.column and flattened nested objects use their physical names in the adapter.

  • MySQL: @db.default.uuid strings are CHAR(36), as the type mapping always stated; they were VARCHAR(255), and the foreign-key columns that reference them follow. The next schema sync changes existing columns in place, dropping and re-creating the foreign keys over them: sync while the referencing tables are not being written, as rows written meanwhile are not checked against the key. A table holding a value longer than 36 characters fails its sync, and no value is cut. CHAR drops trailing spaces from stored ids. To keep the earlier type, declare @db.mysql.type 'VARCHAR(255)' on the uuid field. See uuid columns.

  • Schema sync reports failed when an entry errored. A run that applied what it could but left an error entry (a failed DDL statement, a unique index the stored rows break, a blocked drop) returns status: "failed" instead of "synced". The hash is withheld as before. Code that treated "synced" as success keeps working; code that checks for failure must check "failed" (and "refused"). See Sync Result Statuses.

  • Nested writes check every level before writing. The checks a related table makes before its own write — links that cannot be resolved, a missing @db.rel.via target, a foreign key to a missing row (memory, MongoDB), a relation shape a replace cannot write — now run for the whole tree before anything is written. Before, a refusal two or three levels down came after the levels above were written, which left a partial write on adapters without transactions. A foreign key into a table the same call writes rows in (the record itself, a sibling, an earlier item of the batch) is still checked when its level is written. See Nested write rules.

  • Keyed FROM children that are not stored yet are inserted. A PUT array, $replace or $upsert entry whose primary key names none of the record's stored children — a natural key, a client-chosen uuid, a composite key holding the parent key — is inserted with that key. Before, it was skipped silently: the call reported success and the child was never stored. A key that belongs to another record's child still answers CONFLICT; $update of a missing key still writes nothing. See $upsert.

  • A relation without a foreign key to the table is refused. Nested data for a @db.rel.from (or @db.rel.via) whose child (junction) has no @db.rel.FK referencing the table answers INVALID_QUERY; before, it was dropped silently. The usual cause is a @db.table that extends another table and inherits its relations — the compiler now warns on it.

  • Aggregate alias collisions are rejected (@uniqu/core 0.2.1). Two $select entries with one alias (also two count(*)), an aggregate alias equal to a selected field, or a $groupBy key that is an aggregate alias answer INVALID_QUERY with code: "AGG_ALIAS_COLLISION"; the bucket alias collisions already rejected carry the code too. See Validation and errors.

  • MongoDB: a search index Atlas refuses fails the sync. Creating, updating or dropping an Atlas Search index that Atlas rejects (an unknown analyzer, …) is now a sync error; before, every search-index failure was skipped silently. A server without Atlas Search still skips the search indexes. Atlas free and shared clusters cap the number of search and vector-search indexes (3 on a free cluster): a project with more such indexes now gets status: "failed" on every sync, until an index is removed or the cluster is upgraded — before, the extra indexes were silently missing.

  • The write validator's own errors carry codes. Geo-point, field-operation, navigation-property and array-patch errors report a code (GEO_LONGITUDE_RANGE, NAV_NULL, PATCH_KEY_REQUIRED, … — exported as DB_VALIDATOR_MESSAGES) and the params their message names, instead of code: "CUSTOM" with custom: true. The English messages are unchanged. See db validator codes.

  • db-client preflight error entries carry code and params like the server's entries.

  • Every computed-$select issue keeps its code over HTTP. A rejected $select / $groupBy (bucket, expression or alias rules) answers every issue the core finds, each with its code (AGG_ALIAS_COLLISION, …), and the envelope's code: "INVALID_QUERY"; before, only the first issue came back, without a code. A two-level aggregate answers as the SDK does: the shape rules of both levels first (an inner alias named like an inner $groupBy key is AGG_NESTED_DUPLICATE_COLUMN at $from.$select), then the inner level's computed-$select rules, under the $from. path prefix. A $cas rejected by PUT / PATCH also carries code: "INVALID_QUERY".

Fixes ​

  • Nested FROM children of a parent with a database-generated string key (a native uuid primary key on PostgreSQL / MySQL) are inserted; before, they failed validation (Expected string, got number).
  • Keys under a case-insensitive collation (MySQL's default, @db.column.collate 'nocase') are matched in nested writes as the database matches them: a FROM child echoed in another case is replaced, not deleted as an orphan of a replace; an existing VIA target named in another case is linked instead of answering INVALID_QUERY / FK_VIOLATION.
  • A decimal @db.default is filled as its text ("9.99", like any decimal value) on insert and in the MongoDB backfill; before, the SDK filled a number and the insert failed validation.
  • MySQL @db.mysql.onUpdate on a renamed (@db.column) or nested column renders its ON UPDATE clause; before, it was dropped. Engine and character-set names are compared case-insensitively, so "innodb" no longer looks like a change.
  • MySQL foreign keys over string columns are created. A @db.rel.FK to a string field other than the primary key (a unique slug, without @expect.maxLength) failed the sync of both tables (BLOB/TEXT column … used in key specification without a key length, then Failed to open the referenced table). A referenced string field and the foreign-key column are now both VARCHAR of the same length, and the foreign-key column takes the referenced column's character set and collation (the field's, else its table's), so a reference to a @db.column.collate 'nocase' key or into a latin1 table no longer fails as incompatible. Unreferenced strings keep their type. A referenced column the earlier sync created as TEXT is changed to VARCHAR(255) in place; under MySQL's strict mode that fails while a stored value is longer than 255 characters — declare @expect.maxLength on such a field. See Foreign keys over string columns.
  • MySQL: a column that becomes TEXT while indexed migrates. A unique string losing its @expect.maxLength, or a referenced string losing its last foreign key, failed the sync (BLOB/TEXT column … used in key specification without a key length); its full indexes are now dropped before the change and re-created with the (255) prefix. A foreign key removed in the same run, or the referencing table, is dropped before the change, and dropping a foreign key drops the index MySQL created for it.
  • MySQL index prefixes use the column's character set. A key column with its own @db.mysql.charset (or a foreign-key column in another table's) is measured in that set: a latin1 VARCHAR(1000) is indexed in full instead of a (768) prefix (its index is rebuilt once).
  • MySQL @db.mysql.charset without @db.mysql.collate creates the table in that character set with its default collation; before, the DDL added COLLATE=utf8mb4_unicode_ci and MySQL refused the table for any other set. On a field, @db.mysql.charset now gives the column CHARACTER SET <set>; before, it was ignored (a change of it is not detected by later syncs).

Custom adapters ​

  • Schema sync calls getReferencingForeignKeys also for a table whose column types change: a live foreign key over a changing column is dropped first when its table is removed in the run (the table goes right before) or no longer declares it (dropForeignKeys on that table before the walk).
  • DbSpace.getReferencedFields(tableName, schema?) lists the fields of a table that a foreign key of a registered table references — for a typeMapper whose types depend on it (keep the space in registerSpace). See typeMapper.
  • A field's defaultValue of kind "value" still holds the literal as text (@db.default 0 → "0", as @db.default '0' always did). parseDefaultLiteral(designType, literal) from @atscript/db gives the value an insert fills — the text for a string or decimal field, else the parsed JSON — for a backfill.

0.1.169 ​

Breaking & behaviour changes ​

  • insertedId of a composite primary key is the primary-key object. On a table with several @meta.id fields, insertOne reports { insertedId: { orderId: 1, productId: 2 } } (logical field names) and insertMany one such object per row — also with onConflict: "ignore" and over HTTP POST /. Before, it was the driver's row id (SQLite rowid, MySQL insertId, the first RETURNING column on PostgreSQL, the MongoDB _id; the memory adapter reported the key object under physical column names), which does not name the row. Code that stored or compared that value must use the key object; findById(insertedId) and the db-client's one(insertedId) take it as is. A single-field primary key keeps its scalar id. See Composite primary keys.
  • No id rather than a wrong one. When the adapter cannot learn a database-generated part of a composite key from the insert (SQLite; MySQL batched inserts — see the adapter table), the row reports insertedId: undefined (an undefined entry in insertedIds, null over HTTP) and insertOne(row, { returning }) answers no row.
  • Memory adapter: a table without a primary key reports insertedId: undefined (was {}), like the other adapters report no usable id.
  • moost-db Prefer: return=representation on composite keys reads the row back by its key object, as GET /one?orderId=1&productId=2 does. Before, it answered no row — or the row whose unique field held the reported number. See Get the stored row back.
  • Nested inserts link by the row as written, and fail closed. Each foreign key a nested @db.rel.to / @db.rel.from / @db.rel.via insert fills gets the value of the field it references in the row as stored, and overwrites any value the payload carried for it. Before, a key on a unique field got the new row's id, a composite key was not filled, a TO parent's SDK-defaulted unique field (@db.default.uuid) was left unset (the payload's own foreign key, if any, survived). A key that cannot be resolved is now refused with FK_VIOLATION instead of an unlinked or wrongly linked child. See Nested Inserts.
  • Nested data is read after the write guard on insert and replace (patch already did): a guard that edits or removes a row's nested arrays now changes what the nested phases write.
  • Replace / patch refuse nested children they cannot link by primary key. @db.rel.from / @db.rel.via children on replaceOne / bulkReplace / updateOne / bulkUpdate (and HTTP PUT / PATCH) linked by anything but a single-field foreign key to the parent's primary key answer INVALID_QUERY before any write. Before, such children were skipped silently (composite parent) or written with the parent's id in the wrong field. A nested insert of a junction with a composite foreign key answers INVALID_QUERY too (was skipped). See Nested write rules.
  • VIA: an existing target is linked by its stored value (insert, replace and patch): an entry whose own value differs answers INVALID_QUERY, a missing target FK_VIOLATION, both before any write. Before, the payload's value was linked and a missing target failed only at the junction insert, after the parent was written. A composite-key target counts as existing only with its whole key.
  • Replace with a nested @db.rel.to object needs the referenced field in that object (INVALID_QUERY before any write otherwise) — a replace would reset or regenerate it.
  • insertedId is in read form — every composite part and a single-field id: a MySQL DATETIME key is reported as epoch ms (was the stored datetime string), the value GET /one?… takes.

Fixes ​

  • A write check on an insert resolves a server-generated composite-key part from the reported id instead of failing with cannot resolve the primary key.
  • A patch addressed by a unique field writes its nested @db.rel.from / @db.rel.via data (updateOne({ email, children: { $insert: [...] } })) under the stored row's primary key; before, that data was dropped silently.

Custom adapters ​

  • _resolveInsertedId(data, dbGeneratedId, generated?) builds the composite-key object (each part through the column's fromStorage formatter); pass the server-assigned column values you know exactly for the row as generated (e.g. a RETURNING row). See Creating adapters.

0.1.168 ​

Features ​

  • Fulltext language and term matching. @db.index.fulltext.language "russian" and @db.index.fulltext.match "all" | "any" on the interface (optionally naming one index) set the text language and whether a search needs every term. PostgreSQL uses the language as the text search configuration and switches to websearch_to_tsquery under match; MongoDB sets the text index's default_language; SQLite uses the Porter stemmer for english; MySQL ignores the language with a warning and runs match "all" in boolean mode. A changed language rebuilds the index on the next sync. Indexes without the annotations are unchanged. See Language and term matching.
  • Data steps before new constraints. A data step with phase: "beforeConstraints" runs after the tables and their new columns exist and before indexes and foreign keys are synced, so it can merge the rows a new @db.index.unique or @db.rel.FK would reject. See Fix rows before a new constraint.
  • Prefer: return=representation on POST /, opt-in per controller. Override returnRepresentation() to return true (default false: the header is ignored) and a single-object insert answers the stored row as row, read as GET /one would answer the caller (prepareRequest runs again with endpoint: "one" — authorize that call with the caller's read grant). A failed read-back leaves the plain result. The client sends it with insert(row, { returning: true }). See Get the stored row back.
  • Schema sync resumes after a failed beforeConstraints step, and renameTable on every adapter is a no-op when the old table is gone and the new one exists.
  • Typed patch operators. DbPatch<Row> (and the client's PatchOf<T>) types { $inc | $dec | $mul } on number fields, the array operators on top-level array fields, and $cas. See Typed operators.
  • parseArith is re-exported from @atscript/db, for aggregate $expr entries written as text. See Arithmetic expressions.
  • @db.search.vector takes any embedding size. dimensions is any positive integer (8, 1000, 1280 …); the common sizes stay as editor completion. Before, a size outside 256 … 16384 failed the build. See Vector Search.
  • Distinct combinations, conditional aggregates, two-level aggregates. countDistinct takes a field list (countDistinct(customerId,productId); a row with any NULL field is not counted); an aggregate entry takes a $where filter (sum(amount|status=paid):paid); controls.$from aggregates the rows of an inner grouped query ($from=($groupBy=customerId&$select=sum(amount):spent)&$select=avg(spent):avgSpent). Every bundled adapter runs them; /meta advertises distinctFieldLists, conditionalAggregates and nestedAggregates, and the client types them. See Grouped Queries and Two-Level Aggregates.
  • db-client loads @atscript/typescript/utils only when getActionForm() first needs it, so importing the client keeps it out of an app's first chunk.

Breaking & behaviour changes ​

  • @atscript/db-sql-tools is a dependency of the SQL adapters, no longer a peer. @atscript/db-sqlite, @atscript/db-postgres and @atscript/db-mysql install the matching version themselves. If your app lists @atscript/db-sql-tools only to satisfy the old peer, remove it from your package.json; a custom adapter that imports it keeps its own dependency.
  • Write payload types accept interfaces and are stricter. DbInsert<Row> (new) types inserts, DbPatch<Row> updates and DbRow<Row> replaces; none of them intersects Record<string, unknown> any more. So a value of an interface type — the generated model type or your own interface Product — now fits insertOne(product) / client.insert(product), and on an untyped table or Client any object fits. On a typed table:
    • a key the model does not declare is now a type error in insertOne / updateOne / replaceOne / client.insert / client.update / client.replace payloads (before, the index signature let it through);
    • inserts no longer type $cas or field operators (both were rejected at runtime);
    • db-client's insert() takes InsertOf<T> (new export) instead of PatchOf<T>; overloads now list the array form first.
  • Memory adapter: a unique index over stored duplicates is refused. Sync reports the table as an error entry naming the index (hash withheld) instead of recording the index over the duplicates; fix the rows and the next sync records it. See Unique & primary-key enforcement.
  • @uniqu/core / @uniqu/url 0.2. The query packages move to ^0.2.0 (field lists, $where, $from in the types and the URL grammar). Code that reads an AggregateExpr should expect $field to be a string[] for countDistinct and an optional $where.
  • Unknown controls on a grouped query are rejected. aggregate() and an aggregate /query answer any control other than $groupBy, $select, $having, $sort, $nulls, $rowOrder, $skip, $limit, $count, $search, $index, $from with INVALID_QUERY / 400 Unknown aggregate control "$x" — before, it was ignored and the query ran without it. A non-empty $with on aggregate() is rejected too (moost-db already refused it). $actions stays accepted on /query and is ignored.
  • $from outside an aggregate is rejected on findMany, count, search and the other reads ($from is only valid in an aggregate query).
  • Value-help /query refuses aggregates. $groupBy, $from or an aggregate $select on a value-help controller now answer the controls DTO's 400; before, $groupBy skipped the DTO and the request was served as a plain list. A validateControls override on a DB controller is now also called with type: "nested" for the inner level of a two-level aggregate.
  • $update / $remove helpers (@atscript/db/ops) take the element type as given ($remove(["a"]) on a string array); the payload type checks it.

Custom adapters ​

  • Three new capability flags, all false by default (fail-closed): supportsConditionalAggregates(), supportsDistinctFieldLists(), supportsNestedAggregates(). A third-party adapter keeps working and answers the new shapes with AGG_WHERE_NOT_SUPPORTED, AGG_FIELD_LIST_NOT_SUPPORTED, AGG_NESTED_NOT_SUPPORTED. SQL dialects opt in with conditionalAggregate and countDistinctColumns. See Conditions, field lists and two-level aggregates.
  • renameTable(oldName) should be a no-op when oldName is gone and the new table exists — the next sync retries a rename whose run stopped before recording it. See renameTable.
  • TDbIndex.language / TDbIndex.match and DB_FULLTEXT_LANGUAGES / DB_FULLTEXT_MATCH_MODES (from @atscript/db); TIndexDiffOptions.renderDesiredDefinition with a definition on listExisting entries rebuilds an index whose definition drifted. See Creating adapters — text search.

0.1.166 ​

Fixes ​

  • moost-db: a hidden geo index answers with code: "GEO_INDEX_MISSING". Under an overridden hasField, GET /geo on a geo index that reads a hidden field answered 400 without the envelope's code, while an unknown index answered with it. Both now get the same 400 envelope, code included, so the reply reveals nothing about the hidden index.
  • dbErrorToHttp(error) (from @atscript/moost-db) returns the HttpError a request gets when the core throws that DbError: same status, message, code and per-entry code / params. A permission layer that answers "like the core would" builds the DbError and returns this. See Thrown errors.

0.1.165 ​

Requires @atscript/core / @atscript/typescript / unplugin-atscript 0.2.1, and for @atscript/moost-db moost / @moostjs/event-http 0.7 with @wooksjs/* 0.8 (the peer ranges of this release). Upgrade them together.

Features ​

  • table.validateRows(rows, mode?, { fk? }) validates rows exactly as an insert (default), replace or patch would — defaults and stamps applied to a clone — and returns one { index, path, message } per problem ([] when all are valid). fk: true also looks every foreign key up, on every adapter. Use it for imports and dry runs instead of preValidateItems, which is now internal. See Validation.
  • insertOne(row, { returning }) reads the stored row back by its primary key in the insert's transaction and answers it as row (true: every field, server defaults and stamps included; a field list: those fields). See Read the stored row back.
  • One-time data steps in syncSchema. syncSchema(space, types, { dataSteps: [{ id, run(space) }] }) runs each step once per database, after the DDL, under the sync lock, in its own transaction, and records it in __atscript_control. A step added later runs on the next sync even when the schema is unchanged; planSchema lists pending steps (pendingDataSteps). See Data steps.
  • Error codes for translation. A unique / foreign-key violation now has one DbError entry per field of the violated constraint, each with a code (UNIQUE, FK_MISSING, FK_RESTRICT) and params ({ index, fields }, { fields, target }, { table, referencedBy? }); the engine text moved to the entry's details. The moost-db error envelope carries the DbError code and each entry's code / params (never details). See Constraint violations.
  • @db.rel.FK accepts Target.id | null — the same optional foreign key as field?: Target.id, with the same /meta ref (atscript-ui's value help reads it from its next release). See Optional Foreign Keys.
  • db-client: a per-call { signal } on query, count, aggregate, pages, geoSearch, geoPages, one, insert, update, replace and remove; TransportError.aborted tells a cancelled request from a network failure; ClientError<B> types an error body with fields of its own, and ServerError admits extra fields. See Client.
  • Mutation filters accept relational predicates in their types: deleteMany({ issues: { $some: { … } } }) typechecks like the same filter in findMany (it already worked at runtime).

Breaking & behaviour changes ​

  • Fulltext indexes are named by their declared name. On PostgreSQL and MySQL getSearchIndexes() (and /meta searchIndexes) reported the internal key atscript__fulltext__<name>, and search(…, '<name>') / $index=<name> failed with the declared name. Both now use the declared name; the key still resolves and is listed in the entry's new aliases. On MongoDB the default @db.index.fulltext index is listed under its declared name too (before, only as DEFAULT, which still resolves and is listed as an alias); a static Atlas index that is the default is listed once, with aliases: ["DEFAULT"]. An unknown name now throws Search index "<name>" not found on every adapter. See Text Search.
  • Search results come in relevance order when the read has no $sort: PostgreSQL ranks with ts_rank (per-field weights from @db.index.fulltext now apply), SQLite with bm25() (weights apply), MySQL orders by MATCH … AGAINST; MongoDB already ranked. When the term is a whole number, exact-number hits come first. The primary key breaks ties (_id on MongoDB's classic text index; an Atlas $search keeps its own score order). Before, PostgreSQL and SQLite returned matches in storage order. On PostgreSQL the rank recomputes to_tsvector per matched row before LIMIT — see Ranking. See Text Search.
  • number.timestamp.created is insert-only (top-level fields, including number.timestamp.created | null; a nested one such as audit.at is replaced with its object as before). A replace (replaceOne, bulkReplace, replaceMany, HTTP PUT) keeps the stored value instead of resetting it to "now" when the payload leaves it out, and no replace or patch payload can overwrite it (before, PUT / PATCH could set any value). Set it on insert. See Replace One.
  • Dotted $with loads nested relations. $with: ["comments.author"] (and ?$with=comments.author) is read as comments($with=author); before, it was silently skipped. Entries for the same relation merge ($with=comments($sort=-id),comments.author). The string shorthand ($with: ["comments"]) works too — it used to throw. An unknown relation in a dotted name is now reported like its nested form: Unknown relation "author" with that level's relations (before: the whole dotted name). See Loading relations.
  • Constraint errors changed shape. Paths are logical fields of the violated constraint, one entry each: a compound unique key no longer reports householdId, (SQLite), the index name (PostgreSQL, MySQL) or only the first key (MongoDB, memory); a primary-key collision reports the key field instead of PRIMARY / <table>_pkey; an FK failure reports only the failing key (SQLite probes it) instead of every FK field, and a composite FK checked by the core reports each field instead of "a, b". Messages are plain English (Value must be unique, References a non-existent record in "<table>"); match on code, not on message text.
  • getValidator("insert" | "bulkReplace" | "bulkUpdate" | "patch") binds its write mode when called without a context, so getValidator("insert").validate(row, true) accepts what an insert accepts. Before, it rejected rows without their primary key, defaulted, foreign-key or navigation fields.
  • db-client: ClientValidationError is a ClientError (status 400, body: { message, statusCode: 400, errors }, preflight: true) and a runtime export of @atscript/db-client (same class as @atscript/db-client/validator). An instanceof ClientError branch now also catches preflight failures — check preflight to tell them apart.
  • db-client typing: row types of EVERY table lose their Record<string, unknown> index signature: reading or writing a key the model does not declare (row.extra) is now a type error, on tables with or without navigation props. On tables with navigation props the declared fields also get their real types (they were all unknown). $with narrows without as const (readonly arrays work too).
  • Mutation filter typing: on a table with navigation props, deleteMany / updateMany / replaceMany take MutationFilter<FlatOf<T>, NavPropsOf<T>>. A filter typed as FilterExpr<FlatOf<T>> no longer fits — retype it as MutationFilter<…> (exported from @atscript/db) or build it inline.
  • A plain export type Admin = User is User. With @atscript/typescript 0.2 a bare alias is its target at runtime (the same object), so it carries User's db.table and isDbEntityType(Admin) is true; db.get(Admin) is the users table (before, it opened a table named Admin). syncSchema / planSchema list and hash a type once when an inventory names it twice, so Object.values(models).filter(isDbEntityType) still syncs each table once. A @db.alias type and a field typed with a table still carry no entity annotations. See Syncing a module namespace.
  • moost-db on moost 0.7 / wooks 0.8: a successful PUT, PATCH or DELETE answers 200 (was 201 / 202 / 202); POST stays 201. /meta with If-None-Match: * answers 304 whatever metaHttpCaching() returns (wooks 0.8 answers * for every GET 200); with metaHttpCaching() returning false a real ETag is still never matched. See Customization.
  • SQL adapters render an $or of one field's equalities as IN (…) — same rows, but a large per-row key filter no longer fails with SQLite's "Expression tree is too large".
  • Custom adapters: constraint errors. Throw unique / foreign-key violations with constraintViolation(code, hint, rawText) from @atscript/db so the table layer can build the per-field entries with code / params. A DbError built by hand still passes through unchanged, without entry codes. See Creating adapters.
  • Custom adapters: insert-only columns. replaceOne / replaceMany receive data without the table's number.timestamp.created columns. Keep the stored value: SQL adapters building the column list with replaceColumnsFor already do; a document store rebuilding the row copies this._table.insertOnlyPhysical from the stored row. See Insert-only columns.
  • Custom adapters: search index names and order. Resolve search(text, query, indexName) by the declared name first (resolveFulltextIndex(indexes, name) also accepts an older internal key), list each index under its declared name in getSearchIndexes() with older names in aliases, and without a $sort return rows in relevance order — searchRankOrder() from @atscript/db-sql-tools builds that ORDER BY for SQL adapters. See Text search.

0.1.164 ​

Fixes ​

  • MongoDB: transactions over directConnection=true. On a client connected with directConnection=true to a replica-set member, withTransaction ran fn without a transaction: a thrown error did not roll back its writes, and withTransaction(fn, { isolation }) threw ISOLATION_NOT_SUPPORTED. Transaction support is now read from the server, once per client. If the server is unreachable on first use, withTransaction now fails with the connection error instead of running without a transaction, and a withTransaction sent to a secondary over directConnection=true now opens a real transaction (the server refuses it with NotWritablePrimary) instead of running without one. See Transactions.

0.1.163 ​

Features ​

  • isServerStampedOnUpdate(metadata) and SERVER_STAMPED_ON_UPDATE_KEYS from @atscript/db/validator: true for @db.onUpdate.now and @db.onUpdate.inc fields, which the server overwrites on every update — e.g. to show them read-only in an edit form. See Client-Side Validation.

Fixes ​

  • Plugins and custom adapters built against 0.1.161 keep working. The shared annotation helpers (effectiveAnnotationNames, validateExclusiveWith, validateIncompatible) accept a missing doc argument and then check the field's own annotations only. The deprecated shouldSkipType / warnUnsupportedTypes index-diff options work again (a skipIndex verdict wins), and SqlDialect.stringLiteral is optional — sqlStringLiteral is used when it is missing. The 0.1.162 notes below describe the preferred replacements. See Creating adapters.

0.1.162 ​

Features ​

  • Bounded deleteMany / updateMany. deleteMany(filter, { limit, sort? }) and updateMany(filter, data, { limit, sort? }) touch at most limit matching rows, the first in sort order (default: primary key; the primary key always breaks ties). Loop until the count is 0: a round may touch fewer than limit rows under concurrency, but a round that lost its picked rows to a concurrent writer is retried while matches remain, so 0 means nothing matched; if contention persists the call throws a retryable SERIALIZATION_FAILURE. MySQL runs ORDER BY … LIMIT natively, PostgreSQL and SQLite one statement with a key subquery, MongoDB and memory read the keys first. Not exposed over HTTP. See Bounded Writes.
  • @db.onUpdate.inc — a per-row change counter: 0 on insert, +1 in the same statement on every SDK update (version-exempt patches, replaces, touches and updateMany included), never settable by clients. Unlike @db.onUpdate.now it cannot collide within a millisecond or move backwards. Adding it to a populated table: schema sync creates the column NOT NULL DEFAULT 0 (MongoDB: sets 0 on the existing documents). Editor completion and hover come with the db plugin; /meta.type keeps the annotation. See Change Counter.
  • Locking reads. findOne / findMany / findById take { lock: 'update' | 'share', wait?: 'block' | 'nowait' | 'skipLocked' } inside a withTransaction scope of the table's adapter: native FOR UPDATE / FOR SHARE [NOWAIT / SKIP LOCKED] on PostgreSQL and MySQL, a no-op on SQLite (BEGIN IMMEDIATE already holds the write lock), ignored by memory, LOCK_NOT_SUPPORTED on MongoDB (use $cas). A lock outside a transaction, on a view or on a grouped / aggregate / count read throws INVALID_QUERY; 'nowait' on a held row throws a retryable LOCK_TIMEOUT. Server-side only, never a URL control. See Locking reads.
  • Transaction isolation levels. withTransaction(fn, { isolation: 'read committed' | 'repeatable read' | 'serializable' }) opens the outermost transaction at that level; a nested call asking for a stricter level than the open one throws INVALID_QUERY. MongoDB refuses serializable (and any level on a standalone server) with ISOLATION_NOT_SUPPORTED. See Isolation level.

Fixes ​

  • Compile checks follow field refs: @db.column.searchable / @db.index.fulltext on a ref to an @expect.int field is accepted — you no longer need to repeat @expect.int on the referring field. On a view field, the float refusal now suggests @expect.int (a view column cannot take @db.default.increment).
  • A forced sync of a converged schema reports nothing. Defaults are compared by value, not by text: booleans as 1 / 0, numbers numerically (a MySQL DECIMAL(10,2) reporting 0.00 matches 0), JSON-stored values as parsed JSON with sorted keys, and engine quoting unwrapped (MySQL _utf8mb4\'[]\', SQLite 'it''s'). Before, force: true re-ran MODIFY / ALTER COLUMN on MySQL and PostgreSQL, and recreated the table on SQLite, on every run. Index comparison ignores engine-made indexes, compares MySQL key-length prefixes as rendered, skips indexes the database cannot build and no longer rebuilds pgvector HNSW indexes each run. See What gets synced.
  • MySQL: backslashes in string literals. View filter values, default values and JSON paths rendered into MySQL SQL now escape \ (MySQL reads it as an escape unless NO_BACKSLASH_ESCAPES): a view filter on 'a\\b' matched the wrong value, a default with a backslash lost it, and a JSON key holding a quote broke the statement.
  • MongoDB: $inc / $mul on a missing field. updateOne / bulkUpdate (and HTTP PATCH) render field ops as an update pipeline, where a missing field produced null instead of the value MongoDB's $inc / $mul operators give (updateMany already used the operators). A missing field now counts as 0 on every path — so a document written before a @db.onUpdate.inc counter existed starts counting at 1.

Breaking & behaviour changes ​

  • View fields over a primitive JSON field read the typed value. A view field over a whole @db.json field of type string, number or boolean (score?: User.score) now reads the typed value on SQL adapters: 9, not the JSON text '9'. It also sorts and filters by that value; before, it sorted the text, so '10' came before '9'. Sync recreates such views. A view field that declares @db.json itself keeps the JSON column (Reading JSON leaves).
  • More compile errors through field refs and encrypted fields. A field typed as a ref (ownerId: Account.ownerId) is now checked against the annotations its runtime metadata really carries — its own plus the ones it inherits through the ref — and a view field against the read seals of the source field it reads. Failures that only surfaced at sync or query time are now compile errors. Schemas that hit a new error were already broken at sync or query time (or carried two conflicting currency / unit forms). Remove the refused annotation, or drop the conflicting form on the ref field.
    • @db.column.searchable / @db.index.fulltext on a view field over a @db.writeOnly source (sync and every query already threw).
    • @db.sort.nulls on a view field over a @db.encrypted source (the sort already failed at query time).
    • @db.amount.currency.ref / @db.unit.ref on a field that inherits @db.amount.currency / @db.unit through its ref (the field's metadata carried both forms).
    • No search over ciphertext: @db.column.searchable on an @db.encrypted field (as @db.encrypted's docs always promised); any search feature (@db.column.searchable, @db.index.fulltext, @db.index.geo, @db.search.vector, @db.search.filter, @db.mongo.search.text / .autocomplete) on a field inside an encrypted object (inline, or in the type of a ref-typed encrypted field such as @db.encrypted profile: Address) or on a view field over an encrypted source; and @db.mongo.search.dynamic on a collection with any encrypted field — switch it to @db.mongo.search.static with @db.mongo.search.text on the fields to search. Models built without the compiler get the same refusal when their metadata is built (Encryption).
  • updateMany and replaceMany validate their payload. updateMany(filter, data) runs the patch-mode validator, like updateOne; replaceMany(filter, data) the replace-mode validator without requiring the primary key (the filter picks the rows) — which the docs always promised. A wrong type, null on a non-nullable field, an empty @meta.required field or a failed @expect.* constraint now throws a ValidatorError (HTTP 400 through moost-db's validation interceptor) before any statement runs. Before, only foreign keys were checked: such a payload reached the database and failed there (a NOT NULL error, a 500) or was silently coerced (5 stored as '5.0' in a string column). A navigation field in either payload throws DbError("INVALID_QUERY"), and replaceMany rejects a direct write to the version column, like replaceOne. Code that relied on the lax behaviour — or ran its own validator first — can drop the workaround. See Update Many.
  • A versioned touch and touchMany set @db.onUpdate.now. A key + $cas touch and touchMany write the row (the version moves), so they now also set the row's update time (number.timestamp.updated) in the same statement. Before, the time stayed unchanged. See Update Timestamps.
  • Sync reports index, foreign-key and in-place column work. A plan or run entry for an existing table now lists its index work (entry.indexChanges, { name, action: 'add' | 'drop' | 'rebuild' }) and the foreign keys a live check re-adds or drops, and becomes alter for them; a run entry also lists in-place type, nullability and default changes. Before, this work ran on forced syncs without being reported. Run entries for managed views carry definitionChanged. A failed run's error entry lists the table's planned column work only, not the index or live foreign-key work (a refused run's entries are plan entries and list both). See Reported Index Work and Foreign Keys.
  • Custom adapters: report sync work. BaseDbAdapter now implements syncIndexes() and planIndexes() over the protected _syncIndexes(dryRun, opts?). Implement it with syncIndexesWithDiff({ dryRun, … }), which returns TIndexChange[]. An adapter that still overrides syncIndexes() works and plans no index work. TIndexDiffOptions gains isUnmanaged and skipIndex (a warning string, or null to skip silently). skipIndex replaces shouldSkipType, so change shouldSkipType: (t) => t === 'x' to skipIndex: (i) => (i.type === 'x' ? null : undefined). Relational adapters may return TForeignKeyChange[] from syncForeignKeys() and add planForeignKeys(); syncForeignKeysWithDiff() does both. An adapter that keeps returning void reports nothing. The optional reportedDefault() covers defaults your database cannot report exactly. See Creating adapters.
  • Custom adapters: locking reads and isolation default to off. supportsReadLocks() (default false → LOCK_NOT_SUPPORTED) and isolationLevels() (default empty → ISOLATION_NOT_SUPPORTED); apply opts.isolation in _beginTransaction(opts). @atscript/db-sql-tools adds readLockSql() and a trailing lock argument to buildSelect. See Locking reads and isolation levels.
  • Custom adapters: deleteMany(filter, opts?) takes an optional TDbDeleteManyOptions, and TDbUpdateOptions gains limit — both are sent only when the adapter's new supportsWriteLimit() returns true (default false: the core emulates the bound with your unbounded methods). See supportsWriteLimit().
  • Custom adapters: replaceOne / replaceMany must increment the table's @db.onUpdate.inc columns (this._table.onUpdateIncPhysical, helper this._onUpdateIncOps()); the core leaves them out of the payload. An adapter that ignores them leaves the counter unchanged on a replace. See Change counters.
  • Custom adapters: @atscript/db-sql-tools: the limit argument of buildUpdate / buildDelete also takes { count, orderBy } (TSqlWriteLimit, an ORDER BY … LIMIT write), fillReplacePayload a fourth skip list; new writeLimitWhere / writeLimitCount.
  • Custom adapters: SqlDialect requires stringLiteral(value) — the SQL string literal the builders inline into view predicates, JSON paths and defaults. Use sqlStringLiteral (quotes doubled); an engine that reads \ as an escape in string literals (MySQL) must escape it too. jsonDollarPath(path, stringLiteral?), defaultValueToSqlLiteral(designType, value, stringLiteral?) and queryNodeToSql(node, resolve, stringLiteral?) take that function; quotedJsonPathSegments([]) returns [] (the JSON root) instead of throwing, and the new unquoteSqlStringLiteral(literal) reads back a quoted default.

0.1.159 ​

Fixes ​

  • No install scripts in the published packages. Every @atscript/db* / @atscript/moost-db package declared postinstall: "asc -f dts" — a development step meant for this repository. npm 11 warned about it on install (install-scripts), and npm versions or setups that run dependency install scripts executed asc inside node_modules. The published packages now carry no install-time scripts.

0.1.158 ​

Dependency refresh: built on @atscript/core / @atscript/typescript / unplugin-atscript 0.1.107, moost 0.6.52 and wooks 0.7.30, so an app that upgrades the whole set gets a single copy of each. @atscript/db-client now declares its @atscript/db peer at the same release (^0.1.158) instead of the old ^0.1.44. No behavior change.

0.1.157 ​

Fixes ​

  • Sync error message separator. When a table's sync failed after foreign keys were dropped, the "Dropped foreign keys before the failure: …" note was appended to the engine message without a separator (PostgreSQL and MySQL messages end without a period). It is now a separate sentence. No behavior change — but code that matches the whole error line now needs the period.

0.1.156 ​

Requires @atscript/core / @atscript/typescript / unplugin-atscript 0.1.106. In that release number.timestamp.updated carries @db.default.now and the new @db.onUpdate.now (atscript 0.1.104 and 0.1.105 described it as a marker that nothing sets — it was always documented as set on every write, and now is).

This release changes the SQL schema of tables with number.timestamp.updated fields on PostgreSQL and MySQL. Run planSchema before the first sync — What the first schema sync changes lists it per adapter.

New features ​

  • @db.onUpdate.now sets a number field to the current time on every update and replace — updateOne, bulkUpdate, updateMany, replaceOne, bulkReplace, replaceMany, HTTP PATCH / PUT — on every adapter. The SDK writes the time in the statement, overriding a value in the payload; inserts are left to the field's default. Editor completion and hover come with the db plugin. See Update Timestamps.

Behavior changes ​

  • number.timestamp.updated is set on insert and on every update. A field typed number.timestamp.updated — directly, optional, through a type alias, as number.timestamp.updated | null, also inside an embedded object — behaves like @db.default.now + @db.onUpdate.now: it may be omitted on insert (also in db-client validation) and is filled with the current time (an explicit value wins, as for number.timestamp.created), it may be omitted on replace, and every patch and replace sets it to the current time. Before, nothing set it and it was required on insert. A field that references another one (updatedAt: Order.updatedAt), a member of another union, a tuple item and an array element are not affected.
  • A value for it in a patch or replace is ignored. Code that wrote updatedAt: Date.now() itself keeps working. Code that stored a chosen value through an update (an import or migration copying a row's history) now gets the current time instead — write such rows with an insert, or declare the field number.timestamp (plus @db.default.now if wanted). Over HTTP a client can no longer set it with PATCH / PUT; on POST a supplied value is still stored, like number.timestamp.created — strip it in onWrite if clients must not choose it.
  • A patch that writes only the timestamp is a no-op. updateOne({ id, updatedAt }) used to store the value; now the value is dropped, nothing else is left to write, and no statement runs (as for any empty patch). A patch of only version-exempt fields still keeps the version, and now also sets the time.
  • /meta.type keeps db.onUpdate.now, next to db.default.now, so db-client and forms see the field as server-managed.

What the first schema sync changes ​

Every table with a number.timestamp.updated field gets a new schema hash and is re-planned once. Only those fields change.

  • PostgreSQL — DOUBLE PRECISION → BIGINT DEFAULT (epoch ms), converted in place (values keep their milliseconds; a fractional value is rounded), as for number.timestamp.created in 0.1.155. A column that already has the type but no DEFAULT gets one.
  • MySQL — DOUBLE → TIMESTAMP DEFAULT CURRENT_TIMESTAMP through a temporary column (the column keeps its position), as for number.timestamp.created in 0.1.155. A plain TIMESTAMP holds whole seconds: the stored milliseconds are truncated, and so are the times later updates write. To keep milliseconds, add @db.mysql.type "TIMESTAMP(3)" to the field before this sync (see Fractional seconds). A stored value a TIMESTAMP cannot hold (before 1970-01-01 00:00:01 or after 2038-01-19 UTC, such as a 0 placeholder) fails that table's sync with the column left as it was, and until it succeeds inserts, updates and replaces of the table fail (the adapter writes a datetime into the old DOUBLE column) — fix the rows first (for example UPDATE orders SET updatedAt = createdAt WHERE updatedAt < 1000) and re-run. No ON UPDATE clause is added.
  • SQLite — no DDL change: the SDK fills and sets the value.
  • MongoDB and the in-memory adapter store nothing differently; the tables re-sync once without changes.
  • Views — a managed view that selects an embedded object containing a number.timestamp.updated field gets a new definition hash and is dropped and recreated once (a materialized view is rebuilt), as in 0.1.155.
  • Writes outside atscript-db (raw SQL, other services) do not set the field — no engine trigger is created. On MySQL add @db.mysql.onUpdate "CURRENT_TIMESTAMP" if such writes must set it.

Fixes ​

  • MongoDB: a primary-key change that removes the old key field no longer fails half-way. Moving @meta.id on a populated collection while removing the old key field in the same sync (id → (studentId, courseId), id deleted) unset id while the old __pk unique index on it still existed: the second document collided on { id: null } (E11000), the table's entry was an error with the field already gone from some documents, and every re-run failed the same way. Sync now drops the managed indexes over a removed field before unsetting it (syncIndexes then builds the new __pk index), so the change applies with the documents kept. A collection an earlier run left half-applied completes on the next run — no manual step. This covers any removed field under a managed unique index. See Primary Key Changes.
  • pkChange.populated is set on run entries too. run() reported a populated table's rebuilt (or, in safe mode, skipped) key change as { from, to, rebuild } while plan() and error entries included populated: true; the run entry now matches the plan.
  • MongoDB safe mode leaves the indexes of skipped work alone. run({ safe: true }) reported a primary-key rebuild as skipped ('pk-rebuild') but index sync still replaced the __pk unique index with one on the new key; likewise a @db.sync.method 'drop' type change reported as skipped ('recreate') still retyped the present-only filter of a unique index over the column, so documents holding the old type left the index and their uniqueness was no longer enforced. Those indexes now stay as they are until the next run without safe applies the change. See Safe Mode.
  • MongoDB: a nullability or default change no longer recreates the collection. Making a field optional or required (or changing its @db.default) copied every document into a new collection, although MongoDB has no column constraint to change; in safe mode the change was reported as skipped ('nullable-defaults', entry pending) while index sync already applied the unique index's present-only filter. Now the snapshot is updated and the filter follows the field, in safe mode too, without a recreate. plan({ safe: true }) no longer lists 'nullable-defaults' for MongoDB tables, and they are not pending.
  • A foreign key dropped before a failure is named once. When a child retargeting its foreign key to a parent's new primary key failed later in its step (PostgreSQL / MySQL: a child row with no match under the new key), the error read Dropped foreign keys before the failure: parentRef, parentRef. The child's own step no longer drops that foreign key a second time.

For adapter authors ​

  • syncIndexes(opts?: TSyncIndexesOptions): in safe mode schema sync passes the indexes to keep as they are because the work they depend on was skipped — keepPrimaryKey (a skipped key rebuild; for an adapter whose key is an index) and keepColumns (columns whose 'drop' type change is skipped; for an adapter whose index definitions depend on the column type). Adapters whose indexes depend on neither ignore it.
  • Nullable / default changes need DDL only on an adapter with supportsColumnModify, or with recreateTable and getExistingColumns. A snapshot-based adapter (no getExistingColumns) only updates the snapshot, even when it implements recreateTable.

0.1.155 ​

Requires @atscript/core / @atscript/typescript / unplugin-atscript 0.1.105 (0.1.104 applies the primitive annotations below; 0.1.105 adds ctx.root for the validator plugin). 0.1.104 applies a primitive extension's built-in annotations to every field typed with it, directly or through a type alias: number.timestamp.created carries @db.default.now and string.required / boolean.required carry @meta.required (earlier releases dropped them on every plain field). A field that references another field (createdAt: Order.createdAt) still does not get them.

This release changes the SQL schema of tables with union fields, optional objects, number.timestamp.created fields or embedded @meta.id fields. Run planSchema before the first sync to see what it will do — What the first schema sync changes lists it per adapter.

New features ​

  • Ungrouped aggregates over HTTP. GET /query?status=paid&$select=sum(amount):revenue,count(*):orders — aggregates in $select without $groupBy — returns one row over the filtered set, as table.aggregate() does for $groupBy: []. It goes through the same gates as a grouped query (hidden and write-only fields, the row overlay, validateControls). With @atscript/db-client, pass $groupBy: [] to aggregate(). See Without $groupBy.
  • Client.metaStore in @atscript/db-client: the MetaStore a client revalidates /meta through (undefined with metaStore: false), read-only, so code that receives a client can clear its store on an identity change. The shared default store is exported as defaultMetaStore, so client.metaStore === defaultMetaStore tells it from a custom one. See The client's store.
  • Null tests on a whole object. { address: null }, { address: { $ne: null } } and $exists on an object field — | null, optional, a union of objects or a plain nested object — now work on every adapter and over HTTP (?address=null, ?address!=null); they were rejected for nested objects. The object counts as null when none of its fields holds a value — on MongoDB and the in-memory adapter that includes a stored {} or an object with only null fields (a native { address: null } would not match those). Over HTTP the test is accepted only while every field of the object is visible and takes $exists itself (none is @db.writeOnly or @db.encrypted). Other comparisons and sorting on the whole object stay rejected. See Null tests on an object.

Behavior changes ​

Union fields on SQL get their own column layout ​

On SQLite, PostgreSQL and MySQL a union field used to be one text column, NOT NULL unless the field was optional — so note: string | null rejected null, qty: number.int | null stored "5.0" and read it back as a string — and an object member added dot-named columns (address.street) that were never written. Now the layout follows the members (see Nullable and Union Fields):

  • T | null is a nullable column of T's type, with T's db.* annotations: qty: number.int | null → INTEGER / INT / REAL, paid: boolean | null → BOOLEAN / TINYINT(1) / INTEGER, tags: string[] | null → JSON, @expect.maxLength 10 code: string | null → VARCHAR(10) on PostgreSQL / MySQL, closedAt: number.timestamp.created | null → the now default of number.timestamp.created. Values read back with T's type. The members of any other union or tuple stay off the column as in 0.1.153.
  • Address | null is flattened like Address (address__street, …), every column nullable; a NULL row reads back as address: null.
  • A union of objects (payment: CardPayment | BankPayment, inline object literals too) is flattened with __, without a column for the union itself. A leaf only some members declare is nullable, and reads return only the stored member's fields. With | null, every column is nullable. Before, a union of objects without | null was already flattened, but every member's required leaves were NOT NULL, so no row could be inserted; with | null it was a text column plus dot-named columns.
  • A union mixing an object with another type (extra: Address | string) is one JSON column (JSONB / JSON / TEXT), like @db.json. Tuples are JSON columns too. A mixed union accepts only $exists filters, and paths inside it are rejected.
  • A required field inside an optional object is a nullable column. shipping?: { street: string } used to create shipping__street NOT NULL, so a row without shipping could not be inserted. This changes the schema of every table with an optional object that has required fields.

The first sync moves the stored values into the new layout — see Union columns: the values move.

Defaults ​

  • number.timestamp.created is a @db.default.now column, as documented. A field typed number.timestamp.created (or a type alias of it, or number.timestamp.created | null, also inside an embedded object) now behaves exactly like a field with an explicit @db.default.now: it gets the database default, it may be omitted on insert (also in db-client validation) and is then filled with the current time, and a replace that omits it stores the current time as well. Before, the field got no default, was required on insert and was never filled. An explicit @db.default on the field still wins. A field that references another one (createdAt: Order.createdAt), a member of another union (number.timestamp.created | string), a tuple item and an array element get no default. number.timestamp.updated was unchanged — it is set on insert and on every update since 0.1.156. See Defaults.
  • string.required / boolean.required reject blank strings and false again. On a plain field these types lost @meta.required in atscript 0.1.79–0.1.103, so name: string.required accepted ''. Writes that store an empty (or whitespace-only) string or false in such a field now fail validation (HTTP 400 through moost-db). Check stored data and clients.

Primary keys ​

  • An embedded object's @meta.id is no longer part of the host table's primary key. Only the host's own top-level @meta.id fields form its key. Before, a nested type with its own @meta.id (line: OrderLine where OrderLine declares @meta.id lineId, including an embedded @db.table type, a @db.json object or an array of such objects) silently added that field to the host's primary key: two rows with the same host id but different nested ids could both be stored, /one/:id and findById could not resolve a single id, and MongoDB's __pk unique index covered the nested paths. /meta.primaryKeys / preferredId now list the host's key only, and db-client follows it. Nothing changes for @db.rel.* navigation fields or @db.rel.FK references. See Primary keys.
  • A populated table's primary key is rebuilt when its rows allow it. Any change of the primary-key field set (not only the one above) on a populated table used to be refused. Sync now rebuilds it, rows kept, when the stored rows are unique and non-null in the new key columns, and refuses with the number of offending rows otherwise — see Primary key rebuild. A key column added in the same sync still makes a populated table's change refused.

Queries and writes ​

  • An aggregate $select without $groupBy on /query is no longer a 400. It used to fail the controls check (Value does not match any of the allowed types…). A validateControls override now receives such a request with controls.$groupBy set to []: a rule that blocks aggregates with if (controls.$groupBy) blocks it too, while one testing controls.$groupBy?.length does not — check for presence, not length, to forbid aggregates. prepareRequest runs earlier and sees the controls as parsed — no $groupBy there; put aggregate rules in validateControls.
  • /pages rejects aggregates with a clear message. $groupBy, or an aggregate in $select, on /pages is a 400 Aggregate queries are only valid on /query (errors[0].path is $groupBy or $select). Both were 400s before, with the generic controls-check message.
  • A unique index over a T | null field, or over a field inside an optional or | null object, is present-only on MongoDB and the in-memory adapter. Several rows without a value no longer conflict, as on the SQL adapters. MongoDB rebuilds such indexes with a partialFilterExpression on the first sync.
  • T | null fields can hold NULL for $nulls — a $nulls / @db.sort.nulls entry on one is applied (it was dropped as "not nullable"), and a unique index over one no longer counts as a total order for the primary-key tie-breaker, so the primary key is appended to such sorts.
  • Setting an optional or | null object to null in a patch clears its columns on the SQL adapters. It used to fail with an unknown-column error.
  • MongoDB replaces object values whole in patches. A @db.json object, a union of objects or a T | null object in a patch replaces the stored object, also under a @db.column name (a plain nested object keeps its @db.patch.strategy). Before, the update pipeline merged it into the stored object, so switching payment from a card to a bank payment kept card.
  • JSON string values round-trip on PostgreSQL and MySQL. A JSON column holding a string (a string member of a mixed union, or a @db.json field typed as a string) failed to parse on read, or came back as another type ("5" as 5). Mysql2Driver now reads JSON columns as text (jsonStrings: true, and in its typeCast) — pass jsonStrings: true to a mysql2 pool you create yourself, or a JSON string value read through it fails to parse or changes type.

What the first schema sync changes ​

Every table with a union field, an optional object with required fields, a number.timestamp.created field or an embedded @meta.id gets a new schema hash and is re-planned once.

  • PostgreSQL
    • Union and optional-object columns change in place: DROP NOT NULL, and a type change with ALTER COLUMN … TYPE … USING (TEXT → INTEGER, DOUBLE PRECISION, BOOLEAN, JSONB, geography for a db.geoPoint | null, …). Text that does not convert to the new type fails the sync for that table (an error entry) — clean such values first.
    • number.timestamp.created: DOUBLE PRECISION (TEXT for a number.timestamp.created | null) → BIGINT DEFAULT (epoch ms), converted in place (values keep their milliseconds; a fractional value is rounded).
    • A column whose model has @db.default.now or @db.default.uuid but whose live column has no DEFAULT gets one (ALTER … SET DEFAULT). Those defaults are left to the engine, so such a column rejected inserts that omitted the field. Other default additions still need a baseline, see Default Change.
    • A primary-key change is swapped in place (DROP CONSTRAINT …_pkey, ADD PRIMARY KEY).
  • MySQL
    • Union and optional-object columns change in place: MODIFY … NULL, and a type change with MODIFY COLUMN. Text that does not convert fails the sync for that table.
    • number.timestamp.created: DOUBLE (TEXT for a number.timestamp.created | null) → TIMESTAMP DEFAULT CURRENT_TIMESTAMP: the values are converted from epoch milliseconds through a temporary column (the column keeps its position). A plain TIMESTAMP holds whole seconds, so the stored milliseconds are truncated (1700000000789 reads back as 1700000000000) — exactly what a field with an explicit @db.default.now stores on MySQL for every write. To keep milliseconds, declare the precision before the sync that converts the column: @db.mysql.type "TIMESTAMP(3)" on the field (the conversion and later writes then keep them, and the default becomes CURRENT_TIMESTAMP(3)); see Fractional seconds. A stored value a TIMESTAMP cannot hold (before 1970-01-01 00:00:01 or after 2038-01-19 UTC, e.g. 0) fails that table's sync with the column left as it was — fix the rows and re-run. A key column is converted when it enters or leaves the primary key in the same sync; one that stays keyed refuses the table before any DDL.
    • A missing engine now / uuid default is added (MODIFY COLUMN), as on PostgreSQL.
    • A primary-key change is swapped in place (DROP PRIMARY KEY, ADD PRIMARY KEY).
  • SQLite
    • A nullability change rebuilds the table (data is copied), as before. A type change (TEXT → REAL) is a sync error unless the table declares @db.sync.method 'recreate' (or 'drop'); with 'recreate' the data of the kept columns is copied over.
    • number.timestamp.created: no DDL change (the value is filled by the SDK).
    • A primary-key change recreates the table with its rows; a demoted numeric key column no longer needs @db.sync.method 'recreate': the recreation applies its type change.
  • MongoDB and the in-memory adapter store nothing differently. Tables with a | null field get a new hash and re-sync once without DDL; MongoDB rebuilds present-only unique indexes (above) and its __pk unique index, which now covers the host's @meta.id alone — a table without a top-level @meta.id no longer has a __pk index or a primary key besides _id.
  • Views: managed views that read a union field are recreated (an object field becomes one view column per leaf), and a managed view that selects an embedded object containing a number.timestamp.created field gets a new definition hash and is dropped and recreated once (a materialized view is rebuilt).

Union columns: the values move ​

On the SQL adapters the old union column held the whole value as text — JSON for an object or array, the plain text for a string or number member. Before anything else on the table, the sync:

  • adds the new __ columns and copies each field out of the old column's JSON into them (json_extract / JSON_EXTRACT / #>>, cast to the column type), in rows whose new columns are still all NULL — so a re-run never overwrites newer values. plan() lists it as ~ address → address__street, address__zip — copy JSON values (entry.jsonCopies);
  • rewrites the column of a mixed union with a string member (extra: Address | string) as JSON before it becomes a JSON column: valid JSON is kept, any other text becomes a JSON string (plain → "plain", 42 Main St → "42 Main St") — so a string member whose whole text happens to be JSON (5, true) comes back as that JSON value. Listed as ~ extra — text values as JSON (entry.jsonified);
  • then applies the type and nullability changes and drops the old column and its dot-named columns (address.street, …), which were never written. Safe mode copies but drops nothing; the new columns stay nullable and without their @db.default (entry.skipped includes 'nullable-defaults', in the plan too) until a run without safe mode.

A value that cannot be moved refuses the table, nothing is dropped. Malformed JSON in the old column, or a value that does not convert to its new column's type, fails the copy: the table's entry is an error (JSON column copy failed on <table>: …), the old column and its data stay, and the next run retries. A field the copy cannot fill — an @db.encrypted or db.geoPoint field inside the object, or a MySQL TIMESTAMP / DATETIME column — refuses the table. A field with a @db.default is copied like any other: its column is added without the default (which would fill every existing row), and the default is set after the copy, so rows whose old value lacked the field keep NULL. A required field's column is added nullable for the same reason and becomes NOT NULL after the copy (a row whose value lacked it gets the type's empty value, as in any nullability change). The same copy runs when an object field loses @db.json. A column the stored snapshot knows as a plain scalar (a string field retyped to an object) is not copied; it is dropped as before. Fix the data, or annotate the field @db.json to keep it in one column (address becomes a nullable JSON column with its data, converted in place), or migrate it manually.

Primary key rebuild ​

Sync first checks, in one query, that the host's key columns are unique and non-null in the stored rows, then rebuilds the key as listed above. The former key columns change type with the key: a string one from VARCHAR(255) to TEXT (PostgreSQL, MySQL), a number one from INTEGER to REAL (SQLite). See Primary Key Changes.

Rows the old key allowed but the new one does not — several rows sharing a host id (different nested ids), or a host id that is NULL — make the run refused before any DDL: Primary key of "orders" changed (id, line__lineId → id) but 3 rows have a NULL or duplicate (id) — fix or remove them (or migrate manually) and re-run. Remove or fix those rows and re-run. Only if you need another way out, rebuild the key by hand and re-run the sync:

  • PostgreSQL: ALTER TABLE "orders" DROP CONSTRAINT "orders_pkey", ADD PRIMARY KEY ("id");
  • MySQL: ALTER TABLE `orders` DROP PRIMARY KEY, ADD PRIMARY KEY (`id`);
  • SQLite: create the table under a new name with PRIMARY KEY ("id"), copy the rows, drop the old table and rename the new one.

Fixes ​

  • Defaults of nested fields are applied inside their object. A @db.default / @db.default.now / @db.default.uuid on a field of an embedded object (audit: { at: number.timestamp.created }) used to be written as a top-level "audit.at" key, which failed validation (audit.at: Unexpected property) on every insert. It is now set inside the object when the object is present (an absent or null embedded object stays absent, see below), in every item of an array of objects, and inside @db.json values — where there is no column default, so the SDK fills now / uuid on PostgreSQL and MySQL too. Below a tuple or a union of several object types the item a default belongs to is not known when it is applied, so it is not filled — and such a field is required on insert like a field without a default, also when a client validates against /meta with @atscript/db/validator (db-client, forms).
  • An absent optional object stays absent on PostgreSQL and MySQL. An insert or replace without an optional or | null object (or with null) now writes NULL into the object's columns. Before, a column with a DEFAULT (audit?: { at: number.timestamp.created }) took it, and the row read back with audit: { at: <now> }. In the same way, writing one member of a union of objects writes NULL into the leaves only the other members declare, so no default adds them to the stored member. MongoDB, SQLite and the in-memory adapter already stored such rows without the object. See Nullable and Union Fields.
  • A column renamed in the same sync as a table recreation keeps its values. When a sync recreates a table with its rows (recreateTable: a type, nullability or default change on SQLite, a SQLite foreign-key or primary-key change, @db.sync.method "recreate"), a column renamed with @db.column.renamed in the same run lost its values — the rows were copied by column name before the rename. The rename now runs first.

For adapter authors ​

  • resolveDesignType() leaves null / undefined members out of a union (T | null → T's design type).
  • A field descriptor of a T | null field carries T as its type (with the field's annotations), so a type mapper sees T's tags; optional is true for every column that can hold NULL (inside an optional or | null object, or declared by only some union members). Nested-object adapters keep the field's own optional.
  • TableMetadata.isNullable(path) and presence(path) ("required" / "nullable" / "partial", type TPathPresence) answer nullability per logical path; objectLeaves(path) lists the fields a null test on an object reads (the filter reaches your adapter already rewritten to $exists on them).
  • Schema sync moves values out of the old layout through two new optional primitives: copyFromJsonColumn(source, targets: TJsonCopyTarget[]) and jsonifyTextColumn(column). Without them such a table is refused. SQL dialects built on @atscript/db-sql-tools implement jsonExtractText, jsonExtractValue and jsonFromText and run buildJsonColumnCopy / buildJsonifyText.
  • TFilterRef.nullTest marks a presence-only filter entry; guardPath(…, nullTest) and moost-db's FieldCapabilityIndex.check(…, nullTest) accept it on an object parent.
  • countKeyViolations lets schema sync check a populated table's rows before rebuilding its primary key; see Creating adapters.

0.1.154 ​

Fixes ​

  • A broken PostgreSQL connection no longer crashes the process. A pool connection killed while idle — a server restart, pg_terminate_backend, DROP DATABASE … WITH (FORCE), a failover or a network reset — made pg.Pool emit an unhandled 'error'. A pool PgDriver creates now logs it as a warning (to console, or a logger passed as the second argument: new PgDriver(uri, { logger })) and drops the connection; the next query reconnects. A connection checked out for a transaction is covered too. A pg.Pool you pass in gets no listener — add pool.on("error", …) yourself. See Broken connections.

0.1.153 ​

Requires @atscript/core / @atscript/typescript / unplugin-atscript 0.1.103. That release keeps the built-in annotations of a primitive extension used as a union / tuple member or an array element (number.int | null, string.email[], [number.timestamp.created, string]); see the validation and member-annotation entries under Behavior changes.

New features ​

  • lockConflicts for conflict-ignoring inserts — insertMany(rows, { onConflict: "ignore", lockConflicts: true }) inside your withTransaction locks the stored rows the batch collided with until the transaction ends, so you can update them next without a concurrent transaction doing the same in between. MySQL and PostgreSQL lock them with SELECT … FOR UPDATE in primary-key order (on MySQL: existing keys only, no gap locks); SQLite, MongoDB and the in-memory adapter accept and ignore it. A no-op outside a caller's transaction; INVALID_QUERY without onConflict: "ignore". See Ignore, then update.
  • Retryable lock errors — deadlocks (MySQL errno 1213, PostgreSQL 40P01) and lock wait timeouts (MySQL 1205, PostgreSQL 55P03) now throw DbError with code DEADLOCK / LOCK_TIMEOUT and retryable: true instead of the raw driver error, on every write; PostgreSQL serialization failures (40001) throw SERIALIZATION_FAILURE, also retryable. isRetryableDbError(error) is exported from @atscript/db, and moost-db answers all three with 503. Retry the whole transaction. See Retrying deadlocks.
  • /meta revalidation in @atscript/db-client — Client.meta() keeps /meta bodies in a store keyed by ETag, sends the ETags it knows for the URL as If-None-Match and reuses the stored body on 304. A new Client for a controller already loaded, or meta() after invalidateMeta(), costs a 304 instead of the full body. On by default with one store shared by all clients; opt out with metaStore: false or pass your own new MetaStore({ maxEntries }). Clients of one parametric mount can share a key (metaKey: "/api/tenant/:tenantId/todos"), so a new route param revalidates instead of downloading. Call clearMetaStore() next to invalidateMeta() on an identity change. Cross-origin APIs must send Access-Control-Expose-Headers: ETag and allow If-None-Match; without them the client behaves as before. See Revalidating /meta.
  • NULL placement in sorts — $nulls: { closedAt: "last" } puts null / missing values before ('first') or after ('last') every value of that $sort key, in both directions and on every built-in adapter; over HTTP it is a suffix on the sort key, $sort=-closedAt:last (also $order, $rowOrder, $with sub-sorts). The new field annotation @db.sort.nulls 'first' | 'last' sets the default when a query names none. Opt-in: a sort without either is unchanged, including PostgreSQL's NULLs-last-ascending. Entries on fields that cannot be null are dropped, so their index use stays free; the other placements cost index order (MongoDB runs the read as a pipeline). Grouped $sort and the first() / last() $rowOrder take it too. /meta.nullsPlacement advertises it and the serialized type keeps db.sort.nulls. Requires @uniqu/core / @uniqu/url 0.1.15. See NULL placement.
  • Client.metaEtag() in @atscript/db-client returns the ETag of the /meta body meta() resolved to, so derived data (deserialized types, validators) can be cached per ETag across clients. See Keying derived data by ETag.

Behavior changes ​

  • Validation tightening from atscript 0.1.103. Array elements, tuple items and union members of a primitive extension are now validated against it: emails: string.email[] rejects ["nope"] (emails.0: Invalid email format.), [number.int, string] rejects [1.5, "a"], n: number.int | null rejects 1.5. @expect.* on a nullable union (@expect.maxLength 50 name: string | null) now applies to the non-null value. Writes that used to store such values now fail validation (HTTP 400 through moost-db). Check stored data and clients before upgrading: rows written earlier are not re-validated on read, but a write that sends them back is.
  • An optional @meta.required field rejects null. null was the way to clear an optional column on PATCH; for @meta.required note?: string it is now a validation error (note: Must not be empty), on insert, replace and patch. Omit the field instead, or type it string | null if clearing it is valid. See Update One.
  • A union / tuple member's annotations stay off the column. With atscript 0.1.103 a member such as number.timestamp.created in createdAt: number.timestamp.created | null carries @db.default.now, and string.char in code: string.char | string carries @expect.maxLength 1. Only the prop's own annotations (and, as before, the non-db.* annotations of a named alias member) describe the column, so the DDL, defaults and indexes are the same as with earlier atscript versions — no schema change on sync. As before, such a field gets no DEFAULT, is not filled on insert and is not optional on insert. (Since 0.1.155 the one non-null member of T | null is the exception.)
  • Ties in $sort are broken by the primary key. Rows that tie on every $sort key now come back in primary-key order, in the direction of the last $sort key: $sort: { amount: -1 } reads as ORDER BY amount DESC, id DESC. The missing fields of a composite key are added in declaration order. Nothing is added when $sort already names the whole primary key, or every field of a unique index over required (non-optional) fields. It applies to findMany, findManyWithCount, findOne, an explicit $sort on search / vector search, and $with relation sorts, so /query, /pages and direct calls page consistently. Unchanged: grouped and aggregate reads, count(), relevance / distance order without $sort, and views without @meta.id on a view field. Tied rows may come back in a different order than before, and logged SQL / pipelines show the extra key; an index on (sortKey, id) serves both directions. See Ties and the primary-key tie-breaker.
  • Memory adapter ties. Ties used to be broken by primary key ascending, compared as text (10 before 9). They now follow the same rule as the other adapters: typed comparison, in the last $sort key's direction. Reads without $sort still return insertion order.
  • moost-db /pages without $sort is ordered by primary key ascending, so offset pages never overlap or skip rows. $search / $vector keep relevance order, a view without @meta.id stays unordered, and /query without $sort is unchanged.
  • Value-help /pages without $sort is ordered by primary key ascending too (unless $search is given), like the DB controllers' /pages. A custom AsValueHelpController.query() receives controls.$sort = { [pk]: 1 } there.
  • /meta revalidation retries If-None-Match after 5 minutes. When a conditional /meta request fails at the transport level (a CORS preflight refusing If-None-Match), the client used to stop sending the header for that store key until clearMetaStore(); it now does so for unconditionalMs (default 5 minutes, a MetaStore option) and then tries again.
  • A : suffix on a URL sort key must be first or last. $sort=name:asc (never a valid field) is now a 400 Malformed query string from the URL parser. moost-db's crud.query / crud.pages control lists in /meta now include nulls.
  • MySQL: an ignore-insert inside your transaction looks the keys up first. A failed duplicate INSERT leaves InnoDB's shared lock on the stored row until COMMIT, so two transactions that ignore-insert the same stored row and then update it deadlocked. When insertMany / insertOne with onConflict: "ignore" joins a transaction you opened, the adapter now runs the non-locking key SELECT first and inserts only the missing rows, in primary-key order, in one INSERT: an all-new batch costs two statements there instead of one. Calls outside a transaction keep the single optimistic INSERT. Delete app-side "pre-check, then insert" workarounds. See MySQL — Conflict-ignoring inserts.
  • Lock errors change type. Code that matched the driver error of a deadlock or lock timeout (errno === 1213, code === "40P01", …) now receives a DbError (code: "DEADLOCK" / "LOCK_TIMEOUT" / "SERIALIZATION_FAILURE"); over HTTP these answer 503 instead of 500.
  • Array $update / $remove items follow the plain-array validation. null (or undefined) on an optional field of an embedded-array item is now accepted in $update, as it already was when the whole array is set. Item errors name the item property (items.$update[0].qty, tags.$remove[1]) instead of the array field. $remove / $update on primitive arrays are checked per element, so array-level constraints (@expect.minLength, …) no longer reject them. See Patch operators.
  • Field operators inside array items are rejected. $inc / $dec / $mul inside an embedded-array item (plain set, $insert, $upsert, $update) now fails validation (HTTP 400). The operator object used to be stored as is.
  • View reads skip unused joins (MySQL, SQLite, MongoDB). A read of a managed view now skips the left joins it does not need — a provably at-most-one-row join that nothing in the read uses (filter, $sort including the primary-key tie-breaker and $nulls keys, projection, group keys). Results are unchanged; the SQL / pipeline differs (an inline copy of the view definition instead of FROM <view>), and a COUNT(*) or a narrow page no longer probes every joined table. PostgreSQL is unchanged (it removes such joins itself). See Unused joins are skipped.
    • Reads that skip a join use the definition generated from your .as model, not the one stored in the database — a view altered by hand is honoured only by reads that skip nothing.
    • Opt out with viewJoinPruning: false (adapter option, or db.getAdapter(View).viewJoinPruning = false for one view).
    • Such a read needs read access to the view's tables, not only to the view.
  • MongoDB: a composite @meta.id beside an explicit _id is unique only as a whole. With an explicit _id (@db.mongo.collection), a composite @meta.id is one __pk unique index. Each of its fields used to count as unique on its own, so a conflict-ignoring insert inside a transaction (and lockConflicts) skipped rows that shared just one of them. uniqueKeySets now lists the pair instead.

For adapter authors ​

  • supportsNullsPlacement() (default false): return true once your reads honour controls.$nulls and rowOrder[i].nulls; until then an explicit $nulls is rejected with INVALID_QUERY. See Creating adapters. SQL dialects built on @atscript/db-sql-tools set nullsPlacementSyntax: true when the engine has NULLS FIRST / NULLS LAST (without it the (col IS NULL) form is rendered); orderKeySql takes an optional placement, and nullsOrderSql / orderByList are exported.
  • insertManyIgnore(rows, opts?) receives TDbInsertIgnoreOptions (inCallerTransaction, lockConflicts). See Conflict-ignoring insert. Map your engine's deadlock / lock-timeout errors to DbError("DEADLOCK") / DbError("LOCK_TIMEOUT").
  • View read pruning: BaseDbAdapter.viewJoinPruning (default false; set it from your adapter option once your reads honour AtscriptDbView.readPlan(queryReadColumns(query, kind))). The @atscript/db-sql-tools read builders (buildSelect, buildAggregateSelect, buildAggregateCount, …) take a TSqlFromSource — a table name or a pre-rendered derived table from viewReadSource() — and SqlDialect.derivedMergeHint(alias) (optional) adds an optimizer hint to the SELECT reading it. See Unused joins are skipped.

0.1.151 ​

Requires moost / @moostjs/event-http 0.6.48 (prerenderJson, used by /meta HTTP caching).

Breaking: MongoDB indexes follow @db.column.collate ​

A plain or unique index over a @db.column.collate 'nocase' / 'unicode' field is now built with the collation a read filtering that field runs with ({ locale: "en", strength: 2 } / strength: 1), so collated reads use it. Before, every index was byte-wise and a collated read scanned the collection.

  • A collated unique index is case-insensitive ('nocase') or case- and accent-insensitive ('unicode'): "Ann@x" and "ann@x" now conflict. Stored values that differ only by case make the recreated index fail to build — the sync reports the failure and keeps the other indexes. Deduplicate them before upgrading: the sync then keeps the existing byte-wise unique index (and reports the duplicate key) instead of dropping it.
  • The first schema sync after the upgrade drops and recreates the affected MongoDB indexes (cost proportional to collection size). A field's non-binary collation is now part of the schema hash, so tables with a collated field re-sync once on every adapter; on the SQL adapters and the in-memory one that pass changes nothing, except that a managed view over such a field is dropped and recreated once (a full rebuild for a materialized view).
  • See MongoDB — Schema Sync Notes.

New features ​

  • SQLite statement cache — the better-sqlite3 driver reuses prepared statements (bounded LRU, default 256; statementCacheSize option, 0 disables). DDL and close() clear it.
  • Client.invalidateMeta() (@atscript/db-client) drops the client's cached /meta, the validator built from it and the loaded action forms. Call it on a reused client when the viewer's identity changes (login, logout, role change): /meta is projected per user. See Metadata.
  • estimatedCount option for MongoDB — new MongoAdapter(db, client, { estimatedCount: true | ["events"] }) (or createAdapter(uri, { estimatedCount })) answers unfiltered counts from collection metadata. Off by default. See Estimated counts.
  • sortRows(rows, $sort, tieBreak?, topK?) in @atscript/db-memory takes an optional topK.
  • metaHttpCaching() on the moost-db controllers (TDbMetaHttpCaching) configures or disables /meta HTTP caching, and stableMeta(obj) marks a memoized applyMetaOverlay / metaForm variant as reusable (serialized once, sent with an ETag). See metaHttpCaching.

Behavior changes ​

  • GET /meta is HTTP-cacheable. /meta and /meta/form/:name now send a weak ETag computed from the response bytes, Cache-Control: private, no-cache and Vary: Authorization, Cookie, and answer a matching If-None-Match with 304 Not Modified. A caller whose /meta differs (another role, after re-login) gets a different ETag and a 200. Opt out with metaHttpCaching() { return false }. Under NODE_ENV=test / development served /meta objects are deep-frozen: an applyMetaOverlay or interceptor that mutates them in place now throws. See Caching and revalidation.
  • moost-db action-row reads by id use { id: { $in: [...] } } instead of { $or: [{ id }, …] } when the ids have one single-field shape (a single id reads { id }). Tests that assert the exact filter of an action's row load, $actions scope check or query-target batch see the new shape. Composite, mixed-shape or non-scalar ids keep $or.
  • Stripped rows are copies. A row that loses a column read only for an action's requiredFields, or an unserved @DbDecorations key, is replaced by a copy in the rows array. decorateRows still sees and fills the rows that are sent; a reference to a row object kept from inside the hook may point at a row that is not sent.
  • Single-row writes no longer open a wrapping transaction when the write is one atomic statement: one root-level row, no guard/check, no nested navigation data, and native (or no) foreign keys. Statement logs and tests that count transactions see fewer BEGIN/COMMIT; MongoDB no longer opens a replica-set session for these writes. Multi-statement writes keep their transaction.
  • PostgreSQL: a TIMESTAMP (without time zone) column is read as UTC. PgDriver used to read its wall time in the process's time zone, so the same row came back as a different instant under a different TZ. It now returns the same epoch ms everywhere, matching how the MySQL adapter reads DATETIME. TIMESTAMPTZ and the BIGINT columns of @db.default.now are unchanged. If a non-UTC process wrote local wall times into such a column, those rows now read shifted by that offset. See Custom Type Parsers.
  • MySQL: fractional seconds round-trip on TIMESTAMP(n) / DATETIME(n) columns. A number field with @db.mysql.type "TIMESTAMP(3)" (or DATETIME(n)) is written with its milliseconds and read back with them; both used to be cut to whole seconds. Such a field without @db.default.now is now converted to a datetime string too (the raw number used to be sent and rejected). With @db.default.now, DEFAULT and @db.mysql.onUpdate render as CURRENT_TIMESTAMP(n), which MySQL requires for such a column (the bare form failed the CREATE TABLE). Plain @db.default.now columns stay TIMESTAMP with whole seconds, so no migration runs. @db.default.now with a non-date @db.mysql.type (such as BIGINT) now keeps the epoch ms number instead of sending a datetime string. See Fractional seconds.
  • PostgreSQL geo without schema sync in the process. A process that never runs sync (tables provisioned by another one) used to send db.geoPoint values as JSONB text even to a geography column. It now reads the geo columns' type from the catalog once, read-only, before its first statement that writes or filters a geo value, and sends EWKT to a geography / geometry column and JSON to a JSONB one — so a JSONB column created before PostGIS was installed keeps working until a sync migrates it. Until PostGIS presence is known, isGeoSearchable() reports true for a table with geo fields: the first geo search or $geoWithin learns it and fails with GEO_NOT_SUPPORTED when PostGIS is missing. See Geo Search.
  • Fewer statements per write on PostgreSQL and MySQL. These show up in statement logs and in tests that count statements:
    • An insertMany or insertManyIgnore that is a single statement (one chunk, plus one row for MySQL ignore mode) no longer opens its own transaction.
    • On PostgreSQL, a one-row insertManyIgnore chunk skips the SAVEPOINT.
    • On PostgreSQL, updateOne / deleteOne / replaceOne by an exact primary key run as … WHERE "id" = $1, without the LIMIT 1 subquery.
    • On MySQL, a multi-row insert of generated ids reads @@auto_increment_increment on the insert's own connection, without START TRANSACTION / COMMIT.
  • MongoDB vector search pre-filters. Conditions on @db.search.filter fields now go into the $vectorSearch stage's filter (they were applied after the top-k cut, so a filtered page could come back short). Conditions a pre-filter cannot express still filter afterwards. A vector index with several @db.search.filter fields now declares all of them (only the last one was), so the first sync updates the Atlas index once. $skip is now part of the top-k — a page after the first used to come back empty. See Pre-Filtering.
  • MongoDB @db.default.increment inside a transaction: the first allocation for a table now finds the highest existing value including rows the transaction itself inserted. The counter still advances outside the transaction (like a SQL sequence), so concurrent transactions don't conflict on it and a rollback leaves a gap.
  • MongoDB findManyWithCount (no relational predicate) runs a find and a countDocuments instead of one $facet aggregation — outside a transaction the two are separate reads, so a write landing between them can make the page and the total disagree, as on the SQL adapters. An unfiltered Atlas searchWithCount takes its total from $searchMeta.

For adapter authors ​

  • The core may now call insertMany([row]), replaceOne, updateOne / nativePatch outside a transaction for qualifying single-row writes; keep each of these a single atomic statement.
  • IntegrityStrategy.needsCascade(resolver, tableName?) takes an optional table name; cascade targets are cached per table and cleared when a table registers.
  • finalizeParams (@atscript/db-sql-tools) now leaves ? untouched inside string literals, quoted identifiers, comments and dollar-quoted bodies, and keeps the jsonb ?| / ?& operators. Write the bare jsonb ? operator as ??. InsertSqlCache is exported for adapters that build single-row INSERTs.

0.1.150 ​

Requires moost 0.6.46 (app.addInitHook) and @atscript/typescript 0.1.101 (annotationOverrides).

New features ​

  • canonical option for models served by several controllers — @TableController(Model, { canonical: true }) (or the trailing { canonical } constructor option of the value-help controllers) marks the mount whose route becomes the model's value-help URL. See Several controllers over one model.
  • @db.column.version.exempt — marks derived or reporting fields (scores, counters, caches): a patch that writes only exempt fields (updateOne / bulkUpdate without $cas, updateMany) leaves @db.column.version unchanged and adds no version check. See Version-exempt fields. table.versionExemptFields lists them; isVersionExemptPatch and TDbUpdateOptions are exported from @atscript/db. Nothing changes for tables that do not use it.
  • Numeric search through the existing annotations. @db.column.searchable (the $search fallback) and @db.index.fulltext (native search) now accept integer fields (number.int and its sizes, number + @expect.int, @db.default.increment). The fallback matches the term as a substring of the number's decimal text (2946 finds 29461277); a native index matches the whole number exactly, OR'd with the text match, and requires the integer member to be index-backed. Floats, decimals and timestamps are refused with a diagnostic. See Text Search — Integer fields and Search fallback.
  • $regex on an integer field is accepted on every adapter and matches the number's decimal text (also ~= over HTTP). See Value Types.
  • Exports from @atscript/db: searchTermInteger, splitFulltextFields, describeFulltext, defaultFulltextIndex, searchMemberKind, INTEGER_REGEX_OP; TDbIndexField.integer; SqlDialect.integerText in @atscript/db-sql-tools.

Behavior changes ​

  • The version no longer moves on every write: with exempt fields declared, only writes that change a non-exempt column bump it. Code that uses the version as a whole-row change token must account for that.
  • On MySQL and MongoDB, an exempt-only write of unchanged values reports modifiedCount: 0 (matched rows are still matchedCount). Detect a missing row via matchedCount.
  • $count honours a native $search / $vector term. GET /query?$search=x&$count=true used to count the whole filtered table on a natively searchable table; it now counts the matches (the @db.column.searchable fallback was already counted correctly).
  • An integer member of an existing @db.index.fulltext is now handled instead of failing or indexing garbage. PostgreSQL (coalesce(int, '')) and MySQL (FULLTEXT on INT) could not create the index, MongoDB's text index silently ignored the numbers and SQLite's FTS5 indexed the REAL rendering (29461277.0, so a term 0 matched every row). Integers are now left out of the text index and matched by exact number. The first schema sync after the upgrade recreates the affected SQLite FTS5 table and MongoDB text index (cost proportional to table size) and updates a static Atlas index once; tables without integer members are untouched. A fulltext integer member that is not index-backed now fails when the table metadata is built.
  • @db.index.fulltext on a float, decimal, timestamp or boolean field now fails — a compile diagnostic in the editor and a throw when the table metadata is built (pre-compiled JS included). It used to compile and boot (MongoDB silently ignored the field). Drop the annotation, or retype the field to an integer.
  • @db.writeOnly can no longer be combined with @db.index.fulltext or @db.column.searchable (compile diagnostic, and a throw at metadata build). A search hit would reveal the sealed value — for an integer member it is an exact-value oracle. Remove the search annotation from the field. moost-db also treats a write-only field as not visible in its index gate, so a custom index that reads one is refused.
  • Integer members belong to one index each, on every adapter. $search ORs only the integer members of the index it searches; on MongoDB this used to be every integer member of every fulltext index of the table (a hidden integer in a second index was matched when searching the first). A search naming no index uses the first fulltext index that has at least one text member (the first index if none has); an integer-only index declared first is no longer the default. On MongoDB an integer-only index is addressable with $index=<name>.
  • @db.mongo.search.dynamic plus any @db.writeOnly field now refuses $search (400) and reports searchable: false. A dynamic mapping indexes the sealed field. Switch to a static @db.mongo.search.text mapping that omits it; see Dynamic Atlas Search.
  • MongoDB: a second @db.index.fulltext name with text members throws at metadata build (the collection has a single physical text index). Merge the names; integer-only fulltext indexes may stay separate.
  • Native $count receives the same search controls as /query ($fuzzy, …), so on Atlas the count and the rows agree.
  • A table whose fulltext index holds only integer members is natively searchable, so its @db.column.searchable fields are no longer consulted (native search wins).
  • SQLite: $search terms are quoted for FTS5. -2946, a AND, title:x or a stray quote used to raise an FTS5 syntax error (a 500); they are now plain text. Words are still AND-ed, "phrases" and a trailing * (prefix) keep their meaning; AND / OR / NOT / NEAR and column filters are no longer operators.
  • $regex on a float, decimal or timestamp field still answers INVALID_QUERY; the message now reads a pattern match needs a string or integer field.
  • applySearchFallback stays overridable, but numeric IDs no longer need an override: declare the annotation on the integer field.
  • db.http.path is derived from each controller's own route, per app, after app.init(). The constructor no longer stamps it from whatever controller was being bound, so a controller constructed as another controller's dependency no longer publishes the wrong URL, and registration order no longer matters. /meta and /meta/form/:name carry the path resolved for the app serving the request; the model's runtime metadata mirrors the last app that published.
  • A model served on several routes needs a marker. Without one (and without a mount derived from the model's own @db.http.path), no URL is published for it and the app logs one warning naming the routes, so references to it render no picker (it used to be silently the last controller constructed). Mark the route pickers should use with canonical: true — @TableController(Model, { canonical: true }) (also @ReadableController / @ViewController), or { canonical: true } as the trailing constructor option of AsValueHelpController / AsJsonValueHelpController — or canonical: false on the others. See Several controllers over one model.
  • The root of a controller's own /meta carries that controller's own route, so a secondary mount answers with its own URL while references use the canonical one.
  • Routes with parameters or wildcards (/db/:tenant/issues) are never published. Prefixes are normalized (no //, no trailing /).
  • Decorators and assertExposed read the schema's own @db.http.path, not a path published earlier in the process, so a re-imported controller (HMR, tests) no longer mounts at /api/api/x and assertExposed in default mode audits only models that declare the annotation.
  • resolveMeta() is async in the base controller (every in-repo caller awaits it); a synchronous third-party caller must await. A subclass that overrides serializeForMeta and calls getSerializeOptions() directly keeps the single-app behavior (it reads the mirror).
  • Delete app-side repairs. A @MoostInit hook that rewrites db.http.path (for example with getHandlerPaths(…, "meta") and type.metadata.set(…)) is no longer needed: remove it, and mark multi-mounted models with canonical instead.
  • An app whose readable controllers are all FOR_EVENT publishes on the first construction or first /meta; one SINGLETON readable publishes at init.

For adapter authors ​

  • updateOne, updateMany and nativePatch take an optional trailing opts?: TDbUpdateOptions. With keepVersion: true, skip the version bump (never combined with expectedVersion). An adapter that ignores it keeps bumping. See Versioned tables.
  • Adapters that implement search() should honour TDbIndexField.integer (Creating adapters); until they do, an integer fulltext member is simply not matched by number.

0.1.149 ​

New features ​

  • controller.resolveQuery(q, { select, cap, scope }) — rows of a table controller matching a /query string or query-target envelope, resolved as its read for the current user inside your open transaction. Its hooks see no route params of yours (pass route-derived restrictions as scope) and its route guards do not run (authorize in prepareRequest). See From your own command.
  • Types TDbResolveQueryInput and TDbResolveQueryOpts are exported from @atscript/moost-db.

Behavior changes ​

  • A query target's $search is applied by the read's visibility (action and delegated query targets). A $search that can't be applied is now a 400 TARGET_INVALID ($search is not available here); before, it matched every row. /query, /pages and grouped reads are unchanged. See $search follows the read.
  • A @DbActionsFrom view's unmapped skipped ids list the identity fields the read shows (previously the delegating request's).
  • Docs fix: a query target's query is checked once, as a read (it was documented as checked twice).

0.1.148 ​

Requires @atscript/core / @atscript/typescript / unplugin-atscript 0.1.100 and @uniqu/core / @uniqu/url 0.1.13 (annotation-ref serialization in /meta; the arithmetic and first / last grammar, the $rowOrder control).

New features ​

  • Conflict-ignoring inserts — insertOne / insertMany with { onConflict: "ignore" } skip rows that collide on the primary key or a unique index and report them by input index: Skip conflicting rows. Over HTTP it is POST /?$onConflict=ignore (details), from the client insert(rows, { onConflict: "ignore" }). All bundled adapters support it; /meta lists crud.insert: ["onConflict"]. A custom adapter opts in with insertManyIgnore / supportsInsertIgnore (Creating adapters) and otherwise gets DbError("ON_CONFLICT_NOT_SUPPORTED") (400). prepareRequest receives ctx.onConflict on the insert endpoint.
  • DbSpace.close() — and await using db = createAdapter(…) — close a space and its connections: Closing the space. Pass onClose to the DbSpace constructor to let it close a driver you own; createAdapter() already does. moost-db gets closeDbSpaces() (details), which you call from your shutdown path (it is not hooked into Moost.dispose()). A custom adapter may implement dispose().
  • /meta.fields[*].groupable — true exactly when $groupBy on the field passes the gate: physically filterable (adapter, not @db.writeOnly, not @db.encrypted) and, on a table declaring dimensions / measures, a dimension. It is a capability flag independent of the filter policy (@db.table.filterable 'manual'). Clients use it to offer distinct-value pickers (Query Gate). $groupBy on a non-dimension of a strict table is now refused by the HTTP gate (400 … not a dimension.) like other capability failures.
  • Declared decoration columns — @DbDecorations. Declare the display-only fields decorateRows computes, with a requires list of the columns it reads: /meta lists them (fields[key].decoration, decorations), clients $select them, they are never filtered / sorted / grouped, and visibility follows their sources. Replaces the buildMetaResponse merge, validateControls and transformProjection stripping a decorated-read controller kept by hand: Declared decorations, Client<T, D>.
  • resolveRowIds(ids, ctx) — map a stale or alias id (for example a ticket key that was renamed) to the row's current id on every id-addressed endpoint: resolveRowIds, Resolving ids.
  • Arithmetic in grouped queries. { $fn: "sum", $expr, $as } aggregates a per-row expression and { $expr, $as } computes over the aliases of other entries (URL: sum(price*qty):revenue, expr(est/open):avg) — + - * /, unary -, coalesce, IEEE double, null propagating, division by zero null, the same semantics as @db.compute. Operands are numeric fields only. See Grouped Queries — Arithmetic expressions.
  • first / last with $rowOrder. Read several fields of one representative row per group — the oldest open issue per ticket: Representative row.
  • /meta gains aggregateExpressions, fields[path].numeric and first / last in aggregateFns; $rowOrder joins the query control list. The db-client types expression aliases as number | null and first / last as their field's type.
  • Plain findMany / query do not take expressions: declare a view with @db.compute for per-row computed values. There is no per-aggregate condition ($where) on a query-time aggregate: use a view aggregate or a separate query.

Behavior changes ​

  • Non-finite operands are rejected. $inc / $dec / $mul with NaN, Infinity or -Infinity fail validation (Field operation operand must be a finite number); a non-finite number as a filter value on a number / decimal field is INVALID_QUERY. They used to reach the database and poison the stored value or answer with an engine error. See Field operations and Value Types.
  • MySQL: strict mode is enforced per session by default. Mysql2Driver appends STRICT_TRANS_TABLES to the sql_mode of every pool connection it opens (the server's modes are kept). On a non-strict server (Amazon RDS for MySQL default) a missing NOT NULL value, an out-of-range number or an over-long string used to be coerced and stored, in plain inserts and in onConflict: "ignore" alike; they now fail. Check data pipelines that relied on the coercion, or pass new Mysql2Driver(uri, { strictMode: false }) (createAdapter(uri, { strictMode: false })) to keep the old behavior: Strict mode per session.
  • A closed space refuses work. After space.close(), getTable / getView / get / getAdapter and every operation on an existing table or view handle throw DbError("SPACE_CLOSED") (HTTP 503 in moost-db). Nothing changes until you call close().
  • createAdapter() spaces own their driver. space.close() ends the pool / client / file handle that createAdapter() created; before, nothing could. If you closed the driver separately you can keep doing so: the PostgreSQL, MySQL and SQLite drivers' close() is now idempotent, so a second close is a no-op instead of an error (Called end on pool more than once).
  • An overridden actionRowScope that restricts nothing no longer makes the action gate load rows — missing ids reach id-only handlers again (the 0.1.146 behavior). 0.1.147 loaded the row of a 'row' action (and checked the ids of an ungated 'rows' one) whenever the hook was overridden, so a missing id on a @DbActionID()-only handler got the gate's 404 before the handler, at the cost of a query per request; that change was not listed under 0.1.147. The gate now asks the hook first with the request's ids and loads only when the answer restricts (undefined, null and {} do not), folding the restriction into the one load: 1 query → 0 when unrestricted, 2 → 1 when restricted. See What the gate verifies.
  • actionRowScope at purpose: "execute" is asked before the load when there is no row overlay and the request's ids are in preferredId shape: ctx.ids are then the request's ids and may name rows that do not exist (ctx.loadRows omits them). With a row overlay, another identification shape or a query target the hook still sees the loaded candidates. A scope that assumed "every ctx.ids entry exists" should read rows through ctx.loadRows.
  • When resolveRowIds is overridden, onRemove and guardRemove receive the resolved id, @DbActionID() / useDbActionId() / @DbActionRow() the resolved ids, and error bodies (ActionDisabledError.ids) and summary().skipped / failed keep the ids the client sent. Not overriding it changes nothing. On DELETE the row overlay is now computed before onRemove runs.
  • A malformed computed $select item in a URL is a 400. The URL parser used to drop an item it could not read ($select=ticketId,count(), sum(), so the query ran without it and returned a different shape; it now answers Malformed $select item "…". Plain field names and every form that parsed before are unchanged. Fix a request that relied on the dropped item (the usual case is count() meant as count(*)).
  • The unknown-function message lists first and last: Unknown aggregate function "x" — use sum, count, avg, min, max, countDistinct, first or last.
  • Custom adapters: nothing changes for an adapter that keeps its defaults. first / last are not in the default aggregateFns() and arithmetic is off by default (supportsAggregateExpressions() is false), so such queries are refused with AGG_FN_NOT_SUPPORTED / AGG_EXPR_NOT_SUPPORTED before they reach aggregate(); opt in per Aggregate expressions. UniquSelect.aggregates never lists first / last (see firstLast).
  • PostgreSQL and MySQL: a numeric overflow in a grouped query is INVALID_QUERY instead of a 500: Arithmetic overflow (path $select) when the query has arithmetic expressions, Numeric value out of range (path "") otherwise.
  • min / max of a boolean or decimal field read back typed (true / false, a decimal string) instead of the driver's 0 / 1 or number, and on PostgreSQL min / max of a boolean render BOOL_AND / BOOL_OR (PostgreSQL has no MIN(boolean): the query used to fail).
  • Grouped $sort on a nullable alias, or a nullable group key, puts NULL first ascending and last descending on every adapter (PostgreSQL used to sort NULL as the largest value).
  • An ungrouped aggregate $count is the number of rows the data query returns: 1 (the single aggregated row, even over no input), or 0 when a $having filters it out. SQL counted the source rows and MongoDB answered 0 over no input.
  • Aliases starting with __as_ are reserved for the engine (INVALID_QUERY), a $select entry carrying both $field and $expr is rejected as malformed, and the expressions of one query are capped at 1024 expanded nodes together.
  • MongoDB: a plain sum over a group with no non-null value is null, as on SQL and memory (it was 0). This changes existing queries: sum(estimate) over a group where every document lacks estimate now returns null, and so does an expression over it. Treat null as no value, or coalesce it in an expression.
  • MongoDB: an ungrouped aggregate over no matching document returns one row (counts 0, everything else null), as SQL and memory always did — it used to return no row. $having and $skip apply to that one group, as on SQL.
  • The _id aggregate alias is rejected on every adapter (INVALID_QUERY, with the reserved __as_ prefix): _id is the group key of document stores, so a SQL query with { $as: "_id" } used to work and now fails. Rename the alias.
  • Memory: a scalar $eq / $in / $ne / $nin against an array field matches by containment, as in MongoDB — { labels: "bug" } matches ["bug", "ui"] ($ne / $nin are the negation), and null matches a missing field, a null and an array holding a null element. Tests that relied on an array never equalling a scalar change.
  • MySQL and PostgreSQL: a renamed @db.default.increment primary key is created with AUTO_INCREMENT / IDENTITY. A field with @db.column 'item_id' on an increment key used to get a plain integer column, so inserts without an id failed. Schema sync does not repair a table created by an earlier version: it has no baseline default to compare, and no change is detected. Fix such a table once by hand. PostgreSQL: ALTER TABLE t ALTER COLUMN item_id ADD GENERATED BY DEFAULT AS IDENTITY; then align the sequence, SELECT setval(pg_get_serial_sequence('t', 'item_id'), (SELECT COALESCE(MAX(item_id), 0) + 1 FROM t), false);. MySQL: ALTER TABLE t MODIFY item_id INT NOT NULL AUTO_INCREMENT; (use the column's real type; the counter continues after the largest id).
  • A $select naming a parent object keeps its @db.writeOnly leaves sealed — $select=secret over secret: { @db.writeOnly hash, salt } returns secret: { salt } (and likewise in a $with sub-select, /pages and /one); it used to return the write-only leaf too. A @DbDecorations requires naming a parent with a write-only descendant is refused at boot on every adapter.
  • PostgreSQL: first / last of a native array column (@db.pg.type 'x[]') picks through MIN over the array instead of returning NULL (or failing on a NULL / empty array).
  • MySQL: Mysql2Driver makes a pre-created pool strict as well: a connection opened before the driver was constructed gets the sql_mode statement the first time it is acquired.

Compile-time errors ​

  • @db.rel.FK on one column of a composite primary key (on a @db.table) is an error: @db.rel.FK target 'AttributeValue.value' is one column of a composite primary key — a foreign key must reference a unique column; for value help without a constraint use @ui.valueHelp. Such a schema compiled but failed at sync (PostgreSQL: no unique constraint; MySQL needs an index; SQLite: foreign key mismatch). A composite FK whose columns cover the whole key (one alias) and a column that is also @db.index.unique are accepted. Non-table hosts keep FK as the value-help indicator.

API ​

  • @DbDecorations(type, { requires }) with TDbDecorationsOpts; TDbDecorateContext.decorations (ReadonlySet<string>, empty without the decorator); TFieldMeta.decoration and TMetaResponse.decorations (@atscript/db); Client<T, D> (@atscript/db-client; D defaults to none). FieldCapabilityIndex (@atscript/moost-db) lists a declared decoration as a selectable-only virtual entry; groupSourceVerdict (@atscript/db) is the one $groupBy-key rule the core's strict-table check and the index share.
  • An exact decoration key in a filter, $sort, $groupBy, $having, aggregate or bucket answers 400 Field "x" is display-only…; a key whose requires are hidden answers Unknown field.
  • AsDbReadableController.resolveRowIds(ids, ctx) (protected); TDbRowIdInput, TDbRowIdPurpose ("action" | "available" | "one" | "remove") and TDbRowIdsContext are exported from @atscript/moost-db.
  • An app that rewrote action ids in prepareRequest (mutating the cached action body) or shimmed /one aliases: remove them and override resolveRowIds, returning each id unchanged when it already names a row or can't be resolved.

0.1.147 ​

Requires moost / @moostjs/event-http 0.6.43 and @wooksjs/event-http / @wooksjs/http-body 0.7.26 (withControllerContext, MoostHttp.invoke, seedBody).

New features ​

  • Candidate-aware actionRowScope(action, ctx) — the hook receives the candidate rows; see Scopes that depend on the candidate rows.
  • Query targets — run a 'rows' action on every row matching a query: Query Targets, actionOnQuery.
  • Actions on a view — @DbActionsFrom lists a table controller's row actions on a view controller: Actions on a View.
  • Relational filters: $some / $none. Select rows by their related rows — { ticket: { $some: { status: "open" } } }, { issues: { $none: {} } } — in reads, aggregate(), $with sub-filters and mutation filters, on every bundled adapter. See Queries § Relational filters. Over HTTP the form is ticket=$some(status=open) (URL syntax); a client may use it only on relations marked with the new @db.rel.filterable. Permission layers get transformRelationFilter and ctx.filter in prepareRequest; /meta.relations[] gains filterable: true.
  • Computed view columns and first-row joins — @db.compute columns and joins that pick one related row per entry: Computed Columns, First-row joins. Existing views keep their schema hash, so nothing is recreated on upgrade.
  • Hourly calendar buckets — unit: "hour", labelled YYYY-MM-DDTHH:00 in the bucket's time zone: Hour buckets.

Behavior changes ​

  • Filter values are checked against the field's type. A comparison value that cannot stand for the field's declared type — ?priority=high or { priority: { $gte: "abc" } } on a number, ?done=yes on a boolean, an ISO date on a number.timestamp, a fraction on an integer field (?qty>5.5 on a number.int) — now throws DbError("INVALID_QUERY") (HTTP 400, errors[0].path = the field) on every adapter: for table and view columns, $having, relational-filter operands and updateMany / deleteMany filters. It used to reach the database: PostgreSQL failed with a 500 (invalid input syntax), MySQL cast it ('x' compares as 0) and returned wrong rows, SQLite, MongoDB and memory returned no rows. Values of the field's type, numeric strings on number fields, numbers and booleans on string fields and null are accepted as before. See Value Types.
  • 'rows' actions validate ids in their gate interceptor. Every 'rows' action now reads and validates the body's ids in the action's interceptor, even without a row overlay or disabled — the same 400, but earlier: ahead of interceptors of a lower priority and before the handler's arguments resolve.
  • A query key in a 'rows' action body is a 400 TARGET_INVALID unless the action declares queryTarget (it used to be ignored).
  • actionRowScope runs after the body is read on the action route (it needs the candidate ids); prepareRequest and the row overlay still run before it.
  • A controller overriding actionRowScope loaded rows for every 'row' / 'rows' action (a missing id on an id-only handler got the gate's 404), whatever the hook returned. This was not listed here at release; 0.1.148 restores the earlier behavior for a hook that restricts nothing.
  • @DbActionsFrom views always return the idMap columns on reads, even when $select omits them (the client needs them to address the source) — unless the view's transformProjection drops them, which drops the delegation.
  • A hidden default text index is refused (under an overridden hasField): $search without $index answers 400 No search index available when the default text index reads a field the request can't see — it used to fall back to the substring search over visible fields — and /meta now prunes searchIndexes and turns searchable / vectorSearchable / geoSearchable off by the same rule. Tables without native search keep the fallback. A permission layer that pruned /meta itself through indexFieldPaths() can keep doing so (same result) or drop it.
  • The "no primary key" warning for an overridden actionRowScope is logged only when the controller has row actions of its own.
  • @db.rel.filter is applied. It was validated and documented but ignored at run time, so $with loaded every related row. It now filters $with on every adapter and counts in predicates — a relation carrying it returns fewer rows. Two forms that used to compile cleanly are now compile errors (also shown in the editor): a comparison between two fields (Post.a = Post.b), on any relation kind; and, on a @db.rel.via relation, a single top-level condition that reads both the junction and the related type (an or across them, or a parenthesized group mixing them — split it into top-level and conditions). Recompile (asc) to see them; metadata compiled with an older plugin that still carries such a filter fails every $with and predicate on that relation with INVALID_QUERY. See @db.rel.filter.
  • Application-level cascades with a relational predicate delete the rows they cascaded for (MongoDB, memory, adapters without native foreign keys). When the deleteMany / deleteOne filter (or its row scope) holds a $some / $none, the matching rows are read once, cascade / set-null runs for them, and exactly those rows are deleted by primary key — the filter is not evaluated a second time. A filter that depends on the children ({ issues: { $some: … } }) now deletes the parent instead of leaving it behind with its children gone. Such a delete on a table without a primary key is rejected with REL_FILTER_NOT_SUPPORTED. Deletes without a predicate are unchanged. See Referential Actions.
  • Foreign keys over @db.column-renamed columns. FK metadata now carries the physical column names, and DDL, foreign-key sync, the FK diff and the schema snapshot use them. A table whose @db.rel.FK field — or the field it references — is renamed with @db.column gets a new schema hash and re-syncs once after the upgrade, on every adapter including MongoDB and memory. On SQLite, PostgreSQL and MySQL such tables could not be created before (unknown column in foreign key definition); now they can. Tables without renamed FK columns keep their hash.
  • MongoDB: queries with $some / $none run without an operation-wide collation. A request collation (from 'nocase' fields) used to govern the whole pipeline — the related tables' join keys and fields included. Now each table's 'nocase' fields are compared case-insensitively per field ($eq / $ne / $in / $nin on strings), join keys and binary fields compare byte-wise, and in such queries a string range on a 'nocase' field or a string comparison on a 'unicode' field is rejected with REL_FILTER_NOT_SUPPORTED; $sort there is byte-wise. Predicate-free reads keep their collation. See MongoDB § Collation.
  • MongoDB $with follows the other adapters — see Native Relation Loading: renamed (@db.column) foreign keys and related fields load, sub-query filters are validated (an unknown field is an error, not an empty relation), a null foreign key loads nothing, and on via relations $sort / $skip / $limit / $select apply per parent row instead of per junction row. A @db.rel.to relation now resolves its table through the DbSpace like the generic loader: on a table built without one (new AtscriptDbTable(type, adapter)), $with of a to relation loads nothing instead of falling back to the foreign key's target name.
  • $with $skip / $limit apply per parent row on SQLite, PostgreSQL, MySQL and memory, as on MongoDB. They paged the related rows of all parent rows together — $with=issues($limit=1) gave the first parent one issue and every other parent []; now each parent row gets up to $limit of its own (from, via, and to relations, where a $skip of 1 or more loads null). A via relation's $sort is honoured — it returned junction order. A paged to / from relation is read with a ROW_NUMBER() window: SQLite ≥ 3.25, MySQL ≥ 8.0 (MariaDB ≥ 10.2). See Per-Relation Controls.
  • The 400 for a dotted navigation path (ticket.status=open) now also suggests ticket=$some(status=…).
  • moost-db: an Unknown field refusal from validateInsights — a hidden or nonexistent relation in a predicate, an unknown operand field, a $with sub-query path — now carries the validation envelope errors: [{ path, message }] like every other predicate rejection; same status and message. A string your own validateInsights override returns stays a bare message.

Fixes ​

  • A field path that crosses the same relation twice (parent.parent.status over a self relation, parent=$some(parent=$some(…)), $with=parent($with=parent(…))) answered 400 Unknown field over HTTP under the default hasField. Paths may repeat a relation; the nesting caps still apply.
  • moost-db: $count with a write-only field on a related table. The write-only $select seal also listed related tables' @db.writeOnly paths, so $count failed with Cannot select "ticket.code" — navigation path.
  • PostgreSQL / MySQL: foreign keys into another schema. REFERENCES named the target table without its @db.schema, so a foreign key into a table of another schema (PostgreSQL) or database (MySQL) could not be created. It is qualified now (TDbForeignKey.targetSchema).
  • PostgreSQL: tables without @db.schema follow the connection's current schema. Introspection (existing columns, constraints, indexes, foreign keys) assumed public; with a search_path pointing elsewhere every such table looked missing to schema sync. It now uses current_schema().
  • PostgreSQL: a @db.column-renamed primary key. updateOne / deleteOne and the RETURNING clause of insertOne / insertMany used the field name instead of the column name and failed.
  • Memory: a composite primary key with a @db.column-renamed part. Rows were keyed without the renamed part, so two rows differing only in it were rejected as duplicate primary keys.
  • MySQL: @db.view.having on a JSON-extracted view field named like a grouped column. HAVING referenced the field by its SELECT alias, which MySQL binds to the same-named GROUP BY column, so the view returned the wrong groups. Such a field now reads MIN(<extraction>) in HAVING; other views render the same SQL as before.
  • MongoDB: a @db.column-renamed @meta.id. The managed unique index of a non-_id @meta.id was built on the field name instead of the stored key, so the second insert failed with a duplicate-key error. Schema sync replaces the index with one on the stored key; a collection with such a field needs one sync run.

API ​

  • TDbRequestEndpoint gains "delegatedAction" (prepareRequest of a view's POST /delegated-actions/:name). An exhaustive switch over it with a never check needs the new case.
  • actionRowScope(action, ctx?) — ctx is optional in the signature, so super.actionRowScope(name) keeps compiling; moost-db always passes it.
  • TDbActionInfo gains owner, idMap, queryTarget ({ maxRows, url? }); TDbActionTargetSummary (@atscript/db) is new, with optional aborted, messages, message.
  • New protected hook queryTargetScope(action) — runs as a read; see Which rows.
  • POST {prefix}/delegated-actions/:name exists only on controllers declaring @DbActionsFrom.
  • moost-db recognizes its controller classes by a registered-symbol brand, so a controller class from a second loaded copy of @atscript/moost-db (an SSR bundle next to the installed package) is recognized too.

For custom adapters and filter tooling:

  • BaseDbAdapter.viewCapabilities() — default empty, so schema sync refuses a view with computed columns or a first-row join on a custom adapter until it returns them. See Creating Adapters § viewCapabilities.
  • ALL_BUCKET_UNITS now includes hour: a custom adapter returning it from calendarBucketUnits() must render hour labels, or return a set without hour. See Creating Adapters § Calendar buckets.
  • BaseDbAdapter.supportsRelationFilters(mode) — default false, so a custom adapter rejects predicates with REL_FILTER_NOT_SUPPORTED until it renders them. See Creating Adapters § Relational Predicates.
  • walkFilter dispatches a predicate to the visitor's new relation(field, op, operand) callback and throws when the visitor has none. Visitors you wrote only meet one once a filter contains a predicate — add relation to those that can.
  • @atscript/db-sql-tools: TFilterVisitorOptions.qualifier — required on statements that alias their FROM.
  • @atscript/db-mongo: buildMongoFilter throws on a predicate; mongoFilterStages, buildMongoQuery and planStages build the pipeline form.
  • New exports in @atscript/db: ResolvedRelationFilter, isResolvedRelationFilter (a Symbol.for brand, safe across two loaded copies), containsRelationFilter, forEachResolvedRelation, hasRelationOp, relationStaticFilter, andFilters, REL_FILTER_MAX_DEPTH (4) / REL_FILTER_MAX_NODES (16) — core limits, server-added predicates included; re-exported from @uniqu/core: RELATION_OPS, isRelationOp, RelationOp. In @atscript/moost-db: REL_FILTER_CLIENT_MAX_DEPTH (3) / REL_FILTER_CLIENT_MAX_NODES (8), the HTTP client budget. TRelationInfo.filterable, TDbRequestContext.filter (@atscript/moost-db, a deep-frozen copy of the client filter).
  • BaseDbAdapter.findManyPerPartition(query, partitionBy) — findMany with $skip / $limit applied per group of rows sharing the partitionBy columns; the generic $with loader reads paged relations with it. The default pages a findMany in memory; the SQL adapters use a ROW_NUMBER() window. See Creating Adapters § Native Relation Loading.
  • BaseDbAdapter.sharesStoreWith(other) — whether a related table can be correlated in one statement / pipeline: by default the same adapter class and the same _transactionOwner(); the MongoDB adapter also compares the database name. Two connections of one adapter class are different stores (REL_FILTER_NOT_SUPPORTED).
  • TDbForeignKey.physicalFields / physicalTargetFields — the physical columns of a foreign key (fields / targetFields stay logical). Custom adapters that render FOREIGN KEY clauses should use them, falling back to fields / targetFields when absent.
  • moost-db: the relational-predicate gate judges the client's $with tree as recorded before validateControls. An override that conjoins row scopes into $with entries must wrap the client's filter object, not copy or drop it — see validateControls.
  • @atscript/db-mongo: predicate temp fields are named __atscript_rf_<n> (REL_FILTER_TEMP_PREFIX); buildMongoQuery / mongoFilterStages / buildMongoFilter accept { collation }, buildAggregatePipeline / buildCountPipeline an optional filterOptions, and MongoAdapter.fieldCollation(field) reports a field's collation.

0.1.142 ​

Fixes ​

  • @atscript/db-client: derived columns are server-managed in preflight. The /meta type stripped the db.column.derived annotation, so the client validator built from it treated a derived column as an ordinary field: an insert / replace that left out a required derived field was rejected with a ClientValidationError (the server accepts it and computes the value), and $inc / $dec / $mul on one reached the server before failing there. /meta.type now keeps db.column.derived, and preflight follows the server's rules — see Client-Side Validation. /meta.fields[path].derived is unchanged. A controller that overrides getSerializeOptions() must keep db.column.derived itself to get the fix.

0.1.141 ​

Requires @atscript/typescript 0.1.95. A nav field typed with a table declared in the same .as file resolves its target through the reference the runtime records for it; 0.1.94 records one only for chain refs and imported types, so with it such a relation resolves to a table named after the type id (and loads nothing) instead of the physical table. Tables referencing each other across files are unaffected. Recompile (asc) after upgrading.

New features ​

  • Views over views. @db.view.for and @db.view.joins accept a @db.view (managed or external) as well as a @db.table — see Views over views. The compile error for a non-source type changed from Type 'X' must have @db.table annotation. to Type 'X' must be a @db.table or a @db.view. Sync ordering, cascade recreation and the new refusals are under Behavior changes.
  • Join aliases and self-joins through the new @db.alias annotation — see Join aliases. The two errors that rejected a second join of one table (… no join aliases / self-joins yet) now suggest an alias instead; the rule itself is unchanged.
  • Derived columns: @db.column.derived. A top-level table field typed as a chain reference into a @db.json field of the same table (customerId: Order.payload.customer.id) becomes a real column: a GENERATED ALWAYS AS (…) column on SQLite / MySQL (VIRTUAL) and PostgreSQL (STORED), and a query-time mapping to the source path on MongoDB and the memory adapter. It is filterable, sortable, groupable and indexable; a value supplied for it on a write is dropped, and $inc / $dec / $mul on it is a validation error. /meta.fields[path].derived is true for such a field, and safe mode gains a skip kind (entry.skipped may include 'derived' — a derived-column rebuild left pending). Nothing changes for existing tables: their snapshots and hashes are byte-identical, no column is touched. See Derived Columns and What gets synced → Derived Columns.
  • disabled predicates can say why. A predicate may return a string instead of true for a row: the action is disabled and the string is the reason. It becomes the 409 message, ActionDisabledError gains reason / reasons (server body and @atscript/db-client accessors), and $actions=true reads add $disabledReasons: { [action]: reason } to rows that have one. Boolean predicates and existing wire fields are unchanged. If your own code evaluates the /meta disabled string, test the result for truthiness, not === true. See Disabled reasons.
  • Editor support. With @atscript/core 0.1.94 and the Atscript VSCode extension, the db plugin scopes its own query, field-name and ref arguments: completion, hover, go-to-definition, find-references and rename work inside them, with the scope the diagnostics check (a chained @db.view.joins condition used to offer only the target and the entry). See Annotations → Editor support.
  • isDbEntityType(value) — the runtime test DbSpace.get applies, for filtering a module namespace before a sync — see Syncing a module namespace.

Behavior changes ​

  • Entity annotations stay on the interface that declares them. @db.table (and @db.table.*), @db.view / @db.view.*, @db.schema, @db.space, @db.http.path, @db.sync.method, @db.depth.limit and the interface-level adapter annotations (@db.mongo.collection / .capped / .search.*, @db.mysql.engine, @db.pg.schema) no longer travel across a reference. A nav field (customer?: Customer, orders: Order[]), a plain export type Admin = User and a @db.alias type used to carry the referenced table's db.table (and the rest) in their compiled metadata, which made each of them a second runtime entity of the same table: db.get(Admin) opened another AtscriptDbTable over users, the asc db sync discovery counted it, and a @db.alias was collected as a model. Only the declaring interface carries them now — db.get(Admin) would open a table named Admin (the type-id fallback); pass User. A @db.alias type listed in a syncSchema inventory is skipped. Recompile (asc) to pick the change up; stored data and schemas are untouched. See Annotations never travel across field refs.
  • @db.view.having is validated. Its field references must name the view's own fields, unqualified. A qualified reference (Order.amount) or an unknown field, which used to compile and fail at sync or query time, is now a compile error.
  • Schema sync orders views by dependency. Views that read views are created after the views they read (inventory order otherwise) and recreated when an upstream view is recreated (cascadeFrom, printed as · upstream view "x" recreated); removed views are dropped dependents-first, before the managed views whose definition changed (they used to be dropped after). Two new pre-flight refusals: a removed view a managed view still reads, and a view source that exists nowhere. Plan entries of views list the views they read — managed or external — in dependsOn (tables only before), and every entry carries cascadeFrom (empty unless cascaded). See What gets synced → Views that read views.
  • Existing views are untouched. A view without aliased joins or view sources hashes byte-identically and renders the same SQL / MongoDB pipeline — no view is recreated by the upgrade.
  • A @db.column-renamed version field works with optimistic concurrency, and versionColumn is its field name. For @db.column 'row_version' @db.column.version version: number.int, table.versionColumn and /meta's versionColumn returned the storage column (row_version), and every consumer keyed on it: a PATCH / PUT version was not lifted to $cas, so stale writes succeeded, and it reached the update next to the auto-bump (PostgreSQL and MongoDB answered 500, MySQL stored a version derived from the client's value); $cas: { version } was a 400, withOptimisticRetry could not run, and a version in an SDK patch passed the direct-write check. Both now report the field name (version); only the stored column is row_version. Tables whose version field has no @db.column see no change. If your code read versionColumn to address the physical column (raw SQL, a custom adapter), switch to the field-to-column mapping (columnMap) or, in an adapter, versionColumnPhysical. See Row Versioning.

Fixes ​

  • MongoDB: a unique-index violation on a patch or update is a 409. updateOne / bulkUpdate (the native patch path), updateMany and replaceMany let the driver's E11000 error escape — a 500 over HTTP — while inserts and replaces already threw DbError("CONFLICT") (409), as every other adapter does on all write paths. Every MongoDB write path maps it now.
  • Relation and foreign-key target names resolve a @db.view by its view name (tableNameOf). Relations still target @db.table interfaces only — the compiler rejects @db.rel.to / @db.rel.from to a view. (Corrected in 0.1.147: this entry used to say a relation could target a view.)
  • Document adapters: navigation properties are not columns. On MongoDB (and the memory adapter) the subfields of a @db.rel.to / @db.rel.from / @db.rel.via field (owner.name, orders.id) were listed in a new table's plan (columnsToAdd), although nothing is stored under a nav field — relational adapters never listed them. Schema sync now never adds, backfills or unsets anything under a nav path. The table snapshot still lists them, as before, so no stored hash changes and the upgrade triggers no re-sync.
  • Schema sync: a finished run no longer leaves its lock behind. The lock was released while a heartbeat refresh could still be in flight. On PostgreSQL the release then deleted nothing (see the next fix), so the next run() — on any pod — waited for the lock to expire and returned synced-by-peer without syncing; on every adapter, a refresh landing after the release could overwrite the next holder's lock. The release now waits for the last refresh, and every lock write is conditional: a pod refreshes or releases only its own lock and clears an expired lock only while it is still expired (two pods clearing the same expired lock could both run DDL). Other lock fixes:
    • A force: true run that had to wait for another pod's lock takes the lock and runs; it no longer returns synced-by-peer because the stored hash matched. Non-forced runs keep that answer.
    • Only a duplicate-key CONFLICT on the lock insert counts as "another pod holds the lock". Other insert errors (a lost connection, say) reject run() instead of being read as contention.
    • A waiter that loses a freed lock to another waiter waits again within waitTimeoutMs, instead of throwing Failed to acquire schema sync lock after waiting.
    • A failed lock release is logged with logger.warn instead of being ignored. The run's result is unchanged, and the lock row expires after lockTtlMs.
  • PostgreSQL: deleteOne no longer misses a row that is being updated at the same time. It picked the row by ctid. When a concurrent UPDATE of that row committed first, the row had a new ctid, and the delete silently removed nothing (deletedCount: 0). It now picks the row by primary key, as updateOne does since 0.1.128, and re-applies the filter, so a row updated out of the filter meanwhile is left alone. Tables without a primary key still use ctid.
  • PostgreSQL: a column added by sync gets its @db.pg.collate. ADD COLUMN applied the native collation only when the field also had @db.column.collate; CREATE TABLE always applied it. Both now render the same COLLATE clause.

API ​

For custom adapters and sync tooling:

  • The schema-sync lock now treats only DbError("CONFLICT") from insertOne as a lost race for the lock row, and it writes the row through updateMany / deleteMany with $eq / $lt filters. A custom adapter must throw DbError("CONFLICT") on a duplicate primary key, as the bundled adapters do; a raw driver error now rejects run() when two pods start together.
  • TViewJoin.scope — the name a join is addressed by: the physical targetTable, or the alias name of an aliased join (then TViewJoinSnapshot.table carries the physical table). AtscriptDbView.resolveRefSource(ref).table and TViewColumnMapping.sourceTable return that scope name — a custom adapter that renders joins must alias the physical table under it when they differ (JOIN "employees" AS "Manager"), as @atscript/db-sql-tools does. See Creating Adapters → Views.
  • tableNameOf(type) names a @db.view type by its view name (its type id before).
  • versionColumnPhysical on a table — the stored column of the @db.column.version field. versionColumn is now the field name (see Behavior changes); a custom adapter must bump, compare and default the version with versionColumnPhysical, since it works on rows already mapped to column names. See Creating Adapters → Versioned tables.
  • New exports: aliasTargetOf, isDbEntityType, getPath / deletePath (the core's dot-path helpers), TDbActionDisabledVerdict (@atscript/moost-db — a disabled verdict, boolean | string); DbSpace.get / getTable / getView throw for a @db.alias type.
  • Nav subfields on document adapters are ignored descriptors: fieldDescriptors still declares them, columnDescriptors / storedDescriptors leave them out, and so does snapshotToExistingColumns(snapshot, readable).
  • Derived columns: TDbFieldMeta.derived / TDerivedColumn (sourcePath, sourceColumn, jsonPath, type), TExistingColumn.generated (adapters report generated columns), TColumnDiff.derivedChanged / TDerivedChangeReason, computeColumnDiff(desired, existing, typeMapper, { snapshot }), SyncEntry.derivedChanges / TSyncDerivedChange, TReadControls (the controls a field mapper's reconstructFromRead / reconstructRows receive), and derivedColumnExpr in @atscript/db-sql-tools. A readable exposes columnDescriptors (what schema sync diffs — on document adapters without the derived fields) and storedDescriptors (non-ignored, non-derived — what a recreate copies and a full replace assigns) next to fieldDescriptors. What a custom adapter must do is in Creating Adapters → Derived columns.

0.1.140 ​

MySQL ​

  • Schema sync no longer converts data silently on a server that is not strict. Its converting statements (MODIFY COLUMN, the NOT NULL backfill, the primary-key rebuild, the @db.sync.method 'recreate' copy) now run with STRICT_ALL_TABLES added to the session sql_mode. On a server that is not strict — Amazon RDS for MySQL defaults to NO_ENGINE_SUBSTITUTION — a string → number change used to turn 'abc' into 0, and lowering @expect.maxLength truncated stored text, while the sync reported success. Both now fail the sync as an error entry and leave the data as it was. Clean or migrate values that do not convert before changing a column's type. Strict servers see no change. See MySQL → Conversions are strict.

0.1.139 ​

MongoDB ​

  • @db.sync.method 'recreate' never puts the documents at risk. The new collection is built under a temporary name, filled server-side, and swapped in with one atomic rename; a failed step drops the temporary collection and leaves the original untouched. Before, the original was dropped first, and a failure left the documents only in a <name>__tmp_<ts> collection, with no error naming it — see MongoDB → Capped Collections.

PostgreSQL ​

  • Sync fix: foreign keys of two tables that share a constraint name are no longer mixed up. Constraint names are unique per table, not per schema. Sync read foreign keys by name within the schema, so two tables with a same-named foreign key had their columns merged: the FK diff saw columns from the other table, and a @db.sync.method 'recreate' of the referenced table failed or restored a wrong constraint. Constraints are now read per table from pg_catalog. Names that sync generates (<table>_<cols>_fkey) only collided after truncation to 63 characters; hand-named constraints could collide freely.

0.1.138 ​

Memory ​

  • Sync now runs the pre-flight name checks. The memory adapter implements getObjectKind, so a sync that declares a managed view where a table sits (or a table where a view sits) is refused, as on SQL and MongoDB. Before, the clash went unnoticed.
  • A foreign key to a table that is not in the space is refused. A @db.rel.FK whose target is neither in the sync inventory nor already in the space's in-memory database fails pre-flight (FK <table>.<cols> references "<target>" which is neither in the sync inventory nor present in the database). Before, the check was skipped. Sync the target table in the same run, or sync it first in the same DbSpace — see Memory → Schema Sync.

MySQL ​

  • A failed @db.sync.method 'recreate' no longer leaves a <table>__tmp_<ts> table behind. When the copy fails, the temp table is dropped and the table keeps its rows. Before, every failed attempt left one; drop any you find from earlier runs. A failure after the original table was dropped keeps the temp table, because it holds the only copy of the rows, and the error names it (… its rows are in "<table>__tmp_<ts>") — see What gets synced → Copy and Swap.

SQLite ​

  • @db.sync.method 'recreate' runs in one transaction. A step that fails rolls the whole recreate back: the table keeps its rows and its full-text and vector indexes, and no <table>__tmp_<ts> table is left behind (before, one was, and the indexes were already gone).

0.1.137 ​

MongoDB ​

  • A @db.column on a nested field is ignored on MongoDB. Documents rename top-level keys only, and the nested field was always written at its logical path (address.zip), but filters, $sort, $groupBy, indexes and view pipelines addressed the @db.column name as a top-level key (zip_code) — they matched nothing, sorted by nothing, and views read an empty column. Every path now uses address.zip, so these queries return rows for the first time. Stored data needs no migration. SQL adapters are unchanged (address__zip_code) — see MongoDB → Renamed Fields.
  • The first sync after the upgrade reports such a field once as added (address.zip) and its old name (zip_code) as removed, and recreates an index declared on it on the stored path. Outside --safe mode the removal unsets a top-level zip_code key — only an updateMany merge patch on 0.1.136 could have written one.
  • Sync safety fix: the default backfill no longer overwrites stored values. Adding a field with a literal @db.default used to write the default into every document; it now fills only documents that don't hold the field — which also keeps the stored address.zip values above intact. See What gets synced → Add.
  • Every managed MongoDB view is recreated once on the first sync after the upgrade (plan() / --dry-run shows each as an alter), so existing views pick up the fixes below. Views are metadata-only on MongoDB — no data is copied. External views are untouched.
  • Fields under a renamed top-level object resolve to their stored path everywhere. For profile.bio under @db.column 'prof': an index declared on it now covers prof.bio — the first sync recreates it (before, it indexed the non-existent profile.bio); updateMany merge patches write prof.bio; and $inc / $mul on a renamed field update its @db.column key (before, both wrote a stray key under the logical name). The first sync also reports prof.bio added and profile.bio removed once — the removal unsets a path that holds no data, and the backfill never overwrites prof.bio.
  • View filters with a field-to-field comparison may return fewer rows. `Item.qty > Item.cap` in @db.view.filter / @db.view.having used to match documents where either field is null or missing (a missing value compares below every number); it now excludes them, as on SQL — see MongoDB → Views. Add an explicit or Item.cap not exists where you relied on the old result.

Memory ​

  • Sync fix: a model removed from the schema is really dropped. Sync reported the table as drop but kept its rows, and a model added back was reported in-sync and served them again. The table's rows, unique indexes and increment counter are now deleted; added back, it is reported create and starts empty, as on SQL. Removed views are dropped too. See Memory → Schema Sync.
  • Tables are keyed by name within a DbSpace. Two compiled types that name the same table now share its rows, as on a real database; before, each type had its own store. A new space still starts empty.

PostgreSQL ​

  • Sync fix: @db.sync.method 'recreate' handles a required column's type change. The copy step failed with invalid input syntax for type double precision: "" (number → string), so the table could never be recreated. Changed columns are now converted to their new type the way an in-place type change converts them. A value that does not convert fails the sync inside the transaction, and the table keeps its rows. See PostgreSQL → Column Type Changes.
  • Shrinking a VARCHAR / CHAR column fails instead of truncating. Lowering @expect.maxLength below a stored value used to cut the value silently; the sync now fails with value too long for type character varying(n). Shorten the data first.

Custom adapters ​

  • New optional capability viewRenderRevision() — bump it to have sync recreate your adapter's managed views once.
  • A missing drop primitive is an error, not a silent skip. dropTableByName and dropViewByName are no longer optional: the BaseDbAdapter defaults throw … is not supported by this adapter. Before, DbSpace skipped the drop when the adapter lacked the method, and sync reported the table or view dropped. Sync now reports it as an error entry and keeps it tracked. All built-in adapters implement both.
  • New hook registerSpace(space). DbSpace calls it on every adapter it builds. It does nothing by default; override it to share state across a space's adapters.

0.1.136 ​

Managed views are recreated once ​

  • Every managed view is recreated on the first sync after the upgrade. The view definition now includes each column's physical source and its aggregate, so every stored hash changes. plan() / --dry-run shows each view as an alter on that run. Plan for the costs listed in What gets synced → Views.
  • MongoDB inner joins are now inner. Before, every @db.view.joins kept unmatched documents on MongoDB (left-join behavior), while SQL adapters dropped them. Add 'left' as the third argument where you relied on the old MongoDB behavior — see Joins.
  • Invalid view predicates fail at sync. Join conditions and filters with in, not in, exists or not exists used to render wrong SQL silently; they now render correctly. matches in a view predicate on SQL, and any predicate that can't be rendered, now fails the sync instead of creating a broken view — see View Filters.
  • A view ref to a @db.ignore field or a navigation relation fails at sync (… has no column — "x" is @db.ignore or a navigation relation) instead of producing a broken column.
  • View sync refuses aggregate functions the adapter doesn't declare. A @db.agg.* field whose function is missing from the adapter's aggregateFns() fails the sync. Every built-in adapter declares all six functions; this affects custom adapters only.
  • MongoDB @db.view.filter / @db.view.having use query semantics. exists now means "holds a value" (a stored null counts as absent) instead of key presence, not exists works, and matches accepts /pattern/flags — see MongoDB → Views.

Query results ​

  • MongoDB count(field) no longer counts documents where the field is missing — it now matches SQL COUNT(field), which counts only values that are present and not null. See Aggregate functions.
  • The default aggregate alias over a @db.column-renamed field is the logical name. sum(amount) over @db.column 'amount_cents' used to return sum_amount_cents, and a $having or $sort on sum_amount did not match it. It now returns sum_amount on every adapter. Update clients that read the physical-name key.

Types and APIs ​

  • Compiled db.agg.* metadata is { field?, condition? }. It used to be a string (the field) or true (bare @db.agg.count). The runtime reads every shape, so models compiled by older versions keep working. Update any tooling that reads the metadata directly — see Conditional Aggregates.
  • TDbAggregateFn (@atscript/db/agg) includes countDistinct. A Record<TDbAggregateFn, …> lookup table in your code needs a countDistinct entry.
  • AGG_FN_SQL (@atscript/db-sql-tools) is keyed by Exclude<TDbAggregateFn, "countDistinct">. It still maps sum, count, avg, min and max, but indexing it with a plain TDbAggregateFn no longer type-checks. countDistinct has no function name to map — render it as COUNT(DISTINCT x) yourself.
  • resolveCalendarBuckets is deprecated. Use normalizeComputedSelect, which has the same signature — see Calendar buckets for adapter authors.
  • Class-level actions can declare an input form. @DbActions / @DbRowActions / @DbRowsActions entries take inputForm: a compiled .as type, or { name, url } for a form served by another controller. Any other shape is a type error; JavaScript callers that pass one get the entry dropped with a warning. See Input forms on class-level entries.

Released under the MIT License.