Writing data

put, insert, set and del, and how a write says what it expects to find.

A write names a collection and answers how many rows it wrote: put and insert add documents, set changes the rows a where keeps, and del removes them.

Run in the shell, answers and all; the timings are from one run.

fenec> create collection stock (sku text @unique, qty int);
collection `stock` created
fenec> put stock [{sku: "tea", qty: 10}, {sku: "jam", qty: 2}];
2 records (152.00µs)
fenec> insert stock {sku: "tea", qty: 1};
error: duplicate: `stock.sku` is unique, and document 1 holds "tea" already
fenec> put stock {sku: "tea", qty: 1} if absent;
0 records (94.58µs)
fenec> put stock {sku: "tea", qty: 5} if absent else set {qty: qty + new.qty} returning sku, qty;
sku  qty
---  ---
tea  15
(1 rows, 157.00µs)
fenec> set stock {qty: qty - 3} where sku = "tea" and qty >= 3 require 1 returning sku, qty;
sku  qty
---  ---
tea  12
(1 rows, 194.00µs)
fenec> set stock {qty: qty - 3} where sku = "jam" and qty >= 3 require 1;
error: unmet: `set stock` wrote 0 rows, and requires 1
fenec> del stock where qty < 3 returning sku;
sku
---
jam
(1 rows, 115.71µs)
fenec> get stock select sku, qty
sku  qty
---  ---
tea  12
(1 rows, 85.21µs)

insert never writes over a row and is refused when a @unique value or an id is taken; put … if absent passes over such a row and says it wrote none, and else set changes it instead, an upsert in one statement. A set can read the row it writes, require N makes the count a condition and refuses the statement, and the batch it is in, when it is not met, and returning answers the rows written or removed. The sections below take each in turn, and the last ones put several statements in one batch.

Writes that read the row

A value in a set can be an expression over the row it writes: its fields, parameters and functions, with + - * /. Every write answers how many rows it changed, so a write that names what it expects to find says whether it found it.

set hits {n: n + 1, seen: now()} where key = "ip1"      -- an increment
set hits {n: coalesce(n, 0) + $1} where key = $2         -- from nothing, by a parameter
set docs {body: $1, version: version + 1}
  where id = $2 and version = $3                          -- compare-and-set: 1 or 0
put locks {name: "nightly", owner: $1, at: now()} if absent   -- 1 if taken, 0 if held
insert journal {entry: $1 + ":dr", amount: 0 - $2}           -- two texts joined
  • An increment is atomic. The row is read and written under the write lock, and there is one writer, so increments from any number of clients all land.
  • A field read is the row as it was. In {n: n + 1, m: n} both read the old n; a name on the value side is a field, a quoted string a string.
  • null stays null. n + 1 over a row with no n is null, as in SQL; write coalesce(n, 0) + 1 to count from nothing.
  • Every write answers its count — {"affected": n} over HTTP, the count of the browser module's answer, and the write result of every SDK.
How set reads the row, and its types, precedence and cost

Types. The result goes through the field's type check as a literal would. Two ints make an int, and a result past 64 bits is refused, never wrapped; / between ints divides whole, toward zero, as PostgreSQL's does (7 / 2 is 3), and a division by zero is refused. An int and a float make a float, which an int field takes when it is whole and refuses when it is not — n * 2.0 fits, n + 0.5 does not, as the literal 2.5 does not. A float that is no longer finite is refused. A timestamp plus or minus an int is a timestamp that many milliseconds on (now() + 30000), and two timestamps apart are the milliseconds between them. + joins two texts ($1 + ":dr"), and only two texts. Anything else — text and a number, either way round, or - between texts — is a type error: a number turned into text without asking would hide the mistake a typed field is there to catch. An error refuses the statement whole, a block of one, so nothing of it is written; over no rows there is no error, as there is no row to work it out over.

Precedence. * and / bind tighter than + and -, and all four tighter than a comparison: where n + 1 > 2 * m. -1 where a value starts is a number; after a value, n-1 and n - 1 are a subtraction.

Time. now() is worked out once a statement, so every row a set writes holds the same time. In the browser it is the time the page hands the module with every statement, which has no clock of its own.

What travels. The record holds the document as written, the value worked out, never the expression: a replica, /_changes, an archive and a subscriber see the result, and a restore replays it as it was. A scoped token's rules test the document a set writes, as they test a literal's (Scoped access). A replica of Sync works the expression out over its own row at once and sends the statement, which the server works out again over its own: two replicas' offline increments both land, and both replicas come to the server's sum.

How it runs. The pairs are bound to the collection once a statement, as a filter is: each field written and read by its position, and a value that reads no field — a literal, a parameter, now() — worked out once rather than a row. Each row is read once: a set read it twice before, once for the values and once for the indexes it takes out.

if absent passes over a document whose id, or whose @unique value, a live row holds, where insert refuses the statement: the others are written, and the answer counts those alone. That is Redis's SET NX in one statement, and it neither aborts a /batch nor makes an error a client has to catch. A row past its @ttl holds nothing — it is out of every read — so a write takes its place.

What it costs, measured (make counters-bench, in process over 100 000 rows of a text, an int and a note). A set of a constant over every row took 65.0 ms against 85.5 before expressions — the row read once instead of twice — and {n: n + 1} 66.6 ms; one row by id 1.07 us against 1.26, and 1.18 for n + 1. Sixteen threads incrementing one key 10 000 times each ended at 160 000, 320 000 increments a second, and sixteen HTTP clients the same against fenec-server, 102 000 a second.

Writes that must find their rows

A write that matches no row is no error: it answers {"affected": 0}, and a /batch goes on to the next statement. require N after a set, a del, a put or an insert makes the count a condition: unless the statement wrote exactly N rows it is refused, and the block it is in — a /batch, a run of several in the page, a keyed write — is put back whole, as on any other error.

set accounts {balance: balance - $1} where name = $2 and balance >= $1 require 1
del holds where order_ref = $1 and state = "held" require 1
put locks {name: "nightly", owner: $1} if absent require 1      -- refused while it is held
  • Refused over HTTP with 412, the error naming the statement: `set accounts` wrote 0 rows, and requires 1. A /batch also says which statement stopped it ("at", from 0). Every SDK has its own kind for it (unmet), and every builder an option: .update(patch, { require: 1 }).
  • The count is the write's own, taken under the write lock that made it, so nothing can come between the check and the write. A scoped token's count is of the rows its rules let it write.
  • require 0 asserts that nothing matches.

A read that must find its rows

require N after a get guards a read the same way: unless the get answers exactly N rows it is refused (412, "at" naming it) and its batch put back. In a /batch it reads under the batch's write lock, after the statements before it and before those after, so what it saw is what the writes around it see: "the price is still 12", "the coupon is still good".

get products where sku = $1 and price = $2 and stock > 0 require 1
get coupons where code = $1 and uses < max_uses limit 1 require 1     -- one is enough
get holds where order_ref = $1 and state = "held" require 0           -- none may be held
import { connect, raw } from '@fenecdb/web/client'
await db.from('products')
  .where('sku', 'tee')
  .where('price', 1200)
  .where('stock', '>', 0)
  .require(1)
  .rows();

await db.from('coupons')
  .where('code', 'X1')
  .where(raw('uses < max_uses'))
  .limit(1)
  .require(1)
  .rows();

await db.from('holds')
  .where('order_ref', 'o-17')
  .where('state', 'held')
  .require(0)
  .rows();
from fenecdb import Client, raw
(db.collection("products")
    .where("sku", "tee")
    .where("price", 1200)
    .where("stock", ">", 0)
    .require(1)
    .rows())

(db.collection("coupons")
    .where("code", "X1")
    .where(raw("uses < max_uses"))
    .limit(1)
    .require(1)
    .rows())

(db.collection("holds")
    .where("order_ref", "o-17")
    .where("state", "held")
    .require(0)
    .rows())
import fenecdb "github.com/fenecdb/fenecdb/integrations/go"
db.From("products").
	Where("sku", "=", "tee").
	Where("price", "=", 1200).
	Where("stock", ">", 0).
	Require(1).
	Rows(ctx)

db.From("coupons").
	Where("code", "=", "X1").
	WhereCond(fenecdb.Raw("uses < max_uses")).
	Limit(1).
	Require(1).
	Rows(ctx)

db.From("holds").
	Where("order_ref", "=", "o-17").
	Where("state", "=", "held").
	Require(0).
	Rows(ctx)
using FenecDb;
await db.From("products")
    .Where("sku", "tee")
    .Where("price", 1200)
    .Where("stock", ">", 0)
    .Require(1)
    .RowsAsync();

await db.From("coupons")
    .Where("code", "X1")
    .Where(Cond.Raw("uses < max_uses"))
    .Limit(1)
    .Require(1)
    .RowsAsync();

await db.From("holds")
    .Where("order_ref", "o-17")
    .Where("state", "held")
    .Require(0)
    .RowsAsync();
import FenecDB
try await db.from("products")
    .where("sku", "tee")
    .where("price", 1200)
    .where("stock", ">", 0)
    .require(1)
    .rows()

try await db.from("coupons")
    .where("code", "X1")
    .where(.raw("uses < max_uses"))
    .limit(1)
    .require(1)
    .rows()

try await db.from("holds")
    .where("order_ref", "o-17")
    .where("state", "held")
    .require(0)
    .rows()
import com.fenecdb.*
db.from("products")
    .where("sku", "tee")
    .where("price", 1200)
    .where("stock", ">", 0)
    .require(1)
    .rows()

db.from("coupons")
    .where("code", "X1")
    .where(Cond.raw("uses < max_uses"))
    .limit(1)
    .require(1)
    .rows()

db.from("holds")
    .where("order_ref", "o-17")
    .where("state", "held")
    .require(0)
    .rows()
import 'package:fenecdb/fenecdb.dart';
await db.from('products')
    .where('sku', 'tee')
    .where('price', 1200)
    .where('stock', '>', 0)
    .require(1)
    .rows();

await db.from('coupons')
    .where('code', 'X1')
    .where(Cond.raw('uses < max_uses'))
    .limit(1)
    .require(1)
    .rows();

await db.from('holds')
    .where('order_ref', 'o-17')
    .where('state', 'held')
    .require(0)
    .rows();
sends
get products where sku = $1 and price = $2 and stock > $3 require 1
-- ["tee", 1200, 0]
get coupons where code = $1 and uses < max_uses limit $2 require 1
-- ["X1", 1]
get holds where order_ref = $1 and state = $2 require 0
-- ["o-17", "held"]
  • It counts the rows the get answers, after offset and limit: limit 1 require 1 asks that one exists, and reads no further. A lookup's children are not counted, and require goes before the lookup, whose clauses are the children's.
  • Not with count or an aggregate without group, which answer one row whatever matched; over groups it counts the groups. An inner get of in (get …) takes none.
  • On its own it is a read like any other, refused with 412 when the count is not met. A scoped token's count is of the rows it may read.
  • Every builder has it as a step, .require(1); a write takes it as its option instead. A replica's sync refuses one in a batch of synced writes: it would count the replica's rows, and the server, which lands the batch, would never see it — send a guarded batch to the server itself.

Transfers

Moving money between two accounts is a debit where the money is, a credit where the account is, and a journal of both, as one /batch. Without require an overdraft's debit matched nothing, answered 0, and the credit went ahead: the sum of the balances grew with every refused transfer. With it, a debit that finds too little money or a credit that finds no account puts the whole transfer back, journal and all:

create collection accounts (name text @unique, balance int)
create collection journal (tx text @hash, account text @hash, amount int)

set accounts {balance: balance - $3} where name = $1 and balance >= $3 require 1
set accounts {balance: balance + $3} where name = $2 require 1
insert journal [{tx: $4, account: $1, amount: 0 - $3}, {tx: $4, account: $2, amount: $3}]
printf '%s\n' \
  '{"query":"set accounts {balance: balance - $3} where name = $1 and balance >= $3 require 1","params":["ada","bob",30,"t1"]}' \
  '{"query":"set accounts {balance: balance + $3} where name = $2 require 1","params":["ada","bob",30,"t1"]}' \
  '{"query":"insert journal [{tx: $4, account: $1, amount: 0 - $3}, {tx: $4, account: $2, amount: $3}]","params":["ada","bob",30,"t1"]}' |
curl -s -X POST localhost:8080/batch -H "Idempotency-Key: t1" --data-binary @-
# an overdraft: 412 {"error":"unmet: `set accounts` wrote 0 rows, and requires 1","completed":0,"at":0}
  • Amounts are whole minor units in an int, so a sum is exact.
  • An Idempotency-Key makes a retry after a timeout answer what the first try did, rather than move the money twice (Retrying a write).
  • Who may move what is the app's. The credit is another user's row, which a scoped token may not write: transfers go through an app server holding the server's token.
  • A read can guard it too. A client that showed a balance and moves money only while it is still that puts get accounts where name = $1 and balance = $5 require 1 first; changed meanwhile, the transfer stops there, 412 with "at":0.
  • Held to it: eight threads making 4 800 random transfers among six accounts in process, and eight clients making 2 400 as /batches over HTTP, an eighth of them to an account that is not there, leave the sum where it started, no balance below zero, and a journal that accounts for every balance (crates/fenec-core/tests/require.rs, crates/fenec-http/tests/require.rs).
  • A whole ledger built this way — refunds, holds, an append-only journal, reconciliation, a change-stream sink, scoped tokens — is examples/ledger.

Checkout

An order is placed at the price the shopper saw, against stock that is there, with a coupon that is still good — or not at all. Each condition is a require: the price and the coupon read, the stock taken, the coupon's use counted and the order written, as one /batch.

create collection products (sku text @unique, price int, stock int)
create collection coupons (code text @unique, off int, uses int, max_uses int)
create collection orders (ref text @unique, sku text @hash, paid int)

get products where sku = $1 and price = $2 require 1
get coupons where code = $3 and uses < max_uses require 1
set products {stock: stock - 1} where sku = $1 and stock > 0 require 1
set coupons {uses: uses + 1} where code = $3 require 1
insert orders {ref: $4, sku: $1, paid: $2 - $5}
  • A price that moved stops the batch at its first line, 412 with "at":0: the shopper is shown the new price, and nothing was written.
  • A coupon used up stops it at the second, no stock at the third; the order's @unique ref refuses the same checkout twice (409), and an Idempotency-Key answers a retry what the first try did.
  • Under the write lock, every line sees the ones before it: two shoppers taking the last one cannot both pass the stock line.

Writes that pick their rows

order and limit after a set or a del pick the rows it writes: the page a get with the same where, order and limit would answer, found and written under the one writer's lock. returning answers them rather than their count — a set's rows as it wrote them, a del's as they were, in the order they were picked. A job queue's claim is one statement:

create collection jobs (payload json, run_at timestamp @sorted, owner text, attempts int)
put jobs [{payload: {to: "ada"}, run_at: now(), attempts: 0}, {payload: {to: "bo"}, run_at: now(), attempts: 0}]
set jobs {owner: "w1", run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order run_at asc limit 10 returning *
del jobs where id = 1 and owner = "w1" require 1
del jobs where run_at <= now() order run_at asc limit 10 returning id, payload.to
import { connect, raw, expr } from '@fenecdb/web/client'
await db.from('jobs')
  .insert([{ payload: { to: 'ada' }, run_at: expr('now()'), attempts: 0 }, { payload: { to: 'bo' }, run_at: expr('now()'), attempts: 0 }]);

await db.from('jobs')
  .where(raw('run_at <= now()'))
  .order('run_at')
  .limit(10)
  .update(
    { owner: 'w1', run_at: expr('now() + ?', 30000), attempts: expr('attempts + 1') },
    { returning: true }
  );

await db.from('jobs')
  .where('id', 1)
  .where('owner', 'w1')
  .delete({ require: 1 });

await db.from('jobs')
  .where(raw('run_at <= now()'))
  .order('run_at')
  .limit(10)
  .delete({ returning: ['id', 'payload.to'] });
from fenecdb import Client, raw, expr
(db.collection("jobs")
    .insert([{"payload": {"to": "ada"}, "run_at": expr("now()"), "attempts": 0}, {"payload": {"to": "bo"}, "run_at": expr("now()"), "attempts": 0}]))

(db.collection("jobs")
    .where(raw("run_at <= now()"))
    .order("run_at")
    .limit(10)
    .update(
        {"owner": "w1", "run_at": expr("now() + ?", 30000), "attempts": expr("attempts + 1")},
        returning=True
    ))

(db.collection("jobs")
    .where("id", 1)
    .where("owner", "w1")
    .delete(require=1))

(db.collection("jobs")
    .where(raw("run_at <= now()"))
    .order("run_at")
    .limit(10)
    .delete(returning=["id", "payload.to"]))
import fenecdb "github.com/fenecdb/fenecdb/integrations/go"
db.From("jobs").
	Insert(
		ctx,
		fenecdb.D("payload", fenecdb.D("to", "ada"), "run_at", fenecdb.Expr("now()"), "attempts", 0),
		fenecdb.D("payload", fenecdb.D("to", "bo"), "run_at", fenecdb.Expr("now()"), "attempts", 0),
	)

db.From("jobs").
	WhereCond(fenecdb.Raw("run_at <= now()")).
	Order("run_at", "asc").
	Limit(10).
	Update(
		ctx,
		fenecdb.D("owner", "w1", "run_at", fenecdb.Expr("now() + ?", 30000), "attempts", fenecdb.Expr("attempts + 1")),
		fenecdb.Returning("*"),
	)

db.From("jobs").
	Where("id", "=", 1).
	Where("owner", "=", "w1").
	Delete(ctx, fenecdb.Require(1))

db.From("jobs").
	WhereCond(fenecdb.Raw("run_at <= now()")).
	Order("run_at", "asc").
	Limit(10).
	Delete(ctx, fenecdb.Returning("id", "payload.to"))
using FenecDb;
await db.From("jobs")
    .InsertAsync(new object[] { new { payload = new { to = "ada" }, run_at = Computed.Expr("now()"), attempts = 0 }, new { payload = new { to = "bo" }, run_at = Computed.Expr("now()"), attempts = 0 } });

await db.From("jobs")
    .Where(Cond.Raw("run_at <= now()"))
    .Order("run_at")
    .Limit(10)
    .UpdateAsync(
        new { owner = "w1", run_at = Computed.Expr("now() + ?", 30000), attempts = Computed.Expr("attempts + 1") },
        returning: ["*"]
    );

await db.From("jobs")
    .Where("id", 1)
    .Where("owner", "w1")
    .DeleteAsync(require: 1);

await db.From("jobs")
    .Where(Cond.Raw("run_at <= now()"))
    .Order("run_at")
    .Limit(10)
    .DeleteAsync(returning: ["id", "payload.to"]);
import FenecDB
try await db.from("jobs")
    .insert([["payload": ["to": "ada"], "run_at": .expr("now()"), "attempts": 0], ["payload": ["to": "bo"], "run_at": .expr("now()"), "attempts": 0]] as Value)

try await db.from("jobs")
    .where(.raw("run_at <= now()"))
    .order("run_at")
    .limit(10)
    .updateReturning(["owner": "w1", "run_at": .expr("now() + ?", 30000), "attempts": .expr("attempts + 1")] as Value)

try await db.from("jobs")
    .where("id", 1)
    .where("owner", "w1")
    .delete(require: 1)

try await db.from("jobs")
    .where(.raw("run_at <= now()"))
    .order("run_at")
    .limit(10)
    .deleteReturning(returning: ["id", "payload.to"])
import com.fenecdb.*
db.from("jobs")
    .insert(listOf(mapOf("payload" to mapOf("to" to "ada"), "run_at" to Computed.expr("now()"), "attempts" to 0), mapOf("payload" to mapOf("to" to "bo"), "run_at" to Computed.expr("now()"), "attempts" to 0)))

db.from("jobs")
    .where(Cond.raw("run_at <= now()"))
    .order("run_at")
    .limit(10)
    .updateReturning(mapOf("owner" to "w1", "run_at" to Computed.expr("now() + ?", 30000), "attempts" to Computed.expr("attempts + 1")))

db.from("jobs").where("id", 1).where("owner", "w1").delete(require = 1)

db.from("jobs")
    .where(Cond.raw("run_at <= now()"))
    .order("run_at")
    .limit(10)
    .deleteReturning(returning = listOf("id", "payload.to"))
import 'package:fenecdb/fenecdb.dart';
await db.from('jobs')
    .insert([{'payload': {'to': 'ada'}, 'run_at': Computed.expr('now()'), 'attempts': 0}, {'payload': {'to': 'bo'}, 'run_at': Computed.expr('now()'), 'attempts': 0}]);

await db.from('jobs')
    .where(Cond.raw('run_at <= now()'))
    .order('run_at')
    .limit(10)
    .updateReturning({'owner': 'w1', 'run_at': Computed.expr('now() + ?', [30000]), 'attempts': Computed.expr('attempts + 1')});

await db.from('jobs')
    .where('id', 1)
    .where('owner', 'w1')
    .delete(require: 1);

await db.from('jobs')
    .where(Cond.raw('run_at <= now()'))
    .order('run_at')
    .limit(10)
    .deleteReturning(returning: ['id', 'payload.to']);
sends
put jobs [{payload: $1, run_at: now(), attempts: $2}, {payload: $3, run_at: now(), attempts: $4}]
-- [{"to": "ada"}, 0, {"to": "bo"}, 0]
set jobs {owner: $1, run_at: now() + $2, attempts: attempts + 1} where run_at <= now() order run_at asc limit $3 returning *
-- ["w1", 30000, 10]
del jobs where id = $1 and owner = $2 require 1
-- [1, "w1"]
del jobs where run_at <= now() order run_at asc limit $1 returning id, payload.to
-- [10]
  • The page is a get's. order over an @sorted field walks its index and stops at the page; limit without order takes the first matches by id; order without limit writes every match, in that order. offset is a read's alone.
  • Two writers never pick the same row. The rows are found and written under the write lock, where PostgreSQL's UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING * locks rows and skips those another holds. A client that reads the page and then writes it loses the race whenever another read the same page.
  • returning * answers the id and every field, as select * does; a list answers those named, paths among them. Over HTTP the answer is the rows, as a get's.
  • require N counts the rows answered: limit 1 require 1 is one row or a refusal.
  • A scoped token picks among the rows its rules let it update or delete, and what it answers is held to its read rules as well — a set's rows as it writes them, so a worker that reads only the jobs it holds is answered the job it claims, and a set that would leave a row it may not read is refused whole (403). Every row it writes is held to its rules after the write, its fields to an update(…) grant's and its change to a transition's (Scoped access). A row answered shows the fields of the read rules admitting it, and a value, a where or an order reads a field a rule withholds only as a read may (Fields a read shows).
  • A sync replica refuses one on a synced collection: it would pick and answer the replica's rows, and the server, sent the text, its own.
  • Every builder writes it: order and limit before an update or a delete, and { returning: true } — or the fields — as its option. See Job queues for the whole queue: ack, extend, backoff, dead letters, priorities.
  • An order key may be an expression, so the nearest ready job is one claim too: set jobs {owner: $1} where run_at <= now() order distance(pickup, $2) limit 1 returning * (Ordering by an expression). A write takes no near: the distance is a key of order there, or an inner get's near picks the id — where id in (get jobs select id where run_at <= now() near pickup $2 limit 1), run first under the same lock.

The rows an insert made

returning after a put or an insert answers the rows it wrote rather than their count, ids and all, in the order of its documents — what a client needs to know the id of the row it made, without reading it back by a key of its own:

create collection orders (item text, customer text, n int)
insert orders {item: "tea", customer: "ada"} returning id
insert orders [{item: "pie", customer: "ada"}, {item: "jam", customer: "bo", n: 2}] returning *
put orders {item: "tea", customer: "ada"} if absent else set {n: coalesce(n, 0) + 1} returning id, n
  • Each row as the statement left it: a row if absent passed over is not answered, the row else set set is, and a document written twice in one statement is answered twice, as it ended. require N counts the rows answered.
  • Over HTTP the answer is the rows, with the change it left the database at in Fenec-Seq; POST /orders?returning=id answers a REST insert's rows (201) where it otherwise answers {"inserted": n}.
  • A scoped token is answered only rows its read rules let it read, as a set's are: each row written is held to them as written — a row made as an insert, a row set as an update — and one they leave out refuses the write, put back whole (403). An append to a stream the token may write and not read is written without returning.
  • Every builder takes it: insert(docs, { returning: ['id'] }) and upsert(docs, patch, { returning: true }), Python's returning=, Go's Returning(...) among the documents, .NET's returning:, Swift's, Kotlin's and Dart's insertReturning and upsertReturning. A sync replica refuses it on a synced collection, as a claim: its ids are the replica's until the server answers.

Bulk loading

For a bulk load, build the index afterwards. The write path becomes a pure append and the graph is built in one parallel pass.

create collection docs (title text, embed vector<768>)
put docs [ ... 100 000 documents ... ]
create index on docs (embed) @hnsw(cosine)

Update and delete

set articles {year: 2025} where id = 42
set articles {views: coalesce(views, 0) + 1} where id = 42   -- an increment
del articles where year < 2000
import { connect, inc } from '@fenecdb/web/client'
await db.from('articles').where('id', 42).update({ year: 2025 });

await db.from('articles').where('id', 42).update({ views: inc(1) });

await db.from('articles').where('year', '<', 2000).delete();
from fenecdb import Client, inc
db.collection("articles").where("id", 42).update({"year": 2025})

db.collection("articles").where("id", 42).update({"views": inc(1)})

db.collection("articles").where("year", "<", 2000).delete()
import fenecdb "github.com/fenecdb/fenecdb/integrations/go"
db.From("articles").
	Where("id", "=", 42).
	Update(ctx, fenecdb.D("year", 2025))

db.From("articles").
	Where("id", "=", 42).
	Update(ctx, fenecdb.D("views", fenecdb.Inc(1)))

db.From("articles").Where("year", "<", 2000).Delete(ctx)
using FenecDb;
await db.From("articles")
    .Where("id", 42)
    .UpdateAsync(new { year = 2025 });

await db.From("articles")
    .Where("id", 42)
    .UpdateAsync(new { views = Computed.Inc(1) });

await db.From("articles").Where("year", "<", 2000).DeleteAsync();
import FenecDB
try await db.from("articles")
    .where("id", 42)
    .update(["year": 2025] as Value)

try await db.from("articles")
    .where("id", 42)
    .update(["views": .inc(1)] as Value)

try await db.from("articles").where("year", "<", 2000).delete()
import com.fenecdb.*
db.from("articles").where("id", 42).update(mapOf("year" to 2025))

db.from("articles")
    .where("id", 42)
    .update(mapOf("views" to Computed.inc(1)))

db.from("articles").where("year", "<", 2000).delete()
import 'package:fenecdb/fenecdb.dart';
await db.from('articles').where('id', 42).update({'year': 2025});

await db.from('articles')
    .where('id', 42)
    .update({'views': Computed.inc(1)});

await db.from('articles').where('year', '<', 2000).delete();
sends
set articles {year: $1} where id = $2
-- [2025, 42]
set articles {views: coalesce(views, 0) + $1} where id = $2
-- [1, 42]
del articles where year < $1
-- [2000]