IR and Plan protocol
A client renders the model built with the DSL into the request below, plans it in the calling process, and caches the plan by the request shape. The type definitions in packages/go/engine/ir/ir.go and packages/go/engine/plan/plan.go are authoritative; every client implements the same fields.
1. Request
{
"ir_version": 1,
"manifest_hash": "sha256:e82672f09b5852725e8b4d17ad0500b3d8ef5fbeeeee1c04c7e2b1ec0b9bb785",
"kind": "one | all | count | group_count | sum | avg | paginate | insert | update | delete | restore | restart_identity",
"entity": "author",
"columns": Columns,
"where": Group,
"joins": [Join],
"relations": [Relation],
"order": [Order],
"group_by": ["user_seq"],
"limit": {"offset_p": 6, "count_p": 7},
"force_index": "ix_service",
"lock": "update | share | update_nowait | share_nowait",
"agg": "read_count",
"load": true,
"set": [Assign],
"rows": [[4, 5], [6, 7]],
"on_duplicate": [Assign],
"optimistic": {"column": "updated_ts", "p": 3},
"n_params": 8
}Values never appear in the request. Every value is a parameter index into the client's parameter list, and n_params is the length of that list. The plan is therefore independent of values. A public limit(offset, count) call takes literal integers; canonicalization rewrites only the root query's page limit to the exact pair {"offset_p": N, "count_p": M} and appends the offset and count as i64 parameters. The full parameter walk is columns.fn by sorted output name, columns.sub by sorted output name and each subquery recursively, on, where, joins in array order and recursively, relations in array order and recursively, order functions, the root page offset and count, if_parent.p, request set, on_duplicate, rows, optimistic.p, then unused parameters in original order. MySQL renders and binds offset then count; PostgreSQL and SQLite render and bind count then offset. A malformed pair or out-of-range index is IR_INVALID; a bound value that is null, boolean, text, fractional, or outside the shared safe integer range is CODEC_ENCODE; a negative offset or nonpositive count is IR_INVALID. The builder reports an invalid public limit as CONFIG. Validation happens before each plan-cache lookup. A scalar column subquery keeps its literal count-one limit, an implicit one limit stays LIMIT 1, and the count statement of paginate has no page binds.
A request is decoded strictly in every client. A member that its object does not define, at the top level or inside any object (ref, pred, an item, a group, an order item, limit, a join, a relation, an assignment), is IR_INVALID, and so is a member of the wrong JSON type: a column, agg, entity or op that is not a string, a parameter index or count that is not an integer (also a number written as 0.0 or 1e1, whose literal is not an integer; TypeScript reads a request text through ordered-json, which keeps the literal), a list member that is not an array, or an object member that is not an object. A column on a sum request is such an unknown member, since sum and avg name their column in agg. The shape check comes before every other rule, so the request fails with IR_INVALID, not with COLUMN_UNKNOWN or ENTITY_UNKNOWN for a name of the wrong type. A null member is absent. tests/ir/validate.json (the shape-* cases) holds these cases, which Go, PHP, Rust, TypeScript and Python run.
- PRT-1
manifest_hash: themanifestHashof the document set the generated code carries:sha256:followed by 64 lower-case hexadecimal digits (manifest and hashes). A request whose hash differs from the model the client loaded fails withSCHEMA_HASH_MISMATCH. The field replacesschema_hash; the error code keeps its name - PRT-2
kind:oneandallread rows,countcounts rows or groups,group_countreturns grouped rows withrow_count,sumandavgaggregateagg,paginatereturns the page statement and a count statement,insert,update, anddeletewrite rows,restoreclears the soft delete column of one deleted row (restore), andrestart_identityrestarts the identity of a table past its largest key (load) - PRT-3
load:insertwrites rows with their stored keys: it takes a value for the identity column (load) - PRT-4
set: assignments ofinsertandupdate - PRT-5
rows: parameters of each additional inserted row in the column order ofset; everysetitem is then a value assignment andon_duplicateis not allowed - PRT-6
on_duplicate: assignments applied when an inserted row meets an existing key; an insert withon_duplicatehas exactly one key that can meet the inserted row (the primary key, unless the insert leaves it out, and each unique key) and assigns every column of it, elseIR_INVALID"on_duplicate needs exactly one conflict key; %s can meet the row", because MySQL updates the row met on any unique key while PostgreSQL and SQLite name one target - PRT-7
optimistic:updatematches only when the column still equals the parameter; no match returnsOPTIMISTIC_LOCK - PRT-8
lock: root row selection only; the client allows it only inside a transaction
Query is the shape shared by the root, a join child, a relation child, and a subquery: entity, columns, on (join child only), where, joins, relations, order, group_by, limit, force_index, lock, and the relation options.
group_by is valid only on the root of a count (the number of groups) and of a group_count; on any other kind, and in a join child, a relation child or a subquery, the request fails with IR_INVALID. A grouped row has no value for a column outside the group, so no other kind could name its output in all three databases.
1.1 Columns
Columns = {
"mode": "" | "all" | "none",
"add": ["name"],
"remove": ["description"],
"fn": {"created_date": {"column": "created_ts", "fn": Func}},
"sub": {"read_total": Sub}
}- PRT-9
mode""selects the default select set, every column except those ofselect explicit(runtime model);allselects every column, andnonekeeps primary and foreign keys. - PRT-10
fnandsubadd named outputs. An output name must not be a column of the entity. - PRT-11 The primary key and the keys that relations bind are always selected.
1.2 Joins and relations
Join = {"rel": "service_model", "kind": "inner | left", "left": "service_seq", "right": "seq", "query": Query}
Relation = {"rel": "writer", "kind": "one | many", "keys": [{"left": "user_seq", "right": "seq"}], "query": Query,
"key_by": "user_seq", "flatten": false, "limit_per_parent": 2,
"if_parent": {"column": "is_close", "p": 4}, "no_cascade_delete": false}- PRT-12
relis the result name. In a join,leftis a column of the parent andrighta column of the child; both are required. A relation requireskindandkeys, one{"left", "right"}pair per key component in key order, so a relation over a composite foreign key carries every component:composite_membershiploads fromcomposite_accountwith[{"left": "tenant_id", "right": "tenant_id"}, {"left": "account_id", "right": "account_id"}]. An emptykeys, a pair withoutleftorright, or a column that appears in two pairs on the same side isIR_INVALID. The client never infers keys from names or foreign keys. - PRT-13 A join child's
ongroup is added to theONclause. Itswheregroup is placed where ajoineditem names it, otherwise it is appended to the parentWHEREwithAND. - PRT-14 A relation runs as a separate statement.
limit_per_parentlimits child rows per parent key,if_parentloads the child only for parents whose column equals the parameter (compared as the column's logical type, so a bool column stored as 0 or 1 equals a bool parameter),flattenmerges aonechild into the parent as stated below,key_bykeys the child collection by a non-null column of the child (a nullable column isIR_INVALID, since a NULL key has no answer that every language shares; an emptykey_byis absent, as an omitted one is), andno_cascade_deleteexcludes the relation from recursive delete. - PRT-15
flattenmerges aonechild into the parent's array and JSON forms: after the parent's own members, the members of the child's array form — its outputs, its relations, and a flattened grandchild merged likewise; a name the parent holds keeps the parent's value, and an absent child adds nothing. The rows a plan assembles and an IR executor answers hold the child underrel; the merge is the client model's. The conformance vectorrelations_flattenpins a shared name, an absent child and a flattened grandchild.
1.3 Groups and predicates
Group = {"conn": "and | or", "not": true, "items": [Item]}
Item = {"pred": Pred} | {"group": Group} | {"joined": {"conn": "and | or", "join": "service_model"}}
Pred = {"conn", "column", "op", "p"} // eq not_eq gt gte lt lte contains contains_binary fulltext fulltext_boolean
| {"conn", "column", "op": "in | not_in | between", "ps": [...]}
| {"conn", "column", "op": "is_null | is_not_null"}
| {"conn", "column", "op": "eq_col | not_eq_col | gt_col | gte_col | lt_col | lte_col", "ref": {"path": "service_model", "column": "seq"}}
| {"conn", "op": "tuple_in | tuple_not_in", "cols": ["tenant_id", "account_id"], "ps": [0, 1, 2, 3]}
| {"conn", "column", "op": "in | not_in", "sub": Sub}
| {"conn", "column", "op", "p", "fn": Func}
| {"conn", "column", "op", "value": Func}
Func = {"name": "day_of_week | year | month | date | now | today | days_ago | …", "ps": [0]}
Sub = {"query": Query, "column": "user_seq", "agg": "sum | avg | count"}- PRT-16
connjoins an item to the previous item of its group; the first item has none. A first item withorfails withOR_AT_GROUP_STARTand withandwithIR_INVALID; a top-levelwhereorongroup has no previous sibling, so its ownconnfails withIR_INVALID. - PRT-17
ref.pathis the join path from the statement root (""is the root,a/ba nested join) or^, the model that owns a subquery. A relation and a subquery are statements of their own, so their""is their own root. Validation resolves every ref, also in a relation or subquery that the request kind does not render: a path through a name that is not joined fails withENTITY_NOT_JOINED,^outside a subquery withIR_INVALID, and a column that the target model lacks withCOLUMN_UNKNOWN. - PRT-18 The shared cases of
tests/ir/validate.jsonpin the validation rules: Go, PHP, Rust, TypeScript and Python read the file from their own package and pass each request to their validator alone. The PHP extension also implements request validation and planning in native code; its shared PHP request and plan tests run withORM_PHP_MODE=extensionand assert thatRequest::canonicalizeandPlanner::compileare internal methods. - PRT-19
fnapplies a column function tocolumnbefore the comparison withp.valuecomparescolumnwith a value function; a relative time function outside 0001-01-01 to 9999-12-31 23:59:59.999999, or with an interval past that span, is NULL, so neither the comparison nor its negation matches a row (dialects). Functions are structure only; each dialect renders them as described in dialects, and an unknown name returnsFUNCTION_UNKNOWN. - PRT-20 The two columns of a column comparison, of a column
inornot_ina subquery column, of a join'sleftandrightand of each relation key pair meet as dbspec check operands do (dbspec): integers with integers,decimalof one scale,f64,bool,varcharandtexttogether,uuid,date,timeordatetimeof one precision, andbyteswithbytes; any other pair fails withOPERATOR_NOT_ALLOWED, because the databases then disagree (PostgreSQL refusesinteger = varchar, MySQL coerces, SQLite compares by affinity). The validator reads the table of dbspec's check validation, and the conformance vectorcolumn_pairsruns both kinds of pair. - PRT-21 A scalar column subquery (
columns.subwith noagg) haslimitwith count 1 (anyoffset) and anorderthat includes every primary key column and norandomitem, so it yields at most one row on every database and is NULL when none matches; without them the request isIR_INVALID, because MySQL answers 1242 and PostgreSQL 21000 for several rows and SQLite returns the first.limitstaysIR_INVALIDin aninsubquery (MySQL 1235) and in an aggregate subquery. - PRT-22 A column comparison (
eq_coland the others) is allowed when its base operator (eqforeq_col) is one of the left column's type and neither column has a codec, sobool gt_col,bytes gt_coland anaescolumn'seq_colfail withOPERATOR_NOT_ALLOWED; two columns whose only codec isiptakeeq_colandnot_eq_col, because both sides are the dialect's same stored form. - PRT-23
notnegates a nested group, which rendersNOT (…); a top-levelwhereorongroup withnotfails withIR_INVALID. - PRT-24 The request carries no SQL text: every condition, output, order, grouping and assignment is one of the forms above, and every bind slot of the plan has a dbspec type.
- PRT-25
containsandcontains_binarybind the value between wildcards;containscompares the ASCII letters without regard to case and every other character by code point, andcontains_binarycompares by code point (dialects). - PRT-77 Value function. A value function in a
Funcnames its function inname, the snake_case name of the ORM value function (seconds_ago,now), and gives the request parameter indexes of its arguments inps. A function without arguments has nops:nowis{"name": "now"}. - PRT-78 A canonical root page limit has exactly
offset_pandcount_pindexes. Literaloffsetandcountare accepted only from the public builder before canonicalization and remain literal only in a scalar column subquery. Each page shape is independent of its offset and count values, so page requests with the same query structure share one cached plan.
1.4 Order and assignments
Order = {"column": "seq", "desc": true} | {"column": "start_dt", "fn": Func} | {"random": true}
Assign = {"column", "p"} | {"column", "null": true} | {"column", "plus_p"} | {"column", "minus_p"}An order item sorts NULL before every value ascending and after every value descending: MySQL and SQLite do so natively, and PostgreSQL, which sorts NULL last ascending, renders ASC NULLS FIRST and DESC NULLS LAST on an order expression that can be NULL (a nullable column or a column function of one); a NOT NULL column keeps its btree order. This holds in the root order, in the ROW_NUMBER() order of limit_per_parent and in the group_count order.
With group_by, an order item names a group column, a function of one, or random; any other column fails with IR_INVALID ("order by %s, which is not a group column"), because a group has no single value for it (MySQL 1055, PostgreSQL 42803, an arbitrary row on SQLite).
minus_p never stores a negative value.
An update that assigns a primary key or identity column fails with IR_INVALID ("cannot update %s"), and so does a restore that assigns one or the soft delete column, which it clears itself; the validator refuses it, not only the planner, so an IR executor that does not plan sees the same rule.
An insert assigns by p or null: plus_p and minus_p are IR_INVALID in its set and valid in on_duplicate and in update, because a VALUES list has no current row to read, and MySQL reads the column's default there (3 + 5 = 8) while PostgreSQL and SQLite refuse the statement.
An insert that omits a column with a default leaves it to the database default; the planner adds no value for it. An insert that omits a non-null column without a default fails with IR_INVALID. The planner assigns the columns the executor owns: the AES key version, the updated column on every update, and on a table with an audit setting the audit column of every insert, update, soft delete, restore and duplicate update; a request that assigns the AES key version or the audit column fails with IR_INVALID.
1.5 Restore
A restore request names one soft-deleted row of a table with a soft_delete setting (dbspec). Its set holds the values of the row, under the rules of an update assignment, and its where holds conditions joined by and; an or between two items of the where fails with IR_INVALID. The planner picks the key after the request hook: the set values with a parameter (p) and the eq predicates of a column with a parameter, without a function, value function, ref or subquery, in the items of the where give columns their values, and the key is the primary key when they cover every column of it, otherwise the first unique key, in name order, whose columns they cover. A set value of a key column becomes an eq condition on that column, after the where and in the order of the set; every other set value is a new value written with the restore. The other conditions of the where narrow the restore further, and the key still matches at most one row, so a request hook adds a condition to a restore as it does to an update. A request whose values cover no key fails with IR_INVALID, and so does a new value of a primary key, identity or soft delete column. It has no optimistic. The plan is one main step; for a set of team_id, member_id and note on the unique key (team_id, member_id):
UPDATE `link` SET `note` = ?, `deleted_at` = NULL, `audit_seq` = ? WHERE `link`.`team_id` = ? AND `link`.`member_id` = ? AND `link`.`deleted_at` IS NOT NULLThe step writes the new values, clears the soft delete column and, on a table with an audit setting, writes the audit column; like a soft delete it does not assign the updated column. A row that is not deleted and a missing row match no row, so the statement changes nothing, the new values included. A request on a table without soft_delete fails with IR_INVALID. Reads always exclude soft-deleted rows: the root and each subquery in their WHERE, each join child in its ON condition, so that a left join gives a null child, and each relation in its own statement; restore is the only request that matches one (the conformance vector joins_soft_deleted). The model method restore sends its set values as the set and no where, runs the step and then reads the row by the values of the key the planner picked from the request as the hook left it (usage).
1.6 Load
Loading rows that already exist elsewhere into a database (a rebuild or an export) must keep their keys, which other rows reference. An ordinary insert refuses a value for the identity column, because the database generates every identity key (dsl). A load writes the keys itself and then restarts each identity:
PRT-26 An
insertwith"load": truetakes a value for the identity column. On a table with an identity column, itssetassigns that column a parameter (p); a load that leaves it out fails withIR_INVALIDload of <entity> needs a value for the identity column <column>, and one that assigns itnullfails as anynullfor a non-null column does. Every other rule and the statement are those of aninsert,rowsincluded; a load of a table without an identity column is an ordinaryinsert.loadon another kind or withon_duplicatefails withIR_INVALIDload is only valid on insert without on_duplicate. A request withoutloadthat assigns the identity column still fails withcannot set identity column <column>.PRT-27 A
restart_identityrequest names one entity and nothing else: aset,rows,on_duplicate,whereoroptimisticfails withIR_INVALIDrestart_identity takes no set, rows, on_duplicate, where or optimistic, and an entity without an identity column fails withIR_INVALIDrestart_identity of <entity>, which has no identity column. Its plan is onemainstep without bind slots whosetablesis the table. After it the next generated key is the largest key of the table plus one, or 1 for an empty table, also when the counter stood past it:PRT-28 MySQL:
ALTER TABLE `load_probe` AUTO_INCREMENT = 1: InnoDB sets a value at or below the largest key to the largest key plus onePRT-29 PostgreSQL:
SELECT setval(pg_get_serial_sequence('"load_probe"', 'seq'), COALESCE(MAX("seq"), 0) + 1, false) FROM "load_probe"PRT-30 SQLite:
UPDATE sqlite_sequence SET seq = (SELECT COALESCE(MAX("seq"), 0) FROM "load_probe") WHERE name = 'load_probe'; a table that never took a row has nosqlite_sequencerow, and its next key is already past its largest key
PostgreSQL identity does not advance past an explicit key, so without the restart the next generated key collides with a loaded one; MySQL and SQLite advance their counters past an explicit key, and the restart gives every dialect the same next key. The MySQL statement is DDL, which commits an open transaction, so a load runs the restart after its rows are committed. A client runs both with its execute call (usage); the shared cases contracts/fixtures/identity_load.json state the statements, the errors and the keys after a load on MySQL, PostgreSQL and SQLite.
2. Plan
{
"manifest_hash": "sha256:…", "kind": "all",
"steps": [
{"id": 0, "role": "main", "sql": "SELECT `a`.`seq` AS `a__seq`, … FROM `author` AS `a` … LIMIT 0, 20", "tables": ["author", "service"],
"bind_slots": [{"from": "param", "param": 0}, {"from": "secret", "name": "aes"}],
"assemble": {"entity": "author", "alias": "a",
"columns": [{"index": 0, "name": "seq", "column": "seq", "type": "i64"}],
"key": [{"column": "seq", "index": 0}],
"children": [{"rel": "service_model", "kind": "join", "assemble": {…}}]}},
{"id": 1, "role": "relation", "sql": "…", "tables": ["user"], "bind_slots": [{"from": "parent"}], "parent": {"step": 0, "keys": [{"column": "user_seq", "index": 14}]}}
]
}- PRT-31
tableslists the tables that the statement names, sorted and without duplicates: the target table of a write, the root table of a select, its joined tables and the tables of its subqueries; a relation step lists its own. - PRT-32
bind_slots.fromisparam(a request parameter, withtransformfor contains values andhost_stylesfor AES, hex, and IP stages),secret(the AES key),config(the AES key version),parent(relation key values),now(the client clock in UTC), oraudit(the primary key of the transaction's audit record, withnamethe audit record table). Aparamslot of a column carries the column's size:precisionandscalefordecimal(p,s),precisionfortime(p)anddatetime(p)(six for the argument or result of a function), andlengthforvarchar(n); the planner caseplanner_bind_types_insertand the others ofcontracts/fixtures/planner.jsonpin them. The bind path checks each parameter against these sizes before any statement (§5 "Parameters"). Atransformslot binds its text transformed (like_containsescapes\,%and_and wraps the text in%); SQL NULL binds NULL, also through a condition builder, socol LIKE NULLmatches no row on the three databases (the conformance vectorcontains_null), and a value other than text isCODEC_ENCODE"%s requires a string value" (thecontains-*cases oftests/bind/vectors.json). A write that has anauditslot and runs outside a transaction with audit values, or in one whose audit record is a row of another table thanname, fails withCONFIGbefore it reaches the database. - PRT-33 Clock. This is the one clock rule; dialects and plans refer to it. Every connection reads and writes
datetime(p)in UTC. Thenowslot is the client wall clock in UTC with microsecond resolution, truncated to six fraction digits, in thedatetimetext form; a statement reads the clock once, so itsnowslots are equal and the statement has one clock value. A clock that the ORM writes is truncated to the declared fraction digits of the column, and a clock that it compares keeps six digits. On MySQL the update time and soft deletion assignCURRENT_TIMESTAMP(p)(CURRENT_TIMESTAMPfor p = 0), which truncates the statement clock (mysql.now.precision_truncates), an insert leaves adefault nowcolumn to its database default, and thenowvalue function and its relative forms useNOW(6); adatetime(p)column compares withNOW(6)as its value (mysql.now.compare_six_digits). PostgreSQL rounds a clock intotimestamp(p < 6)(postgres.now.precision_rounds) and itsnow()is the transaction start, not the statement start (postgres.now.transaction_start), so the PostgreSQL dialect binds thenowslot, truncated to p, for the update time, soft deletion and everydefault nowcolumn that an insert omits, and binds thenowslot at six digits, asCAST(<slot> AS timestamp(6)), for thenowvalue function and its relative forms; atimestamp(p)column compares with it as its value (postgres.now.compare_six_digits). The DDL defaultstatement_timestamp()stays for raw SQL. SQLite has no clock with microseconds, so the SQLite dialect binds thenowslot, truncated to p, for the update time, soft deletion and everydefault nowcolumn that an insert omits, binds it at six digits for thenowfunction, and renders a relative form asdatetimeof anowslot with the interval followed by the six fraction digits of a secondnowslot; SQLite compares text, so adatetime(p < 6)column that compares with a clock is extended to six digits,col || '.000000'for p = 0 andcolfollowed by 6 − p zeros otherwise. The conformance vectorsclock_truncate_writeandclock_compare_six_digitsrun under a clock fixed by the test entry point (3.3). The plan history keeps the tool clock with six fraction digits (plans). - PRT-34 Rows are read by position.
assemble.columns[].styleslists the codec stages the client decodes; SQL-side stages are already applied. - PRT-35 In
Collectionassembly,assemble.keyis the row identity; a SQL NULL component fails withCODEC_DECODE. A configured collection key containing SQL NULL also fails withCODEC_DECODE.GroupRowsis a distinct result, not aCollection, and retains nullable group values as specified by INT-68. - PRT-36 A
group_countrow retains the declared types of its selected group columns; a boolean group value is a JSON boolean, and an invalid database boolean fails decoding. - PRT-37
children[].kindisjoinfor a child in the same row, andoneormanyfor a relation step matched byparent_keysandchild_keys.
2.1 Relation steps
- PRT-38 Each relation has one
relationstep after its parent step; nested relations follow the same rule, andpaginateorders the steps as main, relations, count. - PRT-39 The client reads the parent keys of every parent row in order, removes rows with a null key, removes duplicates, and skips the statement when no key remains. With
if_parent, only parents whose column equals the parameter are used. - PRT-40 The
parentslot is one placeholder that the client expands to the key list. The list is padded to a power of two by repeating the last key, so one prepared statement serves each size class. A list longer than the driver bind limit is split into chunks. - PRT-41 A multi-row insert (
creates(rows, batch)) runs its rows as batches in the given order: each batch isbatchrows, the last one the rows left when fewer, and each batch is one multi-row INSERT; the client never chooses another size and never splits a batch.batchis a positive integer, otherwiseCONFIGbefore any statement. A batch whose binds (its rows times its columns) exceed the bind limit, 999 on SQLite and 65535 on MySQL and PostgreSQL, fails withBATCH_TOO_LARGEbefore any statement, and the message names the batch, its bind count and the limit. Rows are never padded, since a repeated row would be inserted. All batches run in one transaction (a savepoint inside an active transaction), so a failing batch leaves no row of the call, and the inserted rows, their generated keys and the statement events keep the order of the given rows. The shared cases arecontracts/fixtures/insert_parts.json. - PRT-42
oneattaches the first child row andmanya collection in child order; a duplicate collection key keeps the last row.
3. Errors
Errors carry a code from errors.yaml and a message, for example IR_INVALID, SCHEMA_HASH_MISMATCH, COLUMN_UNKNOWN, OPERATOR_NOT_ALLOWED, FUNCTION_UNKNOWN, EMPTY_IN, LIMIT_IN_RELATION, and COLUMN_ALIAS_CONFLICT. Executors add CONFIG, OPTIMISTIC_LOCK, LOCK_NOT_AVAILABLE, DEADLOCK, DUPLICATE_KEY, FOREIGN_KEY, CONSTRAINT, READ_ONLY, CONNECTION_LOST, and DRIVER. The client returns every driver error as an ORM error: a condition that the catalog lists has its code, every other driver error has DRIVER, and each keeps the driver message and the driver error as its cause. A connection that the server ended or that broke is CONNECTION_LOST in every client, and no client matches a message text to decide it (config). A write that an audit or immutable trigger refuses is DRIVER. When the callback of a transaction or savepoint fails and its rollback fails too, the client returns one error with the code ROLLBACK; its message names both errors, and it keeps the callback error and the rollback error (PHP: the previous exception and rollback, TypeScript: cause and rollback, Go: errors.Join of both in that order, Rust: Error::Rollback { callback, rollback }, Python: cause and rollback). A ROLLBACK error is not retried. If a SQLite NOWAIT operation and restoring busy_timeout both fail, the returned error keeps the primary lock error's code and both error objects in order (PHP: previous and cleanup, TypeScript: an AggregateError in cause, Go: errors.Join, Rust: Error::Cleanup { primary, cleanup }, Python: an ExceptionGroup in cause). If only one operation fails, that error is returned unchanged. Restoring busy_timeout is attempted after NOWAIT setup, table setup, and lock failures. A NOWAIT lock failure is LOCK_NOT_AVAILABLE and is not retried as a transaction conflict.
- PRT-79 Native driver errors. Each client maps a recognized native driver condition to its catalog code and every unrecognized driver error to
DRIVER; the ORM error keeps the driver's message and the native driver error as its cause. Each implementation tests this at its native error mapping step without a database server.
3.1 Test faults
A test makes the rollback of a transaction fail deterministically on every database through the test entry point of its client; a production process cannot arm the fault. The entry point arms a rollback fault on the connection of a handle, and every handle of that connection shares it. The next rollback of a transaction whose callback failed runs as usual, so the database discards the transaction, and the client then reports the rollback as failed with a FAULT error: the transaction returns one ROLLBACK error that keeps the callback error and the FAULT error. A commit, a savepoint rollback, and a rollback after a failed begin or commit do not consume the fault; it stays armed until a transaction rollback after a failed callback consumes it, and the next transaction whose callback fails returns its callback error alone.
The fault exists only in the test entry point of each client. No DSN, configuration value, or environment variable arms it.
- PRT-43 The
github.com/polyspec/orm/packages/goGo module — Go entry point:orm.FailNextRollback(db); How a production build excludes it: The file is compiled only with the build tagormtest(go test -tags ormtest); without the tag the function does not exist and a call does not compile - PRT-44 The
polyspec-ormcrate — Rust entry point:polyspec_orm::testing::fail_next_rollback(&db); How a production build excludes it: The module is compiled only with the cargo featuretest-faults, which no default feature enables; it is enabled in[dev-dependencies] - PRT-45 The
@polyspec/ormpackage — TypeScript entry point:failNextRollback(db)of@polyspec/orm/testing; How a production build excludes it: The package exports the subpath only under the conditionorm-test; withoutnode --conditions=orm-testthe import fails withERR_PACKAGE_PATH_NOT_EXPORTED, and the package entry point does not export the function. A type check of the test resolves the subpath withcustomConditions: ["orm-test"] - PRT-46 PHP — Entry point:
Polyspec\Orm\Testing\Faults::failNextRollback($db); How a production build excludes it: The class is intesting/Faults.phpof the package, which the package autoloader does not map; a process has it only after it requires that file by its path - PRT-47 The
polyspec-ormwheel — Python entry point:polyspec.orm.testing.fail_next_rollback(db); How a production build excludes it: The packagepolyspec.orm.testingis in the distribution, as the PHP class is in the package, and no module ofpolyspec.ormimports it; a process has it only after it importspolyspec.orm.testingby that name, and the package entry pointpolyspec.ormdoes not export the function
3.2 Test statement fault
A test makes the next statement of a connection fail deterministically with a given error code and message, through the test entry point of each client, which is the same entry point as the rollback fault (3.1). The entry point arms the fault on the connection of a handle, and every handle of that connection shares it. The arguments are the database, the code, a code of errors.yaml, and the message, a nonempty text. An empty code or message is refused with the programmer-error signal of the client and arms nothing: a panic in Go and Rust, a TypeError in TypeScript, an InvalidArgumentException in PHP, and a ValueError in Python.
The next statement that the connection runs for the caller, a model read or write, a query, a schema statement (including the SQLite foreign_keys pragma that a table rebuild sets and restores around its statements), a session value, or a named-lock acquire or release, is not sent to the database. The client reports it as an OrmException with the given code and message, at the same statement end that a driver error takes, so the statement event of that statement carries the error. The database is unchanged by the refused statement, and the fault is consumed by it. The statements of the transaction machinery do not consume it: BEGIN, COMMIT, ROLLBACK, SAVEPOINT, RELEASE, ROLLBACK TO; the settings that a transaction applies when it begins (isolation, read-only, statement timeout, read_uncommitted, query_only); the locals and settings that it restores when it ends; and the statements of the SQLite row-lock table: its creation, its row insertion, and the busy_timeout change that brackets the insertion. Arming a fault while another statement fault is armed replaces it. A refused statement does not consume the rollback fault of 3.1, and a rollback does not consume the statement fault.
The fault exists only in the test entry point of each client. No DSN, configuration value, or environment variable arms it.
- PRT-48 Go — Entry point:
orm.FailNextStatement(db, code, message); How a production build excludes it: The same file asFailNextRollback, compiled only with the build tagormtest - PRT-49 Rust — Entry point:
polyspec_orm::testing::fail_next_statement(&db, code, message); How a production build excludes it: The same module asfail_next_rollback, compiled only with the cargo featuretest-faults - PRT-50 The
@polyspec/ormpackage — TypeScript entry point:failNextStatement(db, code, message)of@polyspec/orm/testing; How a production build excludes it: The same subpath asfailNextRollback, exported only under the conditionorm-test - PRT-51 PHP — Entry point:
Polyspec\Orm\Testing\Faults::failNextStatement($db, $code, $message); How a production build excludes it: The same class asfailNextRollback, intesting/Faults.php, which the package autoloader does not map - PRT-52 The
polyspec-ormwheel — Python entry point:polyspec.orm.testing.fail_next_statement(db, code, message); How a production build excludes it: The same package asfail_next_rollback, which no module ofpolyspec.ormimports
The statement fault is armed on a Db and counts the statements that the Db runs. The dbspec apply, recover, rollback, finalize and introspection of every implementation run on the driver connection that the caller passes, outside the Db, so they do not consume it: Go packages/go/engine/dbspec/apply.go:138,159,200,282 (Execer) and packages/go/engine/dbspec/introspect.go:37 (Querier); Rust packages/rust/orm/src/dbspec/apply.rs:296,313,331,347 (ApplyConnection) and packages/rust/orm/src/dbspec.rs:52 (CatalogQuerier); TypeScript packages/ts/src/dbspec/apply.ts:720,737,768,820 (Connection) and packages/ts/src/dbspec/introspect.ts:33; Python packages/python/src/polyspec/orm/dbspec/apply.py:703,718,743,802 and packages/python/src/polyspec/orm/dbspec/introspect.py:16 (Connection); PHP packages/php/src/Dbspec/PlanApply.php:104,120,151,214 and packages/php/src/Dbspec/Dbspec.php:266,731 (\PDO); the PHP extension polyspec_orm packages/php-ext/src/polyspec_orm.stub.php:665,703,706,709,712 (\PDO), implemented in packages/php-ext/src/apply.c and introspect.c.
3.3 Test clock
A test fixes the executor clock of a connection through the test entry point of its client, the same entry point as the faults of 3.1 and 3.2. The entry point takes the database and a clock, the UTC text YYYY-MM-DD HH:MM:SS.ffffff; a clock of another form is refused with the programmer-error signal of the client (3.2) and fixes nothing. From then on every now slot of the connection and every handle of it, and the now of an IR executor message, is that clock instead of the wall clock; the connection keeps it until it closes. A database clock (MySQL CURRENT_TIMESTAMP(p), NOW(6)) is not fixed. No DSN, configuration value, or environment variable fixes it.
- PRT-53 The
github.com/polyspec/orm/packages/goGo module — Go entry point:orm.FixClock(db, clock); How a production build excludes it: The same file asFailNextRollback, compiled only with the build tagormtest - PRT-54 The
polyspec-ormcrate — Rust entry point:polyspec_orm::testing::fix_clock(&db, clock); How a production build excludes it: The same module asfail_next_rollback, compiled only with the cargo featuretest-faults - PRT-55 The
@polyspec/ormpackage — TypeScript entry point:fixClock(db, clock)of@polyspec/orm/testing; How a production build excludes it: The same subpath asfailNextRollback, exported only under the conditionorm-test - PRT-56 PHP — Entry point:
Polyspec\Orm\Testing\Faults::fixClock($db, $clock); How a production build excludes it: The same class asfailNextRollback, intesting/Faults.php, which the package autoloader does not map - PRT-57 The
polyspec-ormwheel — Python entry point:polyspec.orm.testing.fix_clock(db, clock); How a production build excludes it: The same package asfail_next_rollback, which no module ofpolyspec.ormimports
The conformance runners take it from the same place: the Go runner is built with -tags ormtest, the Rust runner with the feature polyspec-orm/test-faults, the TypeScript runner imports dist/testing.js by its path, the PHP runner requires testing/Faults.php and the Python runner imports polyspec.orm.testing.
3.4 Test write result
A test reads the write result of a connection through the test entry point of its client, the same entry point as 3.1 to 3.3. The entry point takes the database and returns the affected of the last write of the connection and every handle of it that succeeded (an insert, update, delete or restore, 5 "Write"), or none before the first such write. A write that fails leaves the value as it was. Every client keeps the value only to answer this entry point. The conformance vector affected_counts reads it after an upsert that inserts, an upsert that updates, an update of a row that already holds the value and an update that changes it.
- PRT-58 The
github.com/polyspec/orm/packages/goGo module — Go entry point:orm.LastAffected(db), which returns(affected, ok); How a production build excludes it: The same file asFailNextRollback, compiled only with the build tagormtest - PRT-59 The
polyspec-ormcrate — Rust entry point:polyspec_orm::testing::last_affected(&db), anOption<u64>; How a production build excludes it: The same module asfail_next_rollback, compiled only with the cargo featuretest-faults - PRT-60 The
@polyspec/ormpackage — TypeScript entry point:lastAffected(db)of@polyspec/orm/testing, anumberornull; How a production build excludes it: The same subpath asfailNextRollback, exported only under the conditionorm-test - PRT-61 PHP — Entry point:
Polyspec\Orm\Testing\Faults::lastAffected($db), an?int; How a production build excludes it: The same class asfailNextRollback, intesting/Faults.php, which the package autoloader does not map - PRT-62 The
polyspec-ormwheel — Python entry point:polyspec.orm.testing.last_affected(db), anintorNone; How a production build excludes it: The same package asfail_next_rollback, which no module ofpolyspec.ormimports
4. Planning in the client
The shared requirement requires each configured implementation to validate and plan requests in the calling process. The PHP extension native planner remains incomplete; it does not change this requirement. No compiler service or daemon is involved.
| Client | Validation, planning, dialects, and DDL |
|---|---|
| Go | packages/go/engine/ir, packages/go/engine/planner, packages/go/engine/dialect, packages/go/engine/dbspec DDL |
| PHP | packages/php/src/Validator.php, Planner.php, Dialect.php, Dbspec/Renderer.php DDL |
| PHP extension | Shared requirement applies; native ORM validation/planning is incomplete and remains tracked under T182 and KSTD-2-3 |
| Rust | packages/rust/orm/src/engine/, packages/rust/schema/src/dbspec/ DDL |
| TypeScript | packages/ts/src/engine/, packages/ts/src/dbspec/ DDL |
| Python | packages/python/src/polyspec/orm/engine/, packages/python/src/polyspec/orm/dbspec/ DDL |
Generated code carries the manifest text of its document set and its manifestHash. The client builds the runtime model from that text once and rejects text whose hash differs from the declared one (SCHEMA_HASH_MISMATCH). The plan cache key is the manifest hash and the request shape. Parameter values are not part of the key. The PHP client and the PHP extension bound their plan cache and their statement cache by 8192 retained placeholders each, as well as by the number of entries: an entry of a multi-row INSERT holds about 390 bytes of plan and about 190 bytes of bound values per placeholder, so entries are evicted least recently used first while the cached placeholders exceed the bound, and a request with more placeholders than the bound is planned and run without being cached. The bound applies to PHP only, because only the PHP caches were measured to grow with the placeholders of their entries.
A process can load the generated code of several document sets, and one connection can serve several of them. A connection plans a request only with a set registered on that connection, because what a request can run against is decided by the database the connection uses, not by the code the process loaded. A set is registered on a connection in three ways. utils().schema().register(schema) registers the set on the connection. The connect helper of generated code (Go model.Connect(dsn, config), PHP Polyspec\Orm\Tests\Model\connect($dsn, $config), Rust model::connect(dsn, pool_size, config), TypeScript connect(dsn, options) of the generated module, Python connect(dsn, **options) of the generated module) opens the connection and registers its own set the same way through connectSchema (Go orm.ConnectSchema, PHP Orm::connectSchema, Rust Db::connect_schema, TypeScript Db.connectSchema, Python Db.connect_schema). utils().schema().install(schema) creates the tables of the set, verifies the database and registers the set on the same connection. All three take the generated schema value, the manifest text with its declared manifestHash (Go model.Schema, PHP Polyspec\Orm\Tests\Model\schema(), Rust model::SCHEMA, TypeScript SCHEMA, Python SCHEMA), and fail with CONFIG before any statement when the text does not hash to the declared hash. Registering reads and writes nothing in the database, for sets with external documents too: the database is verified when the set is installed or upgraded (install, addTablesAndColumns), not on each connection, so a connection opened per request registers its sets without a statement (schema). A raw connection (connect(dsn, …) of the client) registers no set, and loading generated code registers nothing on a connection. A request whose manifest is not registered on its connection fails with SCHEMA_HASH_MISMATCH before execution, also when a plan of the same shape is cached, and so does a request of generated code whose manifest text does not hash to its declared manifestHash. No other registration call exists. Every client follows this rule.
The shared planning requirement requires all six configured implementations (Go, PHP, PHP extension, TypeScript, Python and Rust) to produce the same SQL and bind slots for the same request. tests/conformance runs the declared vectors and compares outputs; missing PHP extension behavior or database evidence remains incomplete, not an exception to the requirement.
5. IR executors
A DSN scheme may select an IR executor instead of a SQL dialect. The scheme markdown:// selects the flowmark executor, which runs requests on a markdown corpus served by a flowmark server (capability table). Section 4 applies to SQL dialects only.
- PRT-63 Connection. The DSN is
markdown://127.0.0.1:<port>/<corpus>?token=<token>: the port thatflowmark servelistens on, on loopback TCP only; the corpus root's directory name; and the token that the server keeps in.flowmark/server.jsonof the corpus. The DSN takes no other parameter, and another host, a missing port, a path that is not one name or a missing token isCONFIGbefore any connection opens. A connection of the client is one TCP connection to the server, and the client keeps them as it keeps database connections (configuration): a pool of them, or, in the PHP client, which keeps no pool, the one connection of eachDb. - PRT-64 Wire. A message is one JSON object on one line, and the server answers the messages of a connection in order, each with one line. A message with
opis a control message, any other a request message. The first message of a connection is{"op": "connect", "corpus": <corpus>, "token": <token>}with the DSN's corpus and token, answered{"ok": true}; the server answers a corpus or token that differs, or any other first message,CONFIGand closes the connection, so connecting fails with thatCONFIG. - PRT-65 Validation. The client validates each request with
ir.Validateand does not plan it. It sends no SQL and reads no plan; the executor plans and runs the request. - PRT-66 Message. Each request is one message
{ir_version, manifest_hash, request, params, now}.requestis the request of section 1 with the same fields and values as parameter indexes,manifest_hashis checked as in section 1, andnowis the request clock of section 2 in thedatetimetext form. - PRT-67 Parameters.
paramsuses the runtime value model JSON: a decimal is a string, bytes are base64, a datetime isYYYY-MM-DD HH:MM:SS.ffffffin UTC, a date isYYYY-MM-DDand a time isHH:MM:SS.ffffff; a value ofdatetime(p)ortime(p)has zeros past p, and a time text of the client (10:00:00,2024-01-02T03:04:05.5, or a datetime with an offset, converted to UTC) is written in that six-digit form, while a text of another form isCODEC_ENCODE. The shared cases are themessagecases oftests/bind/vectors.json. The slot a parameter fills decides its form:ir.ParamUseslists each parameter's slots with their logical type (the compared or assigned column with its precision, scale, length and nullability, a column function's result,i32orf64for the interval of a relative time function,textforcontainsandcontains_binary, the parent column forif_parent, the column foroptimistic), the type before any SQL-side style; theparam_usescases ofcontracts/fixtures/planner.jsonpin them in the five validators. A time text of the client is written in the form of that type. Ahexoripcolumn's slot receives the encoded value, as on a SQL database. Every client checks each parameter against its slot's logical type before any statement, and a value outside it isCODEC_ENCODE; an IR executor answers the same. A value is outside it when it is another host kind (an integer fori16,i32andi64, a number forf64, a boolean forbool, text fordecimal,varchar,textanduuid, bytes or text forbytes, text fordate,timeanddatetime), an integer past its range, a non-finitef64, a decimal with more than p digits or s fraction digits, a date outside 0001-01-01 to 9999-12-31, a time or datetime with a non-zero digit past p (a zero there is accepted), avarchar(n)value of more than n characters with trailing spaces counted, U+0000 intextorvarchar, or auuidother than lower-case canonical text. A parameter of adate,timeordatetimeslot takes its canonical text form only: a native time value (aDate, aDateTimeor a PHPDateTimeInterface, a Pythondatetime.date,datetime.timeordatetime.datetime, a Gotime.Time, a chronoNaiveDateorNaiveDateTime) isCODEC_ENCODEnaming the parameter and the slot, in every client. Thenowslot and thenowof a message are the client clock, not parameters, and no check applies to them. SQL NULL is accepted, except by an assignment slot (set,rows,on_duplicate, therestoreset) of a non-null column, which carriesnot_nulland thecolumn: before any statement such a NULL parameter isCODEC_ENCODE"parameter %d assigns NULL to the non-null %s", in an IR executor too (thenull-into-not-nullcase and the conformance vectornull_into_not_null), while raw SQL keeps the driver codes 1048, 23502 and 1299 asDRIVER. Avarcharslot without alength, such as a slot of a utils statement, has no length to check. A column with codec stages checks the value against the input of its first stage. The shared cases are thebindcases oftests/bind/vectors.json(make bind-check) and the conformance vectorsdatetime_precision_rejected,varchar_length_rejected,text_nul_rejectedanduuid_form_rejected. - PRT-68 Codec. The client applies the codec stages itself, as for SQL: it encodes the values of a write before it sends them and decodes the values of a read after it receives them.
- PRT-69 Read. A read answers the rows assembled to the request's shape: joins, relations,
key_byandflattenas section 1.2 defines them. A read answers{"rows": [...]}forone,allandgroup_count,{"count": n}forcount,{"value": v}forsumandavg(vnull when no value is left, which the client reads as a sum of 0 and an average of null), and{"rows": [...], "count": n}forpaginate,ncounting every matching row. A row is a JSON object of its outputs in projection order, then each join (an object, or null for a left join without a match) and each relation (aonerelation an object or null, amanyrelation an array, or an object keyed bykey_by); aflattenrelation is answered asone(section 1.2). Values use the parameter value model: a datetimeYYYY-MM-DD HH:MM:SS.ffffff, a timeHH:MM:SS.ffffff, a decimal and bytes (base64) as strings, an integer orf64a JSON number, aboola JSON bool, a date and a uuid their text. The shared answers arecontracts/fixtures/ir_answers.json(make ir-answers-check), which each client decodes into the model array form. The array form of a row (toArray) writes each temporal column in one form by its type, the value model text above, in every client: adateasYYYY-MM-DD, adatetimeasYYYY-MM-DD HH:MM:SS.ffffffand atimeasHH:MM:SS.ffffff, so adateis never written as a datetime. The shared check is the same file. - PRT-70 Write. A write answers
{affected, keys}, wherekeyslists the primary key of each inserted row in insert order.affectedcounts each written row once: an insert withon_duplicateis 1 on every database, whether it inserted or updated (MySQL alone counts 2 for the update), and anupdatecounts the rows it matched, not only the rows it changed (the MySQL clients ask for found rows). The conformance vectoraffected_countsreads these counts through the test entry point (3.4) on the three databases. - PRT-71 Errors. A failed message is answered
{"error": {"code": <code>, "message": <text>}}, whose code is one code of errors.yaml and which the client returns as that code with that message. A hash mismatch isSCHEMA_HASH_MISMATCH. A write whose target changed since it was read isWRITE_CONFLICT, and so is acommitthat finds a change since the transaction read, such as a row that now matches or no longer matches its read, that makes the transaction's writes other writes; it names the file and line. The client never retries it, even with retry options, since running the callback again over a changed read is never safe: unlikeDEADLOCK, which the transaction retries, the commit returnsWRITE_CONFLICTafter one run of the callback (the casescommit_write_conflictandcommit_deadlockofcontracts/fixtures/ir_answers.json). A lock wait that ends isCANCELED. A line that is not a JSON object, anopthat names no control message and a field that a control message does not take areIR_INVALID. A connection that breaks isCONNECTION_LOST, and so is the answer the server writes before it closes the connection of a transaction idle longer than its bound. When the context or signal of a call ends, the client closes the connection and the call isCANCELED; the server rolls back the transaction of a closed connection. - PRT-72 Session. A connection is a session. Control messages
{"op": "begin", "read_only"?},{"op": "commit"},{"op": "rollback"}and{"op": "savepoint" | "release" | "rollback_to", "name"}begin, end and nest a transaction;namenames the savepoint ofsavepoint,releaseandrollback_to. The client sendsread_onlytrue for a transaction with thereadOnlyoption and leaves it out otherwise; the executor then takes no write lock for that transaction and answers every write in itREAD_ONLY, as a SQL database refuses a write in a read-only transaction (thecontrolcases oftests/bind/vectors.jsonand the caseread_only_writeofcontracts/fixtures/ir_answers.json). Each control message is answered{"ok": true}or with an error. A transaction holds one connection frombeginto its end, and a request outside a transaction runs as its own transaction on any connection of the pool. - PRT-73 Executor-assigned columns. The
identity,key_prefixandordercolumns are assigned by the executor, so an insert may omit them. Section 1.4 defines the columns that the planner assigns; these are assigned by the executor instead.
6. Notifications
- PRT-74 Client operations.
notify,listenandunlistenare not IR: a client runs them on its PostgreSQL connection as DIA-192 to DIA-196 define, and an IR request never carries them. The markdown executor and the MySQL and SQLite dialects refuse them withCAPABILITY_UNSUPPORTED. - PRT-75 Full-text predicates.
fulltextandfulltext_booleanare the Pred ops of DSL-32 and DSL-33. Theircolsis a non-empty array of columns of one table, as the tuple ops use it, andpis the parameter of the search text, a string, which the boolean mode binds through the transformfulltext_boolean(DIA-6); the planner renders the match over those columns in the mode of the op, as DIA-6 defines it per dialect. A fulltext predicate has nocolumn, and every other op has nocols: a fulltext op with an emptycolsfails withIR_INVALID. On a dialect without full-text search (SQLite) afulltextpredicate is answeredOPERATOR_NOT_ALLOWED(DIA-187). - PRT-76 A root
inlist of a request that is longer than the bind limit of the driver is split into chunks, each one statement that binds at most that many values; each row of the chunks is returned once, so a value repeated in the list counts its row once. Alimiton a split list fails withIR_INVALIDbefore any statement.