quiverdb 0.12.3 → 0.12.5

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
@@ -104,6 +104,8 @@ bun run example.ts
104
104
  - `readSetBooleansById(collection, attribute, id)` -- Read INTEGER-backed boolean set
105
105
  - `readSetFloatsById(collection, attribute, id)` -- Read float set
106
106
  - `readSetStringsById(collection, attribute, id)` -- Read string set
107
+ - `readVectorGroupById(collection, group, id)` -- Read a whole vector group as rows (`null` for a SQL NULL cell)
108
+ - `readSetGroupById(collection, group, id)` -- Read a whole set group as rows (`null` for a SQL NULL cell)
107
109
 
108
110
  ### Read (IDs)
109
111
 
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.12.3",
3
+ "version": "0.12.5",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/composites.ts CHANGED
@@ -39,9 +39,9 @@ Database.prototype.readVectorsById = function (
39
39
  this: Database,
40
40
  collection: string,
41
41
  id: number,
42
- ): Record<string, number[] | string[]> {
42
+ ): Record<string, (number | null)[] | (string | null)[]> {
43
43
  const groups = this.listVectorGroups(collection);
44
- const result: Record<string, number[] | string[]> = {};
44
+ const result: Record<string, (number | null)[] | (string | null)[]> = {};
45
45
 
46
46
  for (const group of groups) {
47
47
  for (const col of group.valueColumns) {
@@ -69,9 +69,9 @@ Database.prototype.readSetsById = function (
69
69
  this: Database,
70
70
  collection: string,
71
71
  id: number,
72
- ): Record<string, number[] | string[]> {
72
+ ): Record<string, (number | null)[] | (string | null)[]> {
73
73
  const groups = this.listSetGroups(collection);
74
- const result: Record<string, number[] | string[]> = {};
74
+ const result: Record<string, (number | null)[] | (string | null)[]> = {};
75
75
 
76
76
  for (const group of groups) {
77
77
  for (const col of group.valueColumns) {
package/src/create.ts CHANGED
@@ -29,6 +29,16 @@ function setElementArray(
29
29
 
30
30
  const first = values[0];
31
31
 
32
+ // A vector/set/time-series read returns a NULL cell as null. The element surface is non-null
33
+ // (NULL cells go through the group writers), whatever the cell's position or the array's type:
34
+ // the numeric setters below would store a null as 0 / false, and a string array would store an
35
+ // `undefined` as the text "undefined". `includes`, not `some`: it also sees a sparse array's holes.
36
+ if (values.includes(null) || values.includes(undefined)) {
37
+ throw new QuiverError(
38
+ `Unsupported null cell in array '${name}': write NULL cells with updateVectorGroup, updateSetGroup or updateTimeSeriesGroup`,
39
+ );
40
+ }
41
+
32
42
  if (typeof first === "bigint") {
33
43
  const arr = allocNativeInt64(values as bigint[]);
34
44
  check(lib.quiver_element_set_array_integer(elemPtr, nameBuf.buf, arr.buf, values.length, null));
package/src/database.ts CHANGED
@@ -1,11 +1,10 @@
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
+ import type { GroupColumns, TimeSeriesData } from "./group-columns.ts";
5
5
  import type { NativePointer } from "./loader.ts";
6
6
  import { getSymbols } from "./loader.ts";
7
7
  import type { GroupMetadata, ScalarMetadata } from "./metadata.ts";
8
- import type { TimeSeriesData } from "./time-series.ts";
9
8
  import type { DatabaseOptions, ElementData, QueryParam } from "./types.ts";
10
9
 
11
10
  export class Database {
@@ -138,22 +137,64 @@ export class Database {
138
137
  ) => string | null;
139
138
  declare readElementIds: (collection: string) => number[];
140
139
  declare numberOfElements: (collection: string) => number;
141
- declare readVectorIntegers: (collection: string, attribute: string) => number[][];
142
- declare readVectorBooleans: (collection: string, attribute: string) => boolean[][];
143
- declare readVectorFloats: (collection: string, attribute: string) => number[][];
144
- declare readVectorStrings: (collection: string, attribute: string) => string[][];
145
- declare readVectorIntegersById: (collection: string, attribute: string, id: number) => number[];
146
- declare readVectorBooleansById: (collection: string, attribute: string, id: number) => boolean[];
147
- declare readVectorFloatsById: (collection: string, attribute: string, id: number) => number[];
148
- declare readVectorStringsById: (collection: string, attribute: string, id: number) => string[];
149
- declare readSetIntegers: (collection: string, attribute: string) => number[][];
150
- declare readSetBooleans: (collection: string, attribute: string) => boolean[][];
151
- declare readSetFloats: (collection: string, attribute: string) => number[][];
152
- declare readSetStrings: (collection: string, attribute: string) => string[][];
153
- declare readSetIntegersById: (collection: string, attribute: string, id: number) => number[];
154
- declare readSetBooleansById: (collection: string, attribute: string, id: number) => boolean[];
155
- declare readSetFloatsById: (collection: string, attribute: string, id: number) => number[];
156
- declare readSetStringsById: (collection: string, attribute: string, id: number) => string[];
140
+ declare readVectorIntegers: (collection: string, attribute: string) => (number | null)[][];
141
+ declare readVectorBooleans: (collection: string, attribute: string) => (boolean | null)[][];
142
+ declare readVectorFloats: (collection: string, attribute: string) => (number | null)[][];
143
+ declare readVectorStrings: (collection: string, attribute: string) => (string | null)[][];
144
+ declare readVectorIntegersById: (
145
+ collection: string,
146
+ attribute: string,
147
+ id: number,
148
+ ) => (number | null)[];
149
+ declare readVectorBooleansById: (
150
+ collection: string,
151
+ attribute: string,
152
+ id: number,
153
+ ) => (boolean | null)[];
154
+ declare readVectorFloatsById: (
155
+ collection: string,
156
+ attribute: string,
157
+ id: number,
158
+ ) => (number | null)[];
159
+ declare readVectorStringsById: (
160
+ collection: string,
161
+ attribute: string,
162
+ id: number,
163
+ ) => (string | null)[];
164
+ declare readSetIntegers: (collection: string, attribute: string) => (number | null)[][];
165
+ declare readSetBooleans: (collection: string, attribute: string) => (boolean | null)[][];
166
+ declare readSetFloats: (collection: string, attribute: string) => (number | null)[][];
167
+ declare readSetStrings: (collection: string, attribute: string) => (string | null)[][];
168
+ declare readSetIntegersById: (
169
+ collection: string,
170
+ attribute: string,
171
+ id: number,
172
+ ) => (number | null)[];
173
+ declare readSetBooleansById: (
174
+ collection: string,
175
+ attribute: string,
176
+ id: number,
177
+ ) => (boolean | null)[];
178
+ declare readSetFloatsById: (
179
+ collection: string,
180
+ attribute: string,
181
+ id: number,
182
+ ) => (number | null)[];
183
+ declare readSetStringsById: (
184
+ collection: string,
185
+ attribute: string,
186
+ id: number,
187
+ ) => (string | null)[];
188
+ declare readVectorGroupById: (
189
+ collection: string,
190
+ group: string,
191
+ id: number,
192
+ ) => Record<string, number | string | null>[];
193
+ declare readSetGroupById: (
194
+ collection: string,
195
+ group: string,
196
+ id: number,
197
+ ) => Record<string, number | string | null>[];
157
198
 
158
199
  // --- Queries (implemented in query.ts) ---
159
200
  declare queryString: (sql: string, parameters?: QueryParam[]) => string | null;
@@ -268,6 +309,12 @@ export class Database {
268
309
  collection: string,
269
310
  id: number,
270
311
  ) => Record<string, number | string | null>;
271
- declare readVectorsById: (collection: string, id: number) => Record<string, number[] | string[]>;
272
- declare readSetsById: (collection: string, id: number) => Record<string, number[] | string[]>;
312
+ declare readVectorsById: (
313
+ collection: string,
314
+ id: number,
315
+ ) => Record<string, (number | null)[] | (string | null)[]>;
316
+ declare readSetsById: (
317
+ collection: string,
318
+ id: number,
319
+ ) => Record<string, (number | null)[] | (string | null)[]>;
273
320
  }
@@ -1,18 +1,38 @@
1
- import { type Pointer, ptr } from "bun:ffi";
1
+ import { CString, type Pointer, ptr, read, toArrayBuffer } from "bun:ffi";
2
2
  import { check, QuiverError } from "./errors.ts";
3
3
  import {
4
4
  allocNativeFloat64,
5
5
  allocNativeInt64,
6
6
  allocNativePtrTable,
7
7
  allocNativeStringArray,
8
+ allocPtrOut,
9
+ allocUint64Out,
10
+ decodeFloat64Array,
11
+ decodeInt64Array,
12
+ decodePtrArray,
13
+ decodeStringArray,
14
+ readPtrOut,
15
+ readUint64Out,
8
16
  toCString,
9
17
  } 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";
18
+ import { getSymbols, type NativePointer } from "./loader.ts";
19
+ import {
20
+ type Allocation,
21
+ DATA_TYPE_DATE_TIME,
22
+ DATA_TYPE_FLOAT,
23
+ DATA_TYPE_INTEGER,
24
+ DATA_TYPE_STRING,
25
+ } from "./types.ts";
12
26
 
13
27
  /** Column-oriented group payload: one array of cells per column name, `null` for SQL NULL. */
14
28
  export type GroupColumns = Record<string, (number | string | boolean | null)[]>;
15
29
 
30
+ /**
31
+ * Column-oriented group read result: one array of cells per column name, `null` for SQL NULL.
32
+ * The read-side twin of GroupColumns; no reader produces a boolean, so it admits none.
33
+ */
34
+ export type TimeSeriesData = Record<string, (number | string | null)[]>;
35
+
16
36
  /**
17
37
  * The parallel-array signature every columnar group update C function shares
18
38
  * (quiver_database_update_{time_series,vector,set}_group and their _by_label forms). The 4th
@@ -170,3 +190,123 @@ export function updateGroupColumns(
170
190
  ),
171
191
  );
172
192
  }
193
+
194
+ /**
195
+ * The out-parameter signature every columnar group read C function shares
196
+ * (quiver_database_read_time_series_group, quiver_database_read_{vector,set}_group_by_id).
197
+ */
198
+ type ColumnReadFn = (
199
+ db: NativePointer,
200
+ collection: Uint8Array,
201
+ group: Uint8Array,
202
+ id: bigint,
203
+ names: Uint8Array,
204
+ types: Uint8Array,
205
+ data: Uint8Array,
206
+ masks: Uint8Array,
207
+ columnCount: Uint8Array,
208
+ rowCount: Uint8Array,
209
+ ) => number;
210
+
211
+ /**
212
+ * Call one of the columnar group read C functions, decode its typed arrays + per-cell mask into
213
+ * columns, and free the C result. The read-side twin of updateGroupColumns, shared by
214
+ * readTimeSeriesGroup (which returns the columns) and readVectorGroupById / readSetGroupById
215
+ * (which transpose them into rows). A masked cell is `null`; DATE_TIME cells stay ISO 8601 strings.
216
+ */
217
+ export function readGroupColumns(
218
+ handle: NativePointer,
219
+ readGroup: ColumnReadFn,
220
+ collection: string,
221
+ group: string,
222
+ id: number,
223
+ ): TimeSeriesData {
224
+ const collBuf = toCString(collection);
225
+ const grpBuf = toCString(group);
226
+ const outNames = allocPtrOut();
227
+ const outTypes = allocPtrOut();
228
+ const outData = allocPtrOut();
229
+ const outHasValue = allocPtrOut();
230
+ const outColCount = allocUint64Out();
231
+ const outRowCount = allocUint64Out();
232
+
233
+ check(
234
+ readGroup(
235
+ handle,
236
+ collBuf.buf,
237
+ grpBuf.buf,
238
+ BigInt(id),
239
+ outNames.buf,
240
+ outTypes.buf,
241
+ outData.buf,
242
+ outHasValue.buf,
243
+ outColCount.buf,
244
+ outRowCount.buf,
245
+ ),
246
+ );
247
+
248
+ const colCount = readUint64Out(outColCount);
249
+ const rowCount = readUint64Out(outRowCount);
250
+ if (colCount === 0) return {};
251
+
252
+ const namesPtr = readPtrOut(outNames);
253
+ const typesPtr = readPtrOut(outTypes);
254
+ const dataPtr = readPtrOut(outData);
255
+ const hasValuePtr = readPtrOut(outHasValue);
256
+
257
+ try {
258
+ const colNames = decodeStringArray(namesPtr, colCount);
259
+ const typesAb = toArrayBuffer(typesPtr as Pointer, 0, colCount * 4);
260
+ const types = Array.from(new Int32Array(typesAb));
261
+ const dataPtrs = decodePtrArray(dataPtr, colCount);
262
+ const maskPtrs = decodePtrArray(hasValuePtr, colCount);
263
+
264
+ // Per-cell NULL mask: mask[r] === 0 means SQL NULL, surfaced as JS null. A time series'
265
+ // dimension column's mask is always all 1, so it stays dense.
266
+ const result: TimeSeriesData = {};
267
+ for (let c = 0; c < colCount; c++) {
268
+ const colName = colNames[c];
269
+ const maskPtr = maskPtrs[c];
270
+ const mask = maskPtr ? new Uint8Array(toArrayBuffer(maskPtr as Pointer, 0, rowCount)) : null;
271
+ switch (types[c]) {
272
+ case DATA_TYPE_INTEGER: {
273
+ const vals = decodeInt64Array(dataPtrs[c], rowCount);
274
+ result[colName] = mask ? vals.map((v, r) => (mask[r] ? v : null)) : vals;
275
+ break;
276
+ }
277
+ case DATA_TYPE_FLOAT: {
278
+ const vals = decodeFloat64Array(dataPtrs[c], rowCount);
279
+ result[colName] = mask ? vals.map((v, r) => (mask[r] ? v : null)) : vals;
280
+ break;
281
+ }
282
+ case DATA_TYPE_STRING:
283
+ case DATA_TYPE_DATE_TIME: {
284
+ // Read pointer-by-pointer, not with decodeStringArray, which turns a NULL char* into "":
285
+ // a masked-out or NULL cell must come back as null.
286
+ const base = dataPtrs[c];
287
+ const col: (string | null)[] = new Array(rowCount);
288
+ for (let r = 0; r < rowCount; r++) {
289
+ if (mask && !mask[r]) {
290
+ col[r] = null;
291
+ continue;
292
+ }
293
+ const strPtr = base ? read.ptr(base as Pointer, r * 8) : 0;
294
+ col[r] = strPtr === 0 ? null : new CString(strPtr as Pointer).toString();
295
+ }
296
+ result[colName] = col;
297
+ break;
298
+ }
299
+ }
300
+ }
301
+ return result;
302
+ } finally {
303
+ getSymbols().quiver_database_free_time_series_data(
304
+ namesPtr,
305
+ typesPtr,
306
+ dataPtr,
307
+ hasValuePtr,
308
+ BigInt(colCount),
309
+ BigInt(rowCount),
310
+ );
311
+ }
312
+ }
package/src/index.ts CHANGED
@@ -11,11 +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 type { GroupColumns } from "./group-columns.ts";
14
+ export type { GroupColumns, TimeSeriesData } from "./group-columns.ts";
15
15
  export { LUA_DB_API_REFERENCE } from "./lua-api.ts";
16
16
  export { LuaRunner } from "./lua-runner.ts";
17
17
  export type { GroupMetadata, ScalarMetadata } from "./metadata.ts";
18
- export type { TimeSeriesData } from "./time-series.ts";
19
18
  export type {
20
19
  ArrayValue,
21
20
  DatabaseOptions,
package/src/loader.ts CHANGED
@@ -73,29 +73,34 @@ const readSymbols = {
73
73
  quiver_database_read_scalar_integer_by_id: { args: [P, BUF, BUF, I64, P, P], returns: I32 },
74
74
  quiver_database_read_scalar_float_by_id: { args: [P, BUF, BUF, I64, P, P], returns: I32 },
75
75
  quiver_database_read_scalar_string_by_id: { args: [P, BUF, BUF, I64, P, P], returns: I32 },
76
- quiver_database_read_vector_integers: { args: [P, BUF, BUF, P, P, P], returns: I32 },
77
- quiver_database_read_vector_floats: { args: [P, BUF, BUF, P, P, P], returns: I32 },
76
+ quiver_database_read_vector_integers: { args: [P, BUF, BUF, P, P, P, P], returns: I32 },
77
+ quiver_database_read_vector_floats: { args: [P, BUF, BUF, P, P, P, P], returns: I32 },
78
78
  quiver_database_read_vector_strings: { args: [P, BUF, BUF, P, P, P], returns: I32 },
79
- quiver_database_read_set_integers: { args: [P, BUF, BUF, P, P, P], returns: I32 },
80
- quiver_database_read_set_floats: { args: [P, BUF, BUF, P, P, P], returns: I32 },
79
+ quiver_database_read_set_integers: { args: [P, BUF, BUF, P, P, P, P], returns: I32 },
80
+ quiver_database_read_set_floats: { args: [P, BUF, BUF, P, P, P, P], returns: I32 },
81
81
  quiver_database_read_set_strings: { args: [P, BUF, BUF, P, P, P], returns: I32 },
82
- quiver_database_read_vector_integers_by_id: { args: [P, BUF, BUF, I64, P, P], returns: I32 },
83
- quiver_database_read_vector_floats_by_id: { args: [P, BUF, BUF, I64, P, P], returns: I32 },
82
+ quiver_database_read_vector_integers_by_id: { args: [P, BUF, BUF, I64, P, P, P], returns: I32 },
83
+ quiver_database_read_vector_floats_by_id: { args: [P, BUF, BUF, I64, P, P, P], returns: I32 },
84
84
  quiver_database_read_vector_strings_by_id: { args: [P, BUF, BUF, I64, P, P], returns: I32 },
85
- quiver_database_read_set_integers_by_id: { args: [P, BUF, BUF, I64, P, P], returns: I32 },
86
- quiver_database_read_set_floats_by_id: { args: [P, BUF, BUF, I64, P, P], returns: I32 },
85
+ quiver_database_read_set_integers_by_id: { args: [P, BUF, BUF, I64, P, P, P], returns: I32 },
86
+ quiver_database_read_set_floats_by_id: { args: [P, BUF, BUF, I64, P, P, P], returns: I32 },
87
87
  quiver_database_read_set_strings_by_id: { args: [P, BUF, BUF, I64, P, P], returns: I32 },
88
+ quiver_database_read_vector_group_by_id: {
89
+ args: [P, BUF, BUF, I64, P, P, P, P, P, P],
90
+ returns: I32,
91
+ },
92
+ quiver_database_read_set_group_by_id: {
93
+ args: [P, BUF, BUF, I64, P, P, P, P, P, P],
94
+ returns: I32,
95
+ },
88
96
  quiver_database_read_element_ids: { args: [P, BUF, P, P], returns: I32 },
89
97
  quiver_database_number_of_elements: { args: [P, BUF, P], returns: I32 },
90
98
  } as const;
91
99
 
92
100
  const querySymbols = {
93
- quiver_database_query_string: { args: [P, BUF, P, P], returns: I32 },
94
- quiver_database_query_integer: { args: [P, BUF, P, P], returns: I32 },
95
- quiver_database_query_float: { args: [P, BUF, P, P], returns: I32 },
96
- quiver_database_query_string_params: { args: [P, BUF, P, P, USIZE, P, P], returns: I32 },
97
- quiver_database_query_integer_params: { args: [P, BUF, P, P, USIZE, P, P], returns: I32 },
98
- quiver_database_query_float_params: { args: [P, BUF, P, P, USIZE, P, P], returns: I32 },
101
+ quiver_database_query_string: { args: [P, BUF, P, P, USIZE, P, P], returns: I32 },
102
+ quiver_database_query_integer: { args: [P, BUF, P, P, USIZE, P, P], returns: I32 },
103
+ quiver_database_query_float: { args: [P, BUF, P, P, USIZE, P, P], returns: I32 },
99
104
  } as const;
100
105
 
101
106
  const transactionSymbols = {
@@ -131,7 +136,7 @@ const timeSeriesSymbols = {
131
136
  args: [P, BUF, BUF, I64, P, P, P, P, P, P],
132
137
  returns: I32,
133
138
  },
134
- quiver_database_read_time_series_row: { args: [P, BUF, BUF, BUF, BUF, P, P, P], returns: I32 },
139
+ quiver_database_read_time_series_row: { args: [P, BUF, BUF, BUF, BUF, P, P, P, P], returns: I32 },
135
140
  quiver_database_upsert_time_series_row: {
136
141
  args: [P, BUF, BUF, I64, P, P, P, USIZE],
137
142
  returns: I32,
@@ -181,6 +186,7 @@ const freeSymbols = {
181
186
  quiver_database_free_integer_array: { args: [P], returns: I32 },
182
187
  quiver_database_free_float_array: { args: [P], returns: I32 },
183
188
  quiver_database_free_mask: { args: [P], returns: I32 },
189
+ quiver_database_free_masks: { args: [P, USIZE], returns: I32 },
184
190
  quiver_database_free_string_array: { args: [P, USIZE], returns: I32 },
185
191
  quiver_database_free_string: { args: [P], returns: I32 },
186
192
  quiver_database_free_integer_vectors: { args: [P, P, USIZE], returns: I32 },
package/src/lua-api.ts CHANGED
@@ -127,9 +127,14 @@ midnight.
127
127
 
128
128
  **A table with holes is an object, not an array.** A bulk read of a nullable column returns
129
129
  \`nil\` holes (see Reading), so \`return db:read_scalar_integers(c, a)\` encodes as
130
- \`{"1":10,"3":30}\` — not \`[10,null,30]\` — and the keys sort as text (\`"1","11","2"\`). When the host
131
- needs positional data, return the ids alongside and fill the holes yourself:
130
+ \`{"1":10,"3":30}\` — not \`[10,null,30]\` — and the keys sort as text (\`"1","11","2"\`). When the
131
+ host needs positional scalar data, return the ids alongside and fill the holes yourself:
132
132
  \`local v = db:read_scalar_integers(c, a); local out = {}; for i in ipairs(ids) do out[i] = v[i] or false end\`.
133
+ **Inside** a vector/set read an inner list with an interior NULL cell encodes as an object nested
134
+ in the outer array (\`[{"1":10,"3":30},[]]\`), but a trailing NULL cell leaves no trace at all:
135
+ \`[10, NULL]\` encodes as the plain \`[10]\`. The ids count elements, not rows, so they cannot
136
+ restore inner positions — take the row count from a \`NOT NULL\` column of the group (see
137
+ *Vector reads*), or do that read in the host binding.
133
138
 
134
139
  Returning a function, a coroutine, or a userdata (including \`db\` itself) raises
135
140
  \`Cannot run: script returned an unsupported Lua type\`; nesting deeper than 32 levels raises
@@ -280,6 +285,10 @@ Notes:
280
285
  \`Cannot update_element: ...\`.
281
286
  - **Empty arrays are skipped.** An attribute whose value is \`{}\` writes no vector/set (the element
282
287
  type can't be inferred from an empty array), so it is silently dropped.
288
+ - **Arrays must be dense.** A vector/set read returns a NULL cell as a \`nil\` hole, but an element
289
+ array cannot carry one: \`create_element\` / \`update_element\` throw \`array '<name>' has a nil
290
+ hole ...\` rather than cut the array short at the hole. Write NULL cells with
291
+ \`update_vector_group\` / \`update_set_group\`, which write a hole as NULL.
283
292
  - **No \`nil\` scalar attributes.** In Lua a key set to \`nil\` is dropped from the table, so
284
293
  \`{ x = nil }\` is identical to \`{}\`; an update/create table that ends up with no attributes
285
294
  **throws** (\`...must have at least one scalar attribute\` on create, \`...at least one attribute
@@ -314,10 +323,13 @@ db:read_scalar_strings(collection, attribute) -- { "Item 1", "Item 2", ... }
314
323
 
315
324
  ## Vector reads (bulk)
316
325
 
317
- Each returns an array of arrays — one inner array per element.
326
+ Each returns an array of arrays — one inner array per element, aligned with
327
+ \`db:read_element_ids\`; an element with no rows is \`{}\`. A NULL cell is a \`nil\` hole, so on a
328
+ nullable column \`#\` and \`ipairs\` are unreliable on an inner list, and a trailing NULL is
329
+ invisible (\`[10, NULL]\` reads as \`{10}\`; a NULL-only row reads as \`{}\`).
318
330
 
319
331
  \`\`\`lua
320
- db:read_vector_integers(collection, attribute) -- { {1,2,3}, {2,3,4}, ... }
332
+ db:read_vector_integers(collection, attribute) -- { {1,2,3}, {10,nil,30}, {}, ... }
321
333
  db:read_vector_floats(collection, attribute)
322
334
  db:read_vector_strings(collection, attribute)
323
335
  \`\`\`
@@ -326,7 +338,7 @@ db:read_vector_strings(collection, attribute)
326
338
 
327
339
  ## Set reads (bulk)
328
340
 
329
- Same shape as vector reads — an array of arrays.
341
+ Same shape and NULL handling as vector reads — an array of arrays with \`nil\` holes.
330
342
 
331
343
  \`\`\`lua
332
344
  db:read_set_integers(collection, attribute)
@@ -358,7 +370,8 @@ rewriting groups you never named. \`(collection, group)\` names exactly one tabl
358
370
 
359
371
  Rules:
360
372
  - **Row count is the largest index any column reaches.** Shorter or sparse columns write NULL in
361
- the gaps, so \`nil\` holes from a read round-trip.
373
+ the gaps, so interior \`nil\` holes from a read round-trip. A trailing row that is NULL in every
374
+ column is invisible to a read (see *Vector reads*), so writing a read back drops it.
362
375
  - **\`{}\` (no columns) clears the group.** Naming a column whose array is empty is an error, not a
363
376
  clear — a typo'd column name must not destroy data.
364
377
  - **\`id\` and \`vector_index\` are managed by the group** (the element and the row's position) and
@@ -376,20 +389,20 @@ Rules:
376
389
  db:read_element_ids(collection) -- { 1, 2, 3, ... }
377
390
 
378
391
  db:read_scalars_by_id(collection, id) -- { attr = value, ... } (missing -> nil)
379
- db:read_vectors_by_id(collection, id) -- { column = { v1, v2, ... }, ... }
380
- db:read_sets_by_id(collection, id) -- { column = { v1, v2, ... }, ... }
392
+ db:read_vectors_by_id(collection, id) -- { column = { v1, nil, v3, ... }, ... }
393
+ db:read_sets_by_id(collection, id) -- { column = { v1, nil, v3, ... }, ... }
381
394
  db:read_element_by_id(collection, id) -- scalars + vectors + sets merged into one table
382
395
  \`\`\`
383
396
 
384
397
  \`read_element_by_id\` merges every scalar, vector, and set for the element into a single table.
385
398
  Scalar attributes with no value come back as \`nil\`.
386
399
 
387
- **Group columns are returned densely, with NULL cells dropped.** \`read_vectors_by_id\` and
388
- \`read_sets_by_id\` read each column independently, and a column read skips its NULL cells — so two
389
- columns of the same group are **not** positionally aligned with each other whenever one is
390
- nullable (e.g. an \`ON DELETE SET NULL\` relation). Do not zip them into rows. There is no
391
- row-aligned group read in Lua; if you need per-row alignment across a nullable group, do that read
392
- in the host binding instead.
400
+ **Group columns keep NULL cells as \`nil\` holes**, so cell *i* of every column of one group is
401
+ the same row. Zipping them needs the row count: \`#\` of a \`NOT NULL\` column of the group.
402
+ There is no row-shaped group read in Lua. The result is keyed by column name, so when two vector
403
+ (or two set) groups share a column name, which a foreign key may, that name appears once and is
404
+ read from the group it resolves to (the one named after it, else the one whose table name sorts
405
+ first) — do not zip it with the other group's columns.
393
406
 
394
407
  ---
395
408
 
@@ -856,6 +869,6 @@ and \`unit\` default to \`""\`; \`labels\`, \`dimensions\`, \`dimension_sizes\`,
856
869
  DateTime wrapper helpers (Lua uses ISO 8601 strings), boolean *reader* helpers (a stored flag reads
857
870
  back as \`0\`/\`1\` — writing a boolean is supported), \`_by_id\` single-scalar variants (use the
858
871
  composite by-id readers or the bulk readers instead), and the row-aligned whole-group readers the
859
- other bindings have (hence the null-dropping caveat under composite by-id reads). Everything else
872
+ other bindings have (hence the row-count caveat under composite by-id reads). Everything else
860
873
  the native binding exposes — CRUD, reads, time series, metadata, query, CSV, and the
861
874
  binary/expression subsystems — is documented above and callable.`;
package/src/query.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { ptr } from "bun:ffi";
2
1
  import { integerToBoolean } from "./boolean.ts";
3
2
  import { Database } from "./database.ts";
4
3
  import { check, QuiverError } from "./errors.ts";
@@ -22,11 +21,18 @@ import {
22
21
  type QueryParam,
23
22
  } from "./types.ts";
24
23
 
25
- function marshalParams(parameters: QueryParam[]): {
26
- types: Allocation;
27
- values: Allocation;
24
+ // Marshals query parameters into the C API's parallel type/value arrays. No parameters (omitted or
25
+ // empty) marshal to NULL pointers with a count of 0: the C API reads neither array then, and a
26
+ // zero-length TypedArray has no pointer in Bun (ptr() of one returns a TypeError).
27
+ function marshalParams(parameters?: QueryParam[]): {
28
+ types: Uint8Array | null;
29
+ values: Uint8Array | null;
30
+ count: bigint;
28
31
  _keepalive: Allocation[];
29
32
  } {
33
+ if (!parameters || parameters.length === 0)
34
+ return { types: null, values: null, count: 0n, _keepalive: [] };
35
+
30
36
  const n = parameters.length;
31
37
  const typesBuf = new Uint8Array(n * 4);
32
38
  const typesDv = new DataView(typesBuf.buffer);
@@ -66,10 +72,7 @@ function marshalParams(parameters: QueryParam[]): {
66
72
  }
67
73
  }
68
74
 
69
- const types: Allocation = { ptr: ptr(typesBuf), buf: typesBuf };
70
- const values: Allocation = { ptr: ptr(valuesBuf), buf: valuesBuf };
71
- keepalive.push(types, values);
72
- return { types, values, _keepalive: keepalive };
75
+ return { types: typesBuf, values: valuesBuf, count: BigInt(n), _keepalive: keepalive };
73
76
  }
74
77
 
75
78
  Database.prototype.queryString = function (
@@ -81,23 +84,19 @@ Database.prototype.queryString = function (
81
84
  const sqlBuf = toCString(sql);
82
85
  const outValue = allocPtrOut();
83
86
  const outHasValue = new Uint8Array(4);
87
+ const params = marshalParams(parameters);
84
88
 
85
- if (parameters && parameters.length > 0) {
86
- const m = marshalParams(parameters);
87
- check(
88
- lib.quiver_database_query_string_params(
89
- this._handle,
90
- sqlBuf.buf,
91
- m.types.buf,
92
- m.values.buf,
93
- BigInt(parameters.length),
94
- outValue.buf,
95
- outHasValue,
96
- ),
97
- );
98
- } else {
99
- check(lib.quiver_database_query_string(this._handle, sqlBuf.buf, outValue.buf, outHasValue));
100
- }
89
+ check(
90
+ lib.quiver_database_query_string(
91
+ this._handle,
92
+ sqlBuf.buf,
93
+ params.types,
94
+ params.values,
95
+ params.count,
96
+ outValue.buf,
97
+ outHasValue,
98
+ ),
99
+ );
101
100
 
102
101
  if (new DataView(outHasValue.buffer).getInt32(0, true) === 0) return null;
103
102
  const result = decodeStringFromBuf(outValue);
@@ -114,23 +113,19 @@ Database.prototype.queryInteger = function (
114
113
  const sqlBuf = toCString(sql);
115
114
  const outValue = new Uint8Array(8);
116
115
  const outHasValue = new Uint8Array(4);
116
+ const params = marshalParams(parameters);
117
117
 
118
- if (parameters && parameters.length > 0) {
119
- const m = marshalParams(parameters);
120
- check(
121
- lib.quiver_database_query_integer_params(
122
- this._handle,
123
- sqlBuf.buf,
124
- m.types.buf,
125
- m.values.buf,
126
- BigInt(parameters.length),
127
- outValue,
128
- outHasValue,
129
- ),
130
- );
131
- } else {
132
- check(lib.quiver_database_query_integer(this._handle, sqlBuf.buf, outValue, outHasValue));
133
- }
118
+ check(
119
+ lib.quiver_database_query_integer(
120
+ this._handle,
121
+ sqlBuf.buf,
122
+ params.types,
123
+ params.values,
124
+ params.count,
125
+ outValue,
126
+ outHasValue,
127
+ ),
128
+ );
134
129
 
135
130
  if (new DataView(outHasValue.buffer).getInt32(0, true) === 0) return null;
136
131
  return Number(new DataView(outValue.buffer).getBigInt64(0, true));
@@ -153,23 +148,19 @@ Database.prototype.queryFloat = function (
153
148
  const sqlBuf = toCString(sql);
154
149
  const outValue = new Uint8Array(8);
155
150
  const outHasValue = new Uint8Array(4);
151
+ const params = marshalParams(parameters);
156
152
 
157
- if (parameters && parameters.length > 0) {
158
- const m = marshalParams(parameters);
159
- check(
160
- lib.quiver_database_query_float_params(
161
- this._handle,
162
- sqlBuf.buf,
163
- m.types.buf,
164
- m.values.buf,
165
- BigInt(parameters.length),
166
- outValue,
167
- outHasValue,
168
- ),
169
- );
170
- } else {
171
- check(lib.quiver_database_query_float(this._handle, sqlBuf.buf, outValue, outHasValue));
172
- }
153
+ check(
154
+ lib.quiver_database_query_float(
155
+ this._handle,
156
+ sqlBuf.buf,
157
+ params.types,
158
+ params.values,
159
+ params.count,
160
+ outValue,
161
+ outHasValue,
162
+ ),
163
+ );
173
164
 
174
165
  if (new DataView(outHasValue.buffer).getInt32(0, true) === 0) return null;
175
166
  return new DataView(outValue.buffer).getFloat64(0, true);
package/src/read.ts CHANGED
@@ -8,13 +8,13 @@ import {
8
8
  decodeFloat64Array,
9
9
  decodeInt64Array,
10
10
  decodePtrArray,
11
- decodeStringArray,
12
11
  decodeStringFromBuf,
13
12
  decodeUint64Array,
14
13
  readPtrOut,
15
14
  readUint64Out,
16
15
  toCString,
17
16
  } from "./ffi-helpers.ts";
17
+ import { readGroupColumns, type TimeSeriesData } from "./group-columns.ts";
18
18
  import { getSymbols, type NativePointer } from "./loader.ts";
19
19
 
20
20
  // --- Scalar array reads ---
@@ -251,10 +251,11 @@ function readBulkIntegers(
251
251
  fn: string,
252
252
  collection: string,
253
253
  attribute: string,
254
- ): number[][] {
254
+ ): (number | null)[][] {
255
255
  const collBuf = toCString(collection);
256
256
  const attrBuf = toCString(attribute);
257
257
  const outVectors = allocPtrOut();
258
+ const outMasks = allocPtrOut();
258
259
  const outSizes = allocPtrOut();
259
260
  const outCount = allocUint64Out();
260
261
  check(
@@ -263,6 +264,7 @@ function readBulkIntegers(
263
264
  collBuf.buf,
264
265
  attrBuf.buf,
265
266
  outVectors.buf,
267
+ outMasks.buf,
266
268
  outSizes.buf,
267
269
  outCount.buf,
268
270
  ),
@@ -270,14 +272,23 @@ function readBulkIntegers(
270
272
  const count = readUint64Out(outCount);
271
273
  if (count === 0) return [];
272
274
  const vectorsPtr = readPtrOut(outVectors);
275
+ const masksPtr = readPtrOut(outMasks);
273
276
  const sizesPtr = readPtrOut(outSizes);
274
277
  const vectorPtrs = decodePtrArray(vectorsPtr, count);
278
+ const maskPtrs = decodePtrArray(masksPtr, count);
275
279
  const sizes = decodeUint64Array(sizesPtr, count);
276
- const result: number[][] = new Array(count);
280
+ const result: (number | null)[][] = new Array(count);
277
281
  for (let i = 0; i < count; i++) {
278
- result[i] = sizes[i] === 0 ? [] : decodeInt64Array(vectorPtrs[i], sizes[i]);
282
+ if (sizes[i] === 0) {
283
+ result[i] = [];
284
+ continue;
285
+ }
286
+ const values = decodeInt64Array(vectorPtrs[i], sizes[i]);
287
+ const mask = new Uint8Array(toArrayBuffer(maskPtrs[i] as Pointer, 0, sizes[i]));
288
+ result[i] = values.map((v, j) => (mask[j] ? v : null));
279
289
  }
280
290
  lib.quiver_database_free_integer_vectors(vectorsPtr, sizesPtr, BigInt(count));
291
+ lib.quiver_database_free_masks(masksPtr, BigInt(count));
281
292
  return result;
282
293
  }
283
294
 
@@ -287,10 +298,11 @@ function readBulkFloats(
287
298
  fn: string,
288
299
  collection: string,
289
300
  attribute: string,
290
- ): number[][] {
301
+ ): (number | null)[][] {
291
302
  const collBuf = toCString(collection);
292
303
  const attrBuf = toCString(attribute);
293
304
  const outVectors = allocPtrOut();
305
+ const outMasks = allocPtrOut();
294
306
  const outSizes = allocPtrOut();
295
307
  const outCount = allocUint64Out();
296
308
  check(
@@ -299,6 +311,7 @@ function readBulkFloats(
299
311
  collBuf.buf,
300
312
  attrBuf.buf,
301
313
  outVectors.buf,
314
+ outMasks.buf,
302
315
  outSizes.buf,
303
316
  outCount.buf,
304
317
  ),
@@ -306,14 +319,23 @@ function readBulkFloats(
306
319
  const count = readUint64Out(outCount);
307
320
  if (count === 0) return [];
308
321
  const vectorsPtr = readPtrOut(outVectors);
322
+ const masksPtr = readPtrOut(outMasks);
309
323
  const sizesPtr = readPtrOut(outSizes);
310
324
  const vectorPtrs = decodePtrArray(vectorsPtr, count);
325
+ const maskPtrs = decodePtrArray(masksPtr, count);
311
326
  const sizes = decodeUint64Array(sizesPtr, count);
312
- const result: number[][] = new Array(count);
327
+ const result: (number | null)[][] = new Array(count);
313
328
  for (let i = 0; i < count; i++) {
314
- result[i] = sizes[i] === 0 ? [] : decodeFloat64Array(vectorPtrs[i], sizes[i]);
329
+ if (sizes[i] === 0) {
330
+ result[i] = [];
331
+ continue;
332
+ }
333
+ const values = decodeFloat64Array(vectorPtrs[i], sizes[i]);
334
+ const mask = new Uint8Array(toArrayBuffer(maskPtrs[i] as Pointer, 0, sizes[i]));
335
+ result[i] = values.map((v, j) => (mask[j] ? v : null));
315
336
  }
316
337
  lib.quiver_database_free_float_vectors(vectorsPtr, sizesPtr, BigInt(count));
338
+ lib.quiver_database_free_masks(masksPtr, BigInt(count));
317
339
  return result;
318
340
  }
319
341
 
@@ -323,7 +345,7 @@ function readBulkStrings(
323
345
  fn: string,
324
346
  collection: string,
325
347
  attribute: string,
326
- ): string[][] {
348
+ ): (string | null)[][] {
327
349
  const collBuf = toCString(collection);
328
350
  const attrBuf = toCString(attribute);
329
351
  const outVectors = allocPtrOut();
@@ -345,9 +367,16 @@ function readBulkStrings(
345
367
  const sizesPtr = readPtrOut(outSizes);
346
368
  const vectorPtrs = decodePtrArray(vectorsPtr, count);
347
369
  const sizes = decodeUint64Array(sizesPtr, count);
348
- const result: string[][] = new Array(count);
370
+ const result: (string | null)[][] = new Array(count);
349
371
  for (let i = 0; i < count; i++) {
350
- result[i] = sizes[i] === 0 ? [] : decodeStringArray(vectorPtrs[i], sizes[i]);
372
+ // A NULL cell is a NULL char* entry, so decode the pointer array and map NULL -> null
373
+ // (decodeStringArray would construct "" from a NULL pointer instead).
374
+ result[i] =
375
+ sizes[i] === 0
376
+ ? []
377
+ : decodePtrArray(vectorPtrs[i], sizes[i]).map((cell) =>
378
+ cell === null ? null : new CString(cell).toString(),
379
+ );
351
380
  }
352
381
  lib.quiver_database_free_string_vectors(vectorsPtr, sizesPtr, BigInt(count));
353
382
  return result;
@@ -357,7 +386,7 @@ Database.prototype.readVectorIntegers = function (
357
386
  this: Database,
358
387
  collection: string,
359
388
  attribute: string,
360
- ): number[][] {
389
+ ): (number | null)[][] {
361
390
  return readBulkIntegers(
362
391
  getSymbols(),
363
392
  this._handle,
@@ -366,15 +395,12 @@ Database.prototype.readVectorIntegers = function (
366
395
  attribute,
367
396
  );
368
397
  };
369
- /**
370
- * NULL cells are dropped and only elements that own rows are returned, so the result is not
371
- * positionally aligned with `readElementIds` (unlike `readScalarBooleans`).
372
- */
398
+ /** One entry per element; within an entry a SQL NULL cell is `null`. */
373
399
  Database.prototype.readVectorBooleans = function (
374
400
  this: Database,
375
401
  collection: string,
376
402
  attribute: string,
377
- ): boolean[][] {
403
+ ): (boolean | null)[][] {
378
404
  return this.readVectorIntegers(collection, attribute).map((values) =>
379
405
  values.map((value) => integerToBoolean(value, collection, attribute)),
380
406
  );
@@ -383,7 +409,7 @@ Database.prototype.readVectorFloats = function (
383
409
  this: Database,
384
410
  collection: string,
385
411
  attribute: string,
386
- ): number[][] {
412
+ ): (number | null)[][] {
387
413
  return readBulkFloats(
388
414
  getSymbols(),
389
415
  this._handle,
@@ -396,7 +422,7 @@ Database.prototype.readVectorStrings = function (
396
422
  this: Database,
397
423
  collection: string,
398
424
  attribute: string,
399
- ): string[][] {
425
+ ): (string | null)[][] {
400
426
  return readBulkStrings(
401
427
  getSymbols(),
402
428
  this._handle,
@@ -409,7 +435,7 @@ Database.prototype.readSetIntegers = function (
409
435
  this: Database,
410
436
  collection: string,
411
437
  attribute: string,
412
- ): number[][] {
438
+ ): (number | null)[][] {
413
439
  return readBulkIntegers(
414
440
  getSymbols(),
415
441
  this._handle,
@@ -418,12 +444,12 @@ Database.prototype.readSetIntegers = function (
418
444
  attribute,
419
445
  );
420
446
  };
421
- /** Same alignment caveat as `readVectorBooleans`: NULL cells dropped, only ids that own rows. */
447
+ /** Same contract as `readVectorBooleans`: one entry per element, and a NULL cell is `null`. */
422
448
  Database.prototype.readSetBooleans = function (
423
449
  this: Database,
424
450
  collection: string,
425
451
  attribute: string,
426
- ): boolean[][] {
452
+ ): (boolean | null)[][] {
427
453
  return this.readSetIntegers(collection, attribute).map((values) =>
428
454
  values.map((value) => integerToBoolean(value, collection, attribute)),
429
455
  );
@@ -432,7 +458,7 @@ Database.prototype.readSetFloats = function (
432
458
  this: Database,
433
459
  collection: string,
434
460
  attribute: string,
435
- ): number[][] {
461
+ ): (number | null)[][] {
436
462
  return readBulkFloats(
437
463
  getSymbols(),
438
464
  this._handle,
@@ -445,7 +471,7 @@ Database.prototype.readSetStrings = function (
445
471
  this: Database,
446
472
  collection: string,
447
473
  attribute: string,
448
- ): string[][] {
474
+ ): (string | null)[][] {
449
475
  return readBulkStrings(
450
476
  getSymbols(),
451
477
  this._handle,
@@ -464,10 +490,11 @@ function readByIdIntegers(
464
490
  collection: string,
465
491
  attribute: string,
466
492
  id: number,
467
- ): number[] {
493
+ ): (number | null)[] {
468
494
  const collBuf = toCString(collection);
469
495
  const attrBuf = toCString(attribute);
470
496
  const outValues = allocPtrOut();
497
+ const outMask = allocPtrOut();
471
498
  const outCount = allocUint64Out();
472
499
  check(
473
500
  (lib as Record<string, Function>)[fn](
@@ -476,14 +503,19 @@ function readByIdIntegers(
476
503
  attrBuf.buf,
477
504
  BigInt(id),
478
505
  outValues.buf,
506
+ outMask.buf,
479
507
  outCount.buf,
480
508
  ),
481
509
  );
482
510
  const count = readUint64Out(outCount);
483
511
  if (count === 0) return [];
484
512
  const arrPtr = readPtrOut(outValues);
485
- const result = decodeInt64Array(arrPtr, count);
513
+ const maskPtr = readPtrOut(outMask);
514
+ const values = decodeInt64Array(arrPtr, count);
515
+ const mask = new Uint8Array(toArrayBuffer(maskPtr as Pointer, 0, count));
516
+ const result = values.map((v, i) => (mask[i] ? v : null));
486
517
  lib.quiver_database_free_integer_array(arrPtr);
518
+ lib.quiver_database_free_mask(maskPtr);
487
519
  return result;
488
520
  }
489
521
 
@@ -494,10 +526,11 @@ function readByIdFloats(
494
526
  collection: string,
495
527
  attribute: string,
496
528
  id: number,
497
- ): number[] {
529
+ ): (number | null)[] {
498
530
  const collBuf = toCString(collection);
499
531
  const attrBuf = toCString(attribute);
500
532
  const outValues = allocPtrOut();
533
+ const outMask = allocPtrOut();
501
534
  const outCount = allocUint64Out();
502
535
  check(
503
536
  (lib as Record<string, Function>)[fn](
@@ -506,14 +539,19 @@ function readByIdFloats(
506
539
  attrBuf.buf,
507
540
  BigInt(id),
508
541
  outValues.buf,
542
+ outMask.buf,
509
543
  outCount.buf,
510
544
  ),
511
545
  );
512
546
  const count = readUint64Out(outCount);
513
547
  if (count === 0) return [];
514
548
  const arrPtr = readPtrOut(outValues);
515
- const result = decodeFloat64Array(arrPtr, count);
549
+ const maskPtr = readPtrOut(outMask);
550
+ const values = decodeFloat64Array(arrPtr, count);
551
+ const mask = new Uint8Array(toArrayBuffer(maskPtr as Pointer, 0, count));
552
+ const result = values.map((v, i) => (mask[i] ? v : null));
516
553
  lib.quiver_database_free_float_array(arrPtr);
554
+ lib.quiver_database_free_mask(maskPtr);
517
555
  return result;
518
556
  }
519
557
 
@@ -524,7 +562,7 @@ function readByIdStrings(
524
562
  collection: string,
525
563
  attribute: string,
526
564
  id: number,
527
- ): string[] {
565
+ ): (string | null)[] {
528
566
  const collBuf = toCString(collection);
529
567
  const attrBuf = toCString(attribute);
530
568
  const outValues = allocPtrOut();
@@ -542,7 +580,11 @@ function readByIdStrings(
542
580
  const count = readUint64Out(outCount);
543
581
  if (count === 0) return [];
544
582
  const arrPtr = readPtrOut(outValues);
545
- const result = decodeStringArray(arrPtr, count);
583
+ // A NULL cell is a NULL char* entry, so decode the pointer array and map NULL -> null
584
+ // (decodeStringArray would construct "" from a NULL pointer instead).
585
+ const result = decodePtrArray(arrPtr, count).map((cell) =>
586
+ cell === null ? null : new CString(cell).toString(),
587
+ );
546
588
  lib.quiver_database_free_string_array(arrPtr, BigInt(count));
547
589
  return result;
548
590
  }
@@ -552,7 +594,7 @@ Database.prototype.readVectorIntegersById = function (
552
594
  collection: string,
553
595
  attribute: string,
554
596
  id: number,
555
- ): number[] {
597
+ ): (number | null)[] {
556
598
  return readByIdIntegers(
557
599
  getSymbols(),
558
600
  this._handle,
@@ -567,7 +609,7 @@ Database.prototype.readVectorBooleansById = function (
567
609
  collection: string,
568
610
  attribute: string,
569
611
  id: number,
570
- ): boolean[] {
612
+ ): (boolean | null)[] {
571
613
  return this.readVectorIntegersById(collection, attribute, id).map((value) =>
572
614
  integerToBoolean(value, collection, attribute),
573
615
  );
@@ -577,7 +619,7 @@ Database.prototype.readVectorFloatsById = function (
577
619
  collection: string,
578
620
  attribute: string,
579
621
  id: number,
580
- ): number[] {
622
+ ): (number | null)[] {
581
623
  return readByIdFloats(
582
624
  getSymbols(),
583
625
  this._handle,
@@ -592,7 +634,7 @@ Database.prototype.readVectorStringsById = function (
592
634
  collection: string,
593
635
  attribute: string,
594
636
  id: number,
595
- ): string[] {
637
+ ): (string | null)[] {
596
638
  return readByIdStrings(
597
639
  getSymbols(),
598
640
  this._handle,
@@ -607,7 +649,7 @@ Database.prototype.readSetIntegersById = function (
607
649
  collection: string,
608
650
  attribute: string,
609
651
  id: number,
610
- ): number[] {
652
+ ): (number | null)[] {
611
653
  return readByIdIntegers(
612
654
  getSymbols(),
613
655
  this._handle,
@@ -622,7 +664,7 @@ Database.prototype.readSetBooleansById = function (
622
664
  collection: string,
623
665
  attribute: string,
624
666
  id: number,
625
- ): boolean[] {
667
+ ): (boolean | null)[] {
626
668
  return this.readSetIntegersById(collection, attribute, id).map((value) =>
627
669
  integerToBoolean(value, collection, attribute),
628
670
  );
@@ -632,7 +674,7 @@ Database.prototype.readSetFloatsById = function (
632
674
  collection: string,
633
675
  attribute: string,
634
676
  id: number,
635
- ): number[] {
677
+ ): (number | null)[] {
636
678
  return readByIdFloats(
637
679
  getSymbols(),
638
680
  this._handle,
@@ -647,7 +689,7 @@ Database.prototype.readSetStringsById = function (
647
689
  collection: string,
648
690
  attribute: string,
649
691
  id: number,
650
- ): string[] {
692
+ ): (string | null)[] {
651
693
  return readByIdStrings(
652
694
  getSymbols(),
653
695
  this._handle,
@@ -657,3 +699,58 @@ Database.prototype.readSetStringsById = function (
657
699
  id,
658
700
  );
659
701
  };
702
+
703
+ // --- Whole-group reads ---
704
+
705
+ /** One record per row, from decoded columns that all share one length. */
706
+ function columnsToRows(columns: TimeSeriesData): Record<string, number | string | null>[] {
707
+ const names = Object.keys(columns);
708
+ if (names.length === 0) return [];
709
+ return columns[names[0]].map((_, r) =>
710
+ Object.fromEntries(names.map((name) => [name, columns[name][r]])),
711
+ );
712
+ }
713
+
714
+ /**
715
+ * Read an element's vector group as one record per row, in vector_index order. One native read of
716
+ * the named group's own table: a SQL NULL cell is `null` in its row, and a column name another
717
+ * group shares still reads this group (a per-column reader resolves the name). DATE_TIME cells
718
+ * stay ISO 8601 strings.
719
+ */
720
+ Database.prototype.readVectorGroupById = function (
721
+ this: Database,
722
+ collection: string,
723
+ group: string,
724
+ id: number,
725
+ ): Record<string, number | string | null>[] {
726
+ return columnsToRows(
727
+ readGroupColumns(
728
+ this._handle,
729
+ getSymbols().quiver_database_read_vector_group_by_id,
730
+ collection,
731
+ group,
732
+ id,
733
+ ),
734
+ );
735
+ };
736
+
737
+ /**
738
+ * Set-group counterpart of readVectorGroupById. Row order is consistent across every reader of
739
+ * the group, otherwise unspecified.
740
+ */
741
+ Database.prototype.readSetGroupById = function (
742
+ this: Database,
743
+ collection: string,
744
+ group: string,
745
+ id: number,
746
+ ): Record<string, number | string | null>[] {
747
+ return columnsToRows(
748
+ readGroupColumns(
749
+ this._handle,
750
+ getSymbols().quiver_database_read_set_group_by_id,
751
+ collection,
752
+ group,
753
+ id,
754
+ ),
755
+ );
756
+ };
@@ -11,23 +11,19 @@ import {
11
11
  allocUint64Out,
12
12
  decodeFloat64Array,
13
13
  decodeInt64Array,
14
- decodePtrArray,
15
14
  decodeStringArray,
16
15
  readPtrOut,
17
16
  readUint64Out,
18
17
  toCString,
19
18
  } from "./ffi-helpers.ts";
20
- import { type GroupColumns, updateGroupColumns } from "./group-columns.ts";
21
- import { getSymbols, type NativePointer } from "./loader.ts";
22
19
  import {
23
- type Allocation,
24
- DATA_TYPE_DATE_TIME,
25
- DATA_TYPE_FLOAT,
26
- DATA_TYPE_INTEGER,
27
- DATA_TYPE_STRING,
28
- } from "./types.ts";
29
-
30
- export type TimeSeriesData = Record<string, (number | string | null)[]>;
20
+ type GroupColumns,
21
+ readGroupColumns,
22
+ type TimeSeriesData,
23
+ updateGroupColumns,
24
+ } from "./group-columns.ts";
25
+ import { getSymbols, type NativePointer } from "./loader.ts";
26
+ import { type Allocation, DATA_TYPE_FLOAT, DATA_TYPE_INTEGER, DATA_TYPE_STRING } from "./types.ts";
31
27
 
32
28
  Database.prototype.readTimeSeriesGroup = function (
33
29
  this: Database,
@@ -35,93 +31,13 @@ Database.prototype.readTimeSeriesGroup = function (
35
31
  group: string,
36
32
  id: number,
37
33
  ): TimeSeriesData {
38
- const lib = getSymbols();
39
- const collBuf = toCString(collection);
40
- const grpBuf = toCString(group);
41
- const outNames = allocPtrOut();
42
- const outTypes = allocPtrOut();
43
- const outData = allocPtrOut();
44
- const outHasValue = allocPtrOut();
45
- const outColCount = allocUint64Out();
46
- const outRowCount = allocUint64Out();
47
-
48
- check(
49
- lib.quiver_database_read_time_series_group(
50
- this._handle,
51
- collBuf.buf,
52
- grpBuf.buf,
53
- BigInt(id),
54
- outNames.buf,
55
- outTypes.buf,
56
- outData.buf,
57
- outHasValue.buf,
58
- outColCount.buf,
59
- outRowCount.buf,
60
- ),
61
- );
62
-
63
- const colCount = readUint64Out(outColCount);
64
- const rowCount = readUint64Out(outRowCount);
65
- if (colCount === 0) return {};
66
-
67
- const namesPtr = readPtrOut(outNames);
68
- const typesPtr = readPtrOut(outTypes);
69
- const dataPtr = readPtrOut(outData);
70
- const hasValuePtr = readPtrOut(outHasValue);
71
-
72
- const colNames = decodeStringArray(namesPtr, colCount);
73
- const typesAb = toArrayBuffer(typesPtr as Pointer, 0, colCount * 4);
74
- const types = Array.from(new Int32Array(typesAb));
75
- const dataPtrs = decodePtrArray(dataPtr, colCount);
76
- const maskPtrs = decodePtrArray(hasValuePtr, colCount);
77
-
78
- // Per-cell NULL mask: mask[r] === 0 means SQL NULL, surfaced as JS null. The
79
- // dimension column's mask is always all 1, so it stays dense.
80
- const result: TimeSeriesData = {};
81
- for (let c = 0; c < colCount; c++) {
82
- const colName = colNames[c];
83
- const maskPtr = maskPtrs[c];
84
- const mask = maskPtr ? new Uint8Array(toArrayBuffer(maskPtr as Pointer, 0, rowCount)) : null;
85
- switch (types[c]) {
86
- case DATA_TYPE_INTEGER: {
87
- const vals = decodeInt64Array(dataPtrs[c], rowCount);
88
- result[colName] = mask ? vals.map((v, r) => (mask[r] ? v : null)) : vals;
89
- break;
90
- }
91
- case DATA_TYPE_FLOAT: {
92
- const vals = decodeFloat64Array(dataPtrs[c], rowCount);
93
- result[colName] = mask ? vals.map((v, r) => (mask[r] ? v : null)) : vals;
94
- break;
95
- }
96
- case DATA_TYPE_STRING:
97
- case DATA_TYPE_DATE_TIME: {
98
- // Read pointer-by-pointer (not decodeStringArray): a masked-out cell is a
99
- // NULL char* that CString cannot construct from.
100
- const base = dataPtrs[c];
101
- const col: (string | null)[] = new Array(rowCount);
102
- for (let r = 0; r < rowCount; r++) {
103
- if (mask && !mask[r]) {
104
- col[r] = null;
105
- continue;
106
- }
107
- const strPtr = base ? read.ptr(base as Pointer, r * 8) : 0;
108
- col[r] = strPtr === 0 ? null : new CString(strPtr as Pointer).toString();
109
- }
110
- result[colName] = col;
111
- break;
112
- }
113
- }
114
- }
115
-
116
- lib.quiver_database_free_time_series_data(
117
- namesPtr,
118
- typesPtr,
119
- dataPtr,
120
- hasValuePtr,
121
- BigInt(colCount),
122
- BigInt(rowCount),
34
+ return readGroupColumns(
35
+ this._handle,
36
+ getSymbols().quiver_database_read_time_series_group,
37
+ collection,
38
+ group,
39
+ id,
123
40
  );
124
- return result;
125
41
  };
126
42
 
127
43
  Database.prototype.readTimeSeriesRow = function (
@@ -138,6 +54,7 @@ Database.prototype.readTimeSeriesRow = function (
138
54
  const dtBuf = toCString(dateTime);
139
55
  const outDataType = new Uint8Array(4);
140
56
  const outValues = allocPtrOut();
57
+ const outMask = allocPtrOut();
141
58
  const outCount = allocUint64Out();
142
59
 
143
60
  check(
@@ -149,6 +66,7 @@ Database.prototype.readTimeSeriesRow = function (
149
66
  dtBuf.buf,
150
67
  outDataType,
151
68
  outValues.buf,
69
+ outMask.buf,
152
70
  outCount.buf,
153
71
  ),
154
72
  );
@@ -157,26 +75,33 @@ Database.prototype.readTimeSeriesRow = function (
157
75
  const valuesPtr = readPtrOut(outValues);
158
76
  if (count === 0 || !valuesPtr) return [];
159
77
 
78
+ // mask[i] === 0: no data at or before dateTime; the data slot is a placeholder, never read.
79
+ const maskPtr = readPtrOut(outMask);
80
+ const mask = new Uint8Array(toArrayBuffer(maskPtr as Pointer, 0, count));
160
81
  const dataType = new DataView(outDataType.buffer).getInt32(0, true);
161
82
  switch (dataType) {
162
83
  case DATA_TYPE_INTEGER: {
163
- const result = decodeInt64Array(valuesPtr, count);
84
+ const result = decodeInt64Array(valuesPtr, count).map((v, i) => (mask[i] ? v : null));
164
85
  lib.quiver_database_free_integer_array(valuesPtr);
86
+ lib.quiver_database_free_mask(maskPtr);
165
87
  return result;
166
88
  }
167
89
  case DATA_TYPE_FLOAT: {
168
- const result = decodeFloat64Array(valuesPtr, count);
90
+ const result = decodeFloat64Array(valuesPtr, count).map((v, i) => (mask[i] ? v : null));
169
91
  lib.quiver_database_free_float_array(valuesPtr);
92
+ lib.quiver_database_free_mask(maskPtr);
170
93
  return result;
171
94
  }
172
95
  default: {
173
- // STRING or DATE_TIME; NULL entries mark elements with no data
96
+ // STRING or DATE_TIME; never build a CString from a masked-out (NULL) pointer
174
97
  const result: (string | null)[] = new Array(count);
175
98
  for (let i = 0; i < count; i++) {
176
- const strPtr = read.ptr(valuesPtr as Pointer, i * 8);
177
- result[i] = strPtr === 0 ? null : new CString(strPtr as Pointer).toString();
99
+ result[i] = mask[i]
100
+ ? new CString(read.ptr(valuesPtr as Pointer, i * 8) as Pointer).toString()
101
+ : null;
178
102
  }
179
103
  lib.quiver_database_free_string_array(valuesPtr, BigInt(count));
104
+ lib.quiver_database_free_mask(maskPtr);
180
105
  return result;
181
106
  }
182
107
  }
@@ -426,7 +351,12 @@ Database.prototype.updateTimeSeriesFiles = function (
426
351
  const lib = getSymbols();
427
352
  const collBuf = toCString(collection);
428
353
  const entries = Object.entries(data);
429
- if (entries.length === 0) return;
354
+ // An empty map still reaches the core, which validates the collection and its files table
355
+ // before the no-op. null tables, never zero-length buffers (same as updateGroupColumns).
356
+ if (entries.length === 0) {
357
+ check(lib.quiver_database_update_time_series_files(this._handle, collBuf.buf, null, null, 0n));
358
+ return;
359
+ }
430
360
  const keepalive: Allocation[] = [];
431
361
 
432
362
  const colNames = entries.map(([name]) => name);