dbspec
dbspec is the schema language of this repository. One dbspec document defines the tables of one database or schema, the settings that give the ORM and the database their behavior, and the diagrams that show the tables. The same document renders to MySQL 8.4, PostgreSQL 17 and SQLite 3.46 or later with the same meaning, and introspection of any of them restores the same document.
- DBS-1 The schema definitions record decides which database features dbspec supports. A feature is supported only when one common definition exists for it, it renders with the same meaning on the three databases, and introspection restores the same definition without hidden markers. Everything else is rejected with a located error, never dropped or approximated.
Document
dbspec 1 shop
use core { users }
table orders {
id i64 identity
user_id i64
status varchar(32) default 'pending'
total decimal(13,2)
coupon_code varchar(32) null
placed_at datetime(6) default now
primary key (id)
unique uq_orders_coupon (coupon_code)
index ix_orders_user (user_id)
index ix_orders_status (status, placed_at desc)
foreign key fk_orders_user (user_id) references users (id) on delete restrict on update restrict
check ck_orders_total (total >= 0)
settings {
updated placed_at
select explicit coupon_code
}
}
diagram main {
orders at 120 340
users at 520 340
}The document core that it uses:
dbspec 1 core
table users {
id i64 identity
primary key (id)
}DBS-2 A document is UTF-8 text without a byte order mark. Lines end with LF or CRLF, both may appear in one document, and the last line may lack a line end; a bare CR is an
encodingerror. Parsing takes text: a tool that reads a file reports bytes that are not UTF-8 as anencodingerror before parsing. Only the space character separates tokens; a tab is asyntaxerror. A word is a run of ASCII letters, digits,_and.and of other characters that Go'sunicode.IsLetterorunicode.IsDigitaccepts (Unicode 17.0.0); any other character that is not a space, such as€or a combining mark, is asyntaxerror where it stands. Its first line is the headerdbspec 1 <document>: the language version and the document name; no blank or comment line comes before it. After the header, blank lines are allowed anywhere. A line whose first non-space character is#is a comment; a comment never changes the meaning and canonical emission keeps it in place (see Canonical form).DBS-3 After the header come
uselines, thentableblocks, thendiagramblocks, in that order.
Files
- DBS-4 A dbspec document file has the extension
.dbs. Its first line, the headerdbspec 1 <document>, is the file signature: a tool that reads a document file checks that the file starts with the bytesdbspec(dbspecand one space) before parsing it. A file of another format that uses the same extension, such as a DbSchema project file (XML), and an empty file are therefore told apart from a dbspec document without being parsed. A file that does not start with these bytes is onesignatureerror at line 1, column 1 whose message names the file,<path> is not a dbspec document, and nothing else is reported for it; a byte order mark before the header also makes it asignatureerror, while text given to the parser with a byte order mark is anencodingerror. A file that starts with these bytes is parsed, so a header that departs fromdbspec 1 <document>after them, such asdbspec 2 shop, is aheadererror. A file that starts with these bytes but is not UTF-8 is oneencodingerror at the line and column of its first invalid byte,<path> is not valid UTF-8, and is not parsed. Each client has one byte check, which takes the bytes of a file and the name that its messages use and gives these diagnostics or the text: Godbspec.ReadBytes, PHPDbspec::readBytes, TypeScriptreadDbspecBytes, Pythonread_dbspec_bytesand Rustdbspec::read_bytes; a tool that must read files under its own rules, for example without following symbolic links, reads the bytes once and passes them with the name it shows. Each client also has one file reader, which reads the file at a path and gives its bytes to the byte check with the path as name, and every other tool that reads a document file uses it: Godbspec.ReadFile, PHPDbspec::readFile, TypeScriptreadDbspecFile, Pythonread_dbspec_fileand Rustdbspec::read_file. A file that cannot be read is the read error of the platform, not a diagnostic.
Document sets
- DBS-5 A document set is declared once, in a set file named
dbspec-set.json, and every tool that takes a set reads that file: the generators of every client and the editor tools (editor). A set declared a second time, by command arguments or a list in code, drifts from the first, so no tool takes the documents of a set in any other form.
{"schema": 1, "documents": ["shop.dbs", "billing/invoices.dbs"], "external": ["../core/core.dbs"]}DBS-6 The file is one JSON object with the keys
schema,documentsandexternal.schemais the number 1.documentslists the document files that the set owns and is not empty;externallists its external documents and may be left out, which is the empty list. A path is relative to the directory of the set file, which is the root of the set; it is never relative to the working directory of a process. A path is written in one normal form so that every platform reads it alike:DBS-7 segments joined by
/, the last ending in.dbs;DBS-8 each segment a non-empty run of characters other than
/,\,:,<,>,",|,?,*, NUL and U+0001 to U+001F, not., not ending in.or a space, and not a segment whose part before its first., with trailing spaces removed, is, ignoring ASCII case,CON,PRN,AUX,NUL,COM0toCOM9,LPT0toLPT9, orCOMorLPTfollowed by¹,²or³: the device names of Windows;DBS-9 a
documentspath has no..segment, so an owned document lies under the root; anexternalpath may begin with..segments and has none after its first other segment.DBS-10 An absolute path, a drive, a backslash, an empty segment and a trailing
/therefore have no normal form, nor has a name that Windows cannot hold.DBS-11 Each client has one set reader: Go
dbspec.ReadSet, PHPDbspec::readSet, TypeScriptreadDbspecSet, Pythonread_dbspec_setand Rustdbspec::read_set. The reader takes the path of a set file and checks, in this order:DBS-176
@polyspec/orm: the TypeScript package exportsparseSet(bytes, path)from@polyspec/orm/dbspec.bytesmust be aUint8Arrayandpatha string; otherwise it throwsTypeError. It validates the byte content and path without reading a file.readDbspecSetand the editor tools use this pure parser.DBS-12 The set file. The prefix is the given path with its last segment removed, a segment ending at
/or, on Windows, at/or\; it may be empty,/,a/orC:. The root is the prefix, or.when the prefix is empty. The last segment isdbspec-set.jsonand equals a name that the root lists (the comparison in DBS-14). The root, without its trailing separator (a root of/stays/), and the file are examined without following a symbolic link (lstat), sincelstatof a path that ends in a separator follows a link on POSIX: neither is a symbolic link, the root is a directory and the file is a regular file. The reader opens the file once and reads it from that handle. The directories above the root belong to the caller and are not examined; a symbolic link among them is resolved by the platform.DBS-13 The JSON, in this order: the bytes are valid UTF-8 with no byte order mark, checked before parsing; they hold one JSON object (RFC 8259) nested at most 64 levels deep, the object counting as one, in which no string escapes a lone surrogate and no key appears twice in any object, at any depth; the object has no key other than the three, an error naming every unknown key in code-point order;
schemais a JSON number whose value, read as an IEEE 754 double as RFC 8259 advises for interoperability, is 1 (1,1.0and1e0are all 1), and any other number, one beyond the range of a double included, is not;documentsis a non-empty array of strings;external, when present, is an array of strings, andnullis not.DBS-14 Each path,
documentsfirst and thenexternal, in written order. First its normal form. Then its joined path: the prefix defined by DBS-12 followed by the written path unchanged. It is never cleaned, so the platform resolves each..from the root; because the root is not a symbolic link,..from it names the same directory whether the platform resolves it physically (POSIX) or lexically (Windows). Each prefix of the joined path that ends after a segment of the written path is examined withlstat: for../core/core.dbsthese are<root>/..,<root>/../coreand<root>/../core/core.dbs. Every prefix but the last is a directory and the last a regular file. None is a symbolic link, except that a prefix but the last of anexternalpath may be one: the reader follows it (stat) and it must name a directory, and the platform resolves it as written. A symbolic link at the last prefix, and one at any prefix of adocumentspath, is an error. Each segment other than..equals a name that its directory lists: on POSIX the UTF-8 bytes of the segment equal the listed bytes (Node lists with{encoding: 'buffer'}), on Windows its UTF-16 code units equal the listed ones; a..segment is examined as a directory and its name is not compared. A spelling that only a case-folding file system, a short name or a name normalization resolves is therefore an error on every platform. On Windows a symbolic link is any reparse point whose tag is a name surrogate (IsReparseTagNameSurrogate): a symbolic link or a junction. Go, Rust, Python and the PHP extension read the tag and apply that rule to every name-surrogate tag, while Node'slstat(libuv) and PHP'slstatexpose no reparse tag, only the symbolic link and junction tags as links, so the TypeScript and PHP readers detect those two; another name-surrogate tag (such as a WSL symbolic link) reads there as the entry itself, never its target, and Win32 refuses to open a tag that no file system filter handles, so the read fails instead of following it.DBS-15 The read and the file identity. The reader opens each file that passed DBS-14 once, reads its bytes from that handle and takes its identity from the same handle: the device and inode of POSIX
fstat, or on Windows the volume serial number and the 64-bit file index that the handle gives (GoFile.Statwithos.SameFile, Nodefstatwithbigint, Python the low 64 bits ofos.fstat().st_ino, PHPfstat, Rustsame_file::Handle::from_file). A file system whose 64-bit file index is not unique, such as ReFS, is not supported, as a file system without symbolic links is not. Two paths whose files have the same identity, in one list or across both, are aset.duplicateerror at the later path.
Note: the reader reads a file tree that does not change while it runs. It examines each component once and does not detect a file or directory replaced between its examination and its open, so an open may then follow a symbolic link that appeared in between; a tree changed by another process during a read is outside what the reader decides, and a later read of the unchanged tree gives the result.
DBS-17 DBS-12 and DBS-13 stop at their first error, since nothing after them can be read, and report one error. DBS-14 and DBS-15 report every path that breaks them, each with its first error; DBS-15 judges only the paths that passed DBS-14. The errors are listed by step, then by path,
documentsfirst and thenexternal, in written order, and the later path ofset.duplicateis the later in that order. In a tree that does not change while the reader runs, a symbolic link is never followed except a prefix but the last of anexternalpath (DBS-14): one found at the set file, the root, a document file, or any prefix of adocumentspath is an error.DBS-18 On success the reader returns
documentsandexternal, each a list in written order of the files with their joined path and their bytes. It does not check the bytes: the tool gives each file's bytes to the byte check with that path, which reportssignatureandencodingfor that file, and then parses the set (Documents anduse). On failure it returns the errors, each with its rule, the path it concerns and a message that names the path and, for a failed read, the platform's error. The path is the set file's path for DBS-12 and DBS-13 and forset.path, whose message quotes the written path as JSON, and the joined path of the entry otherwise.DBS-19
set.file: The set file is not nameddbspec-set.json, or it or a listed file is missing, cannot be read or is not a regular file; the root or a component of a path is not a directory; or a segment is not a name that its directory listsDBS-20
set.symlink: The set file, the root, the last component of a path or a prefix but the last of adocumentspath is a symbolic linkDBS-21
set.json: The set file is not UTF-8 without a byte order mark, is not one JSON object nested at most 64 levels deep, or escapes a lone surrogateDBS-22
set.schema:schemais missing or is not a number whose value is 1DBS-23
set.key: A key other thanschema,documentsandexternal; a key that appears twice;documentsmissing; ordocumentsorexternalnot an array of stringsDBS-24
set.empty:documentsis emptyDBS-25
set.path: A path has no normal form, adocumentspath has a..segment, or anexternalpath has one after another segmentDBS-26
set.duplicate: A path names a file that an earlier path namesDBS-27 A generator that gets a set file with an error, or a listed file with a diagnostic, exits with its error status, prints every error and diagnostic, and writes nothing. The shared vectors
tests/dbspec/set-files.jsonhold each rule with accepting and rejecting cases as file trees (regular files, directories, symbolic links and hard links), which every client's set reader runs; TypeScript'sparseSetalso runs the cases that need no file tree. The file-tree cases need a file system with symbolic and hard links: on Windows, NTFS and the privilege to create symbolic links.
Parsing a set
DBS-28 A tool parses the texts of a set with the set parse, never by keying the texts itself: Go
dbspec.ParseSet, PHPDbspec::parseSet, TypeScriptparseDbspecSet, Pythonparse_dbspec_setand Rustdbspec::parse_set. It takes the owned texts and the external texts, each in set order, and returns one result per text in that order, owned first.DBS-29 The name of a document is the name that the parser reads from its header line; no tool reads it otherwise. A text has no name when its header fails (a
headerdiagnostic) or when its parse stops before its header line is read: the 32 MiBlimit, a byte order mark, or anencodingerror in the header line. A defect after the header line leaves the name.DBS-30 A text whose name an earlier text has is not parsed; its result carries the one diagnostic
name.duplicateat line 1, column 10,document <name> appears twice in the document set.DBS-31 Every other text is parsed with the texts of all other named documents of the set as the documents its
uselines name, and its result carries its document, its declaration positions and its diagnostics, as one parse of that text returns them. An external text's document is marked external.
The shared vectors tests/dbspec/set-parse.json hold sets with their names, the duplicate and a failed header, and every client runs them.
Names
DBS-32 Every name of a document, table, column, index, key, foreign key, check, setting target and diagram matches
[a-z][a-z0-9_]*and has at most 63 bytes. A longer name is an error; it is never shortened. Upper case is an error, so every database's case folding gives the same name.DBS-33 The words
dbspec,use,table,diagram,primary,unique,index,foreign,check,settings,null,identity,default,true,false,and,or,not,in,betweenandisare reserved and are not valid names; the last six read as check predicate keywords.DBS-34 Index, unique key, foreign key and check names are unique in the whole schema, across all four kinds, against every table name, and across this document and every table and constraint of the documents it uses directly, because MySQL, PostgreSQL and SQLite scope these names differently and PostgreSQL keeps index and table names in one namespace.
primaryis not a valid name. Names are physical names: the renderer writes them unchanged and adds no prefix or suffix. The renderer also writes names of its own (rendered statements):<table>$<column>for the type CHECK of every column other than atext, abytesor the identity column,<table>$immutable_updateand<table>$immutable_deleteforimmutable, and<table>$audit_insert,<table>$audit_updateand<table>$audit_deleteforaudit. A dbspec name never contains$, so these never meet a declared name. Every such renderer name is written by one rule, so it always fits in 63 bytes. Let the plain form be the name the renderer would write without a limit:<table>$<suffix>, where the suffix is the column name or the event name (immutable_update,immutable_delete,audit_insert,audit_update,audit_delete). When the plain form has at most 63 bytes, it is the name. Otherwise the name is the first 54 bytes of the plain form, a$, and the first 8 lowercase hexadecimal digits of the SHA-256 of the plain form's UTF-8 bytes: 54 + 1 + 8 = 63 bytes in all. Names are ASCII ([a-z0-9_$]), so bytes are characters and no code point is cut. Introspection maps a name read from a database back to its table and suffix by recomputing the name of every table and suffix of the document set and comparing; it never splits a name on$. The hash covers the whole plain form, and it separates only plain forms that share the same 54-byte prefix. A generated name is unique in the schema together with the declared names: a generated name equal to another generated name, or to a declared name, is aname.duplicateerror at the later name that names both sources (the table and the column or the setting of each). Declared names never contain$, so only two generated names can meet, one as a plain form and one as a hashed form, or two hashed forms; every such collision is refused and never resolved silently. The renderer shortens only its own names this way; a declared name is never shortened, and a declared name longer than 63 bytes stays aname.lengtherror.
Documents and use
- DBS-35
use <document> { <table>, ... }makes tables of another document available as foreign key targets. The other document is found by its name in the document set that the tool reads from its set file; a document never names a file path. A used table is not rendered by this document; a diagram may place it. A used document is itself parsed and validated with its ownuselines. Using a table that the named document does not define, a document that is not in the set, the document itself, or a document whose header name differs from the used name, and any use cycle, areuseerrors; a used document that fails reports oneuseerror at its name with its first error in the message. A document or table repeated inuselines, and a table named like a used table, arename.duplicateerrors.uselines are sorted by document name; the tables inside one line keep their written order.
Tables
- DBS-36
table <name> { ... }holds column lines, then key, index, foreign key and check lines in any order among themselves, then at most onesettingsblock. A column line after a constraint line, a constraint line aftersettings, or a secondsettingsblock is anordererror. A block that the document leaves open is asyntaxerror at its{. A table has at least one column and exactly one primary key, and at most 1000 columns.
Columns
<name> <type> [null] [identity] [default <value>]
- DBS-37 Columns are NOT NULL unless the line says
null. Primary key columns cannot benull. - DBS-38
identitymakes the column the automatic key: it must be the only primary key column and its typei64. The database generates a key when the insert omits it; clients reject an explicit value. A table has at most one identity column. - DBS-39 The modifiers come in this order:
null,identity,default. Anullcolumn without a default reads as SQL NULL when an insert omits it;default nullis acolumnerror. - DBS-40
default <value>gives a literal of the column's type, ornowfor adatetime(p)column, which writes the UTC statement time.nullanddefaultcannot be combined withidentity. Atextorbytescolumn has no default. A decimal default with more fraction digits than the scale is acolumnerror, even when the extra digits are zeros; fewer digits are padded in canonical form.
Types
| dbspec | Values | MySQL | PostgreSQL | SQLite |
|---|---|---|---|---|
i16 | −32768 to 32767 | SMALLINT | smallint | smallint + range CHECK |
i32 | −2147483648 to 2147483647 | INT | integer | integer + range CHECK |
i64 | signed 64-bit | BIGINT | bigint | bigint + type CHECK |
bool | true, false | tinyint(1) + (0,1) CHECK | boolean | BOOLEAN + (0,1) CHECK |
decimal(p,s) | 1 ≤ p ≤ 18, 0 ≤ s ≤ p | DECIMAL(p,s) | numeric(p,s) | DECIMALINT(p,s) + range CHECK (value × 10^s) |
f64 | finite doubles | DOUBLE | double precision | REAL |
varchar(n) | 1 ≤ n ≤ 16383 characters, no U+0000 | varchar(n) | varchar(n) | varchar(n) + length CHECK |
text | any length, no U+0000 | LONGTEXT | text | TEXT |
bytes | any length | LONGBLOB | bytea | BLOB |
uuid | lower-case canonical text | char(36) ascii_bin + pattern CHECK | uuid | TEXT + pattern CHECK |
date | 0001-01-01 to 9999-12-31 | DATE | date | DATE + CHECK |
time(p) | time of day below 24:00:00, 0 ≤ p ≤ 6 | TIME(p) + day CHECK | time(p) + day CHECK | TIME + CHECK |
datetime(p) | local date-time, 0 ≤ p ≤ 6 | DATETIME(p) | timestamp(p) | DATETIME + CHECK |
DBS-41 Every character column uses one binary collation: MySQL
utf8mb4_0900_bin, PostgreSQLCOLLATE "C", SQLiteBINARY. Every connection reads and writesdatetimein UTC. SQLite tables are not STRICT, and every SQLite connection enablesforeign_keys. The exact CHECK texts belong to the renderer version and are listed in dialects.DBS-42 Not supported: 8-bit and 24-bit integers, unsigned types,
f32,char(n),binary(n),varbinary(n), native enums (usevarchar(n)with anincheck), native JSON (usetextwith theordered_jsoncodec), time zone instants, generated columns, and expression defaults other thannow. A column line takes onlyname type [null] [identity] [default value](packages/go/engine/dbspec/parse.go), so no document produces a generated column. Introspection reports a column that has a generation expression as unsupported, with the reason of its type, and no client reads the expression text; the MySQL double encoding of a non-ASCII literal inGENERATION_EXPRESSIONtherefore reaches no client (see dialects).
Keys and indexes
DBS-43
primary key (<column>, ...): exactly one per table, unnamed.DBS-44
unique <name> (<column>, ...): NULLs are distinct.DBS-45
index <name> (<column> [asc|desc], ...): ascending is the default and is not written in canonical form.DBS-46 A key or index lists 1 to 16 distinct columns of its table. A
textorbytescolumn cannot be part of one. The declared lengths of thevarcharcolumns of one primary key, unique key or index total at most 640. Prefix, partial, expression and full-text indexes are not supported.
Foreign keys
foreign key <name> (<column>, ...) references <table> (<column>, ...) [on delete <action>] [on update <action>]
- DBS-47
<action>isrestrict,cascadeorset_null; an omitted action isrestrictand canonical form writes both actions. - DBS-48 The referenced columns are exactly the columns of the referenced table's primary key or of one of its unique keys, in the key's column order.
- DBS-49
on deletecomes beforeon updatewhen both are written. - DBS-50 Child column types equal the referenced column types.
- DBS-51 The table declares an index or key whose leading columns are the foreign key's columns.
- DBS-52
set_nullrequires every child column to benull. - DBS-53 The target is a table of this document or a used table.
NO ACTION,SET DEFAULT, deferrable constraints andMATCH FULLare not supported.
Checks
check <name> (<predicate>)
A predicate is one of:
DBS-54
<operand> <comparison> <operand>with=,<>,<,<=,>or>=, where at least one operand is a column;DBS-55
<column> [not] in (<literal>, ...);DBS-56
<column> is [not] null;DBS-57
<predicate> and <predicate>,<predicate> or <predicate>,(<predicate>), whereandbinds tighter thanor.DBS-58 An operand is a column of the same table or a literal. The three databases evaluate these predicates alike only when both sides have the same kind of value, so the types decide which operands meet. A literal that meets a column is a valid
defaultliteral of the column's type (atextcolumn takes thevarcharform), and canonical form writes it as that default, so0againstdecimal(13,2)is0.00. Two columns meet when:
| Column type | Other column |
|---|---|
i16, i32, i64 | another integer column |
decimal(p,s) | a decimal column of the same scale s |
f64 | an f64 column |
bool | a bool column |
varchar(n), text | a varchar or text column |
uuid | a uuid column |
date | a date column |
time(p) | a time column of the same p |
datetime(p) | a datetime column of the same p |
bytes | a bytes column |
DBS-59 Each implementation keeps this table once, and the IR validator reads it too, for the two columns of a column comparison, a subquery, a join and a relation (protocol), so the two cannot drift. A check refuses a
bytescolumn before its types are compared, so thebytesrow serves only a request. The conformance vectorcolumn_pairsruns pairs that meet and pairs that do not on the three databases.DBS-60 A
booloperand takes only=,<>andin.DBS-61 A
bytescolumn, arithmetic, functions and thenullliteral are not part of a predicate: integer division, overflow and the scaled SQLite decimal give different results on the three databases. A unary minus applies only to a number literal. A check cannot use a column of a foreign key withcascadeorset_null, because MySQL rejects it (3823).DBS-62 There is no
notoperator, nobetweenand noboolcolumn alone: MySQL writesnotinto the comparisons it negates and rejects a column alone, and PostgreSQL writesbetweenas two comparisons, so introspection could not restore them (dialects). Writea <= 3fornot (a > 3),a >= 1 and a <= 9fora between 1 and 9, andactive = trueforactive.
Settings
DBS-63
settings { ... }holds one setting per line. A setting names the columns it applies to; a column's name never selects a behavior.codecrepeats once per column,navigationonce per foreign key,blind_indexonce per AES column,markdownonce per column,checkboxonce per state, andstate_machineonce per line identity: oneinitialline per state, oneterminalline per state, one transition line per<from> -> <to>pair whatever itsrequirelist, onehistoryline and onelimitline per state; every other setting appears at most once, and a repeat is asettingerror at the later line. A repeated line adds no meaning, and a repeated transition or terminal line with anotherrequirelist contradicts the first. An emptysettings {}block has no meaning, and canonical form omits it; a comment inside it moves before the table's closing}with the indentation of the table's lines.DBS-64 Codec stages run in the written order on write. The storage type follows the last stage:
hex,base64,ordered_json,yamlandserializeproduce text and need avarcharortextcolumn;aes,gzandipproduce bytes and need abytescolumn.ordered_jsonis the first stage when it appears.aes_versionis required when a column usesaes, and anaes_versionsetting without such a column is asettingerror. Theblind_indexindex column is avarchar(n)with n ≥ 64 (abytescolumn cannot be indexed), has the same nullability as the AES column, is not itself AES-encoded, and is the only column of a declared index or unique key.DBS-65
entity <name>: The model name that generated code uses for the table. Without it the model name is the table name (Hash: manifest)DBS-66
updated <column>: The executor assigns the UTC statement time to thedatetimecolumn on everyUPDATEit plans. Nothing is rendered in the database; raw SQL is not stamped (Hash: manifest)DBS-67
soft_delete <column>: Reads exclude rows whose nullabledatetimecolumn is not null, in the root, each join child (in itsONcondition), each relation and each subquery; delete sets it to the UTC statement time, andrestoreclears it for the one row that a primary key or unique key value names (restore) (Hash: manifest)DBS-68
select explicit <column> ...: The columns are left out of the default select set; reading one that was not selected fails withCOLUMN_UNSELECTED(Hash: manifest)DBS-69
codec <column> <stage> ...: The value is encoded by the stages in order on write and decoded in reverse on read:ordered_json,aes,hex,gz,base64,serialize,yaml,ip(codecs) (Hash: manifest)DBS-70
aes_version <column>: The non-null integer column that stores the AES key version of the row; required when a column usesaes(Hash: manifest)DBS-71
blind_index <aes column> <index column>: The executor writes an HMAC of the AES column's plaintext to the indexed column and uses it for equality conditions (Hash: manifest)DBS-72
navigation <foreign key> <child name> <parent name>: The relation names that tools show for the foreign key, from the child and from the parent; generated code joins through its match methods and does not read them (Hash: manifest)DBS-73
state_machine <column> <from> -> <to> [require (<column>, ...)],state_machine <column> terminal <state> [require (<column>, ...)]: The row state machine of one non-null varchar or text column: each line is one transition or one terminal state, in any mix and order, on one column per table;requirenames the columns a transition or a terminal records, and a transition that leaves a terminal state is rejected. The database does not enforce the machine; the executor that owns the rows does (Hash: manifest)DBS-74
markdown <column>: The column holds markdown prose; repeatable per column, on a varchar or text column of any nullability. The database renders nothing for it; the tools that own the documents read it (Hash: manifest)DBS-75
store files,store document list|table,store block <foreign key> list|table: The table's storage in a markdown corpus:fileswrites one file per row,documentwrites every row into one document, andblockwrites the rows inside the parent row that the named foreign key references;listortableis the shape. Exactly one per table (Hash: manifest)DBS-76
store files shard <column> <period>,store document list|table shard <column> <period>: A time shard of the storage: the corpus keeps the rows of each period of the column apart, where the column is a non-nulldateordatetimecolumn and the period isyear,monthorday. Astore blocktable, a table with a self foreign key and a table withorderreject it. SQL backends ignore it: nothing is rendered, and introspection does not restore it (Hash: manifest)DBS-77
key_prefix '<p>': The executor allocates the varchar primary key as<p><n + 1>, where n is the largest decimal suffix among the keys of that form; the primary key is one varchar column (Hash: manifest)DBS-78
title <column>,body <column>: The row's title and body, each a non-null varchar or text column (Hash: manifest)DBS-79
order <column>: The row's position among its siblings, assigned by the executor: a non-nulli32ori64column with no default, in no key, index or check (Hash: manifest)DBS-80
checkbox <column> <state> '<glyph>': The list glyph of one state of the state machine's column. The lines cover the state set exactly, and each glyph is one character and distinct (Hash: manifest)DBS-81
state_machine <column> initial <state>: An initial state of the machine, repeatable. The states of the machine are the states that its initial, transition and terminal lines name; the default of the state column is an initial state, and an initial state is not terminal (Hash: manifest)DBS-82
state_machine <column> history <table> row <foreign key column> from <column> to <column> at <column>: The history table of the machine's transitions: the foreign key column refers to this table,fromandtohave the state column's type,atisdatetime(6), each required column has a nullable column of its type, and no column exists besides the key and these (Hash: manifest)DBS-83
state_machine <column> limit <state> <count>: At mostcountrows may be in the state; the count is a positive integer and the state belongs to the state set (Hash: manifest)DBS-84
immutable: The database rejectsUPDATEandDELETEof the table's rows with generated row triggers.TRUNCATEis not covered. Rejected on a child of acascadeorset_nullforeign key (Hash: schema)DBS-85
audit into <history table> column <column> references <table> action <history column> previous <history column> [tables <table> (<record column>, <name column>)] [exclude (<column>, ...) | include (<column>, ...)]: Generated row triggers copy the recorded columns of everyINSERTandUPDATEinto the history table and, withtables, record the audited table of each audit record; see Audit (Hash: schema)DBS-86
schemaHashcovers the tables' definitions and the settings marked schema; it changes exactly when the database must change.manifestHashcovers the definitions and every setting; generated code checks it. Diagrams and comments belong to neither; Manifest and hashes defines both.
Audit
- DBS-87 An audited table keeps the key of its audit record in its own audit column, and a history table keeps every version of its rows. An audit record is a row of a declared table, written once for each unit of work (a transaction) before its changes; every audited row that the unit of work inserts or updates refers to it. The executor inserts the record in the same transaction right before the unit of work's first write of an audited table, so a unit of work that writes no audited row writes no audit record. A first update, delete or restore that matches no row writes no audited row, and its record is undone with it; a savepoint or a transaction that rolls back undoes the record together with the write that inserted it, and the next audited write inserts it again. The trigger reads everything it records from the row it receives,
OLDandNEW, so the three databases need no other channel between the executor and the trigger.
table service {
id i64 identity
name varchar(191)
audit_seq i64
deleted_at datetime(6) null
primary key (id)
index ix_service_audit (audit_seq)
foreign key fk_service_audit (audit_seq) references audit (seq) on delete restrict on update restrict
settings {
soft_delete deleted_at
audit into service_history column audit_seq references audit action change previous previous_audit_seq tables audit_table (audit_seq, table_name)
}
}
table service_history {
history_id i64 identity
change varchar(8)
previous_audit_seq i64 null
id i64
name varchar(191)
audit_seq i64
deleted_at datetime(6) null
primary key (history_id)
index ix_service_history_row (id)
}
table audit {
seq i64 identity
account_seq i64
request_id varchar(64)
action varchar(64) null
primary key (seq)
}
table audit_table {
audit_seq i64
table_name varchar(64)
primary key (audit_seq, table_name)
foreign key fk_audit_table_audit (audit_seq) references audit (seq) on delete restrict on update restrict
}- DBS-88 Audit column.
column <column>names a non-null column of the audited table. The executor writes the key of the transaction's audit record into it on everyINSERTandUPDATEof the table (usage).NEW.<column>is the audit record of the change andOLD.<column>the audit record of the previous version. - DBS-89 Audit record table.
references <table>names a table of the document or a used table: another table than the audited and the history table, not audited itself, with a primary key of one column whose type is the audit column's type. The audited table declares the foreign key(<column>) references <table> (<key>) on delete restrict on update restrict, with the leading index that every foreign key needs, so the database rejects an audit column value that names no audit record and the deletion of an audit record that a row refers to; the foreign key is rendered and introspected like any other. An unknown table, the audited or the history table, an audited table, a key of another shape or type, and a missing foreign key aresettingerrors at the table name or the audit column. - DBS-90 Recorded columns. Without a list the triggers record every column of the audited table.
exclude (<column>, ...)records every column except the listed ones, andinclude (<column>, ...)records only the listed ones. The audit column is always recorded, so neither list names it. A setting has at most one list, and a listed column is a column of the audited table that appears once. Naming an unknown column, listing a column twice, writing both lists and listing the audit column aresettingerrors at the listed column or at the keyword of the second list. A column added to the table later is recorded underexcludeand not underinclude. - DBS-91 History table.
into <history table>names a table of the document or a used table. It is another table than the audited one. It has ani64 identityprimary key, theactioncolumn (a non-nullvarchar(8)), thepreviouscolumn (nullable, the type of the audit column) and one column for every recorded column of the audited table, with the same name and type; it has no other column. A history table that lacks a copy of a recorded column, has a column of another type, or has any other column, an unrecorded column of the audited table included, is asettingerror at the history table name, one for each mismatch. The history columns may be nullable. The history table is not audited itself. - DBS-92 Audited tables.
tables <table> (<record column>, <name column>)names the tables table, a table of the document or a used table in which the triggers record which audited tables each audit record changed. It is another table than the audited, the history and the audit record table, and it is not audited itself. It has exactly the two named columns, the record column and the name column, avarchar(n)with n >= 63 that holds a table name (names). Its primary key is(<record column>, <name column>), so both columns are non-null, and it declares the foreign key(<record column>) references <audit record table> (<key>) on delete restrict on update restrict, which the primary key leads, so the record column has the type of the audit record key. The table so holds one row for each audit record and audited table, every row names an existing audit record, and an audit record that a row names is not deleted. Several audited tables may name the same tables table. An unknown table, the audited, the history or the audit record table, an audited table, an unknown column, a name column of another type, an extra column, another primary key and a missing foreign key aresettingerrors at the table name or at the column. A setting withouttablesrecords no table. The tables table is an ordinary table of its document: the generated models read it, and a join of the audit record table with it filters and pages the audit records of a table (usage). - DBS-93 Triggers. An
AFTER INSERTrow trigger writesaction = 'insert',previous = NULLand theNEWvalue of every recorded column. AnAFTER UPDATErow trigger writesaction = 'update',previous = OLD.<audit column>and theNEWvalue of every recorded column. ABEFORE DELETErow trigger rejects the delete. Withtables, the insert and the update trigger then insert(NEW.<audit column>, '<audited table>')into the record column and the name column of the tables table unless that row exists, so a transaction that changes several rows of a table writes one row for the table and a transaction that changes no row of it writes none. Values are copied column to column, so the history row is equal on the three databases; no row is serialized as JSON. - DBS-94 Schema text and introspection. The database holds only the recorded columns, which the triggers list. The schema text therefore writes the setting with
excludeand the columns that it does not record, in column order, or without a list when it records every column;include (title)and theexcludelist of the other columns give the sameschemaHash, and the manifest text keeps the list as written. Introspection reads the recorded columns and thetablesclause from theaudit_inserttrigger, the audit record table from the foreign key of the audit column, and restores the setting in the schema text form; nothing else is stored in the database. - DBS-95 Encrypted values. A value that needs encryption has a codec with the
aesstage, and the executor encodes it before the write (codecs), so the row holds the ciphertext and the triggers copy that ciphertext into the history table; the triggers never see a plaintext. Its history column has the same storage type. Exclude such a column to keep it out of the history. - DBS-96 Deleting. The
BEFORE DELETEtrigger makes a physicalDELETEfail. A table that declaressoft_deletedeletes with anUPDATEof the soft delete column, which the history records with its audit record, and a restore of a soft-deleted row is anUPDATEthat the history records in the same way.auditdoes not requiresoft_delete: a schema setting is read back from the database and a manifest setting is not, so a schema setting never depends on a manifest setting. - DBS-97 Limits. The setting is rejected on a child of a
cascadeorset_nullforeign key, because MySQL triggers do not fire for rows changed by a foreign key action.TRUNCATEis not covered. Raw SQL must name an existing audit record in the audit column, which the foreign key checks; anUPDATEthat does not write the column records the previous audit record again, and the executor always writes it. Creating the triggers on MySQL with binary logging needsSUPERorlog_bin_trust_function_creators=ON, which apply checks first.
Manifest and hashes
DBS-98 The declared document set is the only schema source. Generators, schema tools and schema installation read dbspec documents; no other manifest file exists.
DBS-99 The manifest and the rendered statements take the parsed documents of one set. The set holds each document once and every document that its documents use. A document name that repeats is a
name.duplicatediagnostic and a used document missing from the set is ausediagnostic, both at the header name (line 1, column 10) of the later or the using document, with the document named in the message; the documents are checked in name order, and the used names of a document in name order. A set with a diagnostic has no manifest and no statements.DBS-100 The manifest text of a document is its canonical emission with every comment and every diagram removed. The manifest text of a document set is the manifest texts of its owned documents in document name order, concatenated; each starts with its header and ends with a line end, so the concatenation is unambiguous.
DBS-101 The external text is empty for a set without external documents. Otherwise it holds, for each external document in name order that has a used table, the manifest text of that document reduced to the tables that owned documents name in their
uselines, in document order, each with its columns, its primary key, its unique keys, the foreign keys whose referenced table is also a used table of the same external document, and the indexes whose leading columns are the columns of such a foreign key, and nothing else, in canonical emission. The reduced text therefore satisfies the same validation as the full document: a rule over used tables, such as the foreign key that an audittablestable declares to the audit record table, holds in the runtime model that is built from the manifest text and the external text. A foreign key between two used tables, and the index that leads it, is thus part of the external text and ofmanifestHash; a foreign key to a table that no owned document uses is not, and changing it keeps the hash.DBS-102 The schema text is the canonical emission of one document named
schemathat holds every owned table of the set in table name order and oneuseline for each external document with a used table, its tables in name order, with every setting removed exceptimmutableandaudit, and anauditsetting written with theexcludelist of the columns it does not record, in column order, or without a list when it records every column (Audit); asettingsblock left empty is omitted, as in canonical form. Its onlyuselines name the external documents whose tables the owned tables use, and it has no comment or diagram, so it depends only on the tables: moving a table to another document, or renaming or splitting a document, keeps it, and so does introspecting the database the set renders.DBS-103
manifestHashissha256:followed by the lower-case hexadecimal SHA-256 of the UTF-8 bytes of the manifest text followed by the external text;schemaHashis the same over the schema text.DBS-104 Generated code carries the manifest text and the external text of the document set it was generated from and its
manifestHash, and every request it sends carries that hash. A runtime builds its model from the embedded text once, when the process starts; the PHP generator writes that model as PHP arrays instead, so the opcode cache keeps it and no request parses the text.schemaHashidentifies the database state that migration plans and their history record.
External documents
DBS-105 A document set owns the documents that its tool lists as its documents. A document of another set whose tables it uses is an external document of the set and is listed in the
externallist of the set file. Several sets can then share one database: each set creates and changes only its own tables, and the set that owns a table is the only one that renders it. An external document is parsed and validated with the set, so a foreign key or anauditsetting of an owned table checks its target table there, but it is never rendered, installed, altered, compared or generated. An external document that no owned document reaches throughuseis auseerror at its header name, as is a used document that is neither owned nor external.DBS-106 Hashes. The manifest text holds the owned documents and the external text the used tables of the external documents, so
manifestHashchanges when a used table changes and stays when another table of an external document changes. The schema text holds the owned tables and names the used tables in itsuselines.DBS-107 Verification. Installing the set and adding its tables and columns introspect the database (dialects) and require each used table with every column of its external definition, of the same type and
null, its primary key and each of its unique keys; the database may hold more columns. A difference fails withCONFIGbefore any statement, naming each difference:table <t> does not exist,column <t>.<c> does not exist,column <t>.<c> is <type>, not <type>,column <t>.<c> is null, not not null(or the reverse),table <t> has the primary key (<columns>), not (<columns>)andtable <t> has no unique key (<columns>). Registering the set, also through the connect helper, reads nothing from the database (schema).DBS-108 Installation. Install creates the owned tables when none of them exists, changes nothing when all exist and fails with
CONFIGwhen only some exist, and then requires the owned tables to equal the set; addTablesAndColumns compares and changes the owned tables only (schema).DBS-109 Plans. A plan changes a whole database (plans), so a plan whose target schema text has a
useline is aplanerror; a set with external documents is installed and upgraded with install and addTablesAndColumns.DBS-110 Runtime. Each used table of an external document is an external entity of the using set's runtime model, in the place of its document, named as its table and built from its external definition alone: its columns, its primary key and its unique keys, with no setting, index or foreign key. Generated code gives it a model like any entity, so it is selected, joined, used in subqueries and relations, related through the relation getters of the generators and given conditions; a model built from the documents and one built from the manifest text and the external text are equal. An external entity whose name repeats an entity of the set is a
name.duplicatediagnostic. Its owner alone writes it: every write of an external entity (create, creates, update, save, delete, restore, restoreOrCreate, upsert, restartIdentity) fails withCONFIG<table> is owned by another document setbefore any statement. The audit record table of an owned table may be an external table: a transaction with audit values inserts the record into it when the set that owns it is registered on the same connection (Audit).DBS-173 External models. A generator writes the read-only model of every table of an external document that the set uses through a
useline, whether or not a scanned source calls it. The scan decides only which methods a model gets, never which models exist, and no source is given a call only to make a model exist.DBS-174 A dialect name that is none of
mysql,postgresandsqliteis refused before any statement is built:renderDbspecandplanStepsthrow aTypeErrorthat names it.
Runtime model
A client builds one runtime model from the document set, and generated code is produced from the same model:
- DBS-111 Entities. One entity per table of every owned document of the set and per used table of every external document (External documents), documents in name order and tables in document order. The entity name is the
entitysetting or the table name; generated type names are its snake_case segments with their first letters in upper case (service_member→ServiceMember). - DBS-112 Fields. One field per column, named as the column, in column order, with the value type of the table below. A
nullcolumn takes the language's absent value: Go a pointer ([]byteand styled values stay unwrapped), PHP?T, TypeScriptT | null, RustOption<T>, PythonT | None; a styled column takes theStyledValueof its language, TypeScriptStyledValue<T>and Pythonpolyspec.orm.StyledValue, each with the SQL NULL and value variants; a Python or TypeScript getter whose value has another type raisesTypeError. Generated typed columns are accessed through typed getters and setters rather than public typed fields. Go getters return(T, error)and Rust getters returnResult<T>; each reportsCOLUMN_UNSELECTEDwhen its column was neither selected nor assigned. PHP, TypeScript, and Python getters raise their coded error for that unselected read. - DBS-113 Keys. The primary key columns identify a row; the identity column is never written by an insert and its generated key is returned. An upsert conflicts on the primary key or a unique key, which the call names.
- DBS-114 Default select set. Every column except those of
select explicit; reading a column that was not selected fails withCOLUMN_UNSELECTED. No type or codec leaves a column out by itself. - DBS-115 Defaults. An insert that omits a column with a default takes the database default; the executor does not fill it, except that on SQLite, whose clock has millisecond resolution, it binds the client clock for an omitted
default nowcolumn (protocol). An insert that omits a non-null column without a default fails withIR_INVALIDbefore reaching the database. - DBS-116 Codecs. A column with a
codecsetting holds the value that the first stage encodes. When a stage isordered_json,serialize,yaml,gzorbase64, that is a styled value of the common value model (codecs), which distinguishes SQL NULL from a stored null; when every stage isaes,hexorip, it is a string (iptakes the address text). - DBS-117 Settings.
updatedis assigned the UTC statement time on everyUPDATEthe executor plans;soft_deletefilters reads (the root, each join child in itsONcondition, each relation and each subquery) and turns deletes into updates, andrestoreis the only call that matches a soft-deleted row;aes_versionstores the key version of the row;blind_indexwrites the HMAC column and rewrites equality conditions on the AES column. Foraudit, a transaction with audit values inserts one audit record before its callback, from the values of the connection's audit source and its own values, and the executor writes the record's key into the audit column of every audited row it inserts or updates; an insert or update of an audited table without an audit fails withCONFIG.immutablehas no runtime behavior: the database rejects the change.navigationhas none either. - DBS-118 Relations. Generated code joins two entities through the match methods of their columns, as before; a joined result is reached under the alias the call gives or under
<entity>_modeland<entity>_models. - DBS-119 Connection. A raw connection takes the DSN URI and its configuration and registers no set; it never takes a schema path. Generated code carries the manifest text and
manifestHash, and its connect helper opens a connection and registers that set on it;utils().schema().register(schema)registers a set on an open connection andinstallregisters the set it installs. Registering reads nothing from the database. A set registered by its schema value has its runtime model from its manifest text and external text, so it needs no loaded generated code; PHP uses the arrays of the generated classes once their bootstrap has run. A request carriesmanifestHashand runs only on a connection where its set is registered (protocol). - DBS-120 Generated metadata. The manifest is the source of each ORM client's runtime model; generated code never contradicts it. Go, TypeScript, Rust and Python build the runtime model from the manifest and external text carried by generated code, and their generated entities hold only a name and a set (Rust models are also regenerated by the build script). The PHP client uses each generated class's
meta()array and compares it, withoutmanifest_hash, with the entity described by the manifest and external text. A difference, including an entity absent from the manifest, fails withCONFIGbefore any statement and names the class, entity, first differing field path (columns.name.length) and regeneration commandorm-gen gen. The PHP extension implements the PHP model runtime in C. The PHP generator emits the typed model classes that both the PHP library and the extension load; the extension's nativeModelclass is their base class. - DBS-175
orm-gen gen --checkwrites nothing. It compares each file that the generator would write under--outwith the file there: it exits 0 when every file is equal, and exits 1 after printingdiffers: <path>for each file that differs.
| dbspec | Go | PHP | PHP extension | TypeScript | Rust | Python | | --- | --- | --- | --- | --- | --- | | i16 | int16 | int | same as PHP (native Model) | number | i16 | int | | i32 | int32 | int | same as PHP (native Model) | number | i32 | int | | i64 | int64 | int | same as PHP (native Model) | number (a safe integer) | i64 | int | | bool | bool | bool | same as PHP (native Model) | boolean | bool | bool | | decimal(p,s) | string with exactly s fraction digits | string | same as PHP (native Model) | string | String | str | | f64 | float64 | float | same as PHP (native Model) | number | f64 | float | | varchar(n), text, uuid | string | string | same as PHP (native Model) | string | String | str | | bytes | []byte | string | same as PHP (native Model) | Uint8Array | Vec<u8> | bytes | | date | time.Time | \DateTimeImmutable | same as PHP (native Model) | string YYYY-MM-DD | NaiveDate | str YYYY-MM-DD | | time(p) | string HH:MM:SS with p fraction digits | string | same as PHP (native Model) | string | String | str | | datetime(p) | time.Time in UTC | \DateTimeImmutable in UTC | same as PHP (native Model) | string YYYY-MM-DD HH:MM:SS with p fraction digits | NaiveDateTime | str YYYY-MM-DD HH:MM:SS with p fraction digits, in UTC |
- DBS-121 A generated setter of a
date,time(p)ordatetime(p)column takes the text form in every language:stringin Go and TypeScript,strin Python,stringin PHP andStringin Rust. A native date or time value is not accepted by the setter, so the type check reports it before the statement; the text is checked when the setter runs and the bind layer checks it again.
Diagrams
- DBS-122
diagram <name> { <table> at <x> <y> ... }places tables for a view. Coordinates are integers from −2147483648 to 2147483647 in diagram units; a table appears in a diagram at most once, and a diagram may leave tables out. Diagrams change no hash and no rendering. A document may hold several diagrams.
Canonical form
DBS-123 Emission writes a parsed document in one canonical text, so
emit(parse(s)) == sfor canonical input andemit(parse(emit(d))) == emit(d)for any valid input:DBS-124 two-space indentation, one space between tokens, LF line ends, one final newline;
DBS-125 the header, a blank line, the
uselines sorted by document name, a blank line before each block;DBS-126 within a table: columns in declared order,
primary key, thenunique,index,foreign keyandchecklines each sorted by name, thensettingswith its lines in the order of the table above (codec,blind_indexandnavigationlines sorted by column or key name);DBS-127 defaults and actions written in full (
on delete restrict on update restrict),ascomitted;DBS-128 literals in one form: integers without a sign for zero or leading zeros, decimals with exactly the column scale (
0.00fordecimal(13,2)), strings in single quotes with''for a quote,trueandfalse,dateas'YYYY-MM-DD',time(p)anddatetime(p)with exactly p fraction digits ('2026-01-01 00:00:00.000000'fordatetime(6)),uuidin lower case;DBS-129 tables and diagrams in document order; diagram lines in document order;
DBS-130
select explicitcolumns, the columns of anauditexcludeorincludelist and the tables of auseline in their written order, a list written asexclude (a, b);DBS-131
f64literals as the shortest decimal without exponent that reads back as the same value, without a point for an integral value, and0for negative zero; a check literal takes the canonical default form of the column it meets, and a predicate keeps only the parentheses that anorinside anandneeds;DBS-132 a comment line stays attached to the line that follows it and takes that line's indentation; a comment before a closing
}takes the indentation of the block's lines; comments after the last block follow one blank line; a comment keeps its text from#to the line end unchanged.
Syntax analysis
DBS-133 Each implementation has one dbspec lexer and one parser, and syntax analysis is an output of that parser: no editor layer, generated file or regular expression holds a rule of the grammar. A regular-expression grammar such as TextMate's has no parser context, and a name may be a keyword word that is not reserved (
key,textandreferencesare valid column names), so it would classify tokens differently from the parser.DBS-134
analyze(text, set)returns the parse result, unchanged, and the tokens of the text. A token is{line, start, end, class}:lineis 1-based,startis the number of Unicode code points before the token on its line, andendthe number before the code point after it.DBS-135 A line is read into words, strings, punctuation marks (
{,},(,),,) and operators (=,<>,<,<=,>,>=,+,-,*,/); a word of digits with an optional fraction (12,0.50) is a number, and a comment is a whole line. The parser gives each token its class:
| Class | Tokens |
|---|---|
keyword | A word that the grammar fixes at its position, a member of the set of words the position accepts: the header's dbspec; use, table, diagram, settings; primary, key, unique, index, foreign, references, on, delete, update, check; the actions restrict, cascade and set_null; null, identity, default, asc, desc; the value words true, false, null and now; the option and form words of a setting (explicit, files, document, block, list, table, shard, year, month, day, require, exclude, include, row, from, to, at, into, column, action, previous, initial, terminal, history, limit) and the codec stages; the predicate words and, or, not, in, is, and null where a predicate reads it. A word in such a position that is not a member is word |
type | The word that names a column's type |
name | A word that the grammar takes freely at its position: a document, table, column, constraint, index, diagram or state name, or a relation name of navigation, where it is declared and where it is referenced |
setting | The first word of a line of a settings block; the other words of the line take the classes above |
number | A number |
string | A string |
operator | An operator, the - and > of -> included |
punctuation | A punctuation mark |
comment | A comment line, from # to the line end |
word | A word that the parser does not classify |
The cases that the parse does not complete are defined:
DBS-136 a
limiterror yields no tokens, since the check precedes allocation (Limits and errors);DBS-137 after an
encodingorheaderstop, the stop line and every later line yield lexical tokens only: a word isword, and a number, a string, an operator, a punctuation mark and a comment line keep their class; forencoding, a bare CR is not a line end, and lexing goes on after it;DBS-138 an invalid character yields no token;
DBS-139 on a line the parser abandoned with a
syntaxdiagnostic, a word isword; a line that carries only a semantic diagnostic keeps the classes the parser gave it.DBS-140 Comment positions exist only in the tokens, never in the document model. Tokens serve highlighting; a tool that needs the position of a declaration reads the declaration positions of the parse result, never the tokens or the text.
The vectors are tests/dbspec/syntax.json: the tokens of the canonical, normalize, invalid, sets and hashes cases of cases.json; the files cases stay with the file and byte readers. The vectors are generated once from the TypeScript implementation, reviewed, then fixed, and every implementation matches them exactly.
Formatting
- DBS-141
format(text, set)isemit(parse(text, set))in every implementation. A document with a diagnostic is refused and never rewritten. Formatting holds three invariants:emit(parse(format(x))) == emit(parse(x)), the manifest and schema hashes offormat(x)equal those ofx, andformat(format(x)) == format(x). Model equality is not one of them, since emission sorts constraints anduselines (Canonical form). The text of a canonical document is its own format, so an editor that formats it changes nothing. The vectors are those ofcases.json: a canonical or normalize case formats to its canonical text, and an invalid case is refused.
Declaration positions
- DBS-142 A successful parse returns, beside the document, the positions of its declarations: a list in source order of
{kind, path, line, column}, wherelineandcolumnare 1-based andcolumncounts Unicode code points, as a diagnostic's do. The positions are never part of the document model, because a document is also built without source text (by introspection and by Mermaid import) and the same model is compared, diffed, rendered, emitted and hashed; introspection, Mermaid import, emit, comparison, diff, render and the hashes neither take nor produce positions. A tool that needs the place of a declaration reads these positions, never the tokens of syntax analysis or the text.
| Kind | Path | Position |
|---|---|---|
document | [<document>] | the name of the header |
use | [<used document>] | the document name of the use line |
use_table | [<used document>, <table>] | each table name of the use line |
table | [<table>] | the table name |
column | [<table>, <column>] | the column name |
constraint | [<table>, <name>] | the name of a unique, index, foreign key or check line |
constraint | [<table>, "primary key"] | the primary keyword: the primary key is the one unnamed declaration |
setting | [<table>, <keyword>, ...<identity>] | the keyword of the setting line |
diagram | [<diagram>] | the diagram name |
diagram_entry | [<diagram>, <table>] | the table name of the entry |
- DBS-143 The identity of a setting is what the repeat rule of Settings makes unique, so every path is unique and a setting that repeats has one position per line:
| Setting | Identity |
|---|---|
codec, markdown | the column |
navigation | the foreign key |
blind_index | the AES column |
checkbox | the column and the state |
state_machine | transition <from> <to>, terminal <state>, initial <state>, history or limit <state>; the column is left out, since a table holds one machine |
| every other setting | nothing: it appears at most once |
A reference that a tool needs to place beyond these kinds (the target table of a foreign key, the columns of a key, the operands of a check) is added to this list first, in every implementation with its vectors. The vectors are the positions cases of cases.json.
Limits and errors
DBS-144 A document has at most 32 MiB, 4096 tables, 120000 columns and 20000 foreign keys; the check happens before allocation. An error is
SCHEMA_INVALIDwith the 1-based line and column of the offending token, the rule below and a message. Columns count Unicode code points. A stopping error (encoding,header,limit) is reported after the diagnostics found before it. Diagnostics are ordered by line, then column, then the order of the rule table below. A header name that does not match the name rule isname.formatand parsing continues. A rule about a whole table, constraint or setting points at its name or keyword token; any other rule points at the token that breaks it:DBS-145 foreign key: the index,
set_null, type, arity and key-target rules at the constraint name; an unknown column or target at that token;DBS-146 key: the 16-column and 640-character limits at the key name, or at
primary;DBS-147 setting: whole-setting and companion rules at the setting keyword, column type rules at the column, the history table shape at the history table name;
DBS-148 a table without columns reports
columnandkeyat the table name;null identityreportscolumnatidentityandkeyat the primary key;DBS-149 a used document that fails reports one
usediagnostic at its name with its first error in the message; two used documents that repeat a constraint name reportname.duplicateat the later document name; a document or table repeated inuselines isname.duplicate; a use cycle or a header name that differs from the used name isuse;DBS-150 a
headererror points at the first character where the line departs fromdbspec 1 <name>,<name>being a run of ASCII letters, digits and_, or one past the line end when a part is missing;DBS-151 the 32 MiB limit points at line 1, column 1; a table with more than 1000 columns is a
limiterror at its 1001st column name;DBS-152 check: a predicate is read first. Reading reports, at the token, an arithmetic operator, a function, a
nullliteral or any other token outside the predicate forms, a literal where the forms need a column: the subject ofinorisand the first literal of a comparison of two literals, and an operand that no comparison,inorisfollows: at the operand whenand,or,)or the end follows it, and at the following token otherwise. A predicate with a reading diagnostic is not checked further. A predicate that reads completely then has its columns and types checked, and the first of these diagnostics in source order is reported: an unknown column, a column of acascadeorset_nullforeign key and abytescolumn at the column; a column that does not meet the other column of its comparison at the later column; a literal that is not a default of the column it meets at the literal;<,<=,>or>=with abooloperand, column ortrueorfalse, at the operator;DBS-153 a line has at most one
syntaxdiagnostic; a check expression reports only its first diagnostic; a malformed name in a reference reportsname.formatand is not resolved further; a reference to a column or table whose own line failed reports nothing more; a failed line keeps the kind that its first word gives and the name it would declare, reading a tab as a space only to learn them; a failed key or index line therefore still declares a key or index whose columns are unknown, so no rule that depends on them is reported: a failedprimary keyline hides the missing primary key and the identity rule of its table, and a failedprimary key,uniqueorindexline hides the leading-index rule of its table's foreign keys and the key-target rule of foreign keys that reference its table;DBS-154 a header that is not the first line, including a comment or blank line before it, and a header line that is not exactly
dbspec, a space,1, a space and a name (for example one with a tab) is aheadererror. Parsing reports every error of a document in source order, not only the first; anencoding,headerorlimiterror stops parsing.DBS-155
signature: A document file does not start with the bytesdbspec(Files); the byte check and the file reader report it before parsingDBS-156
header: The first line is notdbspec 1 <document>, or the version is not 1DBS-157
syntax: A token is not allowed at its positionDBS-158
order:use,tableanddiagramare out of order, or a table line is out of orderDBS-159
name.format: A name does not match[a-z][a-z0-9_]*or isprimaryDBS-160
name.length: A name is longer than 63 bytesDBS-161
name.duplicate: A table, column, constraint, index or diagram name repeats in its scopeDBS-162
type: A type is unknown or its parameters are out of rangeDBS-163
column: A column combinesnull,identityanddefaultin a way the rules reject, or a default does not fit its typeDBS-164
key: A table has no primary key or more than one, a key column is nullable, or a key or index lists an unknown, repeated,textorbytescolumn, more than 16 columns, or more than 640varcharcharactersDBS-165
foreign_key: A foreign key target, column types, index, arity orset_nullnullability breaks the rulesDBS-166
check: A check predicate uses something outside the predicate forms, an unknown column, operands whose types do not meet, a literal outside its column's type, or a column of acascadeorset_nullforeign keyDBS-167
setting: A setting is unknown, repeats, names an unknown column, or its column type or companion settings are wrongDBS-168
use: A used document is not in the declared set, or a used table is not defined thereDBS-169
diagram: A diagram names an unknown table, repeats a table, or has a coordinate that is not an integerDBS-170
limit: A document exceeds a size or count limitDBS-171
encoding: The text is not valid UTF-8, has a byte order mark, or has a bare CR; the bytes of a file that are not UTF-8 are reported by the byte check and the file reader before parsing
Naming changes from the Mermaid language
| Mermaid language | dbspec | Reason |
|---|---|---|
auto | identity | The SQL term for a generated key |
lazy | select explicit | The column is left out of the default select; it is not loaded on access |
? / =v / =now | null / default v / default now | Keywords that state their meaning |
onupdate | settings { updated <column> } | An executor setting, not a database clause |
jsontext | text + codec <column> ordered_json | Ordered JSON is a codec; the database stores text |
name conventions (is_*, created_ts, aes_hex_*, ip, _seq) | settings | A column name never selects behavior |
rename_table, rename_column | migration plans | A rename is a change, not a state |
orm:route, orm:permission and other API directives | removed | Not a schema concern |
schema_hash | schemaHash, manifestHash | Two different coverages |
ormgen, orm-gen | one CLI name in every language | One tool, one name |
Keep, move or remove
Every feature of the Mermaid language and its manifest:
| Feature | Decision |
|---|---|
| Entities, columns, PK, relations as FK constraints | Kept as tables, columns, primary keys and named foreign keys |
Type vocabulary (tinyint, int, bigint, varchar, decimal, datetime, uuid, jsontext, enum, point, unsigned) | Replaced by the dbspec types; tinyint, unsigned, enum, point and native JSON are removed |
unique, index directives | Kept as named lines; generated names are removed |
fulltext | Removed (no neutral form) |
check with MySQL rewriting and prefixes | Kept with the neutral expression set; rewriting and prefixes are removed |
timestamps and name conventions | Replaced by default now and the updated setting |
soft_delete, aes_version, blind_index, styles | Kept as settings |
lazy, relation names | Kept as select explicit and navigation settings |
table_comment, column_comment | Removed as physical objects; # comments stay in the document |
rename_* | Moved to migration plans |
orm:table schema-qualified names | Removed (one document is one database or schema) |
orm:foreign | Replaced by use and foreign keys to used tables |
orm:immutable | Kept as the immutable setting with row triggers |
orm:audit, orm:audit_log | Replaced by the audit setting with an audit record table and history tables; the separate change tables, the context setting and the JSON row values are removed |
orm:field, route, scope, filter, operation, permission and other pass-through directives | Removed |
-- orm: trigger markers and -- orm-schema-v1 SQL sources | Removed; restoration compares renderer output |
PhysicalGraph records with dialect SQL text (typeSql, predicateSql, expressionSql, collationSql, operatorClassSql) and the annotated Markdown document | Removed; the dbspec model replaces them |
PHP extension
- DBS-172 The php implementation (package
polyspec/orm) and php-extension implementation (packagepolyspec/orm-php-ext) have the same PHP dbspec surface. Thepolyspec/orm-php-extpackage provides the PHP extensionpolyspec_orm(packages/php-ext), which implements every PHP library of this repository in C (S10), as an independent implementation of the same interfaces. It registers the PHP client's own class names: every class ofPolyspec\Orm\Dbspecthat a dbspec call takes or returns, the document classes (Document,UseLine,Table,Column,ColumnType,PrimaryKey,UniqueKey,Index,IndexColumn,ForeignKey,Check,Settings,Setting,Diagram,Placement) with the same public mutable properties, and the result and value classes with the same readonly properties, each in the order of the PHP client, with the same constructors, methods and constants. ItsPolyspec\Orm\Dbspec\DbspechasreadFile,readBytes,readSet,parse,analyze,format,emit,manifest,render,introspect,externalDifferences,parsePlan,emitPlan,chain,diff,compareSchemas,installedDifferences,addTablesAndColumnsSteps,planSteps,apply,recover,rollback,finalize,exportMermaidandimportMermaidwith the arguments, results, diagnostic messages and exceptions of the PHP client, including theApplyEvents,ApplyErrors andApplyCleanupErrors of the apply commands.introspectand the apply commands use the givenPDOobject by calling its methods (query,prepare,exec,getAttribute, andexecute,fetch,fetchAll,closeCursor,errorCodeanderrorInfoof its statements), as the PHP client does, so aPDOsubclass sees the same calls; the apply commands call the clock and event closures and format the clock withDateTimeImmutableas the PHP client does. A document that PHP code built or changed is given to these methods like a document of the PHP client. The extension also carries the error codes ofdocs/spec/errors.yamlasPolyspec\Orm\Code, with the constants of the PHP client's;orm-gen errors --lang php-extensionwrites its stub. User code is the same with or without the extension: where it is loaded its classes are defined before any code runs, and Composer's autoloader, which loads a class of the PHP client only when the class is not yet defined, loads none of the client's files for them; where it is not, the autoloader loads the PHP client. Nothing in the PHP client checks for the extension, and no other name selects either implementation. The PHP client's tests check both: they run with and without the extension, each in its own process.
The extension is built with phpize for the PHP whose phpize and php-config are on PATH: make build-php-ext builds it into var/php-ext/polyspec_orm.so, and packages/php-ext/composer.json declares it as the PIE package polyspec/orm-php-ext whose build path is src:
make build-php-ext
php -d extension=var/php-ext/polyspec_orm.so your-script.phpIts declarations are in packages/php-ext/src/polyspec_orm.stub.php, which the interface check reads (extensions of contracts/interfaces.json); the native build runs the installed gen_stub.php and PHP-Parser offline in a temporary source copy to generate polyspec_orm_arginfo.h, and src/polyspec_orm.c registers the array constants, which gen_stub.php cannot write. make php-ext-arginfo writes verification output under ignored var/. make dbspec-php-extension-check checks the stub declarations, builds the extension into its run directory, and checks that the loaded extension declares exactly what the stub declares and that it gives the results, messages and exceptions of the PHP client on the shared document, syntax, statement, plan and Mermaid vectors, on changed documents and on introspections of in-memory SQLite databases and failing catalog queries, and on apply, recover, rollback and finalize scripts on SQLite files, and that its public classes declare what the PHP client's public dbspec classes declare; make dbspec-compare-check, make dbspec-introspect-compare-check and make dbspec-apply-pairs-check run it as the fifth runner, and make dbspec-introspect-php-extension-check and make dbspec-apply-php-extension-check run the introspection and apply tests of the PHP client on MySQL, PostgreSQL and SQLite.
Verification
Shared vectors live in tests/dbspec/: canonical documents with their emission, non-canonical documents with their canonical emission, and invalid documents with every expected diagnostic, and document files under tests/dbspec/files/: a DbSchema project file (XML) and an empty file, which every client rejects with the signature error, a file with a byte that is not UTF-8, which it rejects with the encoding error, and a .dbs document, which it reads unchanged; each client reads every file through its file reader and through its byte check, and make dbspec-compare-check compares both results of the six configured implementations. Every client and the extension parse and emit them identically. Each vector file has a JSON Schema (draft 2020-12) in tests/dbspec/schema/, and every compare runner reads its vector files through those schemas with a validator of its ecosystem, ajv for TypeScript, santhosh-tekuri/jsonschema for Go, opis/json-schema for PHP and the PHP extension (from the fork min-0-max/json-schema, whose release 2.6.0-p1 reports every refusal of a file), jsonschema-rs for Python and the jsonschema crate for Rust, and reports a refused file in one form, a <file>: <location> <problem> line per problem (tests/dbspec/compare/runners.test.mts); a runner checks by itself only what a schema cannot state, such as a main document that is one of the documents of its case. The 2000-table, 60000-column, 10000-foreign-key case is a performance measurement, and a measurement never fails a test (AGENTS.md). Each client's stress test parses the case five times and prints the CPU and wall-clock time of each parse; the shortest, median and longest CPU time; the median CPU time of a reference work, one pass over the same document computing h = h·31 + b (mod 2^32), timed by the same clock in the same process; the ratio of the median parse to it; and the machine. When the ratio exceeds the client's reference value, the test prints a warning, a WARNING line with the measurement, the reference and the machine, which the check runner keeps in the run record and the summary and writes as a GitHub warning annotation; the test still passes. The reference ratios are 11 for the PHP client (within a 128 MiB memory limit), 34 for the Rust client (release mode), 63 for the Go client and 185 for the TypeScript client on Node. Each is 1.5 times the highest ratio measured on the CI runner (PHP 7.16, Rust 22.5, Go 41.7–43.0, TypeScript 122.5), so a warning marks a regression of about 50% or more, not normal variance. The CI runner (.github/runner) is the machine whose numbers count: its cores are alike, while Apple silicon has performance and efficiency cores and the core a process runs on changes both times, so macOS gave PHP ratios from 6.35 to 10.1 and Rust ratios from 17.3 to 24.0 across runs; there the measurements are only printed. A reference value is never raised to remove a warning; a change needs new CI evidence of the same kind. Each measurement is the CPU time of the thread that parses: the Rust main thread, the locked OS thread of the Go test, the Node main thread and the single-threaded PHP process. Wall-clock time on a shared machine includes the time other processes hold the processors, and the process CPU time of Go and Node includes garbage collector threads that run on other cores. make bench runs the four stress tests (make dbspec-stress-bench) and make timing-check, which runs the four stress tests, the Rust orm-schema vector tests and the PHP dbspec tests while their process group receives one tenth of wall-clock time, and requires every test to pass and print its measurements.