quiverdb 0.12.4 → 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.4",
3
+ "version": "0.12.5",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/create.ts CHANGED
@@ -29,12 +29,13 @@ function setElementArray(
29
29
 
30
30
  const first = values[0];
31
31
 
32
- // A vector/set read returns a NULL cell as null. The element surface is non-null (NULL cells go
33
- // through updateVectorGroup / updateSetGroup), and the numeric setters below would store a null
34
- // as 0 / false. A string array keeps its NULL-pointer path, which the C setter reads as NULL.
35
- if (typeof first !== "string" && values.some((v) => v === null || v === undefined)) {
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)) {
36
37
  throw new QuiverError(
37
- `Unsupported null cell in array '${name}': write NULL cells with updateVectorGroup or updateSetGroup`,
38
+ `Unsupported null cell in array '${name}': write NULL cells with updateVectorGroup, updateSetGroup or updateTimeSeriesGroup`,
38
39
  );
39
40
  }
40
41
 
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 {
@@ -186,6 +185,16 @@ export class Database {
186
185
  attribute: string,
187
186
  id: number,
188
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>[];
189
198
 
190
199
  // --- Queries (implemented in query.ts) ---
191
200
  declare queryString: (sql: string, parameters?: QueryParam[]) => string | null;
@@ -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
@@ -85,17 +85,22 @@ const readSymbols = {
85
85
  quiver_database_read_set_integers_by_id: { args: [P, BUF, BUF, I64, P, P, P], returns: I32 },
86
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,
package/src/lua-api.ts CHANGED
@@ -399,7 +399,10 @@ Scalar attributes with no value come back as \`nil\`.
399
399
 
400
400
  **Group columns keep NULL cells as \`nil\` holes**, so cell *i* of every column of one group is
401
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.
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.
403
406
 
404
407
  ---
405
408
 
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
@@ -14,6 +14,7 @@ import {
14
14
  readUint64Out,
15
15
  toCString,
16
16
  } from "./ffi-helpers.ts";
17
+ import { readGroupColumns, type TimeSeriesData } from "./group-columns.ts";
17
18
  import { getSymbols, type NativePointer } from "./loader.ts";
18
19
 
19
20
  // --- Scalar array reads ---
@@ -698,3 +699,58 @@ Database.prototype.readSetStringsById = function (
698
699
  id,
699
700
  );
700
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);