quiverdb 0.9.6 → 0.9.8

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.
Binary file
Binary file
Binary file
Binary file
Binary file
package/mod.ts CHANGED
@@ -22,4 +22,4 @@ export type {
22
22
  TimeSeriesData,
23
23
  Value,
24
24
  } from "./src/index.ts";
25
- export { Database, LuaRunner, QuiverError } from "./src/index.ts";
25
+ export { Database, LUA_DB_API_REFERENCE, LuaRunner, QuiverError } from "./src/index.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quiverdb",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/database.ts CHANGED
@@ -180,7 +180,9 @@ export class Database {
180
180
  declare isHealthy: () => boolean;
181
181
  declare currentVersion: () => number;
182
182
  declare path: () => string;
183
- declare describe: () => void;
183
+ declare describe: () => string;
184
+ declare describeCollection: (collection: string) => string;
185
+ declare summarizeCollection: (collection: string) => string;
184
186
 
185
187
  // --- Composite helpers (implemented in composites.ts) ---
186
188
  declare readScalarsById: (
@@ -141,14 +141,17 @@ export function allocNativeString(str: string): Allocation {
141
141
  return { ptr: ptr(buf), buf };
142
142
  }
143
143
 
144
- /** Build a native string pointer table (const char* const*) from an array of strings. */
145
- export function allocNativeStringArray(strings: string[]): {
144
+ /**
145
+ * Build a native string pointer table (const char* const*) from an array of strings.
146
+ * A `null` entry becomes a NULL pointer in the table (used for SQL-NULL string cells).
147
+ */
148
+ export function allocNativeStringArray(strings: (string | null)[]): {
146
149
  table: Allocation;
147
150
  keepalive: Allocation[];
148
151
  } {
149
- const strAllocs = strings.map((s) => allocNativeString(s));
150
- const table = allocNativePtrTable(strAllocs.map((a) => a.ptr));
151
- return { table, keepalive: strAllocs };
152
+ const strAllocs = strings.map((s) => (s === null ? null : allocNativeString(s)));
153
+ const table = allocNativePtrTable(strAllocs.map((a) => (a ? a.ptr : null)));
154
+ return { table, keepalive: strAllocs.filter((a): a is Allocation => a !== null) };
152
155
  }
153
156
 
154
157
  /** Get the native address of a pointer as BigInt. */
package/src/index.ts CHANGED
@@ -11,16 +11,10 @@ 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 { LUA_DB_API_REFERENCE } from "./lua-api.ts";
14
15
  export { LuaRunner } from "./lua-runner.ts";
15
16
  export type { GroupMetadata, ScalarMetadata } from "./metadata.ts";
16
17
  export type { TimeSeriesData } from "./time-series.ts";
17
- export {
18
- LOG_LEVEL_DEBUG,
19
- LOG_LEVEL_ERROR,
20
- LOG_LEVEL_INFO,
21
- LOG_LEVEL_OFF,
22
- LOG_LEVEL_WARN,
23
- } from "./types.ts";
24
18
  export type {
25
19
  ArrayValue,
26
20
  DatabaseOptions,
@@ -29,3 +23,10 @@ export type {
29
23
  ScalarValue,
30
24
  Value,
31
25
  } from "./types.ts";
26
+ export {
27
+ LOG_LEVEL_DEBUG,
28
+ LOG_LEVEL_ERROR,
29
+ LOG_LEVEL_INFO,
30
+ LOG_LEVEL_OFF,
31
+ LOG_LEVEL_WARN,
32
+ } from "./types.ts";
@@ -1,6 +1,6 @@
1
1
  import { Database } from "./database.ts";
2
2
  import { check } from "./errors.ts";
3
- import { allocPtrOut, decodeStringFromBuf } from "./ffi-helpers.ts";
3
+ import { allocPtrOut, decodeStringFromBuf, readPtrOut, toCString } from "./ffi-helpers.ts";
4
4
  import { getSymbols } from "./loader.ts";
5
5
 
6
6
  Database.prototype.isHealthy = function (this: Database): boolean {
@@ -24,7 +24,31 @@ Database.prototype.path = function (this: Database): string {
24
24
  return decodeStringFromBuf(outPath);
25
25
  };
26
26
 
27
- Database.prototype.describe = function (this: Database): void {
27
+ Database.prototype.describe = function (this: Database): string {
28
28
  const lib = getSymbols();
29
- check(lib.quiver_database_describe(this._handle));
29
+ const out = allocPtrOut();
30
+ check(lib.quiver_database_describe(this._handle, out.buf));
31
+ const result = decodeStringFromBuf(out);
32
+ lib.quiver_database_free_string(readPtrOut(out));
33
+ return result;
34
+ };
35
+
36
+ Database.prototype.describeCollection = function (this: Database, collection: string): string {
37
+ const lib = getSymbols();
38
+ const collBuf = toCString(collection);
39
+ const out = allocPtrOut();
40
+ check(lib.quiver_database_describe_collection(this._handle, collBuf.buf, out.buf));
41
+ const result = decodeStringFromBuf(out);
42
+ lib.quiver_database_free_string(readPtrOut(out));
43
+ return result;
44
+ };
45
+
46
+ Database.prototype.summarizeCollection = function (this: Database, collection: string): string {
47
+ const lib = getSymbols();
48
+ const collBuf = toCString(collection);
49
+ const out = allocPtrOut();
50
+ check(lib.quiver_database_summarize_collection(this._handle, collBuf.buf, out.buf));
51
+ const result = decodeStringFromBuf(out);
52
+ lib.quiver_database_free_string(readPtrOut(out));
53
+ return result;
30
54
  };
package/src/loader.ts CHANGED
@@ -40,7 +40,7 @@ const lifecycleSymbols = {
40
40
  quiver_database_is_healthy: { args: [P, P], returns: I32 },
41
41
  quiver_database_path: { args: [P, P], returns: I32 },
42
42
  quiver_database_current_version: { args: [P, P], returns: I32 },
43
- quiver_database_describe: { args: [P], returns: I32 },
43
+ quiver_database_describe: { args: [P, P], returns: I32 },
44
44
  } as const;
45
45
 
46
46
  const elementSymbols = {
@@ -112,15 +112,23 @@ const metadataSymbols = {
112
112
  quiver_database_list_time_series_groups: { args: [P, BUF, P, P], returns: I32 },
113
113
  } as const;
114
114
 
115
+ const describeSymbols = {
116
+ quiver_database_describe_collection: { args: [P, BUF, P], returns: I32 },
117
+ quiver_database_summarize_collection: { args: [P, BUF, P], returns: I32 },
118
+ } as const;
119
+
115
120
  const timeSeriesSymbols = {
116
- quiver_database_read_time_series_group: { args: [P, BUF, BUF, I64, P, P, P, P, P], returns: I32 },
121
+ quiver_database_read_time_series_group: {
122
+ args: [P, BUF, BUF, I64, P, P, P, P, P, P],
123
+ returns: I32,
124
+ },
117
125
  quiver_database_read_time_series_row: { args: [P, BUF, BUF, BUF, BUF, P, P, P], returns: I32 },
118
126
  quiver_database_add_time_series_row: { args: [P, BUF, BUF, I64, P, P, P, USIZE], returns: I32 },
119
127
  quiver_database_update_time_series_group: {
120
- args: [P, BUF, BUF, I64, P, P, P, USIZE, USIZE],
128
+ args: [P, BUF, BUF, I64, P, P, P, P, USIZE, USIZE],
121
129
  returns: I32,
122
130
  },
123
- quiver_database_free_time_series_data: { args: [P, P, P, USIZE, USIZE], returns: I32 },
131
+ quiver_database_free_time_series_data: { args: [P, P, P, P, USIZE, USIZE], returns: I32 },
124
132
  quiver_database_has_time_series_files: { args: [P, BUF, P], returns: I32 },
125
133
  quiver_database_list_time_series_files_columns: { args: [P, BUF, P, P], returns: I32 },
126
134
  quiver_database_read_time_series_files: { args: [P, BUF, P, P, P], returns: I32 },
@@ -160,6 +168,7 @@ const allSymbols = {
160
168
  ...querySymbols,
161
169
  ...transactionSymbols,
162
170
  ...metadataSymbols,
171
+ ...describeSymbols,
163
172
  ...timeSeriesSymbols,
164
173
  ...csvSymbols,
165
174
  ...freeSymbols,
package/src/lua-api.ts ADDED
@@ -0,0 +1,512 @@
1
+ // Agent-facing reference for the Lua `db` API available inside run_lua scripts.
2
+ //
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).
7
+ //
8
+ // NOTE: the binary/expression subsystems are bound in the native binding and documented below.
9
+ // File-touching operations (db:open_file, db:bin_to_csv, db:csv_to_bin, expr:save) are sandboxed
10
+ // to the database file's directory; the pure-metadata builders stay under the quiver.* global.
11
+ //
12
+ // FORMAT CONVENTION: every db: method should appear at least once as the literal token
13
+ // `db:<snake_case_name>` so coverage is greppable.
14
+ export const LUA_DB_API_REFERENCE = `
15
+ # Quiver Lua API Reference
16
+
17
+ Quiver embeds a Lua scripting layer (via \`LuaRunner\`) that exposes the same database API as the
18
+ other language bindings. This document is a complete reference of every method available to a Lua
19
+ script.
20
+
21
+ ## Running scripts
22
+
23
+ The database is provided to your script as a global userdata named \`db\`. There are **no
24
+ open/close lifecycle methods in Lua** — the \`LuaRunner\` opens the database and hands you \`db\`
25
+ already connected. All methods are called with the colon syntax:
26
+
27
+ \`\`\`lua
28
+ db:create_element("Collection", { label = "Item 1", value = 42 })
29
+ local ids = db:read_element_ids("Collection")
30
+ \`\`\`
31
+
32
+ If a script raises an error (including any error thrown by a \`db:\` call), it surfaces to the host
33
+ as:
34
+
35
+ \`\`\`
36
+ Failed to run Lua script: <message>
37
+ \`\`\`
38
+
39
+ ## Value type mapping
40
+
41
+ Lua values map to Quiver column values as follows:
42
+
43
+ | Lua value | Quiver value | Notes |
44
+ | ------------------ | ------------ | ---------------------------------------------- |
45
+ | integer | INTEGER | Lua integers map to int64. |
46
+ | number (float) | REAL | REAL columns reject bare integers: write 75.0, not 75. |
47
+ | string | TEXT | Also used for \`date_time\` columns (ISO 8601). |
48
+ | \`nil\` | NULL | In query params, file paths, and ts rows. |
49
+ | table (1-indexed) | array | Used for vectors/sets and column-oriented data.|
50
+
51
+ **Unsupported types throw.** Passing a boolean, a function, or a nested table where a scalar is
52
+ expected raises an error rather than silently dropping the value. This applies to element
53
+ attributes, time-series rows, and query parameters. A skipped positional query parameter would
54
+ shift every later parameter and bind NULL to the trailing placeholder, so this is rejected loudly.
55
+
56
+ Dates are plain strings in ISO 8601 format: \`YYYY-MM-DDTHH:MM:SS\`. (Lua keeps a string-based
57
+ datetime surface — there are no DateTime wrapper helpers, unlike Julia/Dart/Python.)
58
+
59
+ ---
60
+
61
+ ## Critical rules
62
+
63
+ - **STRICT typing.** A REAL column rejects integer literals — write \`75.0\`, not \`75\`; an INTEGER
64
+ column rejects floats. A mismatch raises a validation error and rolls the whole script back.
65
+ - **Errors abort the script.** Any error thrown by a \`db:\` call stops the script and surfaces as
66
+ \`Failed to run Lua script: <message>\`. Validation failures roll back whatever the current
67
+ transaction covered.
68
+ - **Standard library.** Only the \`base\`, \`string\`, and \`table\` libraries are loaded — there is NO
69
+ \`math\` (use \`//\` for integer division), and no \`os\`, \`io\`, or \`require\`; \`dofile\` and
70
+ \`loadfile\` are removed (string-form \`load\` is available).
71
+ - **Filesystem sandbox.** Every file-touching operation (\`db:export_csv\`, \`db:import_csv\`,
72
+ \`db:open_file\`, \`db:bin_to_csv\`, \`db:csv_to_bin\`, \`expr:save\`) resolves relative paths against
73
+ the directory containing the database file and rejects anything outside it (subdirectories are
74
+ fine; \`..\` escapes and outside absolute paths throw \`Cannot <op>: path '...' escapes the
75
+ database directory ...\`). On an in-memory database these operations throw
76
+ \`Cannot <op>: database is in-memory, file operations are unavailable\`.
77
+ - **Output.** Scripts cannot return values to the tool — use \`print()\` for anything you need to
78
+ see (it is captured). Arrays are 1-indexed (iterate with \`ipairs\`); reading a NULL yields \`nil\`,
79
+ writing \`nil\` stores NULL where NULL is accepted (query params, ts rows, file columns — but NOT
80
+ element scalar attributes; see CRUD).
81
+
82
+ ---
83
+
84
+ ## Database info
85
+
86
+ \`\`\`lua
87
+ db:is_healthy() -- boolean
88
+ db:current_version() -- integer (current migration version)
89
+ db:path() -- string (database file path)
90
+ db:describe() -- string: whole-DB text report (returns it, does NOT print)
91
+ db:describe_collection(collection) -- string: one collection's structure (text report)
92
+ db:summarize_collection(collection)-- string: per-scalar null/non-null counts, low-cardinality
93
+ -- integer value distributions, per-group sizes
94
+ \`\`\`
95
+
96
+ All three \`describe*\`/\`summarize*\` methods **return** a string — \`print()\` it to see it.
97
+
98
+ ---
99
+
100
+ ## Transactions
101
+
102
+ \`\`\`lua
103
+ db:begin_transaction() -- start an explicit transaction
104
+ db:commit() -- commit it
105
+ db:rollback() -- roll it back
106
+ db:in_transaction() -- boolean: is a transaction currently open?
107
+ db:transaction(fn) -- run fn(db) inside begin/commit; rollback + rethrow if fn errors
108
+ \`\`\`
109
+
110
+ To make a group of writes atomic, prefer the \`db:transaction\` wrapper:
111
+
112
+ \`\`\`lua
113
+ db:transaction(function(db)
114
+ local id = db:create_element("Collection", { label = "Item 1", some_integer = 42 })
115
+ db:update_element("Collection", id, { some_integer = 99 })
116
+ end) -- both writes commit together; if either throws, both roll back
117
+ \`\`\`
118
+
119
+ **Caveat:** if the host already runs your script inside a transaction, an explicit
120
+ \`db:begin_transaction()\` will error (\`cannot start a transaction within a transaction\`) and a
121
+ mid-script \`db:commit()\` would prematurely end the host's transaction. When unsure whether a
122
+ transaction is already open, check \`db:in_transaction()\` first, or just issue writes directly —
123
+ each \`db:\` write is durable on its own.
124
+
125
+ ---
126
+
127
+ ## CRUD
128
+
129
+ \`\`\`lua
130
+ local id = db:create_element(collection, element_table) -- returns new integer id
131
+ db:update_element(collection, id, element_table)
132
+ db:delete_element(collection, id)
133
+ \`\`\`
134
+
135
+ The element table holds scalar attributes as \`key = value\`, and vector/set attributes as
136
+ 1-indexed arrays. The element type of an array is inferred from its **first** entry:
137
+
138
+ \`\`\`lua
139
+ local id = db:create_element("Collection", {
140
+ label = "Item 1", -- scalar string
141
+ some_integer = 42, -- scalar integer
142
+ some_float = 3.14, -- scalar real
143
+ value_int = { 1, 2, 3 }, -- vector/set of integers
144
+ tags = { "a", "b", "c" }, -- vector/set of strings
145
+ })
146
+ \`\`\`
147
+
148
+ \`update_element\` only touches the attributes you pass:
149
+
150
+ \`\`\`lua
151
+ db:update_element("Collection", id, { some_integer = 999 })
152
+ \`\`\`
153
+
154
+ Notes:
155
+ - **Empty arrays are skipped.** An attribute whose value is \`{}\` writes no vector/set (the element
156
+ type can't be inferred from an empty array), so it is silently dropped.
157
+ - **No \`nil\` scalar attributes.** Passing \`nil\` for a scalar attribute **throws** (unsupported
158
+ type). To leave a column unset, omit the key — you cannot set a scalar to NULL via the element
159
+ table. (\`nil\` → NULL is only accepted by \`add_time_series_row\` and \`update_time_series_files\`.)
160
+
161
+ ---
162
+
163
+ ## Scalar reads (bulk, across all elements)
164
+
165
+ Each returns a flat array (1-indexed table), one value per element, in id order.
166
+
167
+ \`\`\`lua
168
+ db:read_scalar_integers(collection, attribute) -- { 42, 37, ... }
169
+ db:read_scalar_floats(collection, attribute) -- { 3.14, 2.71, ... }
170
+ db:read_scalar_strings(collection, attribute) -- { "Item 1", "Item 2", ... }
171
+ \`\`\`
172
+
173
+ ---
174
+
175
+ ## Vector reads (bulk)
176
+
177
+ Each returns an array of arrays — one inner array per element.
178
+
179
+ \`\`\`lua
180
+ db:read_vector_integers(collection, attribute) -- { {1,2,3}, {2,3,4}, ... }
181
+ db:read_vector_floats(collection, attribute)
182
+ db:read_vector_strings(collection, attribute)
183
+ \`\`\`
184
+
185
+ ---
186
+
187
+ ## Set reads (bulk)
188
+
189
+ Same shape as vector reads — an array of arrays.
190
+
191
+ \`\`\`lua
192
+ db:read_set_integers(collection, attribute)
193
+ db:read_set_floats(collection, attribute)
194
+ db:read_set_strings(collection, attribute)
195
+ \`\`\`
196
+
197
+ ---
198
+
199
+ ## Composite by-id reads (Lua convenience helpers)
200
+
201
+ \`\`\`lua
202
+ db:read_element_ids(collection) -- { 1, 2, 3, ... }
203
+
204
+ db:read_scalars_by_id(collection, id) -- { attr = value, ... } (missing -> nil)
205
+ db:read_vectors_by_id(collection, id) -- { column = { v1, v2, ... }, ... }
206
+ db:read_sets_by_id(collection, id) -- { column = { v1, v2, ... }, ... }
207
+ db:read_element_by_id(collection, id) -- scalars + vectors + sets merged into one table
208
+ \`\`\`
209
+
210
+ \`read_element_by_id\` merges every scalar, vector, and set for the element into a single table.
211
+ Scalar attributes with no value come back as \`nil\`.
212
+
213
+ ---
214
+
215
+ ## Time series
216
+
217
+ Time-series group data is **column-oriented** in Lua: \`{ column = { v1, v2, ... }, ... }\`. The
218
+ dimension (ordering) column is a \`date_*\` text column holding ISO 8601 timestamps.
219
+
220
+ ### Read a whole group (column-oriented)
221
+
222
+ \`\`\`lua
223
+ local ts = db:read_time_series_group(collection, group, id)
224
+ -- ts = { date_time = { "2024-01-01T00:00:00", ... }, value = { 10.5, 20.0, ... } }
225
+ -- returns an empty table {} if the element has no rows
226
+ \`\`\`
227
+
228
+ A \`NULL\` value cell comes back as a \`nil\` hole (the index is simply absent), and an all-\`NULL\`
229
+ value column is an empty table \`{}\` (its key is still present). Because \`nil\` cannot occupy an
230
+ array slot, **take the row count from the dimension column** (\`#ts.date_time\`), never from a value
231
+ column.
232
+
233
+ ### Read one value per element at a date (\`read_time_series_row\`)
234
+
235
+ \`\`\`lua
236
+ local values = db:read_time_series_row(collection, group, attribute, date_time)
237
+ -- { v_elem1, v_elem2, ... } in element-id order
238
+ \`\`\`
239
+
240
+ One value per element using **last non-null value at or before \`date_time\`** semantics. Elements
241
+ with no matching data yield \`nil\` in the array. \`date_time\` is an ISO 8601 string.
242
+
243
+ ### Replace a whole group (column-oriented — SAME shape as the read)
244
+
245
+ \`update_time_series_group\` takes the **exact column-oriented shape \`read_time_series_group\`
246
+ returns**: a table mapping each column name to a 1-indexed array of its values. Read → modify →
247
+ write round-trips. Passing an empty table \`{}\` clears the group.
248
+
249
+ \`\`\`lua
250
+ db:update_time_series_group("Items", "data", id, {
251
+ date_time = { "2024-01-01T00:00:00", "2024-01-02T00:00:00", "2024-01-03T00:00:00" },
252
+ value = { 10.5, 20.0, 30.0 },
253
+ })
254
+
255
+ db:update_time_series_group("Items", "data", id, {}) -- clears the group
256
+ \`\`\`
257
+
258
+ A read-modify-write looks like this:
259
+
260
+ \`\`\`lua
261
+ local ts = db:read_time_series_group("Items", "data", id)
262
+ ts.value[2] = 125.0 -- edit the 2nd row's value
263
+ db:update_time_series_group("Items", "data", id, ts) -- write the whole group back
264
+ \`\`\`
265
+
266
+ **DO NOT pass an array of row tables** (\`{ { date_time = ..., value = ... }, ... }\`) — that is the
267
+ \`add_time_series_row\` shape, not this one. Doing so raises
268
+ \`stack index -1, expected string, received number\` (the integer array indices 1, 2, 3 are not
269
+ column names). Each value of the top-level table must be an **array**, not a scalar.
270
+
271
+ **Rules** (validation throws, rolling the script back):
272
+ - Every column value must be an array — a bare scalar throws \`column '...' must be an array of values\`.
273
+ - The **dimension column(s) set the row count** and must be present and fully populated: the
274
+ \`date_*\` ordering column, plus any extra primary-key columns in a multi-dimensional group (e.g.
275
+ \`block\`). A missing one throws \`missing dimension column '...'\`; a \`nil\` inside one throws
276
+ \`dimension column '...' has nil at index N\` (they are primary-key columns and cannot be NULL).
277
+ - **Value columns may be shorter, sparser, or absent** relative to the dimension column — every
278
+ missing cell is written as \`NULL\`. So \`value = { 10.0, nil, 30.0 }\` or a too-short \`value = { 10.0 }\`
279
+ both write NULLs for the gaps; this is how you round-trip the \`nil\` holes a read produces. A value
280
+ column **longer** than the dimension column throws \`column '...' has length N but expected M\`.
281
+ - Named columns whose dimension transposes to zero rows throw (\`contain no rows; pass an empty
282
+ table {} to clear the group\`) — only a bare \`{}\` clears.
283
+ - Integer values are accepted for REAL columns (converted on insert). Booleans, functions, and
284
+ other unsupported Lua types throw \`column '...' has unsupported Lua type\`.
285
+
286
+ ### Append/upsert a single row (\`add_time_series_row\` — ROW-oriented, the one exception)
287
+
288
+ Unlike the column-oriented group update above, this takes **one row table of scalars** (dimension
289
+ column + value column(s)), and upserts that single row:
290
+
291
+ \`\`\`lua
292
+ db:add_time_series_row("Items", "data", id, {
293
+ date_time = "2024-01-04T00:00:00",
294
+ value = 40.0,
295
+ })
296
+ \`\`\`
297
+
298
+ ---
299
+
300
+ ## Time series files
301
+
302
+ For schemas that reference external time-series files (the \`{Collection}_time_series_files\`
303
+ singleton table):
304
+
305
+ \`\`\`lua
306
+ db:has_time_series_files(collection) -- boolean
307
+ db:list_time_series_files_columns(collection) -- { "data_file", "metadata_file", ... }
308
+ db:read_time_series_files(collection) -- { data_file = "path", metadata_file = nil, ... }
309
+ db:update_time_series_files(collection, { data_file = "path/to/data.bin", metadata_file = nil })
310
+ \`\`\`
311
+
312
+ In \`update_time_series_files\`, a \`nil\` value clears that column.
313
+
314
+ ---
315
+
316
+ ## Metadata
317
+
318
+ ### Single attribute / group
319
+
320
+ \`\`\`lua
321
+ db:get_scalar_metadata(collection, attribute) -- scalar metadata table (below)
322
+ db:get_vector_metadata(collection, group_name) -- group metadata table (below)
323
+ db:get_set_metadata(collection, group_name) -- group metadata table
324
+ db:get_time_series_metadata(collection, group_name) -- group metadata table (+ dimension_column)
325
+ \`\`\`
326
+
327
+ ### Lists (one entry per attribute/group)
328
+
329
+ \`\`\`lua
330
+ db:list_scalar_attributes(collection) -- array of scalar metadata tables
331
+ db:list_vector_groups(collection) -- array of group metadata tables
332
+ db:list_set_groups(collection) -- array of group metadata tables
333
+ db:list_time_series_groups(collection) -- array of group metadata tables (+ dimension_column)
334
+ \`\`\`
335
+
336
+ ### Scalar metadata table shape
337
+
338
+ \`\`\`lua
339
+ {
340
+ name = "value",
341
+ data_type = "integer", -- "integer" | "real" | "text" | "date_time"
342
+ not_null = true,
343
+ primary_key = false,
344
+ default_value = nil, -- string, or nil
345
+ is_foreign_key = false,
346
+ references_collection = nil, -- string, or nil
347
+ references_column = nil, -- string, or nil
348
+ }
349
+ \`\`\`
350
+
351
+ ### Group metadata table shape
352
+
353
+ \`\`\`lua
354
+ {
355
+ group_name = "data",
356
+ value_columns = { <scalar metadata table>, ... },
357
+ dimension_column = "date_time", -- present ONLY for time-series groups
358
+ }
359
+ \`\`\`
360
+
361
+ The \`dimension_column\` key is present only in **time-series** group metadata
362
+ (\`get_time_series_metadata\` / \`list_time_series_groups\`). Vector and set group metadata omit it
363
+ entirely.
364
+
365
+ ---
366
+
367
+ ## Query (parameterized SQL)
368
+
369
+ Positional \`?\` placeholders; \`params\` is an optional 1-indexed array. Each returns the first
370
+ column of the first row as the requested type, or \`nil\` if there is no result.
371
+
372
+ \`\`\`lua
373
+ db:query_string(sql, params) -- string or nil
374
+ db:query_integer(sql, params) -- integer or nil
375
+ db:query_float(sql, params) -- number or nil
376
+ \`\`\`
377
+
378
+ Example:
379
+
380
+ \`\`\`lua
381
+ local label = db:query_string(
382
+ "SELECT label FROM Collection WHERE some_integer = ?",
383
+ { 42 }
384
+ )
385
+ local count = db:query_integer("SELECT COUNT(*) FROM Collection")
386
+ \`\`\`
387
+
388
+ ---
389
+
390
+ ## CSV import / export
391
+
392
+ Export a time-series group to a CSV file, or import one from a CSV file. \`path\` is sandboxed:
393
+ relative paths resolve against the database file's directory and must stay inside it (see
394
+ Critical rules); \`options\` is optional.
395
+
396
+ \`\`\`lua
397
+ db:export_csv(collection, group, path, options)
398
+ db:import_csv(collection, group, path, options)
399
+ \`\`\`
400
+
401
+ The optional \`options\` table has two keys:
402
+
403
+ \`\`\`lua
404
+ {
405
+ date_time_format = "%Y-%m-%d", -- strftime-style format for the dimension column
406
+
407
+ -- enum_labels: write/read integer codes as human labels. Three nested levels:
408
+ -- attribute name -> locale -> { label = integer_id }
409
+ enum_labels = {
410
+ status = {
411
+ en = { active = 1, inactive = 0 },
412
+ pt = { ativo = 1, inativo = 0 },
413
+ },
414
+ },
415
+ }
416
+ \`\`\`
417
+
418
+ **Precondition:** \`db:import_csv\` cannot run inside an open transaction (it toggles
419
+ \`PRAGMA foreign_keys\`, a no-op mid-transaction) — it throws \`Cannot import_csv: transaction already
420
+ active\`. Call it outside any \`db:transaction\` / \`db:begin_transaction\` block.
421
+
422
+ ---
423
+
424
+ ## Complete example
425
+
426
+ \`\`\`lua
427
+ -- create
428
+ db:create_element("Configuration", { label = "Configuration" })
429
+
430
+ local item1 = db:create_element("Collection",
431
+ { label = "Item 1", some_integer = 42, some_float = 3.14, value_int = { 1, 2, 3 } })
432
+ local item2 = db:create_element("Collection",
433
+ { label = "Item 2", some_integer = 37, some_float = 2.71, value_int = { 2, 3, 4 } })
434
+
435
+ -- read every element
436
+ local ids = db:read_element_ids("Collection")
437
+ for _, id in ipairs(ids) do
438
+ local element = db:read_element_by_id("Collection", id)
439
+ print("Element " .. id .. ", label = " .. tostring(element.label))
440
+ end
441
+
442
+ -- update
443
+ db:update_element("Collection", item1, { some_integer = 999 })
444
+
445
+ -- delete
446
+ db:delete_element("Collection", item2)
447
+ \`\`\`
448
+
449
+ ---
450
+
451
+ ## Binary & expression subsystems
452
+
453
+ Dense N-dimensional \`float64\` arrays (\`.qvr\` + \`.toml\` sidecar) plus lazy arithmetic over them.
454
+ File I/O is db-scoped (\`db:open_file\` / \`db:bin_to_csv\` / \`db:csv_to_bin\`); paths are extensionless
455
+ base paths, sandboxed to the database directory (see Critical rules), and \`get_file_path()\` returns
456
+ the resolved absolute path. The pure-metadata builders and expression constructors live under the
457
+ global \`quiver\` table. Mirrors the Julia surface; aggregation ops are strings (Lua has no enums);
458
+ operators are \`+ - * /\` and unary \`-\`, with scalars allowed on either side.
459
+
460
+ \`\`\`lua
461
+ local md = quiver.metadata{
462
+ initial_datetime = "2025-01-01T00:00:00", unit = "MW",
463
+ labels = {"v1", "v2"}, dimensions = {"stage", "block"}, dimension_sizes = {4, 31},
464
+ time_dimensions = {"stage", "block"}, frequencies = {"monthly", "daily"},
465
+ }
466
+ local f = db:open_file(path, "w", md) -- mode "r"/"w"; md required for "w"
467
+ f:write({1.0, 2.0}, {stage = 1, block = 1}) -- data table, dims table
468
+ f:close()
469
+ local r = db:open_file(path, "r")
470
+ local cell = r:read({stage = 1, block = 1}) -- { v1, v2 }; pass true as 2nd arg to allow NaN
471
+ r:get_metadata(); r:get_file_path(); r:is_open()
472
+ md:get_unit(); md:get_version(); md:get_initial_datetime()
473
+ md:get_labels(); md:get_dimensions(); md:get_number_of_time_dimensions(); md:to_toml()
474
+ quiver.metadata_from_toml(text); quiver.metadata_from_element(tbl)
475
+ db:bin_to_csv(path) -- aggregate=true by default; pass false to keep time dims as columns
476
+ db:csv_to_bin(path)
477
+
478
+ local e = (quiver.expression(r) + 10.0) * 2.0 -- files auto-wrap; scalars either side
479
+ e = quiver.abs(e); e = quiver.sqrt(e) -- also quiver.log / quiver.exp
480
+ e = quiver.ifelse(cond_e, then_e, else_e)
481
+ e = e:aggregate("stage", "sum") -- sum/mean/min/max/percentile
482
+ e = e:aggregate("stage", "percentile", 0.9) -- percentile needs the fraction
483
+ e = e:aggregate_agents("mean") -- collapse the label axis
484
+ e = e:select_agents({"v2"}); e = e:rename_agents({v1 = "alpha"})
485
+ e:save(out_path); e:metadata() -- save path is sandboxed like db:open_file
486
+ \`\`\`
487
+
488
+ **\`quiver.metadata{...}\` kwargs and defaults:** \`version\` defaults to \`"1"\`; \`initial_datetime\`
489
+ and \`unit\` default to \`""\`; \`labels\`, \`dimensions\`, \`dimension_sizes\`, \`time_dimensions\`, and
490
+ \`frequencies\` default to empty arrays.
491
+
492
+ **\`get_dimensions()\` / \`get_metadata()\` dimension shape** — returns an array of dimension tables:
493
+
494
+ \`\`\`lua
495
+ {
496
+ name = "stage",
497
+ size = 4,
498
+ is_time_dimension = true,
499
+ frequency = "monthly", -- nil for non-time dimensions
500
+ initial_value = 1, -- nil for non-time dimensions
501
+ parent_dimension_index = -1, -- nil for non-time dimensions
502
+ }
503
+ \`\`\`
504
+
505
+ ---
506
+
507
+ ## What Lua does *not* expose
508
+
509
+ DateTime wrapper helpers (Lua uses ISO 8601 strings) and \`_by_id\` single-scalar variants (use the
510
+ composite by-id readers or the bulk readers instead). Everything else the native binding exposes —
511
+ CRUD, reads, time series, metadata, query, CSV, and the binary/expression subsystems — is
512
+ documented above and callable.`;
@@ -26,7 +26,7 @@ import {
26
26
  DATA_TYPE_STRING,
27
27
  } from "./types.ts";
28
28
 
29
- export type TimeSeriesData = Record<string, (number | string)[]>;
29
+ export type TimeSeriesData = Record<string, (number | string | null)[]>;
30
30
 
31
31
  Database.prototype.readTimeSeriesGroup = function (
32
32
  this: Database,
@@ -40,6 +40,7 @@ Database.prototype.readTimeSeriesGroup = function (
40
40
  const outNames = allocPtrOut();
41
41
  const outTypes = allocPtrOut();
42
42
  const outData = allocPtrOut();
43
+ const outHasValue = allocPtrOut();
43
44
  const outColCount = allocUint64Out();
44
45
  const outRowCount = allocUint64Out();
45
46
 
@@ -52,6 +53,7 @@ Database.prototype.readTimeSeriesGroup = function (
52
53
  outNames.buf,
53
54
  outTypes.buf,
54
55
  outData.buf,
56
+ outHasValue.buf,
55
57
  outColCount.buf,
56
58
  outRowCount.buf,
57
59
  ),
@@ -64,26 +66,49 @@ Database.prototype.readTimeSeriesGroup = function (
64
66
  const namesPtr = readPtrOut(outNames);
65
67
  const typesPtr = readPtrOut(outTypes);
66
68
  const dataPtr = readPtrOut(outData);
69
+ const hasValuePtr = readPtrOut(outHasValue);
67
70
 
68
71
  const colNames = decodeStringArray(namesPtr, colCount);
69
72
  const typesAb = toArrayBuffer(typesPtr as Pointer, 0, colCount * 4);
70
73
  const types = Array.from(new Int32Array(typesAb));
71
74
  const dataPtrs = decodePtrArray(dataPtr, colCount);
75
+ const maskPtrs = decodePtrArray(hasValuePtr, colCount);
72
76
 
77
+ // Per-cell NULL mask: mask[r] === 0 means SQL NULL, surfaced as JS null. The
78
+ // dimension column's mask is always all 1, so it stays dense.
73
79
  const result: TimeSeriesData = {};
74
80
  for (let c = 0; c < colCount; c++) {
75
81
  const colName = colNames[c];
82
+ const maskPtr = maskPtrs[c];
83
+ const mask = maskPtr ? new Uint8Array(toArrayBuffer(maskPtr as Pointer, 0, rowCount)) : null;
76
84
  switch (types[c]) {
77
- case DATA_TYPE_INTEGER:
78
- result[colName] = decodeInt64Array(dataPtrs[c], rowCount);
85
+ case DATA_TYPE_INTEGER: {
86
+ const vals = decodeInt64Array(dataPtrs[c], rowCount);
87
+ result[colName] = mask ? vals.map((v, r) => (mask[r] ? v : null)) : vals;
79
88
  break;
80
- case DATA_TYPE_FLOAT:
81
- result[colName] = decodeFloat64Array(dataPtrs[c], rowCount);
89
+ }
90
+ case DATA_TYPE_FLOAT: {
91
+ const vals = decodeFloat64Array(dataPtrs[c], rowCount);
92
+ result[colName] = mask ? vals.map((v, r) => (mask[r] ? v : null)) : vals;
82
93
  break;
94
+ }
83
95
  case DATA_TYPE_STRING:
84
- case DATA_TYPE_DATE_TIME:
85
- result[colName] = decodeStringArray(dataPtrs[c], rowCount);
96
+ case DATA_TYPE_DATE_TIME: {
97
+ // Read pointer-by-pointer (not decodeStringArray): a masked-out cell is a
98
+ // NULL char* that CString cannot construct from.
99
+ const base = dataPtrs[c];
100
+ const col: (string | null)[] = new Array(rowCount);
101
+ for (let r = 0; r < rowCount; r++) {
102
+ if (mask && !mask[r]) {
103
+ col[r] = null;
104
+ continue;
105
+ }
106
+ const strPtr = base ? read.ptr(base as Pointer, r * 8) : 0;
107
+ col[r] = strPtr === 0 ? null : new CString(strPtr as Pointer).toString();
108
+ }
109
+ result[colName] = col;
86
110
  break;
111
+ }
87
112
  }
88
113
  }
89
114
 
@@ -91,6 +116,7 @@ Database.prototype.readTimeSeriesGroup = function (
91
116
  namesPtr,
92
117
  typesPtr,
93
118
  dataPtr,
119
+ hasValuePtr,
94
120
  BigInt(colCount),
95
121
  BigInt(rowCount),
96
122
  );
@@ -177,6 +203,7 @@ Database.prototype.updateTimeSeriesGroup = function (
177
203
  null,
178
204
  null,
179
205
  null,
206
+ null,
180
207
  0n,
181
208
  0n,
182
209
  ),
@@ -213,32 +240,58 @@ Database.prototype.updateTimeSeriesGroup = function (
213
240
  const { table: namesTable, keepalive: namesPtrs } = allocNativeStringArray(colNames);
214
241
  keepalive.push(namesTable, ...namesPtrs);
215
242
 
216
- // Build column types and data
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.
217
247
  const typesBuf = new Uint8Array(columnCount * 4);
218
248
  const typesDv = new DataView(typesBuf.buffer);
219
249
  const dataPtrs: (Pointer | null)[] = [];
250
+ const maskPtrs: (Pointer | null)[] = [];
220
251
 
221
252
  for (let c = 0; c < columnCount; c++) {
222
- const values = entries[c][1];
223
-
224
- if (typeof values[0] === "string") {
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") {
225
271
  typesDv.setInt32(c * 4, DATA_TYPE_STRING, true);
226
- const { table, keepalive: strPtrs } = allocNativeStringArray(values as string[]);
272
+ const { table, keepalive: strPtrs } = allocNativeStringArray(
273
+ values.map((v) => (v === null ? null : (v as string))),
274
+ );
227
275
  keepalive.push(table, ...strPtrs);
228
276
  dataPtrs.push(table.ptr);
229
- } else if (typeof values[0] === "number") {
230
- const allIntegers = (values as number[]).every((v) => Number.isInteger(v));
231
- if (allIntegers) {
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))) {
232
281
  typesDv.setInt32(c * 4, DATA_TYPE_INTEGER, true);
233
- const p = allocNativeInt64(values as number[]);
282
+ const p = allocNativeInt64(sanitized);
234
283
  keepalive.push(p);
235
284
  dataPtrs.push(p.ptr);
236
285
  } else {
237
286
  typesDv.setInt32(c * 4, DATA_TYPE_FLOAT, true);
238
- const p = allocNativeFloat64(values as number[]);
287
+ const p = allocNativeFloat64(sanitized);
239
288
  keepalive.push(p);
240
289
  dataPtrs.push(p.ptr);
241
290
  }
291
+ } else {
292
+ throw new QuiverError(
293
+ `Cannot updateTimeSeriesGroup: column '${colName}' has unsupported value type ${typeof first}`,
294
+ );
242
295
  }
243
296
  }
244
297
 
@@ -246,6 +299,8 @@ Database.prototype.updateTimeSeriesGroup = function (
246
299
  keepalive.push(typesAlloc);
247
300
  const dataTable = allocNativePtrTable(dataPtrs);
248
301
  keepalive.push(dataTable);
302
+ const maskTable = allocNativePtrTable(maskPtrs);
303
+ keepalive.push(maskTable);
249
304
 
250
305
  check(
251
306
  lib.quiver_database_update_time_series_group(
@@ -256,6 +311,7 @@ Database.prototype.updateTimeSeriesGroup = function (
256
311
  namesTable.buf,
257
312
  typesAlloc.buf,
258
313
  dataTable.buf,
314
+ maskTable.buf,
259
315
  BigInt(columnCount),
260
316
  BigInt(rowCount),
261
317
  ),