S3 language-lane spec — writes long tail
Engine rule (committed, do not change): IR on_duplicate: [Assign] (insert only, never PK/auto), no_cascade_delete: true on a relation child, plan children[].cascade (true when the related rows hold the FK to this row and no_cascade_delete was not set). Executors and generators for each language implement the following identically. Names are one token per meaning in the three spellings (PHP camel / Go Pascal / Rust snake).
1. API to add
| token | PHP | Go | Rust | TypeScript | semantics | |---|---|---|---|---| | onDuplicateSet<Col>(v) | ->onDuplicateSetName($v) | .OnDuplicateSetName(v) | .on_duplicate_set_name(v) | .onDuplicateSetName(v) | adds {column, p} to on_duplicate (styled columns encode like set<Col>) | | onDuplicateSet<Col>Expr(frag, binds) | same pattern | same | same | same | {column, expr, ps} | | onDuplicatePlus<Col>(n) / onDuplicateMinus<Col>(n) | numeric columns | | | numeric columns | {column, plus_p} / {minus_p} | | onDuplicateSetAll() | ->onDuplicateSetAll() | .OnDuplicateSetAll() | .on_duplicate_set_all() | .onDuplicateSetAll() | copies every current set[] assignment except PK/auto columns into on_duplicate (client side, at call time) | | insert(db) | existing | existing | existing | existing | with on_duplicate present the plan carries ON DUPLICATE KEY UPDATE … , pk = LAST_INSERT_ID(pk); the returned row is re-read by last insert id, so an update returns the existing row | | save(db) | ->using($db)->save() | .Using(ctx, db).Save() | .using(&db).save().await | .using(db).save() | on a draft: if the PK column is among set[] → UPDATE the other set columns WHERE pk = value and return the re-read row; else INSERT (same as insert) | | update(db) on a query | ->using($db)->update() | .Using(ctx, db).Update() | .using(&db).update().await | .using(db).update() | UPDATE set[]/plus/minus/expr WHERE <query where>; returns affected count; the engine rejects a missing where | | delete(db) on a query | ->using($db)->delete() | .Using(ctx, db).Delete() | .using(&db).delete().await | .using(db).delete() | DELETE WHERE <query where>; affected count | | deleteCascade(db) on a row | ->using($db)->deleteCascade() | .Using(ctx, db).DeleteCascade() | .using(&db).delete_cascade().await | .using(db).deleteCascade() | depth-first: for every loaded relation whose cascade flag is true (recorded from the assemble at scan time — add it to the row bookkeeping next to flat/rels), delete the related rows (each via its own deleteCascade, collection order), then delete this row. When the executor is a Db (not a Tx), wrap the whole walk in transaction. Statement shape: one DELETE … WHERE pk = ? per row (cached plan). | | noCascadeDelete() | ->noCascadeDelete() | .NoCascadeDelete() | .no_cascade_delete() | .noCascadeDelete() | relation-child option → IR no_cascade_delete: true | | sql(db) on a query | ->using($db)->sql() | .Using(ctx, db).SQL() | .using(&db).sql().await | .using(db).sql() | returns {sql, binds} of the main step without executing (plan compile/caching happens as usual). secret slots render as the string "$SECRET", param slots as their (transformed) values. Kind: whatever the last terminal-kind default is — use all. |
Rows: keep updateOptimistic, update, delete as they are.
2. Deadlock check (integration test, per language)
Two rows A, B (insert them in the test, distinct names per language: dl-go-1, dl-php-1, …). Run two concurrent transactions through transaction: T1 updates A then B, T2 updates B then A, with a barrier/sleep so each has locked its first row before touching the second (MySQL then reports 1213 to one side). Assert: both closures eventually succeed, the losing side re-ran (count closure invocations ≥ 3 in total), final values are those of the last writer, rows cleaned up. PHP: two php processes (proc_open) or pcntl_fork; Rust: two tokio tasks, pool size ≥ 2; Go: two goroutines.
3. Conformance vectors to add to YOUR runner (same names, same order, insert before codec_roundtrip)
Use fixed values; mask the created row's seq/updated_ts as the existing vectors do ($SEQ, $TS). All FKs: user_seq 1, service_seq 999, service_module_seq 1, service_member_seq 1; start/end dt 2026-06-01 / 2026-12-31.
upsert— tx: insert {uuid "conf-upsert", name "u1", read_count 1, FKs, dts} → A; insert again {uuid "conf-upsert", name "u2", read_count 1, FKs, dts}.onDuplicateSetName("u2").onDuplicatePlusReadCount(5) → B; delete B. Result{"same_seq": A==B, "name": B.name, "read_count": B.read_count}→ expected same_seq true, "u2", 6.upsert_set_all— like 1 but the second insert usesonDuplicateSetAll()with name "u3" and read_count 9 → result{"same_seq": true, "name": "u3", "read_count": 9}; delete.save_branch— tx:Battle.setName("conf-save")+FKs+dts.using(tx).save()→ row R (INSERT); thenBattle.setSeq(R.seq).setName("conf-save-2").using(db).save()→ UPDATE; read back; delete. Result{"inserted": R.seq > 0, "after": "conf-save-2"}.bulk_update_plus_minus— tx insert row (read_count 3, name "conf-bulk");Battle.seq(seq).plusReadCount(2).using(db).update()→ affected 1; read → 5;Battle.seq(seq).minusReadCount(10).using(db).update(); read → 0 (clamp);Battle.seq(seq).setReadCountExpr("read_count* ? + 1", [2]).using(db).update(); read → 1;Battle.seq(seq).using(db).delete()→ affected 1. Result{"after_plus": 5, "after_minus": 0, "after_expr": 1, "deleted": 1}.delete_cascade_order— tx: insert service {name "conf-svc"} → S; insert 2 service_member {service_seq S, user_seq 1 / 2}; insert 1 service_module {service_seq S, name "conf-mod"}. ThenService.seq(S).relations(ServiceMember.orderBySeqAsc()).relations(ServiceModule.noCascadeDelete()).using(db).get()→.using(db).deleteCascade(). Then counts: members with service_seq S, modules with service_seq S, services with seq S; finally delete the module by query. Result{"members_left": 0, "modules_left": 1, "service_left": 0}. Mask the service seq and member/module seqs: every parameter equal to one of the created seqs →"$SEQ"(extend your mask to a set of seqs for this vector).sql_dump—Battle.serviceSeq(7).selectAesHexEmail().limit(0, 1).using(db).sql()→ result{"sql": "...", "binds": ["$SECRET", "$SECRET", 7]}(no statements executed →statements: []). Note: aes_hex_email is eager anyway; the binds list shows the two secret slots and the param.
4. Rules for the lane
- Touch only your language:
clients/<lang>/**,cmd/ormgen/gen_<lang>.go, your runner (tests/conformance/runner_go/,tests/conformance/runner.php,clients/rust/tests/src/conformance.rs), your integration test. Do not editengine/**,schema/**,tests/conformance/vectors.json,docs/checklist.md,bin/**. - Build your artifacts in your worktree:
go build -o bin/ormd ./cmd/ormdandGOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o bin/ormengine.wasm ./engine/wasm(Rust needs the wasm; PHP needs ormd). Regenerate withgo run ./cmd/ormgen gen --schema schema/schema.json --lang <lang> --out clients/<lang>/gen. - Verify: your integration test passes; your runner prints all vectors (run it directly — Go:
go run ./tests/conformance/runner_go schema/schema.json; PHP: startbin/ormd -socket <abs>/ormd.sock -schema schema/schema.jsonyourself, thenphp tests/conformance/runner.php <abs>/ormd.sock <abs>/schema/schema.json; Rust:clients/rust/target/release/conformance bin/ormengine.wasm schema/schema.json) and the six new vectors show the expected results above;go run ./cmd/ormgen tokens --schema schema/schema.json <your runner>alone just counts tokens — parity is checked by the integration lane. - Local MySQL:
/tmp/mysql.sock, root, no password, dborm_bench. Rust toolchain:export PATH="$HOME/.cargo/bin:$PATH". - Commit on your branch with a clear message; report: files changed, test outputs (verbatim tail), anything you could not do and why. Do not rebase/merge main.