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 withcontrols.$rank: false($rank=falseover HTTP) computes no relevance score on any adapter; rows come in$sortorder, or primary-key order without one. Use it instead of a placeholder$sortwhen your app orders the matches itself.vectorSearch()andgeoSearch()reject it withINVALID_QUERY.$rankis now in thecrud.query/crud.pagescontrol lists of/meta. See Unranked search. - SQLite: a tokenizer per language. The
ftsTokenizersadapter option gives a@db.index.fulltext.languageits FTS5 tokenizer, such as a Snowball stemmer loaded withloadExtensions. See Tokenizer per language. - SQLite: FTS5 prefix indexes. The
ftsPrefixadapter option ([2, 3]) builds FTS5 prefix indexes for fasterword*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
runcan return{ note }; the note is stored with the step's record and reported inresult.dataSteps.notes. A step withonError: "warn"that fails is logged, listed inresult.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/syncthat 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;mapmust be idempotent. See Rewrite stored rows.- PostgreSQL: a text search configuration per language. The
textSearchConfigsadapter 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(andparams) on the envelope and on its entry:UNKNOWN_FIELD,UNKNOWN_RELATION,FIELD_NOT_PERMITTED,FIELD_WRITE_ONLY,EXPECTED_OBJECT,INVALID_QUERY,ACTION_FORBIDDEN,VERSION_MISMATCHand others. A translator registered withsetTranslatorlooks them up by code instead of by English text; the Englishmessageis unchanged. The 409 version mismatch keepskind: "version_mismatch". Errors that were a bare{ message }(a refusal returned by yourvalidateControlsorvalidateInsightsoverride — its entry iscustomwith no top-levelcode, so a translator still finds the text by itself — a mixed$select,$withwith$groupBy, a geo control, theoneendpoint, the 403 of an action) are now the usual envelope witherrors. The core's query errors (DbErrorINVALID_QUERY) code their entries the same way, and an unknown$withrelation in a direct table call is now aDbErrorINVALID_QUERY(it was a plainError): 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 nowbadRequest(path, message, code, { params?, top? }); the top-level message moved into the options. Code that calledbadRequest(path, message)no longer compiles: pass a code ("INVALID_QUERY", or one of your own registered withregisterMessages). For a hidden field use the newunknownFieldError(path).- Arithmetic with more than two arguments is refused. A hand-built expression node such as
{ $op: "+", $args: ["a", "b", "c"] }is nowINVALID_QUERYon every adapter; before, a direct call dropped the third term silently (MongoDB summed it). Nest longer chains, or build them witharithAdd()/arithMul()from@uniqu/core0.2.2. See Aggregation. ARRAY_OP_ON_JSON. An array patch operator on a@db.jsonfield now fails with this code (server and db-client preflight) instead ofTYPE_ARRAY"Expected array". Send the whole array. See JSON fields.- Contention errors are retryable
DbErrors. A deadlock, lock timeout or serialization failure raised atCOMMIT, or by a plain read, now surfaces as aDbErrorwith codeDEADLOCK,LOCK_TIMEOUTorSERIALIZATION_FAILURE(retryableistrue) on PostgreSQL and MySQL, as it already did on writes; SQLite mapsSQLITE_BUSYatCOMMITtoLOCK_TIMEOUT, and MongoDB maps a transaction that ran out of its transient-error retries toSERIALIZATION_FAILURE. Before, the raw driver error escaped (40001,ER_LOCK_DEADLOCK, aMongoServerError), so a retry loop keyed onretryablenever retried. The driver error is kept as theDbError'scause. Code that matched the raw driver error, such aserror.code === "40001", should checkerror.retryableor theDbErrorcode instead. - A
COMMITthat fails on the client discards the connection. A PostgreSQL or MySQLCOMMITthat 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.TPgConnectionhas an optionaldestroy()for this; a custom driver without it gets a best-effortROLLBACK, thenrelease(). SQLite keeps its transaction gate closed until the rollback after a failedCOMMIT. - A translated schema always carries
$i18n. A translated/metatypeor/meta/form/:nameschema now has$i18neven 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 === falsereachessearch()/searchWithCount(): compute no score and order by$sort, else the primary key. The core rejects it beforevectorSearch()/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
loadRelationsthrowsunknownWithRelation(name)for a$withname the table lacks, and an expression renderer of your own guards each node withassertExprArity()/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
DbErrorcodes on every statement and atCOMMIT, keeping the driver error ascause(new DbError(code, errors, message, { cause })). See Transaction Support.
0.1.174
Fixes
result.dataSteps.pendinglists only unrecorded steps. It now holds the passed steps that are still not recorded after the run, the ones a later sync runs, asplan()'spendingDataStepsdoes. An'up-to-date'run reportspending: [], and a run that resumes after a failed step no longer lists the steps an earlier run recorded. A'synced-by-peer'result now carriesdataSteps({ 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
rannorpending). 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,pendinglisted every step the call did not run itself, so a check forpending.length === 0failed 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, orMongoBulkWriteErrorfrom an insert, with the code on the error or only on its write errors). Before, it checked the error's class. When theMongoClientcame from another installation of themongodbpackage than the adapter's own (the app's own dependency, a linked workspace), the driver's rawMongoBulkWriteErrorescaped instead ofDbError("CONFLICT"): a write answered 500 instead of 409, and two instances racing for the schema-sync lock failed withE11000 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,lockedAtandexpiresAt, as a write retried after its acknowledgement was lost leaves it), the run holds the lock. Before, it waited for its own lock untillockTtlMsexpired. A lock that an earlier run left under the samepodIdstill blocks. Outside a transaction a data step's record is checked the same way, and it now holds the recording run'spodId. - 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. Aword*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 ofmodestores it — the type's@normalize.*applied, copy-on-change, nothing validated. See Normalization.notFoundError()andMOOST_DB_MESSAGESfrom@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
404answers{ "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/:nameand 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 answeredUnknown form "<name>"/Unknown action "<name>". Branch on the status or oncode, not on those texts. A permission layer that refuses a row itself should thrownotFoundError(), so its answer matches a missing row byte for byte. See Not found. onWritereceives the normalized body. The body is passed in with the type's@normalize.*applied, asguardWriteandcheckWritealready 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'snormalizePayload(payload, mode)for it, so a custom stand-in forAtscriptDbTable(a wrapper or a test double) must implement that method too. See onWrite.- Column defaults are normalized. A
@db.defaulton a field with@normalize.*now becomes the normalized value in the columnDEFAULTthat 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--safeleaves 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
--safedrops the removed table first, then changes the type. A--saferun keeps that table, so the type change is now skipped and pending (skippedincludes'retype',typeChanges[].waitsFornames 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
validateRowskeeps codes. Each problem now carries thecodeandparamsthat the same write's error carries: the validator's codes (customfor your own annotation text),DEPTH_EXCEEDED, andFK_MISSINGfor 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 andRESTRICTchecks, 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 anddisabledreasons are served in the request's language (anETagper language; opt out per controller withmetaTranslation()), and the db-client gets alocaleoption. See Translations (i18n). @db.defaulttakes a number or a boolean.@db.default 0,@db.default true,@db.default 9.99next 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 UPDATEprecision.@db.mysql.engineand@db.mysql.charsettake 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.uppercaseproduce, equality filters and ids normalize their operand, and the db-client preflight sends the normalized data (see the behaviour change below). New exports:normalizeWritePayload()anddbValidatorOptions()from@atscript/dband@atscript/db/validator,ClientValidator.normalize()from the client. See Normalization.- Action-form texts on the client.
client.getActionFormTexts(name)returns the$i18nan action form was served with, from the same cached response asgetActionForm(). See Form-schema discovery. - Any Atlas Search analyzer. The
analyzerof 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$havingcompare 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/ABClook for"abc". A patch, replace ortouchManyaddressed by that key matches 0 rows (PATCHanswers 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.vialinks 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:
typescriptimport { 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.columnand flattened nested objects use their physical names in the adapter.
MySQL:
@db.default.uuidstrings areCHAR(36), as the type mapping always stated; they wereVARCHAR(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.CHARdrops 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
failedwhen an entry errored. A run that applied what it could but left anerrorentry (a failed DDL statement, a unique index the stored rows break, a blocked drop) returnsstatus: "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.viatarget, 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
PUTarray,$replaceor$upsertentry 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 answersCONFLICT;$updateof 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.FKreferencing the table answersINVALID_QUERY; before, it was dropped silently. The usual cause is a@db.tablethat extends another table and inherits its relations — the compiler now warns on it.Aggregate alias collisions are rejected (
@uniqu/core0.2.1). Two$selectentries with one alias (also twocount(*)), an aggregate alias equal to a selected field, or a$groupBykey that is an aggregate alias answerINVALID_QUERYwithcode: "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 asDB_VALIDATOR_MESSAGES) and theparamstheir message names, instead ofcode: "CUSTOM"withcustom: true. The English messages are unchanged. See db validator codes.db-client preflight error entries carry
codeandparamslike the server's entries.Every computed-
$selectissue keeps its code over HTTP. A rejected$select/$groupBy(bucket, expression or alias rules) answers every issue the core finds, each with itscode(AGG_ALIAS_COLLISION, …), and the envelope'scode: "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$groupBykey isAGG_NESTED_DUPLICATE_COLUMNat$from.$select), then the inner level's computed-$selectrules, under the$from.path prefix. A$casrejected byPUT/PATCHalso carriescode: "INVALID_QUERY".
Fixes
- Nested FROM children of a parent with a database-generated string key (a native
uuidprimary 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 answeringINVALID_QUERY/FK_VIOLATION. - A
decimal@db.defaultis 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.onUpdateon a renamed (@db.column) or nested column renders itsON UPDATEclause; 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.FKto a string field other than the primary key (a uniqueslug, without@expect.maxLength) failed the sync of both tables (BLOB/TEXT column … used in key specification without a key length, thenFailed to open the referenced table). A referenced string field and the foreign-key column are now bothVARCHARof 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 alatin1table no longer fails as incompatible. Unreferenced strings keep their type. A referenced column the earlier sync created asTEXTis changed toVARCHAR(255)in place; under MySQL's strict mode that fails while a stored value is longer than 255 characters — declare@expect.maxLengthon such a field. See Foreign keys over string columns. - MySQL: a column that becomes
TEXTwhile 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: alatin1VARCHAR(1000)is indexed in full instead of a(768)prefix (its index is rebuilt once). - MySQL
@db.mysql.charsetwithout@db.mysql.collatecreates the table in that character set with its default collation; before, the DDL addedCOLLATE=utf8mb4_unicode_ciand MySQL refused the table for any other set. On a field,@db.mysql.charsetnow gives the columnCHARACTER SET <set>; before, it was ignored (a change of it is not detected by later syncs).
Custom adapters
- Schema sync calls
getReferencingForeignKeysalso 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 (dropForeignKeyson 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 atypeMapperwhose types depend on it (keep the space inregisterSpace). SeetypeMapper.- A field's
defaultValueof kind"value"still holds the literal as text (@db.default 0→"0", as@db.default '0'always did).parseDefaultLiteral(designType, literal)from@atscript/dbgives the value an insert fills — the text for astringordecimalfield, else the parsed JSON — for a backfill.
0.1.169
Breaking & behaviour changes
insertedIdof a composite primary key is the primary-key object. On a table with several@meta.idfields,insertOnereports{ insertedId: { orderId: 1, productId: 2 } }(logical field names) andinsertManyone such object per row — also withonConflict: "ignore"and over HTTPPOST /. Before, it was the driver's row id (SQLite rowid, MySQLinsertId, the firstRETURNINGcolumn 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'sone(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(anundefinedentry ininsertedIds,nullover HTTP) andinsertOne(row, { returning })answers norow. - Memory adapter: a table without a primary key reports
insertedId: undefined(was{}), like the other adapters report no usable id. - moost-db
Prefer: return=representationon composite keys reads the row back by its key object, asGET /one?orderId=1&productId=2does. Before, it answered norow— 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.viainsert 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 withFK_VIOLATIONinstead 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.viachildren onreplaceOne/bulkReplace/updateOne/bulkUpdate(and HTTPPUT/PATCH) linked by anything but a single-field foreign key to the parent's primary key answerINVALID_QUERYbefore 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 answersINVALID_QUERYtoo (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 targetFK_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.toobject needs the referenced field in that object (INVALID_QUERYbefore any write otherwise) — a replace would reset or regenerate it. insertedIdis in read form — every composite part and a single-field id: a MySQLDATETIMEkey is reported as epoch ms (was the stored datetime string), the valueGET /one?…takes.
Fixes
- A write
checkon an insert resolves a server-generated composite-key part from the reported id instead of failing withcannot resolve the primary key. - A patch addressed by a unique field writes its nested
@db.rel.from/@db.rel.viadata (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'sfromStorageformatter); pass the server-assigned column values you know exactly for the row asgenerated(e.g. aRETURNINGrow). 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 towebsearch_to_tsqueryundermatch; MongoDB sets the text index'sdefault_language; SQLite uses the Porter stemmer forenglish; MySQL ignores the language with a warning and runsmatch "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.uniqueor@db.rel.FKwould reject. See Fix rows before a new constraint. Prefer: return=representationonPOST /, opt-in per controller. OverridereturnRepresentation()to returntrue(defaultfalse: the header is ignored) and a single-object insert answers the stored row asrow, read asGET /onewould answer the caller (prepareRequestruns again withendpoint: "one"— authorize that call with the caller's read grant). A failed read-back leaves the plain result. The client sends it withinsert(row, { returning: true }). See Get the stored row back.- Schema sync resumes after a failed
beforeConstraintsstep, andrenameTableon 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'sPatchOf<T>) types{ $inc | $dec | $mul }on number fields, the array operators on top-level array fields, and$cas. See Typed operators. parseArithis re-exported from@atscript/db, for aggregate$exprentries written as text. See Arithmetic expressions.@db.search.vectortakes any embedding size.dimensionsis any positive integer (8,1000,1280…); the common sizes stay as editor completion. Before, a size outside256 … 16384failed the build. See Vector Search.- Distinct combinations, conditional aggregates, two-level aggregates.
countDistincttakes a field list (countDistinct(customerId,productId); a row with anyNULLfield is not counted); an aggregate entry takes a$wherefilter (sum(amount|status=paid):paid);controls.$fromaggregates the rows of an inner grouped query ($from=($groupBy=customerId&$select=sum(amount):spent)&$select=avg(spent):avgSpent). Every bundled adapter runs them;/metaadvertisesdistinctFieldLists,conditionalAggregatesandnestedAggregates, and the client types them. See Grouped Queries and Two-Level Aggregates. - db-client loads
@atscript/typescript/utilsonly whengetActionForm()first needs it, so importing the client keeps it out of an app's first chunk.
Breaking & behaviour changes
@atscript/db-sql-toolsis a dependency of the SQL adapters, no longer a peer.@atscript/db-sqlite,@atscript/db-postgresand@atscript/db-mysqlinstall the matching version themselves. If your app lists@atscript/db-sql-toolsonly to satisfy the old peer, remove it from yourpackage.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 andDbRow<Row>replaces; none of them intersectsRecord<string, unknown>any more. So a value of an interface type — the generated model type or your owninterface Product— now fitsinsertOne(product)/client.insert(product), and on an untyped table orClientany 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.replacepayloads (before, the index signature let it through); - inserts no longer type
$casor field operators (both were rejected at runtime); - db-client's
insert()takesInsertOf<T>(new export) instead ofPatchOf<T>; overloads now list the array form first.
- a key the model does not declare is now a type error in
- Memory adapter: a unique index over stored duplicates is refused. Sync reports the table as an
errorentry 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/url0.2. The query packages move to^0.2.0(field lists,$where,$fromin the types and the URL grammar). Code that reads anAggregateExprshould expect$fieldto be astring[]forcountDistinctand an optional$where.- Unknown controls on a grouped query are rejected.
aggregate()and an aggregate/queryanswer any control other than$groupBy,$select,$having,$sort,$nulls,$rowOrder,$skip,$limit,$count,$search,$index,$fromwithINVALID_QUERY/ 400Unknown aggregate control "$x"— before, it was ignored and the query ran without it. A non-empty$withonaggregate()is rejected too (moost-db already refused it).$actionsstays accepted on/queryand is ignored. $fromoutside an aggregate is rejected onfindMany,count, search and the other reads ($from is only valid in an aggregate query).- Value-help
/queryrefuses aggregates.$groupBy,$fromor an aggregate$selecton a value-help controller now answer the controls DTO's 400; before,$groupByskipped the DTO and the request was served as a plain list. AvalidateControlsoverride on a DB controller is now also called withtype: "nested"for the inner level of a two-level aggregate. $update/$removehelpers (@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
falseby default (fail-closed):supportsConditionalAggregates(),supportsDistinctFieldLists(),supportsNestedAggregates(). A third-party adapter keeps working and answers the new shapes withAGG_WHERE_NOT_SUPPORTED,AGG_FIELD_LIST_NOT_SUPPORTED,AGG_NESTED_NOT_SUPPORTED. SQL dialects opt in withconditionalAggregateandcountDistinctColumns. See Conditions, field lists and two-level aggregates. renameTable(oldName)should be a no-op whenoldNameis gone and the new table exists — the next sync retries a rename whose run stopped before recording it. SeerenameTable.TDbIndex.language/TDbIndex.matchandDB_FULLTEXT_LANGUAGES/DB_FULLTEXT_MATCH_MODES(from@atscript/db);TIndexDiffOptions.renderDesiredDefinitionwith adefinitiononlistExistingentries 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 overriddenhasField,GET /geoon a geo index that reads a hidden field answered400without the envelope'scode, while an unknown index answered with it. Both now get the same400envelope,codeincluded, so the reply reveals nothing about the hidden index. dbErrorToHttp(error)(from@atscript/moost-db) returns theHttpErrora request gets when the core throws thatDbError: same status, message,codeand per-entrycode/params. A permission layer that answers "like the core would" builds theDbErrorand 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: truealso looks every foreign key up, on every adapter. Use it for imports and dry runs instead ofpreValidateItems, 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 asrow(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;planSchemalists pending steps (pendingDataSteps). See Data steps. - Error codes for translation. A unique / foreign-key violation now has one
DbErrorentry per field of the violated constraint, each with acode(UNIQUE,FK_MISSING,FK_RESTRICT) andparams({ index, fields },{ fields, target },{ table, referencedBy? }); the engine text moved to the entry'sdetails. The moost-db error envelope carries theDbErrorcodeand each entry'scode/params(neverdetails). See Constraint violations. @db.rel.FKacceptsTarget.id | null— the same optional foreign key asfield?: Target.id, with the same/metaref(atscript-ui's value help reads it from its next release). See Optional Foreign Keys.- db-client: a per-call
{ signal }onquery,count,aggregate,pages,geoSearch,geoPages,one,insert,update,replaceandremove;TransportError.abortedtells a cancelled request from a network failure;ClientError<B>types an error body with fields of its own, andServerErroradmits extra fields. See Client. - Mutation filters accept relational predicates in their types:
deleteMany({ issues: { $some: { … } } })typechecks like the same filter infindMany(it already worked at runtime).
Breaking & behaviour changes
- Fulltext indexes are named by their declared name. On PostgreSQL and MySQL
getSearchIndexes()(and/metasearchIndexes) reported the internal keyatscript__fulltext__<name>, andsearch(…, '<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 newaliases. On MongoDB the default@db.index.fulltextindex is listed under its declared name too (before, only asDEFAULT, which still resolves and is listed as an alias); a static Atlas index that is the default is listed once, withaliases: ["DEFAULT"]. An unknown name now throwsSearch index "<name>" not foundon every adapter. See Text Search. - Search results come in relevance order when the read has no
$sort: PostgreSQL ranks withts_rank(per-field weights from@db.index.fulltextnow apply), SQLite withbm25()(weights apply), MySQL orders byMATCH … AGAINST; MongoDB already ranked. When the term is a whole number, exact-number hits come first. The primary key breaks ties (_idon MongoDB's classic text index; an Atlas$searchkeeps its own score order). Before, PostgreSQL and SQLite returned matches in storage order. On PostgreSQL the rank recomputesto_tsvectorper matched row beforeLIMIT— see Ranking. See Text Search. number.timestamp.createdis insert-only (top-level fields, includingnumber.timestamp.created | null; a nested one such asaudit.atis replaced with its object as before). A replace (replaceOne,bulkReplace,replaceMany, HTTPPUT) 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/PATCHcould set any value). Set it on insert. See Replace One.- Dotted
$withloads nested relations.$with: ["comments.author"](and?$with=comments.author) is read ascomments($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 ofPRIMARY/<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 oncode, not on message text. getValidator("insert" | "bulkReplace" | "bulkUpdate" | "patch")binds its write mode when called without a context, sogetValidator("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:
ClientValidationErroris aClientError(status400,body: { message, statusCode: 400, errors },preflight: true) and a runtime export of@atscript/db-client(same class as@atscript/db-client/validator). Aninstanceof ClientErrorbranch now also catches preflight failures — checkpreflightto 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 allunknown).$withnarrows withoutas const(readonly arrays work too). - Mutation filter typing: on a table with navigation props,
deleteMany/updateMany/replaceManytakeMutationFilter<FlatOf<T>, NavPropsOf<T>>. A filter typed asFilterExpr<FlatOf<T>>no longer fits — retype it asMutationFilter<…>(exported from@atscript/db) or build it inline. - A plain
export type Admin = UserisUser. With@atscript/typescript0.2 a bare alias is its target at runtime (the same object), so it carriesUser'sdb.tableandisDbEntityType(Admin)istrue;db.get(Admin)is theuserstable (before, it opened a table namedAdmin).syncSchema/planSchemalist and hash a type once when an inventory names it twice, soObject.values(models).filter(isDbEntityType)still syncs each table once. A@db.aliastype 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,PATCHorDELETEanswers200(was201/202/202);POSTstays201./metawithIf-None-Match: *answers304whatevermetaHttpCaching()returns (wooks 0.8 answers*for everyGET200); withmetaHttpCaching()returningfalsea real ETag is still never matched. See Customization. - SQL adapters render an
$orof one field's equalities asIN (…)— 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/dbso the table layer can build the per-field entries withcode/params. ADbErrorbuilt by hand still passes through unchanged, without entry codes. See Creating adapters. - Custom adapters: insert-only columns.
replaceOne/replaceManyreceivedatawithout the table'snumber.timestamp.createdcolumns. Keep the stored value: SQL adapters building the column list withreplaceColumnsForalready do; a document store rebuilding the row copiesthis._table.insertOnlyPhysicalfrom 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 ingetSearchIndexes()with older names inaliases, and without a$sortreturn rows in relevance order —searchRankOrder()from@atscript/db-sql-toolsbuilds thatORDER BYfor SQL adapters. See Text search.
0.1.164
Fixes
- MongoDB: transactions over
directConnection=true. On a client connected withdirectConnection=trueto a replica-set member,withTransactionranfnwithout a transaction: a thrown error did not roll back its writes, andwithTransaction(fn, { isolation })threwISOLATION_NOT_SUPPORTED. Transaction support is now read from the server, once per client. If the server is unreachable on first use,withTransactionnow fails with the connection error instead of running without a transaction, and awithTransactionsent to a secondary overdirectConnection=truenow opens a real transaction (the server refuses it withNotWritablePrimary) instead of running without one. See Transactions.
0.1.163
Features
isServerStampedOnUpdate(metadata)andSERVER_STAMPED_ON_UPDATE_KEYSfrom@atscript/db/validator:truefor@db.onUpdate.nowand@db.onUpdate.incfields, 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 missingdocargument and then check the field's own annotations only. The deprecatedshouldSkipType/warnUnsupportedTypesindex-diff options work again (askipIndexverdict wins), andSqlDialect.stringLiteralis optional —sqlStringLiteralis 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? })andupdateMany(filter, data, { limit, sort? })touch at mostlimitmatching rows, the first insortorder (default: primary key; the primary key always breaks ties). Loop until the count is0: a round may touch fewer thanlimitrows under concurrency, but a round that lost its picked rows to a concurrent writer is retried while matches remain, so0means nothing matched; if contention persists the call throws a retryableSERIALIZATION_FAILURE. MySQL runsORDER BY … LIMITnatively, 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:0on insert,+1in the same statement on every SDK update (version-exempt patches, replaces, touches andupdateManyincluded), never settable by clients. Unlike@db.onUpdate.nowit cannot collide within a millisecond or move backwards. Adding it to a populated table: schema sync creates the columnNOT NULL DEFAULT 0(MongoDB: sets0on the existing documents). Editor completion and hover come with the db plugin;/meta.typekeeps the annotation. See Change Counter.- Locking reads.
findOne/findMany/findByIdtake{ lock: 'update' | 'share', wait?: 'block' | 'nowait' | 'skipLocked' }inside awithTransactionscope of the table's adapter: nativeFOR UPDATE/FOR SHARE[NOWAIT/SKIP LOCKED] on PostgreSQL and MySQL, a no-op on SQLite (BEGIN IMMEDIATEalready holds the write lock), ignored by memory,LOCK_NOT_SUPPORTEDon MongoDB (use$cas). A lock outside a transaction, on a view or on a grouped / aggregate / count read throwsINVALID_QUERY;'nowait'on a held row throws a retryableLOCK_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 throwsINVALID_QUERY. MongoDB refusesserializable(and any level on a standalone server) withISOLATION_NOT_SUPPORTED. See Isolation level.
Fixes
- Compile checks follow field refs:
@db.column.searchable/@db.index.fulltexton a ref to an@expect.intfield is accepted — you no longer need to repeat@expect.inton 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 MySQLDECIMAL(10,2)reporting0.00matches0), JSON-stored values as parsed JSON with sorted keys, and engine quoting unwrapped (MySQL_utf8mb4\'[]\', SQLite'it''s'). Before,force: truere-ranMODIFY/ALTER COLUMNon 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 pgvectorHNSWindexes 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 unlessNO_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/$mulon a missing field.updateOne/bulkUpdate(and HTTPPATCH) render field ops as an update pipeline, where a missing field producednullinstead of the value MongoDB's$inc/$muloperators give (updateManyalready used the operators). A missing field now counts as0on every path — so a document written before a@db.onUpdate.inccounter existed starts counting at1.
Breaking & behaviour changes
- View fields over a primitive JSON field read the typed value. A view field over a whole
@db.jsonfield of typestring,numberorboolean(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.jsonitself 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.fulltexton a view field over a@db.writeOnlysource (sync and every query already threw).@db.sort.nullson a view field over a@db.encryptedsource (the sort already failed at query time).@db.amount.currency.ref/@db.unit.refon a field that inherits@db.amount.currency/@db.unitthrough its ref (the field's metadata carried both forms).- No search over ciphertext:
@db.column.searchableon an@db.encryptedfield (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.dynamicon a collection with any encrypted field — switch it to@db.mongo.search.staticwith@db.mongo.search.texton the fields to search. Models built without the compiler get the same refusal when their metadata is built (Encryption).
updateManyandreplaceManyvalidate their payload.updateMany(filter, data)runs thepatch-mode validator, likeupdateOne;replaceMany(filter, data)thereplace-mode validator without requiring the primary key (the filter picks the rows) — which the docs always promised. A wrong type,nullon a non-nullable field, an empty@meta.requiredfield or a failed@expect.*constraint now throws aValidatorError(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 (5stored as'5.0'in a string column). A navigation field in either payload throwsDbError("INVALID_QUERY"), andreplaceManyrejects a direct write to the version column, likereplaceOne. Code that relied on the lax behaviour — or ran its own validator first — can drop the workaround. See Update Many.- A versioned touch and
touchManyset@db.onUpdate.now. A key +$castouch andtouchManywrite 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 becomesalterfor 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 carrydefinitionChanged. A failed run'serrorentry 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.
BaseDbAdapternow implementssyncIndexes()andplanIndexes()over the protected_syncIndexes(dryRun, opts?). Implement it withsyncIndexesWithDiff({ dryRun, … }), which returnsTIndexChange[]. An adapter that still overridessyncIndexes()works and plans no index work.TIndexDiffOptionsgainsisUnmanagedandskipIndex(a warning string, ornullto skip silently).skipIndexreplacesshouldSkipType, so changeshouldSkipType: (t) => t === 'x'toskipIndex: (i) => (i.type === 'x' ? null : undefined). Relational adapters may returnTForeignKeyChange[]fromsyncForeignKeys()and addplanForeignKeys();syncForeignKeysWithDiff()does both. An adapter that keeps returningvoidreports nothing. The optionalreportedDefault()covers defaults your database cannot report exactly. See Creating adapters. - Custom adapters: locking reads and isolation default to off.
supportsReadLocks()(defaultfalse→LOCK_NOT_SUPPORTED) andisolationLevels()(default empty →ISOLATION_NOT_SUPPORTED); applyopts.isolationin_beginTransaction(opts).@atscript/db-sql-toolsaddsreadLockSql()and a trailing lock argument tobuildSelect. See Locking reads and isolation levels. - Custom adapters:
deleteMany(filter, opts?)takes an optionalTDbDeleteManyOptions, andTDbUpdateOptionsgainslimit— both are sent only when the adapter's newsupportsWriteLimit()returnstrue(defaultfalse: the core emulates the bound with your unbounded methods). SeesupportsWriteLimit(). - Custom adapters:
replaceOne/replaceManymust increment the table's@db.onUpdate.inccolumns (this._table.onUpdateIncPhysical, helperthis._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: thelimitargument ofbuildUpdate/buildDeletealso takes{ count, orderBy }(TSqlWriteLimit, anORDER BY … LIMITwrite),fillReplacePayloada fourthskiplist; newwriteLimitWhere/writeLimitCount. - Custom adapters:
SqlDialectrequiresstringLiteral(value)— the SQL string literal the builders inline into view predicates, JSON paths and defaults. UsesqlStringLiteral(quotes doubled); an engine that reads\as an escape in string literals (MySQL) must escape it too.jsonDollarPath(path, stringLiteral?),defaultValueToSqlLiteral(designType, value, stringLiteral?)andqueryNodeToSql(node, resolve, stringLiteral?)take that function;quotedJsonPathSegments([])returns[](the JSON root) instead of throwing, and the newunquoteSqlStringLiteral(literal)reads back a quoted default.
0.1.159
Fixes
- No install scripts in the published packages. Every
@atscript/db*/@atscript/moost-dbpackage declaredpostinstall: "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 executedascinsidenode_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.nowsets anumberfield to the current time on every update and replace —updateOne,bulkUpdate,updateMany,replaceOne,bulkReplace,replaceMany, HTTPPATCH/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.updatedis set on insert and on every update. A field typednumber.timestamp.updated— directly, optional, through a type alias, asnumber.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 fornumber.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 fieldnumber.timestamp(plus@db.default.nowif wanted). Over HTTP a client can no longer set it withPATCH/PUT; onPOSTa supplied value is still stored, likenumber.timestamp.created— strip it inonWriteif 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.typekeepsdb.onUpdate.now, next todb.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 fornumber.timestamp.createdin 0.1.155. A column that already has the type but noDEFAULTgets one. - MySQL —
DOUBLE→TIMESTAMP DEFAULT CURRENT_TIMESTAMPthrough a temporary column (the column keeps its position), as fornumber.timestamp.createdin 0.1.155. A plainTIMESTAMPholds 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 aTIMESTAMPcannot hold (before 1970-01-01 00:00:01 or after 2038-01-19 UTC, such as a0placeholder) 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 oldDOUBLEcolumn) — fix the rows first (for exampleUPDATE orders SET updatedAt = createdAt WHERE updatedAt < 1000) and re-run. NoON UPDATEclause 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.updatedfield 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.idon a populated collection while removing the old key field in the same sync (id→(studentId, courseId),iddeleted) unsetidwhile the old__pkunique 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 (syncIndexesthen builds the new__pkindex), 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.populatedis set on run entries too.run()reported a populated table's rebuilt (or, in safe mode, skipped) key change as{ from, to, rebuild }whileplan()and error entries includedpopulated: 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__pkunique 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 withoutsafeapplies 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) andkeepColumns(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 withrecreateTableandgetExistingColumns. A snapshot-based adapter (nogetExistingColumns) only updates the snapshot, even when it implementsrecreateTable.
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$selectwithout$groupBy— returns one row over the filtered set, astable.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: []toaggregate(). See Without$groupBy. Client.metaStorein@atscript/db-client: theMetaStorea client revalidates/metathrough (undefinedwithmetaStore: false), read-only, so code that receives a client can clear its store on an identity change. The shared default store is exported asdefaultMetaStore, soclient.metaStore === defaultMetaStoretells it from a custom one. See The client's store.- Null tests on a whole object.
{ address: null },{ address: { $ne: null } }and$existson 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 onlynullfields (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$existsitself (none is@db.writeOnlyor@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 | nullis a nullable column ofT's type, withT'sdb.*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→ thenowdefault ofnumber.timestamp.created. Values read back withT's type. The members of any other union or tuple stay off the column as in 0.1.153.Address | nullis flattened likeAddress(address__street, …), every column nullable; aNULLrow reads back asaddress: 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| nullwas already flattened, but every member's required leaves wereNOT NULL, so no row could be inserted; with| nullit 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$existsfilters, and paths inside it are rejected. - A required field inside an optional object is a nullable column.
shipping?: { street: string }used to createshipping__street NOT NULL, so a row withoutshippingcould 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.createdis a@db.default.nowcolumn, as documented. A field typednumber.timestamp.created(or a type alias of it, ornumber.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.defaulton 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.updatedwas unchanged — it is set on insert and on every update since 0.1.156. See Defaults.string.required/boolean.requiredreject blank strings andfalseagain. On a plain field these types lost@meta.requiredin atscript 0.1.79–0.1.103, soname: string.requiredaccepted''. Writes that store an empty (or whitespace-only) string orfalsein such a field now fail validation (HTTP 400 through moost-db). Check stored data and clients.
Primary keys
- An embedded object's
@meta.idis no longer part of the host table's primary key. Only the host's own top-level@meta.idfields form its key. Before, a nested type with its own@meta.id(line: OrderLinewhereOrderLinedeclares@meta.id lineId, including an embedded@db.tabletype, a@db.jsonobject 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/:idandfindByIdcould not resolve a single id, and MongoDB's__pkunique index covered the nested paths./meta.primaryKeys/preferredIdnow list the host's key only, and db-client follows it. Nothing changes for@db.rel.*navigation fields or@db.rel.FKreferences. 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
$selectwithout$groupByon/queryis no longer a 400. It used to fail the controls check (Value does not match any of the allowed types…). AvalidateControlsoverride now receives such a request withcontrols.$groupByset to[]: a rule that blocks aggregates withif (controls.$groupBy)blocks it too, while one testingcontrols.$groupBy?.lengthdoes not — check for presence, not length, to forbid aggregates.prepareRequestruns earlier and sees the controls as parsed — no$groupBythere; put aggregate rules invalidateControls. /pagesrejects aggregates with a clear message.$groupBy, or an aggregate in$select, on/pagesis a 400Aggregate queries are only valid on /query(errors[0].pathis$groupByor$select). Both were 400s before, with the generic controls-check message.- A unique index over a
T | nullfield, or over a field inside an optional or| nullobject, 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 apartialFilterExpressionon the first sync. T | nullfields can hold NULL for$nulls— a$nulls/@db.sort.nullsentry 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
| nullobject tonullin 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.jsonobject, a union of objects or aT | nullobject in a patch replaces the stored object, also under a@db.columnname (a plain nested object keeps its@db.patch.strategy). Before, the update pipeline merged it into the stored object, so switchingpaymentfrom a card to a bank payment keptcard. - 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.jsonfield typed as a string) failed to parse on read, or came back as another type ("5"as5).Mysql2Drivernow reads JSON columns as text (jsonStrings: true, and in itstypeCast) — passjsonStrings: trueto amysql2pool 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 withALTER COLUMN … TYPE … USING(TEXT→INTEGER,DOUBLE PRECISION,BOOLEAN,JSONB,geographyfor adb.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(TEXTfor anumber.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.nowor@db.default.uuidbut whose live column has noDEFAULTgets 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).
- Union and optional-object columns change in place:
- MySQL
- Union and optional-object columns change in place:
MODIFY … NULL, and a type change withMODIFY COLUMN. Text that does not convert fails the sync for that table. number.timestamp.created:DOUBLE(TEXTfor anumber.timestamp.created | null) →TIMESTAMP DEFAULT CURRENT_TIMESTAMP: the values are converted from epoch milliseconds through a temporary column (the column keeps its position). A plainTIMESTAMPholds whole seconds, so the stored milliseconds are truncated (1700000000789reads back as1700000000000) — exactly what a field with an explicit@db.default.nowstores 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 becomesCURRENT_TIMESTAMP(3)); see Fractional seconds. A stored value aTIMESTAMPcannot 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/uuiddefault is added (MODIFY COLUMN), as on PostgreSQL. - A primary-key change is swapped in place (
DROP PRIMARY KEY, ADD PRIMARY KEY).
- Union and optional-object columns change in place:
- 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.
- A nullability change rebuilds the table (data is copied), as before. A type change (
- MongoDB and the in-memory adapter store nothing differently. Tables with a
| nullfield get a new hash and re-sync once without DDL; MongoDB rebuilds present-only unique indexes (above) and its__pkunique index, which now covers the host's@meta.idalone — a table without a top-level@meta.idno longer has a__pkindex 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.createdfield 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 allNULL— 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.skippedincludes'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.uuidon 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 ornullembedded object stays absent, see below), in every item of an array of objects, and inside@db.jsonvalues — where there is no column default, so the SDK fillsnow/uuidon 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/metawith@atscript/db/validator(db-client, forms). - An absent optional object stays absent on PostgreSQL and MySQL. An insert or replace without an optional or
| nullobject (or withnull) now writesNULLinto the object's columns. Before, a column with aDEFAULT(audit?: { at: number.timestamp.created }) took it, and the row read back withaudit: { at: <now> }. In the same way, writing one member of a union of objects writesNULLinto 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.renamedin 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()leavesnull/undefinedmembers out of a union (T | null→T's design type).- A field descriptor of a
T | nullfield carriesTas itstype(with the field's annotations), so a type mapper seesT's tags;optionalistruefor every column that can holdNULL(inside an optional or| nullobject, or declared by only some union members). Nested-object adapters keep the field's ownoptional. TableMetadata.isNullable(path)andpresence(path)("required"/"nullable"/"partial", typeTPathPresence) 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$existson them).- Schema sync moves values out of the old layout through two new optional primitives:
copyFromJsonColumn(source, targets: TJsonCopyTarget[])andjsonifyTextColumn(column). Without them such a table is refused. SQL dialects built on@atscript/db-sql-toolsimplementjsonExtractText,jsonExtractValueandjsonFromTextand runbuildJsonColumnCopy/buildJsonifyText. TFilterRef.nullTestmarks a presence-only filter entry;guardPath(…, nullTest)and moost-db'sFieldCapabilityIndex.check(…, nullTest)accept it on an object parent.countKeyViolationslets 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 — madepg.Poolemit an unhandled'error'. A poolPgDrivercreates now logs it as a warning (toconsole, or aloggerpassed 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. Apg.Poolyou pass in gets no listener — addpool.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
lockConflictsfor conflict-ignoring inserts —insertMany(rows, { onConflict: "ignore", lockConflicts: true })inside yourwithTransactionlocks 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 withSELECT … FOR UPDATEin 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_QUERYwithoutonConflict: "ignore". See Ignore, then update.- Retryable lock errors — deadlocks (MySQL errno 1213, PostgreSQL
40P01) and lock wait timeouts (MySQL 1205, PostgreSQL55P03) now throwDbErrorwith codeDEADLOCK/LOCK_TIMEOUTandretryable: trueinstead of the raw driver error, on every write; PostgreSQL serialization failures (40001) throwSERIALIZATION_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. /metarevalidation in@atscript/db-client—Client.meta()keeps/metabodies in a store keyed byETag, sends the ETags it knows for the URL asIf-None-Matchand reuses the stored body on304. A newClientfor a controller already loaded, ormeta()afterinvalidateMeta(), costs a304instead of the full body. On by default with one store shared by all clients; opt out withmetaStore: falseor pass your ownnew 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. CallclearMetaStore()next toinvalidateMeta()on an identity change. Cross-origin APIs must sendAccess-Control-Expose-Headers: ETagand allowIf-None-Match; without them the client behaves as before. See Revalidating /meta.- NULL placement in sorts —
$nulls: { closedAt: "last" }putsnull/ missing values before ('first') or after ('last') every value of that$sortkey, 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,$withsub-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 benullare dropped, so their index use stays free; the other placements cost index order (MongoDB runs the read as a pipeline). Grouped$sortand thefirst()/last()$rowOrdertake it too./meta.nullsPlacementadvertises it and the serialized type keepsdb.sort.nulls. Requires@uniqu/core/@uniqu/url0.1.15. See NULL placement. Client.metaEtag()in@atscript/db-clientreturns theETagof the/metabodymeta()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 | nullrejects1.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.requiredfield rejectsnull.nullwas the way to clear an optional column on PATCH; for@meta.required note?: stringit is now a validation error (note: Must not be empty), on insert, replace and patch. Omit the field instead, or type itstring | nullif 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.createdincreatedAt: number.timestamp.created | nullcarries@db.default.now, andstring.charincode: string.char | stringcarries@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 noDEFAULT, is not filled on insert and is not optional on insert. (Since 0.1.155 the one non-nullmember ofT | nullis the exception.) - Ties in
$sortare broken by the primary key. Rows that tie on every$sortkey now come back in primary-key order, in the direction of the last$sortkey:$sort: { amount: -1 }reads asORDER BY amount DESC, id DESC. The missing fields of a composite key are added in declaration order. Nothing is added when$sortalready names the whole primary key, or every field of a unique index over required (non-optional) fields. It applies tofindMany,findManyWithCount,findOne, an explicit$sorton search / vector search, and$withrelation sorts, so/query,/pagesand direct calls page consistently. Unchanged: grouped and aggregate reads,count(), relevance / distance order without$sort, and views without@meta.idon 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 (
10before9). They now follow the same rule as the other adapters: typed comparison, in the last$sortkey's direction. Reads without$sortstill return insertion order. - moost-db
/pageswithout$sortis ordered by primary key ascending, so offset pages never overlap or skip rows.$search/$vectorkeep relevance order, a view without@meta.idstays unordered, and/querywithout$sortis unchanged. - Value-help
/pageswithout$sortis ordered by primary key ascending too (unless$searchis given), like the DB controllers'/pages. A customAsValueHelpController.query()receivescontrols.$sort = { [pk]: 1 }there. /metarevalidation retriesIf-None-Matchafter 5 minutes. When a conditional/metarequest fails at the transport level (a CORS preflight refusingIf-None-Match), the client used to stop sending the header for that store key untilclearMetaStore(); it now does so forunconditionalMs(default 5 minutes, aMetaStoreoption) and then tries again.- A
:suffix on a URL sort key must befirstorlast.$sort=name:asc(never a valid field) is now a 400Malformed query stringfrom the URL parser. moost-db'scrud.query/crud.pagescontrol lists in/metanow includenulls. - MySQL: an ignore-insert inside your transaction looks the keys up first. A failed duplicate
INSERTleaves InnoDB's shared lock on the stored row untilCOMMIT, so two transactions that ignore-insert the same stored row and then update it deadlocked. WheninsertMany/insertOnewithonConflict: "ignore"joins a transaction you opened, the adapter now runs the non-locking keySELECTfirst and inserts only the missing rows, in primary-key order, in oneINSERT: an all-new batch costs two statements there instead of one. Calls outside a transaction keep the single optimisticINSERT. 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 aDbError(code: "DEADLOCK"/"LOCK_TIMEOUT"/"SERIALIZATION_FAILURE"); over HTTP these answer 503 instead of 500. - Array
$update/$removeitems follow the plain-array validation.null(orundefined) 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/$updateon 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/$mulinside 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
leftjoins it does not need — a provably at-most-one-row join that nothing in the read uses (filter,$sortincluding the primary-key tie-breaker and$nullskeys, projection, group keys). Results are unchanged; the SQL / pipeline differs (an inline copy of the view definition instead ofFROM <view>), and aCOUNT(*)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
.asmodel, 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, ordb.getAdapter(View).viewJoinPruning = falsefor one view). - Such a read needs read access to the view's tables, not only to the view.
- Reads that skip a join use the definition generated from your
- MongoDB: a composite
@meta.idbeside an explicit_idis unique only as a whole. With an explicit_id(@db.mongo.collection), a composite@meta.idis one__pkunique index. Each of its fields used to count as unique on its own, so a conflict-ignoring insert inside a transaction (andlockConflicts) skipped rows that shared just one of them.uniqueKeySetsnow lists the pair instead.
For adapter authors
supportsNullsPlacement()(defaultfalse): returntrueonce your reads honourcontrols.$nullsandrowOrder[i].nulls; until then an explicit$nullsis rejected withINVALID_QUERY. See Creating adapters. SQL dialects built on@atscript/db-sql-toolssetnullsPlacementSyntax: truewhen the engine hasNULLS FIRST/NULLS LAST(without it the(col IS NULL)form is rendered);orderKeySqltakes an optional placement, andnullsOrderSql/orderByListare exported.insertManyIgnore(rows, opts?)receivesTDbInsertIgnoreOptions(inCallerTransaction,lockConflicts). See Conflict-ignoring insert. Map your engine's deadlock / lock-timeout errors toDbError("DEADLOCK")/DbError("LOCK_TIMEOUT").- View read pruning:
BaseDbAdapter.viewJoinPruning(defaultfalse; set it from your adapter option once your reads honourAtscriptDbView.readPlan(queryReadColumns(query, kind))). The@atscript/db-sql-toolsread builders (buildSelect,buildAggregateSelect,buildAggregateCount, …) take aTSqlFromSource— a table name or a pre-rendered derived table fromviewReadSource()— andSqlDialect.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;
statementCacheSizeoption,0disables). DDL andclose()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):/metais projected per user. See Metadata.estimatedCountoption for MongoDB —new MongoAdapter(db, client, { estimatedCount: true | ["events"] })(orcreateAdapter(uri, { estimatedCount })) answers unfiltered counts from collection metadata. Off by default. See Estimated counts.sortRows(rows, $sort, tieBreak?, topK?)in@atscript/db-memorytakes an optionaltopK.metaHttpCaching()on the moost-db controllers (TDbMetaHttpCaching) configures or disables/metaHTTP caching, andstableMeta(obj)marks a memoizedapplyMetaOverlay/metaFormvariant as reusable (serialized once, sent with an ETag). See metaHttpCaching.
Behavior changes
GET /metais HTTP-cacheable./metaand/meta/form/:namenow send a weakETagcomputed from the response bytes,Cache-Control: private, no-cacheandVary: Authorization, Cookie, and answer a matchingIf-None-Matchwith304 Not Modified. A caller whose/metadiffers (another role, after re-login) gets a different ETag and a200. Opt out withmetaHttpCaching() { return false }. UnderNODE_ENV=test/developmentserved/metaobjects are deep-frozen: anapplyMetaOverlayor 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,$actionsscope 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@DbDecorationskey, is replaced by a copy in the rows array.decorateRowsstill 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.PgDriverused to read its wall time in the process's time zone, so the same row came back as a different instant under a differentTZ. It now returns the same epoch ms everywhere, matching how the MySQL adapter readsDATETIME.TIMESTAMPTZand theBIGINTcolumns of@db.default.noware 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. Anumberfield with@db.mysql.type "TIMESTAMP(3)"(orDATETIME(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.nowis now converted to a datetime string too (the raw number used to be sent and rejected). With@db.default.now,DEFAULTand@db.mysql.onUpdaterender asCURRENT_TIMESTAMP(n), which MySQL requires for such a column (the bare form failed theCREATE TABLE). Plain@db.default.nowcolumns stayTIMESTAMPwith whole seconds, so no migration runs.@db.default.nowwith a non-date@db.mysql.type(such asBIGINT) 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.geoPointvalues as JSONB text even to ageographycolumn. 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 ageography/geometrycolumn 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()reportstruefor a table with geo fields: the first geo search or$geoWithinlearns it and fails withGEO_NOT_SUPPORTEDwhen 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
insertManyorinsertManyIgnorethat is a single statement (one chunk, plus one row for MySQL ignore mode) no longer opens its own transaction. - On PostgreSQL, a one-row
insertManyIgnorechunk skips theSAVEPOINT. - On PostgreSQL,
updateOne/deleteOne/replaceOneby an exact primary key run as… WHERE "id" = $1, without theLIMIT 1subquery. - On MySQL, a multi-row insert of generated ids reads
@@auto_increment_incrementon the insert's own connection, withoutSTART TRANSACTION/COMMIT.
- An
- MongoDB vector search pre-filters. Conditions on
@db.search.filterfields now go into the$vectorSearchstage'sfilter(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.filterfields now declares all of them (only the last one was), so the first sync updates the Atlas index once.$skipis now part of the top-k — a page after the first used to come back empty. See Pre-Filtering. - MongoDB
@db.default.incrementinside 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 afindand acountDocumentsinstead of one$facetaggregation — 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 AtlassearchWithCounttakes its total from$searchMeta.
For adapter authors
- The core may now call
insertMany([row]),replaceOne,updateOne/nativePatchoutside 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??.InsertSqlCacheis 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
canonicaloption 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/bulkUpdatewithout$cas,updateMany) leaves@db.column.versionunchanged and adds no version check. See Version-exempt fields.table.versionExemptFieldslists them;isVersionExemptPatchandTDbUpdateOptionsare exported from@atscript/db. Nothing changes for tables that do not use it.- Numeric search through the existing annotations.
@db.column.searchable(the$searchfallback) and@db.index.fulltext(native search) now accept integer fields (number.intand its sizes,number+@expect.int,@db.default.increment). The fallback matches the term as a substring of the number's decimal text (2946finds29461277); 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. $regexon 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.integerTextin@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 stillmatchedCount). Detect a missing row viamatchedCount. $counthonours a native$search/$vectorterm.GET /query?$search=x&$count=trueused to count the whole filtered table on a natively searchable table; it now counts the matches (the@db.column.searchablefallback was already counted correctly).- An integer member of an existing
@db.index.fulltextis 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 term0matched 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.fulltexton 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.writeOnlycan no longer be combined with@db.index.fulltextor@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.
$searchORs 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.dynamicplus any@db.writeOnlyfield now refuses$search(400) and reportssearchable: false. A dynamic mapping indexes the sealed field. Switch to a static@db.mongo.search.textmapping that omits it; see Dynamic Atlas Search.- MongoDB: a second
@db.index.fulltextname 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
$countreceives 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.searchablefields are no longer consulted (native search wins). - SQLite:
$searchterms are quoted for FTS5.-2946,a AND,title:xor 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/NEARand column filters are no longer operators. $regexon a float, decimal or timestamp field still answersINVALID_QUERY; the message now readsa pattern match needs a string or integer field.applySearchFallbackstays overridable, but numeric IDs no longer need an override: declare the annotation on the integer field.db.http.pathis derived from each controller's own route, per app, afterapp.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./metaand/meta/form/:namecarry 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 withcanonical: true—@TableController(Model, { canonical: true })(also@ReadableController/@ViewController), or{ canonical: true }as the trailing constructor option ofAsValueHelpController/AsJsonValueHelpController— orcanonical: falseon the others. See Several controllers over one model. - The root of a controller's own
/metacarries 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
assertExposedread 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/xandassertExposedin 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 overridesserializeForMetaand callsgetSerializeOptions()directly keeps the single-app behavior (it reads the mirror).- Delete app-side repairs. A
@MoostInithook that rewritesdb.http.path(for example withgetHandlerPaths(…, "meta")andtype.metadata.set(…)) is no longer needed: remove it, and mark multi-mounted models withcanonicalinstead. - 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,updateManyandnativePatchtake an optional trailingopts?: TDbUpdateOptions. WithkeepVersion: true, skip the version bump (never combined withexpectedVersion). An adapter that ignores it keeps bumping. See Versioned tables.- Adapters that implement
search()should honourTDbIndexField.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/querystring 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 asscope) and its route guards do not run (authorize inprepareRequest). See From your own command.- Types
TDbResolveQueryInputandTDbResolveQueryOptsare exported from@atscript/moost-db.
Behavior changes
- A query target's
$searchis applied by the read's visibility (action and delegated query targets). A$searchthat can't be applied is now a 400TARGET_INVALID($search is not available here); before, it matched every row./query,/pagesand grouped reads are unchanged. See$searchfollows the read. - A
@DbActionsFromview'sunmappedskipped 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/insertManywith{ 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 isPOST /?$onConflict=ignore(details), from the clientinsert(rows, { onConflict: "ignore" }). All bundled adapters support it;/metalistscrud.insert: ["onConflict"]. A custom adapter opts in withinsertManyIgnore/supportsInsertIgnore(Creating adapters) and otherwise getsDbError("ON_CONFLICT_NOT_SUPPORTED")(400).prepareRequestreceivesctx.onConflicton theinsertendpoint. DbSpace.close()— andawait using db = createAdapter(…)— close a space and its connections: Closing the space. PassonCloseto theDbSpaceconstructor to let it close a driver you own;createAdapter()already does. moost-db getscloseDbSpaces()(details), which you call from your shutdown path (it is not hooked intoMoost.dispose()). A custom adapter may implementdispose()./meta.fields[*].groupable—trueexactly when$groupByon 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).$groupByon 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 fieldsdecorateRowscomputes, with arequireslist of the columns it reads:/metalists them (fields[key].decoration,decorations), clients$selectthem, they are never filtered / sorted / grouped, and visibility follows their sources. Replaces thebuildMetaResponsemerge,validateControlsandtransformProjectionstripping 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,nullpropagating, division by zeronull, the same semantics as@db.compute. Operands are numeric fields only. See Grouped Queries — Arithmetic expressions. first/lastwith$rowOrder. Read several fields of one representative row per group — the oldest open issue per ticket: Representative row./metagainsaggregateExpressions,fields[path].numericandfirst/lastinaggregateFns;$rowOrderjoins thequerycontrol list. The db-client types expression aliases asnumber | nullandfirst/lastas their field's type.- Plain
findMany/querydo not take expressions: declare a view with@db.computefor 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/$mulwithNaN,Infinityor-Infinityfail validation (Field operation operand must be a finite number); a non-finite number as a filter value on anumber/decimalfield isINVALID_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.
Mysql2DriverappendsSTRICT_TRANS_TABLESto thesql_modeof every pool connection it opens (the server's modes are kept). On a non-strict server (Amazon RDS for MySQL default) a missingNOT NULLvalue, an out-of-range number or an over-long string used to be coerced and stored, in plain inserts and inonConflict: "ignore"alike; they now fail. Check data pipelines that relied on the coercion, or passnew 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/getAdapterand every operation on an existing table or view handle throwDbError("SPACE_CLOSED")(HTTP 503 in moost-db). Nothing changes until you callclose(). createAdapter()spaces own their driver.space.close()ends the pool / client / file handle thatcreateAdapter()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
actionRowScopethat 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's404before 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,nulland{}do not), folding the restriction into the one load: 1 query → 0 when unrestricted, 2 → 1 when restricted. See What the gate verifies. actionRowScopeatpurpose: "execute"is asked before the load when there is no row overlay and the request's ids are inpreferredIdshape:ctx.idsare then the request's ids and may name rows that do not exist (ctx.loadRowsomits them). With a row overlay, another identification shape or a query target the hook still sees the loaded candidates. A scope that assumed "everyctx.idsentry exists" should read rows throughctx.loadRows.- When
resolveRowIdsis overridden,onRemoveandguardRemovereceive the resolved id,@DbActionID()/useDbActionId()/@DbActionRow()the resolved ids, and error bodies (ActionDisabledError.ids) andsummary().skipped/failedkeep the ids the client sent. Not overriding it changes nothing. OnDELETEthe row overlay is now computed beforeonRemoveruns. - A malformed computed
$selectitem 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 answersMalformed $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 iscount()meant ascount(*)). - The unknown-function message lists
firstandlast: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/lastare not in the defaultaggregateFns()and arithmetic is off by default (supportsAggregateExpressions()isfalse), so such queries are refused withAGG_FN_NOT_SUPPORTED/AGG_EXPR_NOT_SUPPORTEDbefore they reachaggregate(); opt in per Aggregate expressions.UniquSelect.aggregatesnever listsfirst/last(seefirstLast). - PostgreSQL and MySQL: a numeric overflow in a grouped query is
INVALID_QUERYinstead of a 500:Arithmetic overflow(path$select) when the query has arithmetic expressions,Numeric value out of range(path"") otherwise. min/maxof a boolean or decimal field read back typed (true/false, a decimal string) instead of the driver's0/1or number, and on PostgreSQLmin/maxof a boolean renderBOOL_AND/BOOL_OR(PostgreSQL has noMIN(boolean): the query used to fail).- Grouped
$sorton a nullable alias, or a nullable group key, putsNULLfirst ascending and last descending on every adapter (PostgreSQL used to sortNULLas the largest value). - An ungrouped aggregate
$countis the number of rows the data query returns:1(the single aggregated row, even over no input), or0when a$havingfilters it out. SQL counted the source rows and MongoDB answered0over no input. - Aliases starting with
__as_are reserved for the engine (INVALID_QUERY), a$selectentry carrying both$fieldand$expris rejected as malformed, and the expressions of one query are capped at 1024 expanded nodes together. - MongoDB: a plain
sumover a group with no non-null value isnull, as on SQL and memory (it was0). This changes existing queries:sum(estimate)over a group where every document lacksestimatenow returnsnull, and so does an expression over it. Treatnullas no value, orcoalesceit in an expression. - MongoDB: an ungrouped aggregate over no matching document returns one row (counts
0, everything elsenull), as SQL and memory always did — it used to return no row.$havingand$skipapply to that one group, as on SQL. - The
_idaggregate alias is rejected on every adapter (INVALID_QUERY, with the reserved__as_prefix):_idis 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/$ninagainst an array field matches by containment, as in MongoDB —{ labels: "bug" }matches["bug", "ui"]($ne/$ninare the negation), andnullmatches a missing field, anulland an array holding anullelement. Tests that relied on an array never equalling a scalar change. - MySQL and PostgreSQL: a renamed
@db.default.incrementprimary key is created withAUTO_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
$selectnaming a parent object keeps its@db.writeOnlyleaves sealed —$select=secretoversecret: { @db.writeOnly hash, salt }returnssecret: { salt }(and likewise in a$withsub-select,/pagesand/one); it used to return the write-only leaf too. A@DbDecorationsrequiresnaming a parent with a write-only descendant is refused at boot on every adapter. - PostgreSQL:
first/lastof a native array column (@db.pg.type 'x[]') picks throughMINover the array instead of returningNULL(or failing on aNULL/ empty array). - MySQL:
Mysql2Drivermakes a pre-created pool strict as well: a connection opened before the driver was constructed gets thesql_modestatement the first time it is acquired.
Compile-time errors
@db.rel.FKon 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.uniqueare accepted. Non-table hosts keep FK as the value-help indicator.
API
@DbDecorations(type, { requires })withTDbDecorationsOpts;TDbDecorateContext.decorations(ReadonlySet<string>, empty without the decorator);TFieldMeta.decorationandTMetaResponse.decorations(@atscript/db);Client<T, D>(@atscript/db-client;Ddefaults 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 answers400 Field "x" is display-only…; a key whoserequiresare hidden answersUnknown field. AsDbReadableController.resolveRowIds(ids, ctx)(protected);TDbRowIdInput,TDbRowIdPurpose("action" | "available" | "one" | "remove") andTDbRowIdsContextare exported from@atscript/moost-db.- An app that rewrote action ids in
prepareRequest(mutating the cached action body) or shimmed/onealiases: remove them and overrideresolveRowIds, 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 —
@DbActionsFromlists 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(),$withsub-filters and mutation filters, on every bundled adapter. See Queries § Relational filters. Over HTTP the form isticket=$some(status=open)(URL syntax); a client may use it only on relations marked with the new@db.rel.filterable. Permission layers gettransformRelationFilterandctx.filterinprepareRequest;/meta.relations[]gainsfilterable: true. - Computed view columns and first-row joins —
@db.computecolumns 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", labelledYYYY-MM-DDTHH:00in 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=highor{ priority: { $gte: "abc" } }on a number,?done=yeson a boolean, an ISO date on anumber.timestamp, a fraction on an integer field (?qty>5.5on anumber.int) — now throwsDbError("INVALID_QUERY")(HTTP 400,errors[0].path= the field) on every adapter: for table and view columns,$having, relational-filter operands andupdateMany/deleteManyfilters. It used to reach the database: PostgreSQL failed with a 500 (invalid input syntax), MySQL cast it ('x'compares as0) 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 andnullare accepted as before. See Value Types. 'rows'actions validateidsin their gate interceptor. Every'rows'action now reads and validates the body'sidsin the action's interceptor, even without a row overlay ordisabled— the same 400, but earlier: ahead of interceptors of a lower priority and before the handler's arguments resolve.- A
querykey in a'rows'action body is a 400TARGET_INVALIDunless the action declaresqueryTarget(it used to be ignored). actionRowScoperuns after the body is read on the action route (it needs the candidate ids);prepareRequestand the row overlay still run before it.- A controller overriding
actionRowScopeloaded rows for every'row'/'rows'action (a missing id on an id-only handler got the gate's404), whatever the hook returned. This was not listed here at release; 0.1.148 restores the earlier behavior for a hook that restricts nothing. @DbActionsFromviews always return theidMapcolumns on reads, even when$selectomits them (the client needs them to address the source) — unless the view'stransformProjectiondrops them, which drops the delegation.- A hidden default text index is refused (under an overridden
hasField):$searchwithout$indexanswers400 No search index availablewhen 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/metanow prunessearchIndexesand turnssearchable/vectorSearchable/geoSearchableoff by the same rule. Tables without native search keep the fallback. A permission layer that pruned/metaitself throughindexFieldPaths()can keep doing so (same result) or drop it. - The "no primary key" warning for an overridden
actionRowScopeis logged only when the controller has row actions of its own. @db.rel.filteris applied. It was validated and documented but ignored at run time, so$withloaded every related row. It now filters$withon 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.viarelation, a single top-level condition that reads both the junction and the related type (anoracross them, or a parenthesized group mixing them — split it into top-levelandconditions). Recompile (asc) to see them; metadata compiled with an older plugin that still carries such a filter fails every$withand predicate on that relation withINVALID_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/deleteOnefilter (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 withREL_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.FKfield — or the field it references — is renamed with@db.columngets 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/$nonerun 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/$ninon 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 withREL_FILTER_NOT_SUPPORTED;$sortthere is byte-wise. Predicate-free reads keep their collation. See MongoDB § Collation. - MongoDB
$withfollows 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), anullforeign key loads nothing, and onviarelations$sort/$skip/$limit/$selectapply per parent row instead of per junction row. A@db.rel.torelation now resolves its table through theDbSpacelike the generic loader: on a table built without one (new AtscriptDbTable(type, adapter)),$withof atorelation loads nothing instead of falling back to the foreign key's target name. $with$skip/$limitapply 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$limitof its own (from,via, andtorelations, where a$skipof 1 or more loadsnull). Aviarelation's$sortis honoured — it returned junction order. A pagedto/fromrelation is read with aROW_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 suggeststicket=$some(status=…). - moost-db: an
Unknown fieldrefusal fromvalidateInsights— a hidden or nonexistent relation in a predicate, an unknown operand field, a$withsub-query path — now carries the validation envelopeerrors: [{ path, message }]like every other predicate rejection; same status andmessage. A string your ownvalidateInsightsoverride returns stays a baremessage.
Fixes
- A field path that crosses the same relation twice (
parent.parent.statusover a self relation,parent=$some(parent=$some(…)),$with=parent($with=parent(…))) answered400 Unknown fieldover HTTP under the defaulthasField. Paths may repeat a relation; the nesting caps still apply. - moost-db:
$countwith a write-only field on a related table. The write-only$selectseal also listed related tables'@db.writeOnlypaths, so$countfailed withCannot select "ticket.code" — navigation path. - PostgreSQL / MySQL: foreign keys into another schema.
REFERENCESnamed 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.schemafollow the connection's current schema. Introspection (existing columns, constraints, indexes, foreign keys) assumedpublic; with asearch_pathpointing elsewhere every such table looked missing to schema sync. It now usescurrent_schema(). - PostgreSQL: a
@db.column-renamed primary key.updateOne/deleteOneand theRETURNINGclause ofinsertOne/insertManyused 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.havingon 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 readsMIN(<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.idwas 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
TDbRequestEndpointgains"delegatedAction"(prepareRequestof a view'sPOST /delegated-actions/:name). An exhaustiveswitchover it with anevercheck needs the new case.actionRowScope(action, ctx?)—ctxis optional in the signature, sosuper.actionRowScope(name)keeps compiling; moost-db always passes it.TDbActionInfogainsowner,idMap,queryTarget({ maxRows, url? });TDbActionTargetSummary(@atscript/db) is new, with optionalaborted,messages,message.- New protected hook
queryTargetScope(action)— runs as a read; see Which rows. POST {prefix}/delegated-actions/:nameexists 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_UNITSnow includeshour: a custom adapter returning it fromcalendarBucketUnits()must render hour labels, or return a set withouthour. See Creating Adapters § Calendar buckets.BaseDbAdapter.supportsRelationFilters(mode)— defaultfalse, so a custom adapter rejects predicates withREL_FILTER_NOT_SUPPORTEDuntil it renders them. See Creating Adapters § Relational Predicates.walkFilterdispatches a predicate to the visitor's newrelation(field, op, operand)callback and throws when the visitor has none. Visitors you wrote only meet one once a filter contains a predicate — addrelationto those that can.@atscript/db-sql-tools:TFilterVisitorOptions.qualifier— required on statements that alias their FROM.@atscript/db-mongo:buildMongoFilterthrows on a predicate;mongoFilterStages,buildMongoQueryandplanStagesbuild the pipeline form.- New exports in
@atscript/db:ResolvedRelationFilter,isResolvedRelationFilter(aSymbol.forbrand, 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)—findManywith$skip/$limitapplied per group of rows sharing thepartitionBycolumns; the generic$withloader reads paged relations with it. The default pages afindManyin memory; the SQL adapters use aROW_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/targetFieldsstay logical). Custom adapters that renderFOREIGN KEYclauses should use them, falling back tofields/targetFieldswhen absent.- moost-db: the relational-predicate gate judges the client's
$withtree as recorded beforevalidateControls. An override that conjoins row scopes into$withentries must wrap the client's filter object, not copy or drop it — seevalidateControls. @atscript/db-mongo: predicate temp fields are named__atscript_rf_<n>(REL_FILTER_TEMP_PREFIX);buildMongoQuery/mongoFilterStages/buildMongoFilteraccept{ collation },buildAggregatePipeline/buildCountPipelinean optionalfilterOptions, andMongoAdapter.fieldCollation(field)reports a field's collation.
0.1.142
Fixes
@atscript/db-client: derived columns are server-managed in preflight. The/metatype stripped thedb.column.derivedannotation, so the client validator built from it treated a derived column as an ordinary field: aninsert/replacethat left out a required derived field was rejected with aClientValidationError(the server accepts it and computes the value), and$inc/$dec/$mulon one reached the server before failing there./meta.typenow keepsdb.column.derived, and preflight follows the server's rules — see Client-Side Validation./meta.fields[path].derivedis unchanged. A controller that overridesgetSerializeOptions()must keepdb.column.deriveditself 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.forand@db.view.joinsaccept 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 fromType 'X' must have @db.table annotation.toType '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.aliasannotation — 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.jsonfield of the same table (customerId: Order.payload.customer.id) becomes a real column: aGENERATED 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/$mulon it is a validation error./meta.fields[path].derivedistruefor such a field, and safe mode gains a skip kind (entry.skippedmay 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. disabledpredicates can say why. A predicate may return a string instead oftruefor a row: the action is disabled and the string is the reason. It becomes the 409 message,ActionDisabledErrorgainsreason/reasons(server body and@atscript/db-clientaccessors), and$actions=truereads add$disabledReasons: { [action]: reason }to rows that have one. Boolean predicates and existing wire fields are unchanged. If your own code evaluates the/metadisabledstring, test the result for truthiness, not=== true. See Disabled reasons.- Editor support. With
@atscript/core0.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.joinscondition used to offer only the target and the entry). See Annotations → Editor support. isDbEntityType(value)— the runtime testDbSpace.getapplies, 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.limitand 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 plainexport type Admin = Userand a@db.aliastype used to carry the referenced table'sdb.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 anotherAtscriptDbTableoverusers, theasc db syncdiscovery counted it, and a@db.aliaswas collected as a model. Only the declaring interface carries them now —db.get(Admin)would open a table namedAdmin(the type-id fallback); passUser. A@db.aliastype listed in asyncSchemainventory is skipped. Recompile (asc) to pick the change up; stored data and schemas are untouched. See Annotations never travel across field refs. @db.view.havingis 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 — independsOn(tables only before), and every entry carriescascadeFrom(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, andversionColumnis its field name. For@db.column 'row_version' @db.column.version version: number.int,table.versionColumnand/meta'sversionColumnreturned the storage column (row_version), and every consumer keyed on it: a PATCH / PUTversionwas 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,withOptimisticRetrycould not run, and aversionin an SDK patch passed the direct-write check. Both now report the field name (version); only the stored column isrow_version. Tables whose version field has no@db.columnsee no change. If your code readversionColumnto 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),updateManyandreplaceManylet the driver'sE11000error escape — a 500 over HTTP — while inserts and replaces already threwDbError("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.viewby its view name (tableNameOf). Relations still target@db.tableinterfaces only — the compiler rejects@db.rel.to/@db.rel.fromto 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.viafield (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 returnedsynced-by-peerwithout 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: truerun that had to wait for another pod's lock takes the lock and runs; it no longer returnssynced-by-peerbecause the stored hash matched. Non-forced runs keep that answer. - Only a duplicate-key
CONFLICTon the lock insert counts as "another pod holds the lock". Other insert errors (a lost connection, say) rejectrun()instead of being read as contention. - A waiter that loses a freed lock to another waiter waits again within
waitTimeoutMs, instead of throwingFailed to acquire schema sync lock after waiting. - A failed lock release is logged with
logger.warninstead of being ignored. The run's result is unchanged, and the lock row expires afterlockTtlMs.
- A
- PostgreSQL:
deleteOneno longer misses a row that is being updated at the same time. It picked the row byctid. When a concurrentUPDATEof that row committed first, the row had a newctid, and the delete silently removed nothing (deletedCount: 0). It now picks the row by primary key, asupdateOnedoes 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 usectid. - PostgreSQL: a column added by sync gets its
@db.pg.collate.ADD COLUMNapplied the native collation only when the field also had@db.column.collate;CREATE TABLEalways applied it. Both now render the sameCOLLATEclause.
API
For custom adapters and sync tooling:
- The schema-sync lock now treats only
DbError("CONFLICT")frominsertOneas a lost race for the lock row, and it writes the row throughupdateMany/deleteManywith$eq/$ltfilters. A custom adapter must throwDbError("CONFLICT")on a duplicate primary key, as the bundled adapters do; a raw driver error now rejectsrun()when two pods start together. TViewJoin.scope— the name a join is addressed by: the physicaltargetTable, or the alias name of an aliased join (thenTViewJoinSnapshot.tablecarries the physical table).AtscriptDbView.resolveRefSource(ref).tableandTViewColumnMapping.sourceTablereturn 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-toolsdoes. See Creating Adapters → Views.tableNameOf(type)names a@db.viewtype by its view name (its type id before).versionColumnPhysicalon a table — the stored column of the@db.column.versionfield.versionColumnis now the field name (see Behavior changes); a custom adapter must bump, compare and default the version withversionColumnPhysical, 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— adisabledverdict,boolean | string);DbSpace.get/getTable/getViewthrow for a@db.aliastype. - Nav subfields on document adapters are
ignoreddescriptors:fieldDescriptorsstill declares them,columnDescriptors/storedDescriptorsleave them out, and so doessnapshotToExistingColumns(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'sreconstructFromRead/reconstructRowsreceive), andderivedColumnExprin@atscript/db-sql-tools. A readable exposescolumnDescriptors(what schema sync diffs — on document adapters without the derived fields) andstoredDescriptors(non-ignored, non-derived — what a recreate copies and a full replace assigns) next tofieldDescriptors. 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, theNOT NULLbackfill, the primary-key rebuild, the@db.sync.method 'recreate'copy) now run withSTRICT_ALL_TABLESadded to the sessionsql_mode. On a server that is not strict — Amazon RDS for MySQL defaults toNO_ENGINE_SUBSTITUTION— a string → number change used to turn'abc'into0, and lowering@expect.maxLengthtruncated stored text, while the sync reported success. Both now fail the sync as anerrorentry 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 frompg_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.FKwhose 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 sameDbSpace— 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.columnon 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.columnname as a top-level key (zip_code) — they matched nothing, sorted by nothing, and views read an empty column. Every path now usesaddress.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--safemode the removal unsets a top-levelzip_codekey — only anupdateManymerge 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.defaultused to write the default into every document; it now fills only documents that don't hold the field — which also keeps the storedaddress.zipvalues above intact. See What gets synced → Add. - Every managed MongoDB view is recreated once on the first sync after the upgrade (
plan()/--dry-runshows 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.biounder@db.column 'prof': an index declared on it now coversprof.bio— the first sync recreates it (before, it indexed the non-existentprofile.bio);updateManymerge patches writeprof.bio; and$inc/$mulon a renamed field update its@db.columnkey (before, both wrote a stray key under the logical name). The first sync also reportsprof.bioadded andprofile.bioremoved once — the removal unsets a path that holds no data, and the backfill never overwritesprof.bio. - View filters with a field-to-field comparison may return fewer rows.
`Item.qty > Item.cap`in@db.view.filter/@db.view.havingused 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 explicitor Item.cap not existswhere you relied on the old result.
Memory
- Sync fix: a model removed from the schema is really dropped. Sync reported the table as
dropbut kept its rows, and a model added back was reportedin-syncand served them again. The table's rows, unique indexes and increment counter are now deleted; added back, it is reportedcreateand 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 withinvalid 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/CHARcolumn fails instead of truncating. Lowering@expect.maxLengthbelow a stored value used to cut the value silently; the sync now fails withvalue 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.
dropTableByNameanddropViewByNameare no longer optional: theBaseDbAdapterdefaults throw… is not supported by this adapter. Before,DbSpaceskipped the drop when the adapter lacked the method, and sync reported the table or view dropped. Sync now reports it as anerrorentry and keeps it tracked. All built-in adapters implement both. - New hook
registerSpace(space).DbSpacecalls 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-runshows 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.joinskept 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,existsornot existsused to render wrong SQL silently; they now render correctly.matchesin 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.ignorefield 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'saggregateFns()fails the sync. Every built-in adapter declares all six functions; this affects custom adapters only. - MongoDB
@db.view.filter/@db.view.havinguse query semantics.existsnow means "holds a value" (a storednullcounts as absent) instead of key presence,not existsworks, andmatchesaccepts/pattern/flags— see MongoDB → Views.
Query results
- MongoDB
count(field)no longer counts documents where the field is missing — it now matches SQLCOUNT(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 returnsum_amount_cents, and a$havingor$sortonsum_amountdid not match it. It now returnssum_amounton 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) ortrue(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) includescountDistinct. ARecord<TDbAggregateFn, …>lookup table in your code needs acountDistinctentry.AGG_FN_SQL(@atscript/db-sql-tools) is keyed byExclude<TDbAggregateFn, "countDistinct">. It still mapssum,count,avg,minandmax, but indexing it with a plainTDbAggregateFnno longer type-checks.countDistincthas no function name to map — render it asCOUNT(DISTINCT x)yourself.resolveCalendarBucketsis deprecated. UsenormalizeComputedSelect, which has the same signature — see Calendar buckets for adapter authors.- Class-level actions can declare an input form.
@DbActions/@DbRowActions/@DbRowsActionsentries takeinputForm: a compiled.astype, 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.