quiverdb 0.9.15 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -99,6 +99,7 @@ bun run example.ts
99
99
  ### Read (IDs)
100
100
 
101
101
  - `readElementIds(collection)` -- Read all element IDs in a collection
102
+ - `numberOfElements(collection)` -- Current number of elements in a collection
102
103
 
103
104
  ### Metadata
104
105
 
@@ -130,6 +131,10 @@ Parameters are passed as an array of `number | string | null`.
130
131
  - `commit()` -- Commit current transaction
131
132
  - `rollback()` -- Rollback current transaction
132
133
  - `inTransaction()` -- Check if transaction is active
134
+ - `beginDryRun()` -- Begin a dry run: a transaction `endDryRun()` always rolls back. While it is
135
+ active, `beginTransaction`/`commit`/`rollback` are absorbed (no-ops)
136
+ - `endDryRun()` -- End the active dry run, rolling back everything it covered
137
+ - `inDryRun()` -- Check if a dry run is active
133
138
 
134
139
  ### CSV
135
140
 
@@ -152,7 +157,8 @@ Parameters are passed as an array of `number | string | null`.
152
157
  ### Lua
153
158
 
154
159
  - `LuaRunner(db)` -- Create Lua script runner with database access
155
- - `run(script)` -- Execute a Lua script
160
+ - `run(script)` -- Execute a Lua script; returns its return value as a JSON string, or `""` if it
161
+ returned nothing
156
162
  - `close()` -- Close the Lua runner
157
163
 
158
164
  ## Types
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quiverdb",
3
- "version": "0.9.15",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,4 +31,4 @@
31
31
  "@biomejs/biome": "^2.4.6",
32
32
  "@types/bun": "latest"
33
33
  }
34
- }
34
+ }
package/src/create.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  readPtrOut,
9
9
  toCString,
10
10
  } from "./ffi-helpers.ts";
11
+ import { type GroupColumns, updateGroupColumns } from "./group-columns.ts";
11
12
  import { getSymbols, type NativePointer } from "./loader.ts";
12
13
  import type { ElementData, Value } from "./types.ts";
13
14
 
@@ -38,7 +39,9 @@ function setElementArray(
38
39
  const allIntegers = (values as number[]).every((v) => Number.isInteger(v));
39
40
  if (allIntegers) {
40
41
  const arr = allocNativeInt64(values as number[]);
41
- check(lib.quiver_element_set_array_integer(elemPtr, nameBuf.buf, arr.buf, values.length, null));
42
+ check(
43
+ lib.quiver_element_set_array_integer(elemPtr, nameBuf.buf, arr.buf, values.length, null),
44
+ );
42
45
  } else {
43
46
  const arr = allocNativeFloat64(values as number[]);
44
47
  check(lib.quiver_element_set_array_float(elemPtr, nameBuf.buf, arr.buf, values.length, null));
@@ -48,7 +51,9 @@ function setElementArray(
48
51
 
49
52
  if (typeof first === "string") {
50
53
  const { table, keepalive: _keepalive } = allocNativeStringArray(values as string[]);
51
- check(lib.quiver_element_set_array_string(elemPtr, nameBuf.buf, table.buf, values.length, null));
54
+ check(
55
+ lib.quiver_element_set_array_string(elemPtr, nameBuf.buf, table.buf, values.length, null),
56
+ );
52
57
  return;
53
58
  }
54
59
 
@@ -148,3 +153,48 @@ Database.prototype.deleteElement = function (this: Database, collection: string,
148
153
  const collBuf = toCString(collection);
149
154
  check(lib.quiver_database_delete_element(this._handle, collBuf.buf, BigInt(id)));
150
155
  };
156
+
157
+ /**
158
+ * Replace all of an element's rows in one *named* vector group, from column arrays keyed by name.
159
+ *
160
+ * Pass `{}` to clear the group. Prefer this over routing the group's columns through
161
+ * updateElement when a column name is shared by two groups of the collection (legal for foreign
162
+ * keys): (collection, group) names exactly one table, a column name alone does not, and
163
+ * updateElement writes an ambiguous column to every match.
164
+ */
165
+ Database.prototype.updateVectorGroup = function (
166
+ this: Database,
167
+ collection: string,
168
+ group: string,
169
+ id: number,
170
+ data: GroupColumns,
171
+ ): void {
172
+ updateGroupColumns(
173
+ this._handle,
174
+ "updateVectorGroup",
175
+ getSymbols().quiver_database_update_vector_group,
176
+ collection,
177
+ group,
178
+ id,
179
+ data,
180
+ );
181
+ };
182
+
183
+ /** Set-group counterpart of updateVectorGroup. */
184
+ Database.prototype.updateSetGroup = function (
185
+ this: Database,
186
+ collection: string,
187
+ group: string,
188
+ id: number,
189
+ data: GroupColumns,
190
+ ): void {
191
+ updateGroupColumns(
192
+ this._handle,
193
+ "updateSetGroup",
194
+ getSymbols().quiver_database_update_set_group,
195
+ collection,
196
+ group,
197
+ id,
198
+ data,
199
+ );
200
+ };
package/src/database.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { CsvOptions } from "./csv.ts";
2
2
  import { check, QuiverError } from "./errors.ts";
3
3
  import { allocPtrOut, makeDefaultOptions, readPtrOut, toCString } from "./ffi-helpers.ts";
4
+ import type { GroupColumns } from "./group-columns.ts";
4
5
  import type { NativePointer } from "./loader.ts";
5
6
  import { getSymbols } from "./loader.ts";
6
7
  import type { GroupMetadata, ScalarMetadata } from "./metadata.ts";
@@ -103,6 +104,7 @@ export class Database {
103
104
  id: number,
104
105
  ) => string | null;
105
106
  declare readElementIds: (collection: string) => number[];
107
+ declare numberOfElements: (collection: string) => number;
106
108
  declare readVectorIntegers: (collection: string, attribute: string) => number[][];
107
109
  declare readVectorFloats: (collection: string, attribute: string) => number[][];
108
110
  declare readVectorStrings: (collection: string, attribute: string) => string[][];
@@ -126,6 +128,9 @@ export class Database {
126
128
  declare commit: () => void;
127
129
  declare rollback: () => void;
128
130
  declare inTransaction: () => boolean;
131
+ declare beginDryRun: () => void;
132
+ declare endDryRun: () => void;
133
+ declare inDryRun: () => boolean;
129
134
 
130
135
  // --- Metadata (implemented in metadata.ts) ---
131
136
  declare getScalarMetadata: (collection: string, attribute: string) => ScalarMetadata;
@@ -151,6 +156,18 @@ export class Database {
151
156
  id: number,
152
157
  data: TimeSeriesData,
153
158
  ) => void;
159
+ declare updateVectorGroup: (
160
+ collection: string,
161
+ group: string,
162
+ id: number,
163
+ data: GroupColumns,
164
+ ) => void;
165
+ declare updateSetGroup: (
166
+ collection: string,
167
+ group: string,
168
+ id: number,
169
+ data: GroupColumns,
170
+ ) => void;
154
171
  declare upsertTimeSeriesRow: (
155
172
  collection: string,
156
173
  group: string,
@@ -0,0 +1,161 @@
1
+ import { type Pointer, ptr } from "bun:ffi";
2
+ import { check, QuiverError } from "./errors.ts";
3
+ import {
4
+ allocNativeFloat64,
5
+ allocNativeInt64,
6
+ allocNativePtrTable,
7
+ allocNativeStringArray,
8
+ toCString,
9
+ } from "./ffi-helpers.ts";
10
+ import type { NativePointer } from "./loader.ts";
11
+ import { DATA_TYPE_FLOAT, DATA_TYPE_INTEGER, DATA_TYPE_STRING, type Allocation } from "./types.ts";
12
+
13
+ /** Column-oriented group payload: one array of cells per column name, `null` for SQL NULL. */
14
+ export type GroupColumns = Record<string, (number | string | null)[]>;
15
+
16
+ /**
17
+ * The parallel-array signature every columnar group update C function shares
18
+ * (quiver_database_update_{time_series,vector,set}_group).
19
+ */
20
+ type ColumnUpdateFn = (
21
+ db: NativePointer,
22
+ collection: Uint8Array,
23
+ group: Uint8Array,
24
+ id: bigint,
25
+ names: Uint8Array | null,
26
+ types: Uint8Array | null,
27
+ data: Uint8Array | null,
28
+ masks: Uint8Array | null,
29
+ columnCount: bigint,
30
+ rowCount: bigint,
31
+ ) => number;
32
+
33
+ /**
34
+ * Marshal a column-oriented payload and forward it to one of the columnar group update C
35
+ * functions. Shared by updateTimeSeriesGroup / updateVectorGroup / updateSetGroup: the three
36
+ * differ only in which C entry point they call.
37
+ *
38
+ * Pass `{}` (no columns) to clear the group.
39
+ */
40
+ export function updateGroupColumns(
41
+ handle: NativePointer,
42
+ caller: string,
43
+ update: ColumnUpdateFn,
44
+ collection: string,
45
+ group: string,
46
+ id: number,
47
+ data: GroupColumns,
48
+ ): void {
49
+ const collBuf = toCString(collection);
50
+ const grpBuf = toCString(group);
51
+ const entries = Object.entries(data);
52
+
53
+ if (entries.length === 0) {
54
+ check(update(handle, collBuf.buf, grpBuf.buf, BigInt(id), null, null, null, null, 0n, 0n));
55
+ return;
56
+ }
57
+
58
+ const columnCount = entries.length;
59
+ const rowCount = entries[0][1].length;
60
+
61
+ // Validate before marshalling: a jagged column would desync the parallel arrays the C API
62
+ // reads against row_count, and a zero-length column (with columns present) would otherwise
63
+ // marshal a null data pointer. Named-but-empty columns are a caller mistake -- pass {} to
64
+ // clear the group instead. The C API rejects both too; failing here names the column.
65
+ for (const [name, values] of entries) {
66
+ if (values.length !== rowCount) {
67
+ throw new QuiverError(
68
+ `Cannot ${caller}: column '${name}' has length ${values.length} but expected ${rowCount}`,
69
+ );
70
+ }
71
+ }
72
+ if (rowCount === 0) {
73
+ const names = entries.map(([name]) => name).join(", ");
74
+ throw new QuiverError(
75
+ `Cannot ${caller}: columns [${names}] contain no rows; pass {} to clear the group`,
76
+ );
77
+ }
78
+
79
+ const keepalive: Allocation[] = [];
80
+
81
+ // Build column names as native string array
82
+ const colNames = entries.map(([name]) => name);
83
+ const { table: namesTable, keepalive: namesPtrs } = allocNativeStringArray(colNames);
84
+ keepalive.push(namesTable, ...namesPtrs);
85
+
86
+ // Build column types, data, and per-cell NULL masks. A null cell becomes mask 0 + a
87
+ // placeholder in the data array (the C API never reads it). An all-null column is tagged
88
+ // FLOAT with zeroed data — the type tag is ignored for masked-out cells.
89
+ const typesBuf = new Uint8Array(columnCount * 4);
90
+ const typesDv = new DataView(typesBuf.buffer);
91
+ const dataPtrs: (Pointer | null)[] = [];
92
+ const maskPtrs: (Pointer | null)[] = [];
93
+
94
+ for (let c = 0; c < columnCount; c++) {
95
+ const [colName, values] = entries[c];
96
+ const first = values.find((v) => v !== null);
97
+
98
+ // Mask via direct indexing — never a DataView, to avoid the documented
99
+ // .buffer-materialization pitfall between ptr() and the FFI call.
100
+ const maskBuf = new Uint8Array(rowCount);
101
+ for (let r = 0; r < rowCount; r++) maskBuf[r] = values[r] === null ? 0 : 1;
102
+ const maskAlloc: Allocation = { ptr: ptr(maskBuf), buf: maskBuf };
103
+ keepalive.push(maskAlloc);
104
+ maskPtrs.push(maskAlloc.ptr);
105
+
106
+ if (first === undefined) {
107
+ // All-null column
108
+ typesDv.setInt32(c * 4, DATA_TYPE_FLOAT, true);
109
+ const p = allocNativeFloat64(new Array(rowCount).fill(0));
110
+ keepalive.push(p);
111
+ dataPtrs.push(p.ptr);
112
+ } else if (typeof first === "string") {
113
+ typesDv.setInt32(c * 4, DATA_TYPE_STRING, true);
114
+ const { table, keepalive: strPtrs } = allocNativeStringArray(
115
+ values.map((v) => (v === null ? null : (v as string))),
116
+ );
117
+ keepalive.push(table, ...strPtrs);
118
+ dataPtrs.push(table.ptr);
119
+ } else if (typeof first === "number") {
120
+ const nonNull = values.filter((v) => v !== null) as number[];
121
+ const sanitized = values.map((v) => (v === null ? 0 : (v as number)));
122
+ if (nonNull.every((v) => Number.isInteger(v))) {
123
+ typesDv.setInt32(c * 4, DATA_TYPE_INTEGER, true);
124
+ const p = allocNativeInt64(sanitized);
125
+ keepalive.push(p);
126
+ dataPtrs.push(p.ptr);
127
+ } else {
128
+ typesDv.setInt32(c * 4, DATA_TYPE_FLOAT, true);
129
+ const p = allocNativeFloat64(sanitized);
130
+ keepalive.push(p);
131
+ dataPtrs.push(p.ptr);
132
+ }
133
+ } else {
134
+ throw new QuiverError(
135
+ `Cannot ${caller}: column '${colName}' has unsupported value type ${typeof first}`,
136
+ );
137
+ }
138
+ }
139
+
140
+ const typesAlloc: Allocation = { ptr: ptr(typesBuf), buf: typesBuf };
141
+ keepalive.push(typesAlloc);
142
+ const dataTable = allocNativePtrTable(dataPtrs);
143
+ keepalive.push(dataTable);
144
+ const maskTable = allocNativePtrTable(maskPtrs);
145
+ keepalive.push(maskTable);
146
+
147
+ check(
148
+ update(
149
+ handle,
150
+ collBuf.buf,
151
+ grpBuf.buf,
152
+ BigInt(id),
153
+ namesTable.buf,
154
+ typesAlloc.buf,
155
+ dataTable.buf,
156
+ maskTable.buf,
157
+ BigInt(columnCount),
158
+ BigInt(rowCount),
159
+ ),
160
+ );
161
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import "./composites.ts";
11
11
  export type { CsvOptions } from "./csv.ts";
12
12
  export { Database } from "./database.ts";
13
13
  export { QuiverError } from "./errors.ts";
14
+ export type { GroupColumns } from "./group-columns.ts";
14
15
  export { LUA_DB_API_REFERENCE } from "./lua-api.ts";
15
16
  export { LuaRunner } from "./lua-runner.ts";
16
17
  export type { GroupMetadata, ScalarMetadata } from "./metadata.ts";
package/src/loader.ts CHANGED
@@ -81,6 +81,7 @@ const readSymbols = {
81
81
  quiver_database_read_set_floats_by_id: { args: [P, BUF, BUF, I64, P, P], returns: I32 },
82
82
  quiver_database_read_set_strings_by_id: { args: [P, BUF, BUF, I64, P, P], returns: I32 },
83
83
  quiver_database_read_element_ids: { args: [P, BUF, P, P], returns: I32 },
84
+ quiver_database_number_of_elements: { args: [P, BUF, P], returns: I32 },
84
85
  } as const;
85
86
 
86
87
  const querySymbols = {
@@ -97,6 +98,9 @@ const transactionSymbols = {
97
98
  quiver_database_commit: { args: [P], returns: I32 },
98
99
  quiver_database_rollback: { args: [P], returns: I32 },
99
100
  quiver_database_in_transaction: { args: [P, P], returns: I32 },
101
+ quiver_database_begin_dry_run: { args: [P], returns: I32 },
102
+ quiver_database_end_dry_run: { args: [P], returns: I32 },
103
+ quiver_database_in_dry_run: { args: [P, P], returns: I32 },
100
104
  } as const;
101
105
 
102
106
  const metadataSymbols = {
@@ -131,6 +135,14 @@ const timeSeriesSymbols = {
131
135
  args: [P, BUF, BUF, I64, P, P, P, P, USIZE, USIZE],
132
136
  returns: I32,
133
137
  },
138
+ quiver_database_update_vector_group: {
139
+ args: [P, BUF, BUF, I64, P, P, P, P, USIZE, USIZE],
140
+ returns: I32,
141
+ },
142
+ quiver_database_update_set_group: {
143
+ args: [P, BUF, BUF, I64, P, P, P, P, USIZE, USIZE],
144
+ returns: I32,
145
+ },
134
146
  quiver_database_free_time_series_data: { args: [P, P, P, P, USIZE, USIZE], returns: I32 },
135
147
  quiver_database_has_time_series_files: { args: [P, BUF, P], returns: I32 },
136
148
  quiver_database_list_time_series_files_columns: { args: [P, BUF, P, P], returns: I32 },
@@ -160,7 +172,8 @@ const freeSymbols = {
160
172
  const luaSymbols = {
161
173
  quiver_lua_runner_new: { args: [P, P], returns: I32 },
162
174
  quiver_lua_runner_free: { args: [P], returns: I32 },
163
- quiver_lua_runner_run: { args: [P, BUF], returns: I32 },
175
+ quiver_lua_runner_run: { args: [P, BUF, BUF], returns: I32 },
176
+ quiver_lua_runner_free_string: { args: [P], returns: I32 },
164
177
  } as const;
165
178
 
166
179
  // Combined symbol map for dlopen.
package/src/lua-api.ts CHANGED
@@ -1,16 +1,21 @@
1
1
  // Agent-facing reference for the Lua `db` API available inside run_lua scripts.
2
2
  //
3
3
  // Authority: `src/lua_runner.cpp` `bind_database()` (this repo) — extracted by hand, NOT imported.
4
- // The shipped quiverdb native binding is the runtime truth; this is docs. Whenever the C++ Lua
5
- // binding changes, re-diff every signature below against `bind_database()` (names, arg order, arg
6
- // types, return shapes).
4
+ // The shipped quiverdb native binding is the runtime truth; this is docs.
5
+ //
6
+ // SYNC: `test/lua-api-sync.test.ts` derives the bound surface from `src/lua_runner.cpp` and checks
7
+ // it AUTOMATICALLY — every `db:`/`quiver.*` name is documented, no documented name has been
8
+ // removed, and the stdlib sentence matches `open_libraries` exactly. What it CANNOT check, and you
9
+ // must still re-diff by hand when the binding changes: arg order, arity, arg types, return shapes,
10
+ // and whether the prose is semantically true.
7
11
  //
8
12
  // NOTE: the binary/expression subsystems are bound in the native binding and documented below.
9
13
  // File-touching operations (db:open_file, db:bin_to_csv, db:csv_to_bin, expr:save) are sandboxed
10
14
  // to the database file's directory; the pure-metadata builders stay under the quiver.* global.
11
15
  //
12
- // FORMAT CONVENTION: every db: method should appear at least once as the literal token
13
- // `db:<snake_case_name>` so coverage is greppable.
16
+ // FORMAT CONVENTION: every db: method appears at least once as the literal token
17
+ // `db:<snake_case_name>`, and every quiver.* function as `quiver.<name>`, so coverage is greppable
18
+ // (the sync test relies on this).
14
19
  export const LUA_DB_API_REFERENCE = `
15
20
  # Quiver Lua API Reference
16
21
 
@@ -66,19 +71,51 @@ datetime surface — there are no DateTime wrapper helpers, unlike Julia/Dart/Py
66
71
  - **Errors abort the script.** Any error thrown by a \`db:\` call stops the script and surfaces as
67
72
  \`Failed to run Lua script: <message>\`. Validation failures roll back whatever the current
68
73
  transaction covered.
69
- - **Standard library.** Only the \`base\`, \`string\`, and \`table\` libraries are loaded — there is NO
70
- \`math\` (use \`//\` for integer division), and no \`os\`, \`io\`, or \`require\`; \`dofile\` and
71
- \`loadfile\` are removed (string-form \`load\` is available).
74
+ - **Standard library.** Loaded standard libraries: base, string, table, math, coroutine, utf8.
75
+ That is the pure-computation set — there is no \`os\`, \`io\`, \`debug\`, or \`package\`/\`require\`,
76
+ and \`dofile\`/\`loadfile\` are removed (string-form \`load\` stays available). Integer division is
77
+ the Lua 5.4 \`//\` operator — a language operator, unrelated to \`math\`.
72
78
  - **Filesystem sandbox.** Every file-touching operation (\`db:export_csv\`, \`db:import_csv\`,
73
79
  \`db:open_file\`, \`db:bin_to_csv\`, \`db:csv_to_bin\`, \`expr:save\`) resolves relative paths against
74
80
  the directory containing the database file and rejects anything outside it (subdirectories are
75
81
  fine; \`..\` escapes and outside absolute paths throw \`Cannot <op>: path '...' escapes the
76
82
  database directory ...\`). On an in-memory database these operations throw
77
83
  \`Cannot <op>: database is in-memory, file operations are unavailable\`.
78
- - **Output.** Scripts cannot return values to the tool — use \`print()\` for anything you need to
79
- see (it is captured). Arrays are 1-indexed (iterate with \`ipairs\`); reading a NULL yields \`nil\`,
80
- writing \`nil\` stores NULL where NULL is accepted (query params, ts rows, file columns — but NOT
81
- element scalar attributes; see CRUD).
84
+ - **Output.** A script can \`return\` one value and the host receives it as JSON — prefer this over
85
+ \`print()\` when you need structured data back (\`print()\` still works and is captured). Only the
86
+ **first** returned value is encoded. Arrays are 1-indexed (iterate with \`ipairs\`); reading a NULL
87
+ yields \`nil\`, writing \`nil\` stores NULL where NULL is accepted (query params, ts rows, file
88
+ columns — but NOT element scalar attributes; see CRUD).
89
+
90
+ \`\`\`lua
91
+ return { ids = db:read_element_ids("Collection"), total = 3 }
92
+ -- host receives: {"ids":[1,2,3],"total":3}
93
+ \`\`\`
94
+
95
+ | Returned | JSON |
96
+ | -------------------------- | ------------------------------------------------------- |
97
+ | nothing | \`""\` (empty — distinct from \`nil\`) |
98
+ | \`nil\` | \`null\` |
99
+ | integer / float / string | the value; a float keeps its shortest round-trip form |
100
+ | boolean | \`true\` / \`false\` |
101
+ | NaN, \`math.huge\` | \`null\` (JSON has no NaN/Infinity) |
102
+ | table keyed \`1..n\` | array — an empty table \`{}\` encodes as \`[]\` |
103
+ | any other table | object, keys sorted; integer keys stringify |
104
+
105
+ **A table with holes is an object, not an array.** A bulk read of a nullable column returns
106
+ \`nil\` holes (see Reading), so \`return db:read_scalar_integers(c, a)\` encodes as
107
+ \`{"1":10,"3":30}\` — not \`[10,null,30]\` — and the keys sort as text (\`"1","11","2"\`). When the host
108
+ needs positional data, return the ids alongside and fill the holes yourself:
109
+ \`local v = db:read_scalar_integers(c, a); local out = {}; for i in ipairs(ids) do out[i] = v[i] or false end\`.
110
+
111
+ Returning a function, a coroutine, or a userdata (including \`db\` itself) raises
112
+ \`Cannot run: script returned an unsupported Lua type\`; nesting deeper than 32 levels raises
113
+ \`Cannot run: script return value nests deeper than 32 levels\` (this is also what stops a
114
+ self-referencing table); a result over 64 MB raises
115
+ \`Cannot run: script return value exceeds ... bytes of JSON\`; a string holding bytes that are not
116
+ valid UTF-8 raises \`Cannot run: script return value contains a string that is not valid UTF-8\`;
117
+ and a table where an integer key and a string key spell the same thing (\`{[1] = 'a', ['1'] = 'b'}\`)
118
+ raises \`Cannot run: script returned a table with duplicate key '1'\`.
82
119
  - **Embedding harness may restrict further.** The library itself allows transactions and the
83
120
  (sandboxed) CSV/file operations below. A host that runs your script (e.g. a hosted \`run_lua\`
84
121
  tool) may disable some of them and report \`disabled in the run_lua sandbox\` — that limit comes
@@ -92,6 +129,7 @@ datetime surface — there are no DateTime wrapper helpers, unlike Julia/Dart/Py
92
129
  db:is_healthy() -- boolean
93
130
  db:current_version() -- integer (current migration version)
94
131
  db:path() -- string (database file path)
132
+ db:number_of_elements(collection) -- integer: how many elements the collection holds right now
95
133
  db:describe() -- string: whole-DB text report (returns it, does NOT print)
96
134
  db:describe_collection(collection) -- string: one collection's structure (text report)
97
135
  db:summarize_collection(collection)-- string: per-scalar null/non-null counts, low-cardinality
@@ -121,14 +159,54 @@ db:transaction(function(db)
121
159
  end) -- both writes commit together; if either throws, both roll back
122
160
  \`\`\`
123
161
 
124
- **Caveat:** if the host already runs your script inside a transaction, an explicit
125
- \`db:begin_transaction()\` will error (\`cannot start a transaction within a transaction\`) and a
126
- mid-script \`db:commit()\` would prematurely end the host's transaction. When unsure whether a
127
- transaction is already open, check \`db:in_transaction()\` first, or just issue writes directly —
162
+ **Caveat:** if the host already runs your script inside a plain transaction (not a dry run), an
163
+ explicit \`db:begin_transaction()\` will error (\`cannot start a transaction within a transaction\`)
164
+ and a mid-script \`db:commit()\` would prematurely end the host's transaction. When unsure whether
165
+ a transaction is already open, check \`db:in_transaction()\` first, or just issue writes directly —
128
166
  each \`db:\` write is durable on its own.
129
167
 
130
168
  ---
131
169
 
170
+ ## Dry runs
171
+
172
+ A dry run executes writes and then throws them away. Use it to check that a sequence works — that
173
+ the collections exist, the types match, the foreign keys resolve — before committing to it.
174
+
175
+ \`\`\`lua
176
+ db:dry_run(fn) -- run fn(db), roll everything back, return fn's result
177
+ db:begin_dry_run() -- start one explicitly
178
+ db:end_dry_run() -- end it, rolling back everything it covered
179
+ db:in_dry_run() -- boolean: is a dry run currently active?
180
+ \`\`\`
181
+
182
+ \`\`\`lua
183
+ local preview = db:dry_run(function(db)
184
+ db:create_element("Collection", { label = "Item 1", some_integer = 42 })
185
+ return db:read_element_ids("Collection") -- reads see the uncommitted writes
186
+ end)
187
+ -- nothing was kept; preview holds what the reads saw
188
+ \`\`\`
189
+
190
+ Rules worth knowing:
191
+
192
+ - **Nested transaction control is absorbed.** Inside a dry run, \`db:begin_transaction\`,
193
+ \`db:commit\` and \`db:rollback\` become no-ops, so the \`db:transaction\` pattern above composes
194
+ instead of erroring — \`db:transaction(fn)\` still runs \`fn\` and still performs its writes, only
195
+ its BEGIN/COMMIT are absorbed. The flip side: a nested \`db:rollback\` does **not** partially
196
+ undo — everything is undone when the dry run ends, whatever the nested calls asked for.
197
+ - \`db:in_transaction()\` still reports \`true\` during a dry run: a real transaction is open.
198
+ - \`db:import_csv\` cannot run inside a dry run (it toggles a pragma that is a no-op mid-transaction)
199
+ and throws \`Cannot import_csv: transaction already active\`.
200
+ - **Dry runs do not nest.** The host may have already opened one around your whole script, in which
201
+ case both \`db:begin_dry_run()\` and \`db:dry_run(fn)\` (which calls it internally) error with
202
+ \`Cannot begin_dry_run: dry run already active\` — check \`db:in_dry_run()\` first and skip the
203
+ wrapper when one is already active. Do **not** call \`db:end_dry_run()\` to get around it: that
204
+ ends the host's dry run, and everything you write afterwards is committed for real.
205
+ - For a rough sense of how much a run touched, \`db:query_integer("SELECT total_changes()")\` gives
206
+ the number of rows inserted, updated or deleted on this connection.
207
+
208
+ ---
209
+
132
210
  ## CRUD
133
211
 
134
212
  \`\`\`lua
@@ -164,8 +242,9 @@ Notes:
164
242
  type can't be inferred from an empty array), so it is silently dropped.
165
243
  - **No \`nil\` scalar attributes.** In Lua a key set to \`nil\` is dropped from the table, so
166
244
  \`{ x = nil }\` is identical to \`{}\`; an update/create table that ends up with no attributes
167
- **throws** (\`element must have at least one attribute\`). To leave a column unchanged, omit the
168
- key — you cannot set a scalar to NULL via the element table. (\`nil\` → NULL is only accepted by
245
+ **throws** (\`...must have at least one scalar attribute\` on create, \`...at least one attribute
246
+ to update\` on update). To leave a column unchanged, omit the key — you cannot set a scalar to
247
+ NULL via the element table. (\`nil\` → NULL is only accepted by
169
248
  \`upsert_time_series_row\` and \`update_time_series_files\`.)
170
249
 
171
250
  ---
@@ -206,6 +285,36 @@ db:read_set_strings(collection, attribute)
206
285
 
207
286
  ---
208
287
 
288
+ ## Replace a whole vector or set group (column-oriented)
289
+
290
+ \`update_vector_group\` / \`update_set_group\` replace **all** of one element's rows in one *named*
291
+ group, taking the same column-oriented shape as the time-series writer:
292
+
293
+ \`\`\`lua
294
+ db:update_vector_group("Child", "refs", id, { parent_ref = { 1, 2, 3 } })
295
+ db:update_set_group("Child", "parents", id, { parent_ref = { 1, 2 } })
296
+
297
+ db:update_vector_group("Child", "refs", id, {}) -- clears the group
298
+ \`\`\`
299
+
300
+ Use these instead of routing a group's columns through \`update_element\` whenever a column name is
301
+ shared by two groups of the collection (legal for foreign-key columns): \`update_element\` routes an
302
+ array **by column name**, so it writes to *every* group table that has that column — silently
303
+ rewriting groups you never named. \`(collection, group)\` names exactly one table.
304
+
305
+ Rules:
306
+ - **Row count is the largest index any column reaches.** Shorter or sparse columns write NULL in
307
+ the gaps, so \`nil\` holes from a read round-trip.
308
+ - **\`{}\` (no columns) clears the group.** Naming a column whose array is empty is an error, not a
309
+ clear — a typo'd column name must not destroy data.
310
+ - **\`id\` and \`vector_index\` are managed by the group** (the element and the row's position) and
311
+ are rejected if passed.
312
+ - **Foreign-key columns accept a label string** and resolve it to the referenced id, exactly as in
313
+ \`create_element\` / \`update_element\`.
314
+ - The element id must exist, same as \`update_element\` / \`delete_element\`.
315
+
316
+ ---
317
+
209
318
  ## Composite by-id reads (Lua convenience helpers)
210
319
 
211
320
  \`\`\`lua
@@ -489,7 +598,8 @@ db:csv_to_bin(path)
489
598
 
490
599
  local e = (quiver.expression(r) + 10.0) * 2.0 -- files auto-wrap; scalars either side
491
600
  e = quiver.abs(e); e = quiver.sqrt(e) -- also quiver.log / quiver.exp
492
- local cond_e = quiver.gt(e, 3.0) -- gt/lt/gte/lte/eq/neq -> 1.0/0.0 (NaN -> NaN)
601
+ local cond_e = quiver.gt(e, 3.0) -- also quiver.lt/quiver.gte/quiver.lte/quiver.eq/quiver.neq
602
+ -- all -> 1.0/0.0 per element (NaN operand -> NaN)
493
603
  cond_e = cond_e & ~quiver.lt(e, 1.0) -- boolean logic via & | ~ operators (and/or/not are keywords)
494
604
  e = quiver.ifelse(cond_e, then_e, else_e) -- build cond_e with comparison + logical operators
495
605
  e = e:aggregate("stage", "sum") -- sum/mean/min/max/percentile
package/src/lua-runner.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Database } from "./database.ts";
2
2
  import { check, QuiverError } from "./errors.ts";
3
- import { allocPtrOut, readPtrOut, toCString } from "./ffi-helpers.ts";
3
+ import { allocPtrOut, decodeStringFromBuf, readPtrOut, toCString } from "./ffi-helpers.ts";
4
4
  import type { NativePointer } from "./loader.ts";
5
5
  import { getSymbols } from "./loader.ts";
6
6
 
@@ -15,11 +15,21 @@ export class LuaRunner {
15
15
  this._ptr = readPtrOut(outRunner);
16
16
  }
17
17
 
18
- run(script: string): void {
18
+ /**
19
+ * Runs a Lua script and returns its return value encoded as JSON, or "" if it returned nothing.
20
+ *
21
+ * To execute a script without keeping its writes, wrap the call in
22
+ * `db.beginDryRun()` / `db.endDryRun()`.
23
+ */
24
+ run(script: string): string {
19
25
  this.ensureOpen();
20
26
  const lib = getSymbols();
21
27
  const scriptBuf = toCString(script);
22
- check(lib.quiver_lua_runner_run(this._ptr, scriptBuf.buf));
28
+ const outResult = allocPtrOut();
29
+ check(lib.quiver_lua_runner_run(this._ptr, scriptBuf.buf, outResult.buf));
30
+ const result = decodeStringFromBuf(outResult);
31
+ lib.quiver_lua_runner_free_string(readPtrOut(outResult));
32
+ return result;
23
33
  }
24
34
 
25
35
  close(): void {
package/src/read.ts CHANGED
@@ -211,6 +211,14 @@ Database.prototype.readElementIds = function (this: Database, collection: string
211
211
  return result;
212
212
  };
213
213
 
214
+ Database.prototype.numberOfElements = function (this: Database, collection: string): number {
215
+ const lib = getSymbols();
216
+ const collBuf = toCString(collection);
217
+ const outBuf = new Uint8Array(8);
218
+ check(lib.quiver_database_number_of_elements(this._handle, collBuf.buf, outBuf));
219
+ return Number(new DataView(outBuf.buffer).getBigInt64(0, true));
220
+ };
221
+
214
222
  // --- Vector bulk reads ---
215
223
 
216
224
  function readBulkIntegers(
@@ -17,6 +17,7 @@ import {
17
17
  readUint64Out,
18
18
  toCString,
19
19
  } from "./ffi-helpers.ts";
20
+ import { updateGroupColumns } from "./group-columns.ts";
20
21
  import { getSymbols } from "./loader.ts";
21
22
  import {
22
23
  type Allocation,
@@ -188,133 +189,14 @@ Database.prototype.updateTimeSeriesGroup = function (
188
189
  id: number,
189
190
  data: TimeSeriesData,
190
191
  ): void {
191
- const lib = getSymbols();
192
- const collBuf = toCString(collection);
193
- const grpBuf = toCString(group);
194
- const entries = Object.entries(data);
195
-
196
- if (entries.length === 0) {
197
- check(
198
- lib.quiver_database_update_time_series_group(
199
- this._handle,
200
- collBuf.buf,
201
- grpBuf.buf,
202
- BigInt(id),
203
- null,
204
- null,
205
- null,
206
- null,
207
- 0n,
208
- 0n,
209
- ),
210
- );
211
- return;
212
- }
213
-
214
- const columnCount = entries.length;
215
- const rowCount = entries[0][1].length;
216
-
217
- // Validate before marshalling: a jagged column would desync the parallel
218
- // arrays the C API reads against row_count, and a zero-length column (with
219
- // columns present) would otherwise marshal a null data pointer that the C API
220
- // dereferences. Named-but-empty columns are a caller mistake -- pass {} to
221
- // clear the group instead.
222
- for (const [name, values] of entries) {
223
- if (values.length !== rowCount) {
224
- throw new QuiverError(
225
- `Cannot updateTimeSeriesGroup: column '${name}' has length ${values.length} but expected ${rowCount}`,
226
- );
227
- }
228
- }
229
- if (rowCount === 0) {
230
- const names = entries.map(([name]) => name).join(", ");
231
- throw new QuiverError(
232
- `Cannot updateTimeSeriesGroup: columns [${names}] contain no rows; pass {} to clear the group`,
233
- );
234
- }
235
-
236
- const keepalive: Allocation[] = [];
237
-
238
- // Build column names as native string array
239
- const colNames = entries.map(([name]) => name);
240
- const { table: namesTable, keepalive: namesPtrs } = allocNativeStringArray(colNames);
241
- keepalive.push(namesTable, ...namesPtrs);
242
-
243
- // Build column types, data, and per-cell NULL masks. A null cell becomes
244
- // mask 0 + a placeholder in the data array (the C API never reads it). An
245
- // all-null column is tagged FLOAT with zeroed data — the type tag is ignored
246
- // for masked-out cells.
247
- const typesBuf = new Uint8Array(columnCount * 4);
248
- const typesDv = new DataView(typesBuf.buffer);
249
- const dataPtrs: (Pointer | null)[] = [];
250
- const maskPtrs: (Pointer | null)[] = [];
251
-
252
- for (let c = 0; c < columnCount; c++) {
253
- const [colName, values] = entries[c];
254
- const first = values.find((v) => v !== null);
255
-
256
- // Mask via direct indexing — never a DataView, to avoid the documented
257
- // .buffer-materialization pitfall between ptr() and the FFI call.
258
- const maskBuf = new Uint8Array(rowCount);
259
- for (let r = 0; r < rowCount; r++) maskBuf[r] = values[r] === null ? 0 : 1;
260
- const maskAlloc: Allocation = { ptr: ptr(maskBuf), buf: maskBuf };
261
- keepalive.push(maskAlloc);
262
- maskPtrs.push(maskAlloc.ptr);
263
-
264
- if (first === undefined) {
265
- // All-null column
266
- typesDv.setInt32(c * 4, DATA_TYPE_FLOAT, true);
267
- const p = allocNativeFloat64(new Array(rowCount).fill(0));
268
- keepalive.push(p);
269
- dataPtrs.push(p.ptr);
270
- } else if (typeof first === "string") {
271
- typesDv.setInt32(c * 4, DATA_TYPE_STRING, true);
272
- const { table, keepalive: strPtrs } = allocNativeStringArray(
273
- values.map((v) => (v === null ? null : (v as string))),
274
- );
275
- keepalive.push(table, ...strPtrs);
276
- dataPtrs.push(table.ptr);
277
- } else if (typeof first === "number") {
278
- const nonNull = values.filter((v) => v !== null) as number[];
279
- const sanitized = values.map((v) => (v === null ? 0 : (v as number)));
280
- if (nonNull.every((v) => Number.isInteger(v))) {
281
- typesDv.setInt32(c * 4, DATA_TYPE_INTEGER, true);
282
- const p = allocNativeInt64(sanitized);
283
- keepalive.push(p);
284
- dataPtrs.push(p.ptr);
285
- } else {
286
- typesDv.setInt32(c * 4, DATA_TYPE_FLOAT, true);
287
- const p = allocNativeFloat64(sanitized);
288
- keepalive.push(p);
289
- dataPtrs.push(p.ptr);
290
- }
291
- } else {
292
- throw new QuiverError(
293
- `Cannot updateTimeSeriesGroup: column '${colName}' has unsupported value type ${typeof first}`,
294
- );
295
- }
296
- }
297
-
298
- const typesAlloc: Allocation = { ptr: ptr(typesBuf), buf: typesBuf };
299
- keepalive.push(typesAlloc);
300
- const dataTable = allocNativePtrTable(dataPtrs);
301
- keepalive.push(dataTable);
302
- const maskTable = allocNativePtrTable(maskPtrs);
303
- keepalive.push(maskTable);
304
-
305
- check(
306
- lib.quiver_database_update_time_series_group(
307
- this._handle,
308
- collBuf.buf,
309
- grpBuf.buf,
310
- BigInt(id),
311
- namesTable.buf,
312
- typesAlloc.buf,
313
- dataTable.buf,
314
- maskTable.buf,
315
- BigInt(columnCount),
316
- BigInt(rowCount),
317
- ),
192
+ updateGroupColumns(
193
+ this._handle,
194
+ "updateTimeSeriesGroup",
195
+ getSymbols().quiver_database_update_time_series_group,
196
+ collection,
197
+ group,
198
+ id,
199
+ data,
318
200
  );
319
201
  };
320
202
 
@@ -23,3 +23,27 @@ Database.prototype.inTransaction = function (this: Database): boolean {
23
23
  check(lib.quiver_database_in_transaction(this._handle, outBuf));
24
24
  return new DataView(outBuf.buffer).getInt32(0, true) !== 0;
25
25
  };
26
+
27
+ /**
28
+ * Begin a dry run: a transaction that endDryRun always rolls back.
29
+ *
30
+ * While it is active, beginTransaction/commit/rollback are absorbed (no-ops), so code that
31
+ * manages its own transactions composes instead of erroring on a nested BEGIN. A nested rollback
32
+ * is therefore not partial -- everything is undone when the dry run ends.
33
+ */
34
+ Database.prototype.beginDryRun = function (this: Database): void {
35
+ const lib = getSymbols();
36
+ check(lib.quiver_database_begin_dry_run(this._handle));
37
+ };
38
+
39
+ Database.prototype.endDryRun = function (this: Database): void {
40
+ const lib = getSymbols();
41
+ check(lib.quiver_database_end_dry_run(this._handle));
42
+ };
43
+
44
+ Database.prototype.inDryRun = function (this: Database): boolean {
45
+ const lib = getSymbols();
46
+ const outBuf = new Uint8Array(4);
47
+ check(lib.quiver_database_in_dry_run(this._handle, outBuf));
48
+ return new DataView(outBuf.buffer).getInt32(0, true) !== 0;
49
+ };