on master help sign in
Remove record update syntax {base | f v}; unify supersedes it

The record update syntax {base | field value, ...} is removed end-to-end now that base.unify (right-biased record merge) covers it: unify base {field value} is update AND extension, on closed and open rows alike. To make unify a true superset, its open-row handling adopts record-update's row-split: merging into an open base (a lambda parameter / relation row) names the merged fields with a fresh row tail and constrains the base to contain them, so unifying the result with the base (case … -> unify r {..} ; _ -> r) no longer reports fields-dont-match. Deferred unify resolution (lambda params pinned at the call site) uses the same merge. Removed: the parser production and parse_record_update_fields; ExprKind:: RecordUpdate from the AST and formatter; its inference rule, codegen, and every traversal arm across knot-compiler (base/codegen/infer/types/stratify/nonterm/ desugar/sql_lint/unused) and knot-lsp (7 files); the now-dead runtime knot_record_update_batch; the tree-sitter record_update_expression rule. The single-arg knot_record_update record-clone helper stays — relation_add_fields uses it, unrelated to the syntax. Migrated the 4 examples (stm_row_filter/todo/let_pushdown/nested) and all doc references (README/DESIGN/knot/stdlib/AGENTS/CLAUDE) to unify. tools/migrate parses its own colon-syntax record update and is untouched. {base | f v} now fails with a normal parse error. 75/75 tests, 34/34 examples.

commitb27826f566527e02137f8d7b9c709c88b67f8e52 authoriko <mail@iko.soy> dateMon 3 Aug 2026, 10:38 (2 months ago) parent1e6b39e
30 files changed+109−620
AGENTS.md+1−1 DESIGN.md+15−15 README.md+9−6 crates/knot-compiler/src/base.rs−6 crates/knot-compiler/src/codegen.rs+1−112 crates/knot-compiler/src/desugar.rs+1−27 crates/knot-compiler/src/infer.rs+60−109 crates/knot-compiler/src/nonterm.rs−6 crates/knot-compiler/src/sql_lint.rs−10 crates/knot-compiler/src/stratify.rs−6 crates/knot-compiler/src/types.rs−18 crates/knot-compiler/src/unused.rs−6 crates/knot-lsp/src/defs.rs−6 crates/knot-lsp/src/folding.rs−6 crates/knot-lsp/src/linked_editing.rs−12 crates/knot-lsp/src/rename.rs−16 crates/knot-lsp/src/selection_range.rs−6 crates/knot-lsp/src/semantic_tokens.rs−6 crates/knot-lsp/src/utils.rs−6 crates/knot-runtime/src/lib.rs+2−83 crates/knot/src/ast.rs−6 crates/knot/src/format.rs−32 crates/knot/src/parser.rs+4−86 examples/let_pushdown.knot+1−1 examples/nested.knot+2−2 examples/stm_row_filter.knot+1−1 examples/todo.knot+1−1 knot.md+9−9 stdlib.md+2−2 tree-sitter-knot/grammar.js−17
AGENTS.md+1−1
@@ -126,7 +126,7 @@ Key codegen patterns:
126126
127127## Supported Language Features
128128
129−Currently compiled: source declarations, type aliases, data declarations, functions, literals, records, field access, record updates, relation literals, nested relations (record fields holding `[T]` stored as JSON in SQLite, queryable via `m <- t.field` bind), binary/unary operations, do blocks (bind/where/yield/groupBy — pure comprehension do blocks are desugared to `__bind`/`__yield`/`__empty` calls that dispatch through Monad/Applicative/Alternative trait impls — works with any monad, not just `[]` (e.g., `Maybe`); monad type resolved at compile time via HKT unification; IO do blocks are excluded from desugaring and use the dedicated `compile_io_do` path; mixed/sequential do blocks use direct loop codegen; set-value do blocks preserved for SQL optimization; do blocks can appear in atom position as function arguments — `f do ...` and `f (do ...)` both work; `groupBy {key}` collects rows, groups by key, and rebinds the variable to each group sub-relation — field access on a group returns the first element's field, aggregates like `count` operate on the group), set expressions, lambdas, closures, function application, case expressions (with exhaustiveness checking for ADTs), constructors (including built-in `Bool.True {}`/`Bool.False {}` for `Bool`, usable in `case` patterns), atomic transactions, migrations (schema tracking + `migrate` blocks), schema lockfile (`<name>.schema.lock`), views (`*view = do { ... yield {...} }` with bidirectional read/write, constant column filtering, auto-fill on write), derived relations (`&name = expr` — read-only computed relations, compiled as 0-param functions recomputed on each access; recursive derived relations with self-references use Datalog-style fixpoint iteration via `knot_relation_fixpoint` runtime function, converging when the relation stabilizes), traits and impls (single-dispatch on runtime type tags for primitives and ADTs, default methods, deriving from defaults, supertrait enforcement, associated types with type-level pattern matching), trait bounds (`Display a => [a] -> [Text]` syntax — explicit constraints on function type signatures validated at call sites; trait method calls automatically carry constraints; multiple bounds via `Trait1 a => Trait2 a => Type`; compile-time error when a concrete type lacks a required impl), type inference (Hindley-Milner with row polymorphism for both records and variants, let-generalization, ADT/trait/view-aware, higher-kinded type polymorphism), higher-kinded types (trait params with kind annotations like `(f : Type -> Type)`, type-level application `f a` in method signatures, `[]` and ADT names as bare type constructors in impl args, `TyCon`/`App` internal representation with normalization-based unification), IO monad (`IO {effects} a` type for pure effect descriptions — all relation operations (`*rel`, `&rel`, `set`, `replace`) return `IO {} value` with empty effect set, external builtins like `println`, `readFile`, `now` return IO with specific effects; IO do-blocks sequence actions via bind; effects tracked in the type: `IO {console} {}`, `IO {fs} Text`, `IO {clock} Int`, `IO {} [T]` for DB ops; `knot_io_run` executes IO thunks at the top level in `main`), concurrency (`fork : ∀a r. IO {| r} a -> IO {| r} {}` fire-and-forget OS thread spawning with independent SQLite connections via WAL mode, deep-cloned environments — the spawned IO's effect row propagates through `fork`, so its effects remain visible in the caller's IO type; `race : ∀a b r1 r2. IO {| r1} a -> IO {| r2} b -> IO {| r1 \/ r2} (Result a b)` runs two IO actions concurrently with cooperative aggressive cancellation via per-thread `CancelToken` checked between IO thunks and inside `sleep`, winner is reported via the built-in `Result a b` ADT — `Err {error: a}` if left wins, `Ok {value: b}` if right wins; each arg carries its own effect row and the result row is the union of both (`r1 \/ r2`), so both sides' effects propagate to the caller; `race` cannot appear inside `atomic`; `retry` for STM-style atomic retry — inside `atomic` blocks, causes rollback and waits for relation changes via Condvar with row-level read-filter wakeups so a watcher on `WHERE id = 1` is not woken by writes to `id = 2`, predicates support equality, ordered comparison (`<`/`<=`/`>`/`>=`), and `IN` sets; UPDATE/DELETE paths emit per-row `WriteEvent::Rows` so STM watchers can filter on those too; `atomic : IO {} a -> IO {} a` runs IO body in a transaction with savepoint-based retry loop, body must contain only DB interactions), pattern matching in do-bind (`Circle c <- *shapes` filters relation to matching constructors and destructures; `InProgress ip <- t.status` matches a single value and skips if no match), built-ins (println, putLine (alias for println), print, show, union, count, countWhere, now, filter, match, map, fold, single, any, all, diff, inter, sum, avg, minOn, maxOn, min, max, toUpper, toLower, take, drop, length, trim, contains, elem, reverse, chars, sortBy, head, findFirst, upsertBy ((a -> Bool) -> a -> [a] -> [a] — replace matching rows or append if none match), when, unless, forEach, id, not, stripUnit/withUnit (Int u ↔ Int identity for unit conversion), stripFloatUnit/withFloatUnit (Float versions), toJson, parseJson (`Maybe` follows the standard JSON convention on the wire: `Nothing` encodes as `null` and `Just x` encodes as `x`'s JSON — applies to toJson, HTTP request/response bodies, and fetch; decoding is type-directed where a type is known: `null`/absent → `Nothing`, present value → `Just` — route body fields use their `?`-marked descriptors, fetch responses use the route's response descriptor, and parseJson sites with an inferable target type get a wire schema emitted by infer's `ty_to_wire_descriptor` and dispatch to `knot_json_decode_typed`, which normalizes via `apply_wire_type`; schema-less parseJson still maps `null` → `Nothing` but cannot Just-wrap bare values; SQLite storage is unaffected — Maybe values in JSON columns keep the `__knot_ctor` marker via the separate `value_to_json_db`/`json_to_value_db` storage encoders so existing databases round-trip), hash (BLAKE3, polymorphic over any value type — Bytes/Text hash raw contents, structured values hash a canonical serialization so equal logical values produce equal digests), generateKeyPair (X25519, IO {random}), generateSigningKeyPair (Ed25519, IO {random}), encrypt (sealed-box X25519+ChaCha20-Poly1305, `IO {random} (Maybe Bytes)` — `Nothing {}` if the public key is not 32 bytes or is a low-order point), decrypt (`Maybe Bytes` — `Nothing {}` on a wrong-length key, a ciphertext too short to hold the header, or a wrong key / tampered body failing the Poly1305 tag check), sign (Ed25519, `Maybe Bytes` — `Nothing {}` if the private key is not 32 bytes), verify (Ed25519, `Bool` — false on a malformed key or signature); keys and ciphertexts arrive from untrusted sources, so none of these abort the process on bad input, textToBytes, bytesToText (Maybe Text on UTF-8 error), bytesToHex, bytesFromHex/hexDecode, bytesConcat, bytesGet (Maybe Int — Nothing when out of bounds), bytesSlice, bytesLength, readFile, writeFile, appendFile, fileExists, removeFile, listDir, readLine, randomInt, randomFloat, randomUuid, fork, race, retry, sleep, logInfo/logWarn/logError/logDebug — leveled logging to stderr, colored when TTY / JSON otherwise; logDebug only emits when the program is run with `--debug` — IO-returning builtins produce `IO {effect} a` values; aggregates `count`/`countWhere`/`sum`/`avg`/`minOn`/`maxOn` push down to SQL (`COUNT(*)`/`COUNT(*) WHERE`/`SUM`/`AVG`/`MIN`/`MAX`) when applied to a source relation or its bound alias, including `filter`-then-aggregate, do-block-then-aggregate, and pipe forms (`*rel |> filter ... |> minOn ...`); `minOn`/`maxOn` use `knot_source_query_value` runtime which returns Int/Float/Text matching the SQLite column type and panics on empty relations; binary `min`/`max` (prelude functions, `Ord a => a -> a -> a`) operate on two values; modulo operator `%` dispatches through `Num.mod`, calls `knot_value_mod` at runtime (panics on division by zero), pushes down to SQLite's `%` operator inside SQL-compilable comprehensions; postfix type annotations on any expression (`expr : Type`) parse without surrounding parens — useful for unit ascription on literals; `Uuid` primitive type registered alongside `Bytes` in inference and resolution, stored as TEXT in SQLite, generated via `randomUuid : IO {random} Uuid` (RFC 9562 UUIDv7 — time-ordered, suitable as primary key); `Maybe` ships with built-in `Functor`/`Applicative`/`Monad`/`Alternative` impls in the prelude (parsed from `PRELUDE_SOURCE`, skipped when the user defines their own impl for the same `(trait, type)` pair via `user_impls` tracking in `inject_prelude`)), units of measure (`unit M`, `unit N = Kg * M / S^2` declarations — concrete units are uppercase; `Float M`, `Int Usd` type annotations; `42.0 M` literal syntax; compile-time unit algebra — `+`/`-` require unit match, `*`/`/` compose units, negation preserves units; plain numeric types are unit-agnostic; unit polymorphism via lowercase unit variables in type annotations `Float u -> Float u` — lowercase names in `<...>` that are not declared units become polymorphic unit variables, freshened on each instantiation; units fully erased at runtime; all stdlib functions with numeric types are unit-polymorphic: `avg` as `∀a u. (a -> Float u) -> [a] -> Float u`, `sum`/`minOn`/`maxOn` fully polymorphic `(a -> b) -> [a] -> b` (units flow through naturally), `countWhere` as `∀a u. (a -> Bool) -> [a] -> Int u`, `randomInt` as `∀u. Int u -> IO {random} Int u` (preserves bound unit), `randomFloat` as `∀u. IO {random} Float u`, `now` as `IO {clock} Int Ms`, `sleep` as `Int Ms -> IO {clock} {}` (built-in `Ms` unit for clock ops), `count` as `∀a u. [a] -> Int u`, `length` as `∀u. Text -> Int u`, `take`/`drop` as `∀u. Int u -> Text -> Text`, `bytesLength`/`bytesSlice`/`bytesGet` also unit-polymorphic; derived unit aliases expand at use site; `(expr : Type)` annotation syntax for explicit unit ascription), refined types (`type Nat = Int where \x -> x >= 0` — base type restricted by predicate function; per-field `{age: Int where \x -> x >= 0}`, cross-field `{...} where \p -> p.lo <= p.hi`, ADT constructor `Circle {radius: Float where \r -> r > 0.0}` refinements; `refine expr` returns `Result RefinementError T` with target refined type inferred from context — `case refine x of Ok {value: n} -> ... | Err {error: e} -> ...`; refined types are nominal in inference (`Ty::Con(name, [])`) with subsumption in unification enabling `Nat ↔ Int` compatibility; `set` validates refined fields via `knot_refinement_validate_relation` before writes — panics on violation; route handlers auto-validate refined body fields after JSON decoding — return HTTP 400 on failure; `RefinementError = {typeName: Text, violations: [{field: Maybe Text, message: Text}]}`; `Result` has built-in monad impls via `knot_result_bind`/`knot_result_yield`/`knot_result_empty` for do-block support), effect inference (per-declaration effect tracking for reads/writes/console/clock/network/fs/random, IO-in-atomic validation, explicit annotation checking), subset constraints (`*orders.customer <= *people.name` for referential integrity, `*users <= *users.email` for uniqueness — validated at runtime before each write commits), routes (`route Name where` with HTTP method/path/body/query/response declarations, path prefix nesting, `route Name = A | B` composition, `headers {field: Type}` for typed request/response headers — request headers appear after query params and before `->`, response headers appear after the response type; header field names use camelCase and auto-convert to HTTP-Header-Case (`authorization` → `Authorization`, `contentType` → `Content-Type`); optional headers use `Maybe` type (`headers {authorization: Maybe Text}` — server gets `Nothing` if absent, `Just {value: "..."}` if present; fetch skips `Nothing` headers); request header fields become constructor fields like body/query/path params; routes desugar to ADTs whose constructors carry only request inputs (no `respond` field) — handlers are bound per-endpoint via `serve API where Ctor = handler` producing a value of type `Server API _` (a row variable when handlers have no concrete effects) or `Server API {effects}` (when handlers carry concrete effects), and each handler returns `Result HttpError T` where `T` is the response type declared in the route and `HttpError = {status: Int, message: Text}`. `Ok {value: T}` responds 200; `Err {error: {status, message}}` responds with the given status (clamped to 100..=599) and a JSON `{"error": message}` body. When response headers are declared, the success body is `{body: T, headers: H}` (still wrapped inside `Ok`); the listen loop extracts body for JSON and headers for HTTP response headers. Error responses do not carry custom headers. `listen : Int u -> Server a r -> IO {network | r} {}` starts a `tiny_http` server, dispatching requests to the appropriate handler with JSON request/response serialization; the row variable unifies with the server's effect row so handler effects (e.g. `console`) flow into the IO type of the program calling `listen`. `fetch url (Ctor {fields})` is a type-safe HTTP client reusing route declarations — response type inferred from route's `response:` annotation, returns `IO {network} (Result {status: Int, message: Text} ResponseType)` or `IO {network} (Result ... {body: ResponseType, headers: {h: T}})` when response headers declared. `fetchWith url {headers: [...]} (Ctor {fields})` variant merges ad-hoc headers with route-declared headers, uses `ureq` for HTTP requests. Per-endpoint rate limiting via the `rateLimit <expr>` clause placed after the response type/headers and before `=`: the expression has type `RateLimit input a = {key: input -> RequestCtx -> Maybe a, limit: {requests: Int, window: Int Ms}}` with `Ord a` enforced via a deferred constraint; `input` is the same record the handler receives (built from path params + query params + body fields + request headers — `route_input_record_ty` factored out of `serve_handler_type` for shared use); absence of the clause means unlimited; `key` returning `Nothing` exempts the request, `Just v` keys the bucket on `show v`. Token-bucket algorithm refilled lazily, persisted in `_knot_rate_limits (route TEXT, key TEXT, tokens REAL, last_refill INTEGER, PRIMARY KEY(route, key)) WITHOUT ROWID`, created on first use; check runs in `BEGIN IMMEDIATE` so concurrent same-key requests serialize. Codegen: parser introduces an `Option<Expr>` field on `RouteEntry`; type checker validates the expression in `check_rate_limit_expr` (called from `infer_declarations` Route arm); `can_start_type_atom` stops at `rateLimit` so the response type doesn't eat the keyword; runtime registers configs via `knot_route_set_rate_limit(table, ctor, rate_limit_value)` which unpacks the `{key, limit}` record; both the main_init route table loop and the `listen`/`listenOn` codegen path register them on their respective tables; check happens in the worker thread inside `catch_unwind`, after the input record is built but before refinement validation, so the user's `key_fn` sees the same destructured input the handler will. The worker's Err type is `(u16, String, Option<i64>)` where the third element is `retry_after_ms` — the response handler attaches `Retry-After: <seconds>` when present and uses `Content-Type: application/json` body `{"error":"..."}`; rate-limit rejections return `Err((429, "Rate limit exceeded", Some(retry_after_ms)))`).
129+Currently compiled: source declarations, type aliases, data declarations, functions, literals, records, field access, record merge (`unify`), relation literals, nested relations (record fields holding `[T]` stored as JSON in SQLite, queryable via `m <- t.field` bind), binary/unary operations, do blocks (bind/where/yield/groupBy — pure comprehension do blocks are desugared to `__bind`/`__yield`/`__empty` calls that dispatch through Monad/Applicative/Alternative trait impls — works with any monad, not just `[]` (e.g., `Maybe`); monad type resolved at compile time via HKT unification; IO do blocks are excluded from desugaring and use the dedicated `compile_io_do` path; mixed/sequential do blocks use direct loop codegen; set-value do blocks preserved for SQL optimization; do blocks can appear in atom position as function arguments — `f do ...` and `f (do ...)` both work; `groupBy {key}` collects rows, groups by key, and rebinds the variable to each group sub-relation — field access on a group returns the first element's field, aggregates like `count` operate on the group), set expressions, lambdas, closures, function application, case expressions (with exhaustiveness checking for ADTs), constructors (including built-in `Bool.True {}`/`Bool.False {}` for `Bool`, usable in `case` patterns), atomic transactions, migrations (schema tracking + `migrate` blocks), schema lockfile (`<name>.schema.lock`), views (`*view = do { ... yield {...} }` with bidirectional read/write, constant column filtering, auto-fill on write), derived relations (`&name = expr` — read-only computed relations, compiled as 0-param functions recomputed on each access; recursive derived relations with self-references use Datalog-style fixpoint iteration via `knot_relation_fixpoint` runtime function, converging when the relation stabilizes), traits and impls (single-dispatch on runtime type tags for primitives and ADTs, default methods, deriving from defaults, supertrait enforcement, associated types with type-level pattern matching), trait bounds (`Display a => [a] -> [Text]` syntax — explicit constraints on function type signatures validated at call sites; trait method calls automatically carry constraints; multiple bounds via `Trait1 a => Trait2 a => Type`; compile-time error when a concrete type lacks a required impl), type inference (Hindley-Milner with row polymorphism for both records and variants, let-generalization, ADT/trait/view-aware, higher-kinded type polymorphism), higher-kinded types (trait params with kind annotations like `(f : Type -> Type)`, type-level application `f a` in method signatures, `[]` and ADT names as bare type constructors in impl args, `TyCon`/`App` internal representation with normalization-based unification), IO monad (`IO {effects} a` type for pure effect descriptions — all relation operations (`*rel`, `&rel`, `set`, `replace`) return `IO {} value` with empty effect set, external builtins like `println`, `readFile`, `now` return IO with specific effects; IO do-blocks sequence actions via bind; effects tracked in the type: `IO {console} {}`, `IO {fs} Text`, `IO {clock} Int`, `IO {} [T]` for DB ops; `knot_io_run` executes IO thunks at the top level in `main`), concurrency (`fork : ∀a r. IO {| r} a -> IO {| r} {}` fire-and-forget OS thread spawning with independent SQLite connections via WAL mode, deep-cloned environments — the spawned IO's effect row propagates through `fork`, so its effects remain visible in the caller's IO type; `race : ∀a b r1 r2. IO {| r1} a -> IO {| r2} b -> IO {| r1 \/ r2} (Result a b)` runs two IO actions concurrently with cooperative aggressive cancellation via per-thread `CancelToken` checked between IO thunks and inside `sleep`, winner is reported via the built-in `Result a b` ADT — `Err {error: a}` if left wins, `Ok {value: b}` if right wins; each arg carries its own effect row and the result row is the union of both (`r1 \/ r2`), so both sides' effects propagate to the caller; `race` cannot appear inside `atomic`; `retry` for STM-style atomic retry — inside `atomic` blocks, causes rollback and waits for relation changes via Condvar with row-level read-filter wakeups so a watcher on `WHERE id = 1` is not woken by writes to `id = 2`, predicates support equality, ordered comparison (`<`/`<=`/`>`/`>=`), and `IN` sets; UPDATE/DELETE paths emit per-row `WriteEvent::Rows` so STM watchers can filter on those too; `atomic : IO {} a -> IO {} a` runs IO body in a transaction with savepoint-based retry loop, body must contain only DB interactions), pattern matching in do-bind (`Circle c <- *shapes` filters relation to matching constructors and destructures; `InProgress ip <- t.status` matches a single value and skips if no match), built-ins (println, putLine (alias for println), print, show, union, count, countWhere, now, filter, match, map, fold, single, any, all, diff, inter, sum, avg, minOn, maxOn, min, max, toUpper, toLower, take, drop, length, trim, contains, elem, reverse, chars, sortBy, head, findFirst, upsertBy ((a -> Bool) -> a -> [a] -> [a] — replace matching rows or append if none match), when, unless, forEach, id, not, stripUnit/withUnit (Int u ↔ Int identity for unit conversion), stripFloatUnit/withFloatUnit (Float versions), toJson, parseJson (`Maybe` follows the standard JSON convention on the wire: `Nothing` encodes as `null` and `Just x` encodes as `x`'s JSON — applies to toJson, HTTP request/response bodies, and fetch; decoding is type-directed where a type is known: `null`/absent → `Nothing`, present value → `Just` — route body fields use their `?`-marked descriptors, fetch responses use the route's response descriptor, and parseJson sites with an inferable target type get a wire schema emitted by infer's `ty_to_wire_descriptor` and dispatch to `knot_json_decode_typed`, which normalizes via `apply_wire_type`; schema-less parseJson still maps `null` → `Nothing` but cannot Just-wrap bare values; SQLite storage is unaffected — Maybe values in JSON columns keep the `__knot_ctor` marker via the separate `value_to_json_db`/`json_to_value_db` storage encoders so existing databases round-trip), hash (BLAKE3, polymorphic over any value type — Bytes/Text hash raw contents, structured values hash a canonical serialization so equal logical values produce equal digests), generateKeyPair (X25519, IO {random}), generateSigningKeyPair (Ed25519, IO {random}), encrypt (sealed-box X25519+ChaCha20-Poly1305, `IO {random} (Maybe Bytes)` — `Nothing {}` if the public key is not 32 bytes or is a low-order point), decrypt (`Maybe Bytes` — `Nothing {}` on a wrong-length key, a ciphertext too short to hold the header, or a wrong key / tampered body failing the Poly1305 tag check), sign (Ed25519, `Maybe Bytes` — `Nothing {}` if the private key is not 32 bytes), verify (Ed25519, `Bool` — false on a malformed key or signature); keys and ciphertexts arrive from untrusted sources, so none of these abort the process on bad input, textToBytes, bytesToText (Maybe Text on UTF-8 error), bytesToHex, bytesFromHex/hexDecode, bytesConcat, bytesGet (Maybe Int — Nothing when out of bounds), bytesSlice, bytesLength, readFile, writeFile, appendFile, fileExists, removeFile, listDir, readLine, randomInt, randomFloat, randomUuid, fork, race, retry, sleep, logInfo/logWarn/logError/logDebug — leveled logging to stderr, colored when TTY / JSON otherwise; logDebug only emits when the program is run with `--debug` — IO-returning builtins produce `IO {effect} a` values; aggregates `count`/`countWhere`/`sum`/`avg`/`minOn`/`maxOn` push down to SQL (`COUNT(*)`/`COUNT(*) WHERE`/`SUM`/`AVG`/`MIN`/`MAX`) when applied to a source relation or its bound alias, including `filter`-then-aggregate, do-block-then-aggregate, and pipe forms (`*rel |> filter ... |> minOn ...`); `minOn`/`maxOn` use `knot_source_query_value` runtime which returns Int/Float/Text matching the SQLite column type and panics on empty relations; binary `min`/`max` (prelude functions, `Ord a => a -> a -> a`) operate on two values; modulo operator `%` dispatches through `Num.mod`, calls `knot_value_mod` at runtime (panics on division by zero), pushes down to SQLite's `%` operator inside SQL-compilable comprehensions; postfix type annotations on any expression (`expr : Type`) parse without surrounding parens — useful for unit ascription on literals; `Uuid` primitive type registered alongside `Bytes` in inference and resolution, stored as TEXT in SQLite, generated via `randomUuid : IO {random} Uuid` (RFC 9562 UUIDv7 — time-ordered, suitable as primary key); `Maybe` ships with built-in `Functor`/`Applicative`/`Monad`/`Alternative` impls in the prelude (parsed from `PRELUDE_SOURCE`, skipped when the user defines their own impl for the same `(trait, type)` pair via `user_impls` tracking in `inject_prelude`)), units of measure (`unit M`, `unit N = Kg * M / S^2` declarations — concrete units are uppercase; `Float M`, `Int Usd` type annotations; `42.0 M` literal syntax; compile-time unit algebra — `+`/`-` require unit match, `*`/`/` compose units, negation preserves units; plain numeric types are unit-agnostic; unit polymorphism via lowercase unit variables in type annotations `Float u -> Float u` — lowercase names in `<...>` that are not declared units become polymorphic unit variables, freshened on each instantiation; units fully erased at runtime; all stdlib functions with numeric types are unit-polymorphic: `avg` as `∀a u. (a -> Float u) -> [a] -> Float u`, `sum`/`minOn`/`maxOn` fully polymorphic `(a -> b) -> [a] -> b` (units flow through naturally), `countWhere` as `∀a u. (a -> Bool) -> [a] -> Int u`, `randomInt` as `∀u. Int u -> IO {random} Int u` (preserves bound unit), `randomFloat` as `∀u. IO {random} Float u`, `now` as `IO {clock} Int Ms`, `sleep` as `Int Ms -> IO {clock} {}` (built-in `Ms` unit for clock ops), `count` as `∀a u. [a] -> Int u`, `length` as `∀u. Text -> Int u`, `take`/`drop` as `∀u. Int u -> Text -> Text`, `bytesLength`/`bytesSlice`/`bytesGet` also unit-polymorphic; derived unit aliases expand at use site; `(expr : Type)` annotation syntax for explicit unit ascription), refined types (`type Nat = Int where \x -> x >= 0` — base type restricted by predicate function; per-field `{age: Int where \x -> x >= 0}`, cross-field `{...} where \p -> p.lo <= p.hi`, ADT constructor `Circle {radius: Float where \r -> r > 0.0}` refinements; `refine expr` returns `Result RefinementError T` with target refined type inferred from context — `case refine x of Ok {value: n} -> ... | Err {error: e} -> ...`; refined types are nominal in inference (`Ty::Con(name, [])`) with subsumption in unification enabling `Nat ↔ Int` compatibility; `set` validates refined fields via `knot_refinement_validate_relation` before writes — panics on violation; route handlers auto-validate refined body fields after JSON decoding — return HTTP 400 on failure; `RefinementError = {typeName: Text, violations: [{field: Maybe Text, message: Text}]}`; `Result` has built-in monad impls via `knot_result_bind`/`knot_result_yield`/`knot_result_empty` for do-block support), effect inference (per-declaration effect tracking for reads/writes/console/clock/network/fs/random, IO-in-atomic validation, explicit annotation checking), subset constraints (`*orders.customer <= *people.name` for referential integrity, `*users <= *users.email` for uniqueness — validated at runtime before each write commits), routes (`route Name where` with HTTP method/path/body/query/response declarations, path prefix nesting, `route Name = A | B` composition, `headers {field: Type}` for typed request/response headers — request headers appear after query params and before `->`, response headers appear after the response type; header field names use camelCase and auto-convert to HTTP-Header-Case (`authorization` → `Authorization`, `contentType` → `Content-Type`); optional headers use `Maybe` type (`headers {authorization: Maybe Text}` — server gets `Nothing` if absent, `Just {value: "..."}` if present; fetch skips `Nothing` headers); request header fields become constructor fields like body/query/path params; routes desugar to ADTs whose constructors carry only request inputs (no `respond` field) — handlers are bound per-endpoint via `serve API where Ctor = handler` producing a value of type `Server API _` (a row variable when handlers have no concrete effects) or `Server API {effects}` (when handlers carry concrete effects), and each handler returns `Result HttpError T` where `T` is the response type declared in the route and `HttpError = {status: Int, message: Text}`. `Ok {value: T}` responds 200; `Err {error: {status, message}}` responds with the given status (clamped to 100..=599) and a JSON `{"error": message}` body. When response headers are declared, the success body is `{body: T, headers: H}` (still wrapped inside `Ok`); the listen loop extracts body for JSON and headers for HTTP response headers. Error responses do not carry custom headers. `listen : Int u -> Server a r -> IO {network | r} {}` starts a `tiny_http` server, dispatching requests to the appropriate handler with JSON request/response serialization; the row variable unifies with the server's effect row so handler effects (e.g. `console`) flow into the IO type of the program calling `listen`. `fetch url (Ctor {fields})` is a type-safe HTTP client reusing route declarations — response type inferred from route's `response:` annotation, returns `IO {network} (Result {status: Int, message: Text} ResponseType)` or `IO {network} (Result ... {body: ResponseType, headers: {h: T}})` when response headers declared. `fetchWith url {headers: [...]} (Ctor {fields})` variant merges ad-hoc headers with route-declared headers, uses `ureq` for HTTP requests. Per-endpoint rate limiting via the `rateLimit <expr>` clause placed after the response type/headers and before `=`: the expression has type `RateLimit input a = {key: input -> RequestCtx -> Maybe a, limit: {requests: Int, window: Int Ms}}` with `Ord a` enforced via a deferred constraint; `input` is the same record the handler receives (built from path params + query params + body fields + request headers — `route_input_record_ty` factored out of `serve_handler_type` for shared use); absence of the clause means unlimited; `key` returning `Nothing` exempts the request, `Just v` keys the bucket on `show v`. Token-bucket algorithm refilled lazily, persisted in `_knot_rate_limits (route TEXT, key TEXT, tokens REAL, last_refill INTEGER, PRIMARY KEY(route, key)) WITHOUT ROWID`, created on first use; check runs in `BEGIN IMMEDIATE` so concurrent same-key requests serialize. Codegen: parser introduces an `Option<Expr>` field on `RouteEntry`; type checker validates the expression in `check_rate_limit_expr` (called from `infer_declarations` Route arm); `can_start_type_atom` stops at `rateLimit` so the response type doesn't eat the keyword; runtime registers configs via `knot_route_set_rate_limit(table, ctor, rate_limit_value)` which unpacks the `{key, limit}` record; both the main_init route table loop and the `listen`/`listenOn` codegen path register them on their respective tables; check happens in the worker thread inside `catch_unwind`, after the input record is built but before refinement validation, so the user's `key_fn` sees the same destructured input the handler will. The worker's Err type is `(u16, String, Option<i64>)` where the third element is `retry_after_ms` — the response handler attaches `Retry-After: <seconds>` when present and uses `Content-Type: application/json` body `{"error":"..."}`; rate-limit rejections return `Err((429, "Rate limit exceeded", Some(retry_after_ms)))`).
130130
131131## Version Control
132132
DESIGN.md+15−15
@@ -167,7 +167,7 @@ addMember \teamName person -> do
167167 *teams = do
168168 t <- teams
169169 yield (case t.name == teamName of
170− Bool.True {} -> {t | members (base.union t.members [person])}
170+ Bool.True {} -> (base.unify t {members (base.union t.members [person])})
171171 Bool.False {} -> t)
172172
173173-- Remove a member from all teams
@@ -175,10 +175,10 @@ removePerson \personName -> do
175175 teams <- full *teams
176176 *teams = do
177177 t <- teams
178− yield {t | members do
178+ yield (base.unify t {members do
179179 m <- t.members
180180 where m.name != personName
181− yield m}
181+ yield m})
182182```
183183
184184#### Flattening and Nesting
@@ -657,7 +657,7 @@ birthday \name -> do
657657 people <- full *people
658658 *people = do
659659 p <- people
660− yield (case p.name == name of Bool.True {} -> {p | age (p.age + 1)}; Bool.False {} -> p)
660+ yield (case p.name == name of Bool.True {} -> (base.unify p {age (p.age + 1)}); Bool.False {} -> p)
661661```
662662
663663### Effect Annotations
@@ -670,7 +670,7 @@ birthday \name -> do
670670 people <- full *people
671671 *people = do
672672 p <- people
673− yield (case p.name == name of Bool.True {} -> {p | age (p.age + 1)}; Bool.False {} -> p)
673+ yield (case p.name == name of Bool.True {} -> (base.unify p {age (p.age + 1)}); Bool.False {} -> p)
674674```
675675
676676### IO and Transactions
@@ -910,7 +910,7 @@ assign \title owner person -> do
910910 *todos = do
911911 t <- todos
912912 yield (case t.title == title of
913− Bool.True {} -> {t | owner person}
913+ Bool.True {} -> (base.unify t {owner person})
914914 Bool.False {} -> t)
915915
916916pendingFor \user page limit -> do
@@ -1173,9 +1173,9 @@ transfer \from to amount -> do
11731173 *accounts = do
11741174 a <- accounts
11751175 yield (case a.name == from of
1176− Bool.True {} -> {a | balance (a.balance - amount)}
1176+ Bool.True {} -> (base.unify a {balance (a.balance - amount)})
11771177 Bool.False {} -> (case a.name == to of
1178− Bool.True {} -> {a | balance (a.balance + amount)}
1178+ Bool.True {} -> (base.unify a {balance (a.balance + amount)})
11791179 Bool.False {} -> a))
11801180
11811181batchTransfer \transfers ->
@@ -1201,7 +1201,7 @@ birthday \name -> do
12011201 people <- full *people
12021202 *people = do
12031203 p <- people
1204− yield (case p.name == "Alice" of Bool.True {} -> {p | age (p.age + 1)}; Bool.False {} -> p)
1204+ yield (case p.name == "Alice" of Bool.True {} -> (base.unify p {age (p.age + 1)}); Bool.False {} -> p)
12051205
12061206-- Delete: filter to keep the rest
12071207removePerson \name -> do
@@ -1365,7 +1365,7 @@ data Status
13651365*people : [{name: Text, age: Int 1}]
13661366 migrate from {name: Text, age: Int 1}
13671367 to {name: Text, age: Int 1, email: Text}
1368− using (\old -> {old | email (old.name ++ "@unknown.com")})
1368+ using (\old -> (base.unify old {email (old.name ++ "@unknown.com")}))
13691369
13701370*todos : [{title: Text, owner: Text, priority: Priority, status: Status}]
13711371```
@@ -1396,7 +1396,7 @@ Breaking changes require a `migrate` block:
13961396*people : [{name: Text, age: Int 1}]
13971397 migrate from {name: Text, age: Int 1}
13981398 to {name: Text, age: Int 1, email: Text}
1399− using (\old -> {old | email (old.name ++ "@unknown.com")})
1399+ using (\old -> (base.unify old {email (old.name ++ "@unknown.com")}))
14001400```
14011401
14021402ADT migrations use pattern matching:
@@ -1407,9 +1407,9 @@ data Status = Open {} | InProgress {assignee: Text} | Resolved {resolution: Text
14071407*todos : [{title: Text, owner: Text, priority: Priority, status: Status}]
14081408 migrate from {title: Text, owner: Text, priority: Priority, status: Status}
14091409 to {title: Text, owner: Text, priority: Priority, status: Status}
1410− using (\old -> {old | status (case old.status of
1410+ using (\old -> (base.unify old {status (case old.status of
14111411 Status.InProgress {assignee a} -> Status.Resolved {resolution ("closed by " ++ a)}
1412− other -> other)})
1412+ other -> other)}))
14131413```
14141414
14151415After a successful compile, `schema.lock` is updated.
@@ -2062,7 +2062,7 @@ assign \title owner person -> do
20622062 *todos = do
20632063 t <- todos
20642064 yield (case t.title == title && t.owner == owner of
2065− Bool.True {} -> {t | status (Status.InProgress {assignee person})}
2065+ Bool.True {} -> (base.unify t {status (Status.InProgress {assignee person})})
20662066 Bool.False {} -> t)
20672067
20682068resolve \title owner msg -> do
@@ -2070,7 +2070,7 @@ resolve \title owner msg -> do
20702070 *todos = do
20712071 t <- todos
20722072 yield (case t.title == title && t.owner == owner of
2073− Bool.True {} -> {t | status (Status.Resolved {resolution msg})}
2073+ Bool.True {} -> (base.unify t {status (Status.Resolved {resolution msg})})
20742074 Bool.False {} -> t)
20752075
20762076api (serve Api where
README.md+9−6
@@ -136,18 +136,21 @@ person {name "Ada" addr {city "London" zip "E1"}}
136136 yield {})
137137```
138138
139−Records are immutable, but a **record update** builds a new record from an old
140−one with some fields replaced — `{base | field newValue ...}` (spaces again, no
141−commas):
139+Records are immutable, but `base.unify` builds a new record by merging an old
140+one with some fields replaced or added — right-biased, so the second record's
141+fields win on a name conflict:
142142
143143```knot
144144with {
145145pt {x 3 y 4}
146−moved ({pt | x 10}) -- like pt, but x is 10
146+moved (base.unify pt {x 10}) -- like pt, but x is 10
147147}
148148(base.println (base.show moved)) -- "{x: 10, y: 4}"
149149```
150150
151+`unify` can also add fields the base didn't have (`base.unify pt {z 5}` is
152+`{x: 3, y: 4, z: 5}`); see `unify` in the stdlib reference.
153+
151154The empty record `{}` is the **unit** value — the "nothing interesting here"
152155result. A `do` block that only performs effects ends in `yield {}` to produce
153156it. Records are how `with` declarations, constructor payloads, and function
@@ -332,12 +335,12 @@ type Todo = {title: Text, done: Int 1}
332335 base.println ("open: " ++ base.show (base.count open))
333336
334337 -- update: bind the rows to a local, then map, marking one done
335− -- ({t | done 1} is a record update)
338+ -- (base.unify t {done 1} merges the row with the new field value)
336339 todos <- full *todos
337340 *todos = (do
338341 t <- todos
339342 yield (case t.title == "write guide" of
340− Bool.True {} -> {t | done 1}
343+ Bool.True {} -> (base.unify t {done 1})
341344 Bool.False {} -> t))
342345 yield {})
343346```
crates/knot-compiler/src/base.rs−6
@@ -277,12 +277,6 @@ fn shift_expr_spans(e: &mut ast::Expr, offset: usize) {
277277 shift_expr_spans(&mut fl.value, offset);
278278 }
279279 }
280− RecordUpdate { base, fields } => {
281− shift_expr_spans(base, offset);
282− for fl in fields {
283− shift_expr_spans(&mut fl.value, offset);
284− }
285− }
286280 List(items) => {
287281 for it in items {
288282 shift_expr_spans(it, offset);
crates/knot-compiler/src/codegen.rs+1−112
@@ -1190,7 +1190,6 @@ impl Codegen {
11901190 self.declare_rt("knot_record_field_by_index", &[p, p], &[p]);
11911191 self.declare_rt("knot_record_from_pairs", &[p, p], &[p]);
11921192 self.declare_rt("knot_record_update", &[p], &[p]);
1193− self.declare_rt("knot_record_update_batch", &[p, p, p], &[p]);
11941193
11951194 // Relation operations
11961195 self.declare_rt("knot_relation_empty", &[], &[p]);
@@ -4349,34 +4348,6 @@ impl Codegen {
43494348 }
43504349 }
43514350
4352− ast::ExprKind::RecordUpdate { base, fields } => {
4353− let base_val = self.compile_expr(builder, base, env, db);
4354− let n = fields.len();
4355− // Compile and sort update fields for batch merge
4356− let mut compiled: Vec<(&str, Value)> = Vec::with_capacity(n);
4357− for f in fields {
4358− let val = self.compile_expr(builder, &f.value, env, db);
4359− compiled.push((&f.name, val));
4360− }
4361− compiled.sort_by_key(|(name, _)| *name);
4362−
4363− let ptr_bytes = self.ptr_type.bytes() as i32;
4364− let slot_size = (3 * n as u32) * ptr_bytes as u32;
4365− let slot = builder.create_sized_stack_slot(
4366− StackSlotData::new(StackSlotKind::ExplicitSlot, slot_size, 3),
4367− );
4368− for (i, (name, val)) in compiled.iter().enumerate() {
4369− let (key_ptr, key_len) = self.string_ptr(builder, name);
4370− let base_off = (i as i32) * (3 * ptr_bytes);
4371− builder.ins().stack_store(key_ptr, slot, base_off);
4372− builder.ins().stack_store(key_len, slot, base_off + ptr_bytes);
4373− builder.ins().stack_store(*val, slot, base_off + 2 * ptr_bytes);
4374− }
4375− let data_ptr = builder.ins().stack_addr(self.ptr_type, slot, 0);
4376− let count = builder.ins().iconst(self.ptr_type, n as i64);
4377− self.call_rt(builder, "knot_record_update_batch", &[base_val, data_ptr, count])
4378− }
4379−
43804351 ast::ExprKind::FieldAccess { expr, field } => {
43814352 // `base.todo` — the unimplemented hole, reached through the
43824353 // `base` namespace. Dispatch-only (no `base` record field holds
@@ -5809,12 +5780,6 @@ impl Codegen {
58095780 Record(fields) => fields
58105781 .iter()
58115782 .all(|f| self.collect_direct_write_targets(&f.value, out)),
5812− RecordUpdate { base, fields } => {
5813− self.collect_direct_write_targets(base, out)
5814− && fields
5815− .iter()
5816− .all(|f| self.collect_direct_write_targets(&f.value, out))
5817− }
58185783 List(items) => items
58195784 .iter()
58205785 .all(|e| self.collect_direct_write_targets(e, out)),
@@ -9581,9 +9546,7 @@ impl Codegen {
95819546 matches!(&expr.node, ast::ExprKind::Var(n) if n == "base")
95829547 && builtins.contains(field.as_str())
95839548 }
9584− ast::ExprKind::Record(_)
9585− | ast::ExprKind::RecordUpdate { .. }
9586− | ast::ExprKind::List(_) => false,
9549+ ast::ExprKind::Record(_) | ast::ExprKind::List(_) => false,
95879550 _ => false,
95889551 }
95899552 }
@@ -9786,10 +9749,6 @@ impl Codegen {
97869749 ast::ExprKind::Record(fields) => fields
97879750 .iter()
97889751 .any(|f| Self::expr_contains_writes(&f.value, write_fns, known_fns, passthrough_fns)),
9789− ast::ExprKind::RecordUpdate { base, fields } => {
9790− Self::expr_contains_writes(base, write_fns, known_fns, passthrough_fns)
9791− || fields.iter().any(|f| Self::expr_contains_writes(&f.value, write_fns, known_fns, passthrough_fns))
9792− }
97939752 ast::ExprKind::FieldAccess { expr, .. } => {
97949753 Self::expr_contains_writes(expr, write_fns, known_fns, passthrough_fns)
97959754 }
@@ -12430,10 +12389,6 @@ impl Codegen {
1243012389 ast::ExprKind::Record(fields) => {
1243112390 fields.iter().any(|f| Self::references_source(&f.value, source_name))
1243212391 }
12433− ast::ExprKind::RecordUpdate { base, fields } => {
12434− Self::references_source(base, source_name)
12435− || fields.iter().any(|f| Self::references_source(&f.value, source_name))
12436− }
1243712392 ast::ExprKind::FieldAccess { expr, .. } => Self::references_source(expr, source_name),
1243812393 ast::ExprKind::List(elems) => {
1243912394 elems.iter().any(|e| Self::references_source(e, source_name))
@@ -15839,10 +15794,6 @@ fn expr_has_user_calls(expr: &ast::Expr, user_fns: &HashMap<String, (FuncId, usi
1583915794 ast::ExprKind::Record(fields) => {
1584015795 fields.iter().any(|f| expr_has_user_calls(&f.value, user_fns))
1584115796 }
15842− ast::ExprKind::RecordUpdate { base, fields } => {
15843− expr_has_user_calls(base, user_fns)
15844− || fields.iter().any(|f| expr_has_user_calls(&f.value, user_fns))
15845− }
1584615797 ast::ExprKind::FieldAccess { expr, .. } => expr_has_user_calls(expr, user_fns),
1584715798 ast::ExprKind::Lambda { body, .. } => expr_has_user_calls(body, user_fns),
1584815799 ast::ExprKind::Annot { expr, .. } => expr_has_user_calls(expr, user_fns),
@@ -15891,12 +15842,6 @@ fn expr_references_var(expr: &ast::Expr, var_name: &str) -> bool {
1589115842 .any(|p| matches!(&p.node, ast::PatKind::Var(n) if n == var_name));
1589215843 !rebinds && expr_references_var(body, var_name)
1589315844 }
15894− ast::ExprKind::RecordUpdate { base, fields } => {
15895− expr_references_var(base, var_name)
15896− || fields
15897− .iter()
15898− .any(|f| expr_references_var(&f.value, var_name))
15899− }
1590015845 ast::ExprKind::TimeUnitLit { value, .. } => expr_references_var(value, var_name),
1590115846 ast::ExprKind::Annot { expr, .. } => expr_references_var(expr, var_name),
1590215847 ast::ExprKind::Refine(inner) => expr_references_var(inner, var_name),
@@ -17351,16 +17296,6 @@ fn beta_reduce_inner(
1735117296 })
1735217297 .collect(),
1735317298 ),
17354− RecordUpdate { base, fields } => RecordUpdate {
17355− base: Box::new(beta_reduce_inner(base, fun_bodies, let_bindings, visited, fuel)),
17356− fields: fields
17357− .iter()
17358− .map(|f| ast::Field {
17359− name: f.name.clone(),
17360− value: beta_reduce_inner(&f.value, fun_bodies, let_bindings, visited, fuel),
17361− })
17362− .collect(),
17363− },
1736417299 List(items) => List(
1736517300 items
1736617301 .iter()
@@ -17457,18 +17392,6 @@ fn substitute_inner(
1745717392 })
1745817393 .collect::<Option<Vec<_>>>()?,
1745917394 ),
17460− RecordUpdate { base, fields } => RecordUpdate {
17461− base: Box::new(substitute_inner(base, var, value, value_fv)?),
17462− fields: fields
17463− .iter()
17464− .map(|f| {
17465− substitute_inner(&f.value, var, value, value_fv).map(|v| ast::Field {
17466− name: f.name.clone(),
17467− value: v,
17468− })
17469− })
17470− .collect::<Option<Vec<_>>>()?,
17471− },
1747217395 List(items) => List(
1747317396 items
1747417397 .iter()
@@ -17524,10 +17447,6 @@ fn expr_mentions_var(expr: &ast::Expr, var: &str) -> bool {
1752417447 | DataCtor { .. } | SourceDecl { .. } | SubsetConstraint { .. } | RouteDecl { .. } | RouteCompositeDecl { .. } => false,
1752517448 ViewDecl { body, .. } | DerivedDecl { body, .. } => expr_mentions_var(body, var),
1752617449 Record(fields) => fields.iter().any(|f| expr_mentions_var(&f.value, var)),
17527− RecordUpdate { base, fields } => {
17528− expr_mentions_var(base, var)
17529− || fields.iter().any(|f| expr_mentions_var(&f.value, var))
17530− }
1753117450 FieldAccess { expr: e, .. } => expr_mentions_var(e, var),
1753217451 List(items) => items.iter().any(|e| expr_mentions_var(e, var)),
1753317452 Lambda { body, .. } => expr_mentions_var(body, var),
@@ -17615,12 +17534,6 @@ fn collect_free_vars_set(expr: &ast::Expr, bound: &HashSet<String>, free: &mut H
1761517534 collect_free_vars_set(&f.value, bound, free);
1761617535 }
1761717536 }
17618− RecordUpdate { base, fields } => {
17619− collect_free_vars_set(base, bound, free);
17620− for f in fields {
17621− collect_free_vars_set(&f.value, bound, free);
17622− }
17623− }
1762417537 List(items) => {
1762517538 for e in items {
1762617539 collect_free_vars_set(e, bound, free);
@@ -18566,10 +18479,6 @@ fn expr_contains_derived_ref(expr: &ast::Expr, name: &str) -> bool {
1856618479 ast::ExprKind::Record(fields) => {
1856718480 fields.iter().any(|f| expr_contains_derived_ref(&f.value, name))
1856818481 }
18569− ast::ExprKind::RecordUpdate { base, fields } => {
18570− expr_contains_derived_ref(base, name)
18571− || fields.iter().any(|f| expr_contains_derived_ref(&f.value, name))
18572− }
1857318482 ast::ExprKind::FieldAccess { expr, .. } => expr_contains_derived_ref(expr, name),
1857418483 ast::ExprKind::List(elems) => elems.iter().any(|e| expr_contains_derived_ref(e, name)),
1857518484 ast::ExprKind::Lambda { body, .. } => expr_contains_derived_ref(body, name),
@@ -18680,12 +18589,6 @@ fn collect_free_vars(expr: &ast::Expr, bound: &HashSet<&str>, free: &mut Vec<Str
1868018589 collect_free_vars(&f.value, bound, free);
1868118590 }
1868218591 }
18683− ast::ExprKind::RecordUpdate { base, fields } => {
18684− collect_free_vars(base, bound, free);
18685− for f in fields {
18686− collect_free_vars(&f.value, bound, free);
18687− }
18688− }
1868918592 ast::ExprKind::FieldAccess { expr, .. } => {
1869018593 collect_free_vars(expr, bound, free);
1869118594 }
@@ -18858,9 +18761,6 @@ pub(crate) fn expr_refs_var(expr: &ast::Expr, var: &str) -> bool {
1885818761 })
1885918762 }
1886018763 ast::ExprKind::Record(fields) => fields.iter().any(|f| expr_refs_var(&f.value, var)),
18861− ast::ExprKind::RecordUpdate { base, fields } => {
18862− expr_refs_var(base, var) || fields.iter().any(|f| expr_refs_var(&f.value, var))
18863− }
1886418764 ast::ExprKind::List(elems) => elems.iter().any(|e| expr_refs_var(e, var)),
1886518765 ast::ExprKind::Do(stmts) => {
1886618766 for stmt in stmts {
@@ -18969,10 +18869,6 @@ fn expr_uses_var_as_value(expr: &ast::Expr, var: &str) -> bool {
1896918869 }
1897018870 // `{p | age: 1}` rebuilds the whole record `p`, so the base is a value
1897118871 // use even though field names appear next to it.
18972− ast::ExprKind::RecordUpdate { base, fields } => {
18973− expr_uses_var_as_value(base, var)
18974− || fields.iter().any(|f| expr_uses_var_as_value(&f.value, var))
18975− }
1897618872 ast::ExprKind::List(elems) => {
1897718873 elems.iter().any(|e| expr_uses_var_as_value(e, var))
1897818874 }
@@ -19283,13 +19179,6 @@ fn pretty_expr(expr: &ast::Expr) -> String {
1928319179 .collect();
1928419180 format!("{{{}}}", fs.join(", "))
1928519181 }
19286− ast::ExprKind::RecordUpdate { base, fields } => {
19287− let fs: Vec<String> = fields
19288− .iter()
19289− .map(|f| format!("{}: {}", f.name, pretty_expr(&f.value)))
19290− .collect();
19291− format!("{{{} | {}}}", pretty_expr(base), fs.join(", "))
19292− }
1929319182 ast::ExprKind::FieldAccess { expr, field } => {
1929419183 format!("{}.{}", pretty_expr(expr), field)
1929519184 }
crates/knot-compiler/src/desugar.rs+1−27
@@ -136,12 +136,6 @@ fn walk_expr_children_read(expr: &Expr, f: &mut impl FnMut(&Expr)) {
136136 f(&field.value);
137137 }
138138 }
139− ExprKind::RecordUpdate { base, fields } => {
140− f(base);
141− for field in fields {
142− f(&field.value);
143− }
144− }
145139 ExprKind::FieldAccess { expr: e, .. } => f(e),
146140 ExprKind::List(elems) => {
147141 for e in elems {
@@ -343,12 +337,6 @@ fn collect_fun_bodies<'a>(
343337 collect_fun_bodies(&fl.value, fun_bodies, fun_sig_io);
344338 }
345339 }
346− ExprKind::RecordUpdate { base, fields } => {
347− collect_fun_bodies(base, fun_bodies, fun_sig_io);
348− for fl in fields {
349− collect_fun_bodies(&fl.value, fun_bodies, fun_sig_io);
350− }
351− }
352340 ExprKind::List(items) => {
353341 for it in items {
354342 collect_fun_bodies(it, fun_bodies, fun_sig_io);
@@ -481,9 +469,7 @@ fn expr_contains_io(expr: &Expr, builtins: &HashSet<&str>, io_fns: &HashSet<Stri
481469 matches!(&expr.node, ExprKind::Var(n) if n == "base")
482470 && builtins.contains(field.as_str())
483471 }
484− ExprKind::Record(_)
485− | ExprKind::RecordUpdate { .. }
486− | ExprKind::List(_) => false,
472+ ExprKind::Record(_) | ExprKind::List(_) => false,
487473 _ => {
488474 if let Some(inner) = expr.node.as_yield_arg() {
489475 expr_contains_io(inner, builtins, io_fns)
@@ -604,12 +590,6 @@ fn walk_expr_children(expr: &mut Expr, f: &mut impl FnMut(&mut Expr)) {
604590 f(&mut field.value);
605591 }
606592 }
607− ExprKind::RecordUpdate { base, fields } => {
608− f(base);
609− for field in fields {
610− f(&mut field.value);
611− }
612− }
613593 ExprKind::FieldAccess { expr: e, .. } => f(e),
614594 ExprKind::List(elems) => {
615595 for e in elems {
@@ -731,12 +711,6 @@ fn recurse_into_children(expr: &mut Expr, io_fns: &IoFns, source_vars: &HashSet<
731711 desugar_expr(&mut f.value, io_fns, source_vars);
732712 }
733713 }
734− ExprKind::RecordUpdate { base, fields } => {
735− desugar_expr(base, io_fns, source_vars);
736− for f in fields {
737− desugar_expr(&mut f.value, io_fns, source_vars);
738− }
739− }
740714 ExprKind::FieldAccess { expr: e, .. } => desugar_expr(e, io_fns, source_vars),
741715 ExprKind::List(elems) => {
742716 for e in elems {
crates/knot-compiler/src/infer.rs+60−109
@@ -5514,60 +5514,6 @@ impl Infer {
55145514 Ty::Record(field_tys, None)
55155515 }
55165516
5517− ast::ExprKind::RecordUpdate { base, fields } => {
5518− let base_ty = self.infer_expr(base);
5519− let mut update_fields = BTreeMap::new();
5520− for field in fields {
5521− let val_ty = self.infer_expr(&field.value);
5522− update_fields.insert(field.name.clone(), val_ty);
5523− }
5524− // The base must be some record; `rv` captures its row.
5525− let rv = self.fresh_var();
5526− let constraint = Ty::Record(BTreeMap::new(), Some(rv));
5527− self.unify(&base_ty, &constraint, base.span);
5528−
5529− match self.apply(&Ty::Var(rv)) {
5530− // The base's fields are fully known: overlay the updates
5531− // on them. Fields the base already has keep their type;
5532− // any others extend it — `{base | field: val}` is both
5533− // update and extension syntax.
5534− Ty::Record(base_fields, None) => {
5535− let mut result = base_fields.clone();
5536− for (name, update_ty) in update_fields {
5537− if let Some(base_field_ty) = base_fields.get(&name)
5538− {
5539− let base_field_ty = base_field_ty.clone();
5540− self.unify(
5541− &update_ty,
5542− &base_field_ty,
5543− base.span,
5544− );
5545− }
5546− result.insert(name, update_ty);
5547− }
5548− Ty::Record(result, None)
5549− }
5550− // The base's row is still open, so we cannot tell whether
5551− // it already holds the updated fields. Split the row into
5552− // those fields and a `rest` tail carrying everything else,
5553− // which requires the base to have them.
5554− //
5555− // Reusing the base's whole row as the result's tail (the
5556− // previous approach) let a field sit in the tail *and* be
5557− // named explicitly in the result. `{r | f: v}` and `r`
5558− // then looked like they disagreed about `f`, so unifying
5559− // the two — as `if c then {r | f: v} else r` does — failed
5560− // with "record fields don't match".
5561− _ => {
5562− let rest = self.fresh_var();
5563− let constraint =
5564− Ty::Record(update_fields.clone(), Some(rest));
5565− self.unify(&base_ty, &constraint, base.span);
5566− Ty::Record(update_fields, Some(rest))
5567− }
5568− }
5569− }
5570−
55715517 ast::ExprKind::FieldAccess { expr: e, field } => {
55725518 // Qualified constructor: `Color.Red`. The base parsed as a
55735519 // `Constructor` (capitalized) but names a DATA TYPE; resolve
@@ -7281,8 +7227,53 @@ impl Infer {
72817227 return Some(Ty::Var(result));
72827228 }
72837229
7284− match (Self::closed_record_fields(&left_res), Self::closed_record_fields(&right_res)) {
7285− (Ok(f1), Ok(f2)) => Some(Self::merge_record_fields(f1, f2)),
7230+ // Right-biased merge over possibly-open records. The common case is
7231+ // `unify base {updates}` — an open left (a lambda parameter / relation
7232+ // row whose full shape isn't pinned) merged with a closed right (a
7233+ // literal of updates). Overlay the right's fields on the left, keeping
7234+ // the left's row tail so the rest of the base's fields flow through —
7235+ // the same right-biased overlay record-update syntax used. A genuinely
7236+ // non-record argument is the only hard error.
7237+ match (
7238+ Self::record_fields(&left_res),
7239+ Self::record_fields(&right_res),
7240+ ) {
7241+ (Ok((f1, None)), Ok((f2, tail2))) => {
7242+ // Closed left: overlay the right's fields, keep the right's tail
7243+ // (a closed left contributes no tail).
7244+ let mut merged = f1;
7245+ for (k, v) in f2 {
7246+ merged.insert(k, v);
7247+ }
7248+ Some(Ty::Record(merged, tail2))
7249+ }
7250+ (Ok((f1, Some(_))), Ok((f2, tail2))) => {
7251+ // Open left: the base's other fields live in its row tail, so we
7252+ // cannot just name the right's fields explicitly AND keep the
7253+ // base's tail — a field would sit in both, and unifying the
7254+ // result with the base (as `case … -> unify r {..} ; _ -> r`
7255+ // does) would then report "record fields don't match". Split
7256+ // the row instead (record-update's approach): the result names
7257+ // the merged fields and a FRESH tail, and the base is
7258+ // constrained to have the right's fields, so its own tail
7259+ // becomes that fresh tail with the named fields excluded.
7260+ let rest = self.fresh_var();
7261+ let mut merged = f1;
7262+ for (k, v) in &f2 {
7263+ merged.insert(k.clone(), v.clone());
7264+ }
7265+ let result = Ty::Record(merged.clone(), Some(rest));
7266+ // Constrain the base: it must contain the merged known fields,
7267+ // with the same fresh tail (so its other fields flow into
7268+ // `rest`).
7269+ let base_constraint = Ty::Record(merged, Some(rest));
7270+ let base_ty = self.apply(&left_ty).clone();
7271+ self.unify(&base_ty, &base_constraint, args[0].span);
7272+ // A right tail beyond the fresh one is not expressible here;
7273+ // the common `unify base {literal-updates}` case has none.
7274+ let _ = tail2;
7275+ Some(result)
7276+ }
72867277 (Err(msg), _) | (_, Err(msg)) => {
72877278 self.error(msg, expr.span);
72887279 // Return Unit to stop the generic application path emitting a
@@ -7292,20 +7283,15 @@ impl Infer {
72927283 }
72937284 }
72947285
7295− /// Extract a closed record's field map, or an error message if the type
7296− /// is an open record (free row tail) or not a record at all.
7297− fn closed_record_fields(
7286+ /// Extract a record's field map and row tail (`None` = closed). The only
7287+ /// error is a genuinely non-record argument.
7288+ fn record_fields(
72987289 ty: &Ty,
7299− ) -> Result<std::collections::BTreeMap<String, Ty>, String> {
7290+ ) -> Result<(std::collections::BTreeMap<String, Ty>, Option<TyVar>), String> {
73007291 match ty.peel_alias() {
7301− Ty::Record(fields, None) => Ok(fields.clone()),
7302− Ty::Record(_, Some(_)) => Err(
7303− "unify requires a statically-known record shape, but an argument has an open record type".into(),
7304− ),
7292+ Ty::Record(fields, tail) => Ok((fields.clone(), *tail)),
73057293 other => Err(format!(
73067294 "unify expects record arguments, got {}",
7307− // Display via a temporary; the Infer method needs &self, so
7308− // format the variant name structurally here.
73097295 match other {
73107296 Ty::Int => "Int".to_string(),
73117297 Ty::Float => "Float".to_string(),
@@ -7318,18 +7304,6 @@ impl Infer {
73187304 }
73197305 }
73207306
7321− /// Right-biased record field union: `f2`'s type wins on a name conflict.
7322− fn merge_record_fields(
7323− f1: std::collections::BTreeMap<String, Ty>,
7324− f2: std::collections::BTreeMap<String, Ty>,
7325− ) -> Ty {
7326− let mut merged = f1;
7327− for (k, v) in f2 {
7328− merged.insert(k, v);
7329− }
7330− Ty::Record(merged, None)
7331− }
7332−
73337307 fn try_infer_fetch(&mut self, expr: &ast::Expr) -> Option<Ty> {
73347308 let ctor_name = fetch_ctor_name(expr)?;
73357309
@@ -7801,11 +7775,15 @@ impl Infer {
78017775 let left = self.apply(&d.left).clone();
78027776 let right = self.apply(&d.right).clone();
78037777 match (
7804− Self::closed_record_fields(&left),
7805− Self::closed_record_fields(&right),
7778+ Self::record_fields(&left),
7779+ Self::record_fields(&right),
78067780 ) {
7807− (Ok(f1), Ok(f2)) => {
7808− let merged = Self::merge_record_fields(f1, f2);
7781+ (Ok((f1, tail1)), Ok((f2, tail2))) => {
7782+ let mut merged = f1;
7783+ for (k, v) in f2 {
7784+ merged.insert(k, v);
7785+ }
7786+ let merged = Ty::Record(merged, tail1.or(tail2));
78097787 self.unify(&Ty::Var(d.result), &merged, d.span);
78107788 }
78117789 (Err(msg), _) | (_, Err(msg)) => {
@@ -12251,15 +12229,6 @@ fn value_references_source_inner(
1225112229 &f.value, source_name, aliases, let_bindings, visited,
1225212230 )
1225312231 }),
12254− ast::ExprKind::RecordUpdate { base, fields } => {
12255− value_references_source_inner(
12256− base, source_name, aliases, let_bindings, visited,
12257− ) || fields.iter().any(|f| {
12258− value_references_source_inner(
12259− &f.value, source_name, aliases, let_bindings, visited,
12260− )
12261− })
12262− }
1226312232 ast::ExprKind::FieldAccess { expr, .. } => value_references_source_inner(
1226412233 expr, source_name, aliases, let_bindings, visited,
1226512234 ),
@@ -13043,12 +13012,6 @@ fn walk_expr_children_mut(expr: &mut ast::Expr, f: &mut impl FnMut(&mut ast::Exp
1304313012 f(&mut fl.value);
1304413013 }
1304513014 }
13046− RecordUpdate { base, fields } => {
13047− f(base);
13048− for fl in fields {
13049− f(&mut fl.value);
13050− }
13051− }
1305213015 FieldAccess { expr, .. } => f(expr),
1305313016 List(items) => {
1305413017 for it in items {
@@ -13167,12 +13130,6 @@ pub(crate) fn walk_exprs_read<'a>(e: &'a ast::Expr, f: &mut impl FnMut(&'a ast::
1316713130 walk_exprs_read(&fl.value, f);
1316813131 }
1316913132 }
13170− RecordUpdate { base, fields } => {
13171− walk_exprs_read(base, f);
13172− for fl in fields {
13173− walk_exprs_read(&fl.value, f);
13174− }
13175− }
1317613133 List(items) => {
1317713134 for it in items {
1317813135 walk_exprs_read(it, f);
@@ -13279,12 +13236,6 @@ fn for_each_data_ctor_scoped<'a>(
1327913236 walk(&fl.value, d, f);
1328013237 }
1328113238 }
13282− RecordUpdate { base, fields } => {
13283− walk(base, d, f);
13284− for fl in fields {
13285− walk(&fl.value, d, f);
13286− }
13287− }
1328813239 List(items) => {
1328913240 for it in items {
1329013241 walk(it, d, f);
crates/knot-compiler/src/nonterm.rs−6
@@ -196,12 +196,6 @@ fn collect_unguarded_calls<'a>(
196196 collect_unguarded_calls(&f.value, defs, caller_name, caller, out);
197197 }
198198 }
199− RecordUpdate { base, fields } => {
200− collect_unguarded_calls(base, defs, caller_name, caller, out);
201− for f in fields {
202− collect_unguarded_calls(&f.value, defs, caller_name, caller, out);
203− }
204− }
205199 FieldAccess { expr, .. } => collect_unguarded_calls(expr, defs, caller_name, caller, out),
206200 List(items) => {
207201 for it in items {
crates/knot-compiler/src/sql_lint.rs−10
@@ -151,12 +151,6 @@ fn lint_expr(
151151 lint_expr(&f.value, source_schemas, views, fun_bodies, diags);
152152 }
153153 }
154− ExprKind::RecordUpdate { base, fields } => {
155− lint_expr(base, source_schemas, views, fun_bodies, diags);
156− for f in fields {
157− lint_expr(&f.value, source_schemas, views, fun_bodies, diags);
158− }
159− }
160154 ExprKind::List(elems) => {
161155 for e in elems {
162156 lint_expr(e, source_schemas, views, fun_bodies, diags);
@@ -956,10 +950,6 @@ fn references_source(expr: &Expr, source_name: &str) -> bool {
956950 ExprKind::TypeCtor { .. } | ExprKind::DataCtor { .. } | ExprKind::SourceDecl { .. } | ExprKind::SubsetConstraint { .. } | ExprKind::RouteDecl { .. } | ExprKind::RouteCompositeDecl { .. } => false,
957951 ExprKind::ViewDecl { body, .. } | ExprKind::DerivedDecl { body, .. } => references_source(body, source_name),
958952 ExprKind::Record(fields) => fields.iter().any(|f| references_source(&f.value, source_name)),
959− ExprKind::RecordUpdate { base, fields } => {
960− references_source(base, source_name)
961− || fields.iter().any(|f| references_source(&f.value, source_name))
962− }
963953 ExprKind::FieldAccess { expr, .. } => references_source(expr, source_name),
964954 ExprKind::List(elems) => elems.iter().any(|e| references_source(e, source_name)),
965955 ExprKind::BinOp { lhs, rhs, .. } => {
crates/knot-compiler/src/stratify.rs−6
@@ -334,12 +334,6 @@ fn collect_edges(
334334 collect_edges(&f.value, polarity, node_names, env, partial_diffs, diff_wrappers, out);
335335 }
336336 }
337− ast::ExprKind::RecordUpdate { base, fields } => {
338− collect_edges(base, polarity, node_names, env, partial_diffs, diff_wrappers, out);
339− for f in fields {
340− collect_edges(&f.value, polarity, node_names, env, partial_diffs, diff_wrappers, out);
341− }
342− }
343337 ast::ExprKind::FieldAccess { expr, .. } => {
344338 collect_edges(expr, polarity, node_names, env, partial_diffs, diff_wrappers, out);
345339 }
crates/knot-compiler/src/types.rs−18
@@ -371,12 +371,6 @@ fn walk_exprs(e: &Expr, f: &mut impl FnMut(&Expr)) {
371371 walk_exprs(&fl.value, f);
372372 }
373373 }
374− ExprKind::RecordUpdate { base, fields } => {
375− walk_exprs(base, f);
376− for fl in fields {
377− walk_exprs(&fl.value, f);
378− }
379− }
380374 ExprKind::List(items) => {
381375 for it in items {
382376 walk_exprs(it, f);
@@ -517,12 +511,6 @@ fn collect_type_and_data_refs<'a>(
517511 collect_type_and_data_refs(&fl.value, aliases, data_decls);
518512 }
519513 }
520− ExprKind::RecordUpdate { base, fields } => {
521− collect_type_and_data_refs(base, aliases, data_decls);
522− for fl in fields {
523− collect_type_and_data_refs(&fl.value, aliases, data_decls);
524− }
525− }
526514 ExprKind::List(items) => {
527515 for it in items {
528516 collect_type_and_data_refs(it, aliases, data_decls);
@@ -769,12 +757,6 @@ fn walk_record_sources<'m>(
769757 walk_record_sources(&fld.value, f);
770758 }
771759 }
772− ExprKind::RecordUpdate { base, fields } => {
773− walk_record_sources(base, f);
774− for fld in fields {
775− walk_record_sources(&fld.value, f);
776− }
777− }
778760 _ => {}
779761 }
780762}
crates/knot-compiler/src/unused.rs−6
@@ -267,12 +267,6 @@ fn walk_expr(e: &Expr, r: &mut Refs) {
267267 walk_expr(&f.value, r);
268268 }
269269 }
270− ExprKind::RecordUpdate { base, fields } => {
271− walk_expr(base, r);
272− for f in fields {
273− walk_expr(&f.value, r);
274− }
275− }
276270 ExprKind::FieldAccess { expr, field } => {
277271 // Qualified constructor `Color.Red`: the base is the data type and
278272 // `field` is the constructor. Count the ctor as used so the data
crates/knot-lsp/src/defs.rs−6
@@ -544,12 +544,6 @@ impl<'a> DefResolver<'a> {
544544 self.resolve_expr(&f.value);
545545 }
546546 }
547− ast::ExprKind::RecordUpdate { base, fields } => {
548− self.resolve_expr(base);
549− for f in fields {
550− self.resolve_expr(&f.value);
551− }
552− }
553547 ast::ExprKind::FieldAccess { expr, .. } => self.resolve_expr(expr),
554548 ast::ExprKind::List(elems) => {
555549 for e in elems {
crates/knot-lsp/src/folding.rs−6
@@ -123,12 +123,6 @@ fn collect_folding_ranges_expr(expr: &ast::Expr, source: &str, ranges: &mut Vec<
123123 collect_folding_ranges_expr(&f.value, source, ranges);
124124 }
125125 }
126− ast::ExprKind::RecordUpdate { base, fields } => {
127− collect_folding_ranges_expr(base, source, ranges);
128− for f in fields {
129− collect_folding_ranges_expr(&f.value, source, ranges);
130− }
131− }
132126 ast::ExprKind::FieldAccess { expr, .. } => {
133127 collect_folding_ranges_expr(expr, source, ranges);
134128 }
crates/knot-lsp/src/linked_editing.rs−12
@@ -177,18 +177,6 @@ fn collect_field_name_spans(
177177 search_start = f.value.span.end;
178178 }
179179 }
180− ast::ExprKind::RecordUpdate { base, fields } => {
181− let mut search_start = base.span.end;
182− for f in fields {
183− if f.name == field_name {
184− match find_word_in_source(source, field_name, search_start, f.value.span.start) {
185− Some(span) => ranges.push(span),
186− None => *pun_seen = true,
187− }
188− }
189− search_start = f.value.span.end;
190− }
191− }
192180 ast::ExprKind::FieldAccess {
193181 expr: inner, field, ..
194182 }
crates/knot-lsp/src/rename.rs−16
@@ -524,11 +524,6 @@ fn span_is_record_pun(module: &ast::Expr, source: &str, span: Span) -> bool {
524524 *found = true;
525525 return;
526526 }
527− ast::ExprKind::RecordUpdate { base, fields }
528− if pun_field_in_fields(fields, base.span.end, source, span) => {
529− *found = true;
530− return;
531− }
532527 ast::ExprKind::Lambda { params, .. }
533528 if params.iter().any(|p| pun_in_pat(p, source, span)) => {
534529 *found = true;
@@ -994,17 +989,6 @@ fn field_sites_in_expr<F: FnMut(&str, Span)>(expr: &ast::Expr, source: &str, f:
994989 search_start = fld.value.span.end;
995990 }
996991 }
997− ast::ExprKind::RecordUpdate { base, fields } => {
998− let mut search_start = base.span.end;
999− for fld in fields {
1000− if let Some(span) =
1001− find_word_in_source(source, &fld.name, search_start, fld.value.span.start)
1002− {
1003− f(&fld.name, span);
1004− }
1005− search_start = fld.value.span.end;
1006− }
1007− }
1008992 ast::ExprKind::FieldAccess { field, expr: rec } => {
1009993 // The field token sits between the receiver's end and the access
1010994 // expression's end. It is NOT reliably the exact suffix of the
crates/knot-lsp/src/selection_range.rs−6
@@ -163,12 +163,6 @@ fn collect_containing_spans(expr: &ast::Expr, offset: usize, spans: &mut Vec<Spa
163163 collect_containing_spans(&f.value, offset, spans);
164164 }
165165 }
166− ast::ExprKind::RecordUpdate { base, fields } => {
167− collect_containing_spans(base, offset, spans);
168− for f in fields {
169− collect_containing_spans(&f.value, offset, spans);
170− }
171− }
172166 ast::ExprKind::List(elems) => {
173167 for e in elems {
174168 collect_containing_spans(e, offset, spans);
crates/knot-lsp/src/semantic_tokens.rs−6
@@ -559,12 +559,6 @@ impl<'a> TokenCollector<'a> {
559559 self.visit_expr(&f.value);
560560 }
561561 }
562− ast::ExprKind::RecordUpdate { base, fields } => {
563− self.visit_expr(base);
564− for f in fields {
565− self.visit_expr(&f.value);
566− }
567− }
568562 ast::ExprKind::List(elems) => {
569563 for e in elems {
570564 self.visit_expr(e);
crates/knot-lsp/src/utils.rs−6
@@ -526,12 +526,6 @@ pub fn recurse_expr<F: FnMut(&ast::Expr)>(expr: &ast::Expr, mut f: F) {
526526 f(&fld.value);
527527 }
528528 }
529− ast::ExprKind::RecordUpdate { base, fields } => {
530− f(base);
531− for fld in fields {
532− f(&fld.value);
533− }
534− }
535529 ast::ExprKind::List(elems) => {
536530 for e in elems {
537531 f(e);
crates/knot-runtime/src/lib.rs+2−83
@@ -16906,8 +16906,8 @@ pub extern "C-unwind" fn knot_atomic_rollback(db: *mut c_void) {
1690616906
1690716907// ── Record update ─────────────────────────────────────────────────
1690816908
16909−/// Create a new record by copying `base` and overriding fields.
16910−/// This implements `{base | field1: val1, field2: val2}`.
16909+/// Copy a record (`relation_add_fields` clones each row before overriding
16910+/// fields on the copy).
1691116911#[unsafe(no_mangle)]
1691216912pub extern "C-unwind" fn knot_record_update(base: *mut Value) -> *mut Value {
1691316913 match unsafe { as_ref(base) } {
@@ -16925,87 +16925,6 @@ pub extern "C-unwind" fn knot_record_update(base: *mut Value) -> *mut Value {
1692516925 }
1692616926}
1692716927
16928−/// Batch record update: copy base and merge sorted update fields in one pass.
16929−/// `data` points to a flat array of triples: [key_ptr, key_len, value, ...]
16930−/// Fields MUST be pre-sorted by name. O(n+m) merge vs O(m log n) repeated insert.
16931−#[unsafe(no_mangle)]
16932−pub extern "C-unwind" fn knot_record_update_batch(
16933− base: *mut Value,
16934− data: *const usize,
16935− count: usize,
16936−) -> *mut Value {
16937− let base_fields = match unsafe { as_ref(base) } {
16938− Value::Record(fields) => fields,
16939− _ => panic!("knot runtime: record update requires a Record base, got {}", type_name(base)),
16940− };
16941−
16942− // Parse update fields from flat array
16943− let mut updates: Vec<(String, *mut Value)> = (0..count)
16944− .map(|i| {
16945− let offset = i * 3;
16946− let key_ptr = unsafe { *data.add(offset) as *const u8 };
16947− let key_len = unsafe { *data.add(offset + 1) };
16948− let value = unsafe { *data.add(offset + 2) as *mut Value };
16949− let name = unsafe { str_from_raw(key_ptr, key_len) }.to_string();
16950− (name, value)
16951− })
16952− .collect();
16953−
16954− // Defensive sort by field name — codegen is expected to pre-sort, but a
16955− // bug there would silently produce wrong merge results. Sort here too.
16956− updates.sort_by(|(a, _), (b, _)| a.cmp(b));
16957−
16958− // Merge sorted base fields with sorted update fields
16959− let mut result = Vec::with_capacity(base_fields.len() + count);
16960− let mut base_idx = 0;
16961− let mut upd_idx = 0;
16962−
16963− while base_idx < base_fields.len() && upd_idx < updates.len() {
16964− let base_name: &str = &base_fields[base_idx].name;
16965− let upd_name = updates[upd_idx].0.as_str();
16966− match base_name.cmp(upd_name) {
16967− std::cmp::Ordering::Less => {
16968− result.push(RecordField {
16969− name: base_fields[base_idx].name.clone(),
16970− value: base_fields[base_idx].value,
16971− });
16972− base_idx += 1;
16973− }
16974− std::cmp::Ordering::Equal => {
16975− result.push(RecordField {
16976− name: base_fields[base_idx].name.clone(),
16977− value: updates[upd_idx].1,
16978− });
16979− base_idx += 1;
16980− upd_idx += 1;
16981− }
16982− std::cmp::Ordering::Greater => {
16983− result.push(RecordField {
16984− name: intern_str(&updates[upd_idx].0),
16985− value: updates[upd_idx].1,
16986− });
16987− upd_idx += 1;
16988− }
16989− }
16990− }
16991− while base_idx < base_fields.len() {
16992− result.push(RecordField {
16993− name: base_fields[base_idx].name.clone(),
16994− value: base_fields[base_idx].value,
16995− });
16996− base_idx += 1;
16997− }
16998− while upd_idx < updates.len() {
16999− result.push(RecordField {
17000− name: intern_str(&updates[upd_idx].0),
17001− value: updates[upd_idx].1,
17002− });
17003− upd_idx += 1;
17004− }
17005−
17006− alloc(Value::Record(result))
17007−}
17008−
1700916928// ── View operations ──────────────────────────────────────────────
1701016929
1701116930/// Read through a view: SELECT only view columns WHERE constant columns match.
crates/knot/src/ast.rs−6
@@ -97,12 +97,6 @@ pub enum ExprKind {
9797 /// `{name: "Alice", age: 30}`
9898 Record(Vec<RecordField>),
9999
100− /// `{t | age: t.age + 1}`
101− RecordUpdate {
102− base: Box<Expr>,
103− fields: Vec<Field<Expr>>,
104− },
105−
106100 /// `t.name`
107101 FieldAccess { expr: Box<Expr>, field: Name },
108102
crates/knot/src/format.rs−32
@@ -586,21 +586,6 @@ fn render_expr_inline(e: &Expr, parent: Prec) -> String {
586586 ExprKind::ImplicitRef(n) => format!("^{}", n),
587587 ExprKind::TypeHole => "_".to_string(),
588588 ExprKind::Record(fields) => render_record_inline(fields),
589− ExprKind::RecordUpdate { base, fields } => {
590− let mut s = String::from("{");
591− s.push_str(&render_expr_inline(base, Prec::Lowest));
592− s.push_str(" | ");
593− for (i, f) in fields.iter().enumerate() {
594− if i > 0 {
595− s.push(' ');
596− }
597− s.push_str(&f.name);
598− s.push(' ');
599− s.push_str(&render_expr_inline(&f.value, Prec::Atom));
600− }
601− s.push('}');
602− s
603− }
604589 ExprKind::FieldAccess { expr, field } => {
605590 format!("{}.{}", render_expr_inline(expr, Prec::Atom), field)
606591 }
@@ -1094,7 +1079,6 @@ fn render_expr_block(p: &mut Printer, e: &Expr, parent: Prec) {
10941079 }
10951080 ExprKind::List(items) => render_list_block(p, items),
10961081 ExprKind::Record(fields) => render_record_block(p, fields),
1097− ExprKind::RecordUpdate { base, fields } => render_record_update_block(p, base, fields),
10981082 ExprKind::App { .. } => render_app_block(p, e, parent),
10991083 ExprKind::BinOp { op, lhs, rhs } => render_binop_block(p, *op, lhs, rhs, parent),
11001084 ExprKind::Set { target, value } => {
@@ -1306,22 +1290,6 @@ fn render_record_block(p: &mut Printer, fields: &[RecordField]) {
13061290 p.write("}");
13071291}
13081292
1309−fn render_record_update_block(p: &mut Printer, base: &Expr, fields: &[Field<Expr>]) {
1310− p.write("{");
1311− p.write(&render_expr_inline(base, Prec::Lowest));
1312− p.write(" |");
1313− p.newline();
1314− p.with_indent(|p| {
1315− for f in fields.iter() {
1316− p.write(&f.name);
1317− p.write(" ");
1318− render_expr(p, &f.value, Prec::Atom);
1319− p.newline();
1320− }
1321− });
1322− p.write("}");
1323−}
1324−
13251293fn render_app_block(p: &mut Printer, e: &Expr, parent: Prec) {
13261294 // Flatten left-spine of applications. Stop at a `let … in` node — it is
13271295 // an App in the AST but renders as a binding, not as head + args.
crates/knot/src/parser.rs+4−86
@@ -2299,66 +2299,13 @@ impl Parser {
22992299 ));
23002300 }
23012301
2302− // New record syntax: whitespace-separated `name value` pairs, no `:` or
2302+ // Record literal: whitespace-separated `name value` pairs, no `:` or
23032303 // `,`. A field value is a single atom / postfix chain / parenthesized
23042304 // (or list / nested-record) compound — i.e. `parse_postfix`, which never
23052305 // consumes a following bare identifier as an application argument. A bare
2306− // `Lower` after a value therefore always opens the next field.
2307− //
2308− // A record UPDATE `{base | name value, ...}` is detected by speculatively
2309− // parsing a leading postfix expression and checking for a top-level `|`.
2310− // A leading `Lower` is always a field name, never an update base, so we
2311− // only speculate when the first token is NOT a plain identifier.
2312− let first_is_lower = matches!(self.peek(), TokenKind::Lower(_));
2313− if !first_is_lower {
2314− // Speculative base parse for `{base | …}` (e.g. base is a parenthesized
2315− // expr, field-access chain, etc.). A bare-identifier base is handled by
2316− // the named-field path below and disambiguated there.
2317− let saved = self.save();
2318− let diag_count = self.diagnostics.len();
2319− if let Some(first_expr) = self.parse_postfix() {
2320− self.skip_newlines();
2321− if self.eat(&TokenKind::Pipe) {
2322− let update_fields = self.parse_record_update_fields()?;
2323− let end_tok = self
2324− .expect(&TokenKind::RBrace, "expected '}' to close record update")
2325− .ok()?;
2326− return Some(Spanned::new(
2327− ExprKind::RecordUpdate {
2328− base: Box::new(first_expr),
2329− fields: update_fields,
2330− },
2331− Span::new(start.start, end_tok.span.end),
2332− ));
2333− }
2334− }
2335− self.restore(saved);
2336− self.diagnostics.truncate(diag_count);
2337− } else {
2338− // Leading identifier: could still be an update `{base | …}` where base
2339− // is a plain var or field-access chain. Parse the postfix, check `|`.
2340− let saved = self.save();
2341− let diag_count = self.diagnostics.len();
2342− if let Some(head) = self.parse_postfix() {
2343− self.skip_newlines();
2344− if self.eat(&TokenKind::Pipe) {
2345− let update_fields = self.parse_record_update_fields()?;
2346− let end_tok = self
2347− .expect(&TokenKind::RBrace, "expected '}' to close record update")
2348− .ok()?;
2349− return Some(Spanned::new(
2350− ExprKind::RecordUpdate {
2351− base: Box::new(head),
2352− fields: update_fields,
2353− },
2354− Span::new(start.start, end_tok.span.end),
2355− ));
2356− }
2357− }
2358− self.restore(saved);
2359− self.diagnostics.truncate(diag_count);
2360− }
2361−
2306+ // `Lower` after a value therefore always opens the next field. (Record
2307+ // UPDATE syntax `{base | name value, …}` was removed: use `unify base
2308+ // {name value, …}`.)
23622309 // Named fields: `name value name2 value2 …`
23632310 //
23642311 // A field may carry a standalone type-signature line, written like a
@@ -2903,35 +2850,6 @@ impl Parser {
29032850 Some(crate::ast::SourceMigration { from_ty, to_ty, using_fn })
29042851 }
29052852
2906− /// Parse the `name value …` fields after the `|` in a record update
2907− /// `{base | name value, …}`. Stops before the closing `}`.
2908− fn parse_record_update_fields(&mut self) -> Option<Vec<Field<Expr>>> {
2909− let mut update_fields = Vec::new();
2910− self.skip_newlines();
2911− if !self.at(&TokenKind::RBrace) {
2912− loop {
2913− self.skip_newlines();
2914− if self.at(&TokenKind::RBrace) {
2915− break;
2916− }
2917− let (fname, _) = self
2918− .expect_lower("expected field name in record update")
2919− .ok()?;
2920− self.skip_newlines();
2921− let Some(val) = self.parse_postfix() else {
2922− self.error("expected field value after field name in record update");
2923− return None;
2924− };
2925− update_fields.push(Field {
2926− name: fname,
2927− value: val,
2928− });
2929− }
2930− }
2931− self.skip_newlines();
2932− Some(update_fields)
2933− }
2934−
29352853 fn parse_list_expr(&mut self, start: Span) -> Option<Expr> {
29362854 // Already consumed `[`. Elements are SPACE-separated (no commas), so
29372855 // each is parsed with `parse_postfix` — the same non-greedy parse used
examples/let_pushdown.knot+1−1
@@ -17,7 +17,7 @@ discountDemo (do
1717 with {discounted (do
1818 i <- items
1919 yield (case i.price > 20 of
20− Bool.True {} -> {i | price (i.price - 5)}
20+ Bool.True {} -> (base.unify i {price (i.price - 5)})
2121 Bool.False {} -> i))} *items = discounted)
2222
2323-- Filter only: do-block filter body bound to a with.
examples/nested.knot+2−2
@@ -20,10 +20,10 @@ type Person = {name: Text, age: Int 1}
2020 -- Remove Eve from every team: rewrite each team's members list.
2121 *teams = do
2222 t <- teams
23− yield { t | members (do
23+ yield (base.unify t {members (do
2424 m <- t.members
2525 where m.name != "Eve"
26− yield m) }
26+ yield m) })
2727
2828 base.println "After:"
2929 teams2 <- full *teams
examples/stm_row_filter.knot+1−1
@@ -17,7 +17,7 @@ findById (\aid rows -> base.single (base.filter (\a -> a.id == aid) rows))
1717setBalance (\aid newBalance rows -> do
1818 r <- rows
1919 yield (case r.id == aid of
20− Bool.True {} -> {r | balance newBalance}
20+ Bool.True {} -> (base.unify r {balance newBalance})
2121 Bool.False {} -> r))
2222
2323credit (\aid amount -> atomic do
examples/todo.knot+1−1
@@ -12,7 +12,7 @@ complete (\title -> do
1212 *todos = do
1313 t <- todos
1414 yield (case t.title == title of
15− Bool.True {} -> {t | done 1}
15+ Bool.True {} -> (base.unify t {done 1})
1616 Bool.False {} -> t))
1717
1818pending (\owner -> do
knot.md+9−9
@@ -90,7 +90,7 @@ type Person = {name: Text, age: Int 1}
9090
9191Field access: `person.name`
9292
93−Record update: `{person | age (person.age + 1)}`
93+Record merge: `base.unify person {age (person.age + 1)}` — right-biased; see `unify`.
9494
9595Note the two syntaxes differ: **values** are `{name "Alice" age 30}` (space-separated, no `:` or `,`); **types** are `{name: Text, age: Int 1}` (`:` after each field name, `,` between fields). There is no field-name punning — a record value always pairs an explicit field name with its value (`{name t.name age t.age}`).
9696
@@ -422,7 +422,7 @@ birthday \name -> do
422422 *people = do
423423 p <- people
424424 yield (case p.name == name of
425− Bool.True {} -> {p | age (p.age + 1)}
425+ Bool.True {} -> (base.unify p {age (p.age + 1)})
426426 Bool.False {} -> p)
427427
428428-- Delete (filter to keep)
@@ -594,10 +594,10 @@ updateTeams do
594594 teams <- full *teams
595595 *teams = do
596596 t <- teams
597− yield {t | members do
597+ yield (base.unify t {members do
598598 m <- t.members
599599 where m.name != "Eve"
600− yield m}
600+ yield m})
601601```
602602
603603---
@@ -749,7 +749,7 @@ birthday \name -> do
749749 *people = do -- IO {}
750750 p <- people
751751 yield (case p.name == name of
752− Bool.True {} -> {p | age (p.age + 1)}
752+ Bool.True {} -> (base.unify p {age (p.age + 1)})
753753 Bool.False {} -> p)
754754-- Inferred effects: {rw *people}
755755-- Type: Text -> IO {} {}
@@ -1191,7 +1191,7 @@ The compiler maintains a lockfile (`<name>.schema.lock`) tracking persisted sche
11911191*people : [{name: Text, age: Int 1}]
11921192 migrate from {name: Text, age: Int 1}
11931193 to {name: Text, age: Int 1, email: Text}
1194− using (\old -> {old | email (old.name ++ "@unknown.com")})
1194+ using (\old -> (base.unify old {email (old.name ++ "@unknown.com")}))
11951195```
11961196
11971197---
@@ -1346,7 +1346,7 @@ complete \title -> do
13461346 *todos = do
13471347 t <- todos
13481348 yield (case t.title == title of
1349− Bool.True {} -> {t | status (Status.Resolved {resolution "done"})}
1349+ Bool.True {} -> (base.unify t {status (Status.Resolved {resolution "done"})})
13501350 Bool.False {} -> t)
13511351
13521352assign \title person -> do
@@ -1354,7 +1354,7 @@ assign \title person -> do
13541354 *todos = do
13551355 t <- todos
13561356 yield (case t.title == title of
1357− Bool.True {} -> {t | status (Status.InProgress {assignee person})}
1357+ Bool.True {} -> (base.unify t {status (Status.InProgress {assignee person})})
13581358 Bool.False {} -> t)
13591359
13601360pending \owner -> do
@@ -1425,7 +1425,7 @@ updateWhere \target newValue -> do
14251425 *rel = do
14261426 r <- rel
14271427 yield (case r.id == target of
1428− Bool.True {} -> {r | field newValue}
1428+ Bool.True {} -> (base.unify r {field newValue})
14291429 Bool.False {} -> r)
14301430```
14311431
stdlib.md+2−2
@@ -462,9 +462,9 @@ transfer \from to amount -> atomic do
462462 *accounts = do
463463 a <- accounts
464464 yield (case a.name == from of
465− Bool.True {} -> {a | balance (a.balance - amount)}
465+ Bool.True {} -> (base.unify a {balance (a.balance - amount)})
466466 Bool.False {} -> (case a.name == to of
467− Bool.True {} -> {a | balance (a.balance + amount)}
467+ Bool.True {} -> (base.unify a {balance (a.balance + amount)})
468468 Bool.False {} -> a))
469469```
470470
tree-sitter-knot/grammar.js−17
@@ -665,7 +665,6 @@ module.exports = grammar({
665665 $.source_ref,
666666 $.derived_ref,
667667 $.record_expression,
668− $.record_update_expression,
669668 $.list_expression,
670669 $.parenthesized_expression,
671670 ),
@@ -689,22 +688,6 @@ module.exports = grammar({
689688 field("value", $._expression),
690689 ),
691690
692− record_update_expression: ($) =>
693− seq(
694− "{",
695− field("base", $._expression),
696− "|",
697− sep1($.update_field, ","),
698− "}",
699− ),
700−
701− update_field: ($) =>
702− seq(
703− field("name", $.lower_identifier),
704− ":",
705− field("value", $._expression),
706− ),
707−
708691 list_expression: ($) =>
709692 seq("[", optional(sep1($._expression, ",")), "]"),
710693