quiverdb 0.10.2 → 0.10.4

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
@@ -64,6 +64,7 @@ bun run example.ts
64
64
 
65
65
  - `Database.fromSchema(dbPath, schemaPath)` -- Create database from SQL schema file
66
66
  - `Database.fromMigrations(dbPath, migrationsPath)` -- Create database from migrations directory
67
+ - `Database.validateMigrations(migrationsPath)` -- Validate a migrations directory (every `up.sql`, then every `down.sql`, ending with no table left behind) in-memory; throws on failure
67
68
  - `close()` -- Close the database connection
68
69
 
69
70
  ### Create / Delete
@@ -77,24 +78,30 @@ bun run example.ts
77
78
  ### Read (bulk)
78
79
 
79
80
  - `readScalarIntegers(collection, attribute)` -- Read all integer scalars
81
+ - `readScalarBooleans(collection, attribute)` -- Read INTEGER-backed boolean scalars
80
82
  - `readScalarFloats(collection, attribute)` -- Read all float scalars
81
83
  - `readScalarStrings(collection, attribute)` -- Read all string scalars
82
84
  - `readVectorIntegers(collection, attribute)` -- Read all integer vectors
85
+ - `readVectorBooleans(collection, attribute)` -- Read INTEGER-backed boolean vectors
83
86
  - `readVectorFloats(collection, attribute)` -- Read all float vectors
84
87
  - `readVectorStrings(collection, attribute)` -- Read all string vectors
85
88
  - `readSetIntegers(collection, attribute)` -- Read all integer sets
89
+ - `readSetBooleans(collection, attribute)` -- Read INTEGER-backed boolean sets
86
90
  - `readSetFloats(collection, attribute)` -- Read all float sets
87
91
  - `readSetStrings(collection, attribute)` -- Read all string sets
88
92
 
89
93
  ### Read (by ID)
90
94
 
91
95
  - `readScalarIntegerById(collection, attribute, id)` -- Read integer or null
96
+ - `readScalarBooleanById(collection, attribute, id)` -- Read INTEGER-backed boolean or null
92
97
  - `readScalarFloatById(collection, attribute, id)` -- Read float or null
93
98
  - `readScalarStringById(collection, attribute, id)` -- Read string or null
94
99
  - `readVectorIntegersById(collection, attribute, id)` -- Read integer vector
100
+ - `readVectorBooleansById(collection, attribute, id)` -- Read INTEGER-backed boolean vector
95
101
  - `readVectorFloatsById(collection, attribute, id)` -- Read float vector
96
102
  - `readVectorStringsById(collection, attribute, id)` -- Read string vector
97
103
  - `readSetIntegersById(collection, attribute, id)` -- Read integer set
104
+ - `readSetBooleansById(collection, attribute, id)` -- Read INTEGER-backed boolean set
98
105
  - `readSetFloatsById(collection, attribute, id)` -- Read float set
99
106
  - `readSetStringsById(collection, attribute, id)` -- Read string set
100
107
 
@@ -123,9 +130,11 @@ bun run example.ts
123
130
 
124
131
  - `queryString(sql, parameters?)` -- Query returning string or null
125
132
  - `queryInteger(sql, parameters?)` -- Query returning integer or null
133
+ - `queryBoolean(sql, parameters?)` -- Query returning an INTEGER-backed boolean or null
126
134
  - `queryFloat(sql, parameters?)` -- Query returning float or null
127
135
 
128
- Parameters are passed as an array of `number | string | null`.
136
+ Parameters are passed as an array of `number | boolean | string | null` (a `boolean` binds as the
137
+ INTEGER 1 or 0).
129
138
 
130
139
  ### Transaction
131
140
 
Binary file
Binary file
Binary file
Binary file
Binary file
package/mod.ts CHANGED
@@ -15,6 +15,7 @@ export type {
15
15
  ArrayValue,
16
16
  CsvOptions,
17
17
  ElementData,
18
+ GroupColumns,
18
19
  GroupMetadata,
19
20
  QueryParam,
20
21
  ScalarMetadata,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quiverdb",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/boolean.ts ADDED
@@ -0,0 +1,20 @@
1
+ export function integerToBoolean(value: number, collection?: string, attribute?: string): boolean;
2
+ export function integerToBoolean(value: null, collection?: string, attribute?: string): null;
3
+ export function integerToBoolean(
4
+ value: number | null,
5
+ collection?: string,
6
+ attribute?: string,
7
+ ): boolean | null;
8
+ export function integerToBoolean(
9
+ value: number | null,
10
+ collection?: string,
11
+ attribute?: string,
12
+ ): boolean | null {
13
+ if (value === null) return null;
14
+ if (value === 0) return false;
15
+ if (value === 1) return true;
16
+ const source = collection ? ` in '${collection}.${attribute}'` : "";
17
+ // A RangeError, not a QuiverError: the message is crafted here, not read from
18
+ // quiver_get_last_error — these readers are a binding-only convenience.
19
+ throw new RangeError(`Cannot convert integer ${value} to boolean${source}: expected 0 or 1`);
20
+ }
package/src/create.ts CHANGED
@@ -35,6 +35,12 @@ function setElementArray(
35
35
  return;
36
36
  }
37
37
 
38
+ if (typeof first === "boolean") {
39
+ const arr = allocNativeInt64((values as boolean[]).map((v) => (v ? 1 : 0)));
40
+ check(lib.quiver_element_set_array_integer(elemPtr, nameBuf.buf, arr.buf, values.length, null));
41
+ return;
42
+ }
43
+
38
44
  if (typeof first === "number") {
39
45
  const allIntegers = (values as number[]).every((v) => Number.isInteger(v));
40
46
  if (allIntegers) {
@@ -73,6 +79,11 @@ function setElementField(lib: Symbols, elemPtr: NativePointer, name: string, val
73
79
  return;
74
80
  }
75
81
 
82
+ if (typeof value === "boolean") {
83
+ check(lib.quiver_element_set_integer(elemPtr, nameBuf.buf, value ? 1n : 0n));
84
+ return;
85
+ }
86
+
76
87
  if (typeof value === "number") {
77
88
  if (Number.isInteger(value)) {
78
89
  check(lib.quiver_element_set_integer(elemPtr, nameBuf.buf, BigInt(value)));
@@ -175,6 +186,53 @@ Database.prototype.updateElementByLabel = function (
175
186
  }
176
187
  };
177
188
 
189
+ /**
190
+ * Points one scalar foreign-key relation at the element labeled `targetLabel`; `null` clears it.
191
+ * The column is derived as `collectionTo.toLowerCase() + "_" + relationType`.
192
+ */
193
+ Database.prototype.updateRelation = function (
194
+ this: Database,
195
+ collectionFrom: string,
196
+ collectionTo: string,
197
+ relationType: string,
198
+ id: number,
199
+ targetLabel: string | null,
200
+ ): void {
201
+ const lib = getSymbols();
202
+ check(
203
+ lib.quiver_database_update_relation(
204
+ this._handle,
205
+ toCString(collectionFrom).buf,
206
+ toCString(collectionTo).buf,
207
+ toCString(relationType).buf,
208
+ BigInt(id),
209
+ targetLabel === null ? null : toCString(targetLabel).buf,
210
+ ),
211
+ );
212
+ };
213
+
214
+ /** Label-addressed counterpart of updateRelation. */
215
+ Database.prototype.updateRelationByLabel = function (
216
+ this: Database,
217
+ collectionFrom: string,
218
+ collectionTo: string,
219
+ relationType: string,
220
+ label: string,
221
+ targetLabel: string | null,
222
+ ): void {
223
+ const lib = getSymbols();
224
+ check(
225
+ lib.quiver_database_update_relation_by_label(
226
+ this._handle,
227
+ toCString(collectionFrom).buf,
228
+ toCString(collectionTo).buf,
229
+ toCString(relationType).buf,
230
+ toCString(label).buf,
231
+ targetLabel === null ? null : toCString(targetLabel).buf,
232
+ ),
233
+ );
234
+ };
235
+
178
236
  Database.prototype.deleteElement = function (this: Database, collection: string, id: number): void {
179
237
  const lib = getSymbols();
180
238
  const collBuf = toCString(collection);
package/src/database.ts CHANGED
@@ -64,6 +64,17 @@ export class Database {
64
64
  return new Database(readPtrOut(outDb));
65
65
  }
66
66
 
67
+ /**
68
+ * Applies every migration's up.sql in order, then every down.sql in reverse.
69
+ * Throws on failure, including a round trip that leaves any table behind.
70
+ */
71
+ static validateMigrations(migrationsPath: string): void {
72
+ const lib = getSymbols();
73
+ const migrPathBuf = toCString(migrationsPath);
74
+
75
+ check(lib.quiver_database_validate_migrations(migrPathBuf.buf));
76
+ }
77
+
67
78
  close(): void {
68
79
  if (this._closed) return;
69
80
  const lib = getSymbols();
@@ -89,9 +100,24 @@ export class Database {
89
100
  declare updateElementByLabel: (collection: string, label: string, data: ElementData) => void;
90
101
  declare deleteElement: (collection: string, id: number) => void;
91
102
  declare deleteElementByLabel: (collection: string, label: string) => void;
103
+ declare updateRelation: (
104
+ collectionFrom: string,
105
+ collectionTo: string,
106
+ relationType: string,
107
+ id: number,
108
+ targetLabel: string | null,
109
+ ) => void;
110
+ declare updateRelationByLabel: (
111
+ collectionFrom: string,
112
+ collectionTo: string,
113
+ relationType: string,
114
+ label: string,
115
+ targetLabel: string | null,
116
+ ) => void;
92
117
 
93
118
  // --- Reads (implemented in read.ts) ---
94
119
  declare readScalarIntegers: (collection: string, attribute: string) => (number | null)[];
120
+ declare readScalarBooleans: (collection: string, attribute: string) => (boolean | null)[];
95
121
  declare readScalarFloats: (collection: string, attribute: string) => (number | null)[];
96
122
  declare readScalarStrings: (collection: string, attribute: string) => (string | null)[];
97
123
  declare readScalarIntegerById: (
@@ -99,6 +125,11 @@ export class Database {
99
125
  attribute: string,
100
126
  id: number,
101
127
  ) => number | null;
128
+ declare readScalarBooleanById: (
129
+ collection: string,
130
+ attribute: string,
131
+ id: number,
132
+ ) => boolean | null;
102
133
  declare readScalarFloatById: (collection: string, attribute: string, id: number) => number | null;
103
134
  declare readScalarStringById: (
104
135
  collection: string,
@@ -108,21 +139,26 @@ export class Database {
108
139
  declare readElementIds: (collection: string) => number[];
109
140
  declare numberOfElements: (collection: string) => number;
110
141
  declare readVectorIntegers: (collection: string, attribute: string) => number[][];
142
+ declare readVectorBooleans: (collection: string, attribute: string) => boolean[][];
111
143
  declare readVectorFloats: (collection: string, attribute: string) => number[][];
112
144
  declare readVectorStrings: (collection: string, attribute: string) => string[][];
113
145
  declare readVectorIntegersById: (collection: string, attribute: string, id: number) => number[];
146
+ declare readVectorBooleansById: (collection: string, attribute: string, id: number) => boolean[];
114
147
  declare readVectorFloatsById: (collection: string, attribute: string, id: number) => number[];
115
148
  declare readVectorStringsById: (collection: string, attribute: string, id: number) => string[];
116
149
  declare readSetIntegers: (collection: string, attribute: string) => number[][];
150
+ declare readSetBooleans: (collection: string, attribute: string) => boolean[][];
117
151
  declare readSetFloats: (collection: string, attribute: string) => number[][];
118
152
  declare readSetStrings: (collection: string, attribute: string) => string[][];
119
153
  declare readSetIntegersById: (collection: string, attribute: string, id: number) => number[];
154
+ declare readSetBooleansById: (collection: string, attribute: string, id: number) => boolean[];
120
155
  declare readSetFloatsById: (collection: string, attribute: string, id: number) => number[];
121
156
  declare readSetStringsById: (collection: string, attribute: string, id: number) => string[];
122
157
 
123
158
  // --- Queries (implemented in query.ts) ---
124
159
  declare queryString: (sql: string, parameters?: QueryParam[]) => string | null;
125
160
  declare queryInteger: (sql: string, parameters?: QueryParam[]) => number | null;
161
+ declare queryBoolean: (sql: string, parameters?: QueryParam[]) => boolean | null;
126
162
  declare queryFloat: (sql: string, parameters?: QueryParam[]) => number | null;
127
163
 
128
164
  // --- Transactions (implemented in transaction.ts) ---
@@ -156,13 +192,13 @@ export class Database {
156
192
  collection: string,
157
193
  group: string,
158
194
  id: number,
159
- data: TimeSeriesData,
195
+ data: GroupColumns,
160
196
  ) => void;
161
197
  declare updateTimeSeriesGroupByLabel: (
162
198
  collection: string,
163
199
  group: string,
164
200
  label: string,
165
- data: TimeSeriesData,
201
+ data: GroupColumns,
166
202
  ) => void;
167
203
  declare updateVectorGroup: (
168
204
  collection: string,
@@ -192,13 +228,13 @@ export class Database {
192
228
  collection: string,
193
229
  group: string,
194
230
  id: number,
195
- row: Record<string, number | bigint | string>,
231
+ row: Record<string, number | bigint | string | boolean>,
196
232
  ) => void;
197
233
  declare upsertTimeSeriesRowByLabel: (
198
234
  collection: string,
199
235
  group: string,
200
236
  label: string,
201
- row: Record<string, number | bigint | string>,
237
+ row: Record<string, number | bigint | string | boolean>,
202
238
  ) => void;
203
239
  declare hasTimeSeriesFiles: (collection: string) => boolean;
204
240
  declare listTimeSeriesFilesColumns: (collection: string) => string[];
@@ -11,7 +11,7 @@ import type { NativePointer } from "./loader.ts";
11
11
  import { DATA_TYPE_FLOAT, DATA_TYPE_INTEGER, DATA_TYPE_STRING, type Allocation } from "./types.ts";
12
12
 
13
13
  /** Column-oriented group payload: one array of cells per column name, `null` for SQL NULL. */
14
- export type GroupColumns = Record<string, (number | string | null)[]>;
14
+ export type GroupColumns = Record<string, (number | string | boolean | null)[]>;
15
15
 
16
16
  /**
17
17
  * The parallel-array signature every columnar group update C function shares
@@ -94,7 +94,16 @@ export function updateGroupColumns(
94
94
  const maskPtrs: (Pointer | null)[] = [];
95
95
 
96
96
  for (let c = 0; c < columnCount; c++) {
97
- const [colName, values] = entries[c];
97
+ const [colName, rawValues] = entries[c];
98
+ // SQLite has no boolean type: a boolean is INTEGER 1/0, as in setElementField and
99
+ // marshalParams. Normalizing per cell before the dispatch (rather than adding a boolean
100
+ // branch after it) is what makes a mixed [true, 5] column write 1 and 5 instead of
101
+ // truthiness-mapping every cell, and matches Python's per-cell `int(v)`. A string column is
102
+ // left alone so normalizing cannot change what a mixed ['a', true] column already wrote.
103
+ const isStringColumn = typeof rawValues.find((v) => v !== null) === "string";
104
+ const values = isStringColumn
105
+ ? rawValues
106
+ : rawValues.map((v) => (typeof v === "boolean" ? (v ? 1 : 0) : v));
98
107
  const first = values.find((v) => v !== null);
99
108
 
100
109
  // Mask via direct indexing — never a DataView, to avoid the documented
package/src/loader.ts CHANGED
@@ -35,6 +35,7 @@ const lifecycleSymbols = {
35
35
  // struct in JS.
36
36
  quiver_database_from_schema: { args: [BUF, BUF, BUF, P], returns: I32 },
37
37
  quiver_database_from_migrations: { args: [BUF, BUF, BUF, P], returns: I32 },
38
+ quiver_database_validate_migrations: { args: [BUF], returns: I32 },
38
39
  quiver_database_open: { args: [BUF, BUF, P], returns: I32 },
39
40
  quiver_database_close: { args: [P], returns: I32 },
40
41
  quiver_database_is_healthy: { args: [P, P], returns: I32 },
@@ -61,6 +62,8 @@ const crudSymbols = {
61
62
  quiver_database_update_element_by_label: { args: [P, BUF, BUF, P], returns: I32 },
62
63
  quiver_database_delete_element: { args: [P, BUF, I64], returns: I32 },
63
64
  quiver_database_delete_element_by_label: { args: [P, BUF, BUF], returns: I32 },
65
+ quiver_database_update_relation: { args: [P, BUF, BUF, BUF, I64, BUF], returns: I32 },
66
+ quiver_database_update_relation_by_label: { args: [P, BUF, BUF, BUF, BUF, BUF], returns: I32 },
64
67
  } as const;
65
68
 
66
69
  const readSymbols = {
package/src/lua-api.ts CHANGED
@@ -10,8 +10,9 @@
10
10
  // and whether the prose is semantically true.
11
11
  //
12
12
  // NOTE: the binary/expression subsystems are bound in the native binding and documented below.
13
- // File-touching operations (db:open_file, db:bin_to_csv, db:csv_to_bin, expr:save) are sandboxed
14
- // to the database file's directory; the pure-metadata builders stay under the quiver.* global.
13
+ // File-touching operations (db:open_file, db:bin_to_csv, db:csv_to_bin, db:validate_migrations,
14
+ // expr:save) are sandboxed to the database file's directory; the pure-metadata builders stay under
15
+ // the quiver.* global.
15
16
  //
16
17
  // FORMAT CONVENTION: every db: method appears at least once as the literal token
17
18
  // `db:<snake_case_name>`, and every quiver.* function as `quiver.<name>`, so coverage is greppable
@@ -50,24 +51,42 @@ Lua values map to Quiver column values as follows:
50
51
  | integer | INTEGER | Also accepted for REAL columns (coerced to real). |
51
52
  | number (float) | REAL | A float is rejected for an INTEGER column. |
52
53
  | string | TEXT | Also used for \`date_time\` columns (ISO 8601). |
53
- | \`nil\` | NULL | In query params, file paths, and ts rows. |
54
+ | boolean | INTEGER 1/0 | SQLite has no boolean type; \`true\` writes 1, \`false\` 0. |
55
+ | \`nil\` | NULL | In query params, file paths, ts rows, relations.|
54
56
  | table (1-indexed) | array | Used for vectors/sets and column-oriented data.|
55
57
 
56
- **Unsupported types throw.** Passing a boolean, a function, or a nested table where a scalar is
57
- expected raises an error rather than silently dropping the value. This applies to element
58
- attributes, time-series rows, and query parameters. A skipped positional query parameter would
59
- shift every later parameter and bind NULL to the trailing placeholder, so this is rejected loudly.
58
+ A boolean is accepted **wherever an integer is** — element scalars and arrays, query parameters,
59
+ the group writers, and \`upsert_time_series_row\` — so it also reaches a REAL column through the
60
+ usual int-for-REAL coercion. Reading is the asymmetric half: there are no boolean readers in Lua
61
+ (unlike Julia/Dart/Python/JS), so a stored flag comes back as \`0\`/\`1\` from
62
+ \`read_scalar_integers\`. Compare against 0 rather than truth-testing —
63
+ \`read_scalar_integers(c, a)[i] == 1\` — because in Lua a \`nil\` from a NULL cell is *not* equal to
64
+ 0 and \`nil ~= 0\` evaluates to \`true\`.
65
+
66
+ The one place a boolean is deliberately refused is \`db:update_relation\`, where the argument is a
67
+ target label and only \`nil\` (or omitting it) may clear the relation.
68
+
69
+ **Unsupported types throw.** Passing a function or a nested table where a scalar is expected raises
70
+ an error rather than silently dropping the value. This applies to element attributes, time-series
71
+ rows, and query parameters. A skipped positional query parameter would shift every later parameter
72
+ and bind NULL to the trailing placeholder, so this is rejected loudly.
60
73
 
61
74
  Dates are plain strings in ISO 8601 format: \`YYYY-MM-DDTHH:MM:SS\`. (Lua keeps a string-based
62
- datetime surface — there are no DateTime wrapper helpers, unlike Julia/Dart/Python.)
75
+ datetime surface — there are no DateTime wrapper helpers, unlike Julia/Dart/Python.) The time part
76
+ is optional, so \`"2024-01-15"\` is also valid, and a space may replace the \`T\`. Every field is
77
+ fixed-width and zero-padded. Anything shorter or malformed — \`"2005"\`, \`"2005-01"\`,
78
+ \`"2024-02-31"\`, \`"2024-1-5"\`, \`"2024-01-15T10:30"\` — is **rejected when you write it**, not
79
+ silently stored. The value is stored exactly as written; a date-only value is not padded to
80
+ midnight.
63
81
 
64
82
  ---
65
83
 
66
84
  ## Critical rules
67
85
 
68
86
  - **Type coercion.** An integer is accepted for a REAL column (coerced to real on insert); a float
69
- is rejected for an INTEGER column. Other type mismatches raise a validation error and roll the
70
- whole script back.
87
+ is rejected for an INTEGER column. A string bound to a \`date_*\` column must parse as ISO 8601
88
+ (\`YYYY-MM-DD\`, optionally \`THH:MM:SS\` or \` HH:MM:SS\`). Other type mismatches raise a
89
+ validation error and roll the whole script back.
71
90
  - **Errors abort the script.** Any error thrown by a \`db:\` call stops the script and surfaces as
72
91
  \`Failed to run Lua script: <message>\`. Validation failures roll back whatever the current
73
92
  transaction covered.
@@ -76,16 +95,16 @@ datetime surface — there are no DateTime wrapper helpers, unlike Julia/Dart/Py
76
95
  and \`dofile\`/\`loadfile\` are removed (string-form \`load\` stays available). Integer division is
77
96
  the Lua 5.4 \`//\` operator — a language operator, unrelated to \`math\`.
78
97
  - **Filesystem sandbox.** Every file-touching operation (\`db:export_csv\`, \`db:import_csv\`,
79
- \`db:open_file\`, \`db:bin_to_csv\`, \`db:csv_to_bin\`, \`expr:save\`) resolves relative paths against
80
- the directory containing the database file and rejects anything outside it (subdirectories are
81
- fine; \`..\` escapes and outside absolute paths throw \`Cannot <op>: path '...' escapes the
98
+ \`db:open_file\`, \`db:bin_to_csv\`, \`db:csv_to_bin\`, \`db:validate_migrations\`, \`expr:save\`) resolves
99
+ relative paths against the directory containing the database file and rejects anything outside it
100
+ (subdirectories are fine; \`..\` escapes and outside absolute paths throw \`Cannot <op>: path '...' escapes the
82
101
  database directory ...\`). On an in-memory database these operations throw
83
102
  \`Cannot <op>: database is in-memory, file operations are unavailable\`.
84
103
  - **Output.** A script can \`return\` one value and the host receives it as JSON — prefer this over
85
104
  \`print()\` when you need structured data back (\`print()\` still works and is captured). Only the
86
105
  **first** returned value is encoded. Arrays are 1-indexed (iterate with \`ipairs\`); reading a NULL
87
106
  yields \`nil\`, writing \`nil\` stores NULL where NULL is accepted (query params, ts rows, file
88
- columns — but NOT element scalar attributes; see CRUD).
107
+ columns, relation targets — but NOT element scalar attributes; see CRUD).
89
108
 
90
109
  \`\`\`lua
91
110
  return { ids = db:read_element_ids("Collection"), total = 3 }
@@ -134,10 +153,15 @@ db:describe() -- string: whole-DB text report (returns it,
134
153
  db:describe_collection(collection) -- string: one collection's structure (text report)
135
154
  db:summarize_collection(collection)-- string: per-scalar null/non-null counts, low-cardinality
136
155
  -- integer value distributions, per-group sizes
156
+ db:validate_migrations(path) -- validate a migrations dir (up then down) in-memory; no return
137
157
  \`\`\`
138
158
 
139
159
  All three \`describe*\`/\`summarize*\` methods **return** a string — \`print()\` it to see it.
140
160
 
161
+ \`db:validate_migrations(path)\` applies every \`up.sql\` in version order, then every \`down.sql\` in
162
+ reverse, against a throwaway in-memory database — nothing in \`db\` itself is touched. The round trip
163
+ must end with an empty database; leftover tables are named in the error.
164
+
141
165
  ---
142
166
 
143
167
  ## Transactions
@@ -215,6 +239,9 @@ db:update_element(collection, id, element_table)
215
239
  db:update_element_by_label(collection, label, element_table) -- same update, addressed by label
216
240
  db:delete_element(collection, id)
217
241
  db:delete_element_by_label(collection, label) -- same delete, addressed by label
242
+
243
+ db:update_relation(collection_from, collection_to, relation_type, id, target_label)
244
+ db:update_relation_by_label(collection_from, collection_to, relation_type, label, target_label)
218
245
  \`\`\`
219
246
 
220
247
  The element table holds scalar attributes as \`key = value\`, and vector/set attributes as
@@ -254,7 +281,18 @@ Notes:
254
281
  **throws** (\`...must have at least one scalar attribute\` on create, \`...at least one attribute
255
282
  to update\` on update). To leave a column unchanged, omit the key — you cannot set a scalar to
256
283
  NULL via the element table. (\`nil\` → NULL is only accepted by
257
- \`upsert_time_series_row\` and \`update_time_series_files\`.)
284
+ \`upsert_time_series_row\`, \`update_time_series_files\` and \`update_relation\`.)
285
+ - **\`update_relation\` points one scalar foreign-key relation at another element**, named by the
286
+ target's label. The column is derived from the naming convention —
287
+ \`lowercase(collection_to) .. "_" .. relation_type\`, so
288
+ \`db:update_relation("Child", "Parent", "id", id, "Parent A")\` writes \`Child.parent_id\`. A
289
+ \`nil\` or omitted \`target_label\` clears the relation; anything that is not a string throws
290
+ (\`target_label has unsupported Lua type\`). The derived column must exist and be a
291
+ foreign key to \`collection_to\`, otherwise \`Cannot update_relation: ...\`. The write delegates
292
+ to \`update_element\`, so a missing id reports that method's error;
293
+ \`update_relation_by_label\` takes a label in place of the id, with
294
+ \`update_element_by_label\`'s resolution and miss semantics. A relation living in a vector, set
295
+ or time-series group is a list of targets — use that group's writer instead.
258
296
 
259
297
  ---
260
298
 
@@ -342,6 +380,13 @@ db:read_element_by_id(collection, id) -- scalars + vectors + sets mer
342
380
  \`read_element_by_id\` merges every scalar, vector, and set for the element into a single table.
343
381
  Scalar attributes with no value come back as \`nil\`.
344
382
 
383
+ **Group columns are returned densely, with NULL cells dropped.** \`read_vectors_by_id\` and
384
+ \`read_sets_by_id\` read each column independently, and a column read skips its NULL cells — so two
385
+ columns of the same group are **not** positionally aligned with each other whenever one is
386
+ nullable (e.g. an \`ON DELETE SET NULL\` relation). Do not zip them into rows. There is no
387
+ row-aligned group read in Lua; if you need per-row alignment across a nullable group, do that read
388
+ in the host binding instead.
389
+
345
390
  ---
346
391
 
347
392
  ## Time series
@@ -655,7 +700,9 @@ and \`unit\` default to \`""\`; \`labels\`, \`dimensions\`, \`dimension_sizes\`,
655
700
 
656
701
  ## What Lua does *not* expose
657
702
 
658
- DateTime wrapper helpers (Lua uses ISO 8601 strings) and \`_by_id\` single-scalar variants (use the
659
- composite by-id readers or the bulk readers instead). Everything else the native binding exposes —
660
- CRUD, reads, time series, metadata, query, CSV, and the binary/expression subsystems — is
661
- documented above and callable.`;
703
+ DateTime wrapper helpers (Lua uses ISO 8601 strings), boolean *reader* helpers (a stored flag reads
704
+ back as \`0\`/\`1\` — writing a boolean is supported), \`_by_id\` single-scalar variants (use the
705
+ composite by-id readers or the bulk readers instead), and the row-aligned whole-group readers the
706
+ other bindings have (hence the null-dropping caveat under composite by-id reads). Everything else
707
+ the native binding exposes — CRUD, reads, time series, metadata, query, CSV, and the
708
+ binary/expression subsystems — is documented above and callable.`;
package/src/query.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ptr } from "bun:ffi";
2
+ import { integerToBoolean } from "./boolean.ts";
2
3
  import { Database } from "./database.ts";
3
4
  import { check, QuiverError } from "./errors.ts";
4
5
  import {
@@ -38,6 +39,11 @@ function marshalParams(parameters: QueryParam[]): {
38
39
  if (p === null) {
39
40
  typesDv.setInt32(i * 4, DATA_TYPE_NULL, true);
40
41
  valuesDv.setBigInt64(i * 8, 0n, true);
42
+ } else if (typeof p === "boolean") {
43
+ typesDv.setInt32(i * 4, DATA_TYPE_INTEGER, true);
44
+ const native = allocNativeInt64([p ? 1 : 0]);
45
+ keepalive.push(native);
46
+ valuesDv.setBigInt64(i * 8, nativeAddress(native.ptr), true);
41
47
  } else if (typeof p === "number") {
42
48
  if (Number.isInteger(p)) {
43
49
  typesDv.setInt32(i * 4, DATA_TYPE_INTEGER, true);
@@ -130,6 +136,14 @@ Database.prototype.queryInteger = function (
130
136
  return Number(new DataView(outValue.buffer).getBigInt64(0, true));
131
137
  };
132
138
 
139
+ Database.prototype.queryBoolean = function (
140
+ this: Database,
141
+ sql: string,
142
+ parameters?: QueryParam[],
143
+ ): boolean | null {
144
+ return integerToBoolean(this.queryInteger(sql, parameters));
145
+ };
146
+
133
147
  Database.prototype.queryFloat = function (
134
148
  this: Database,
135
149
  sql: string,
package/src/read.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CString, type Pointer, toArrayBuffer } from "bun:ffi";
2
+ import { integerToBoolean } from "./boolean.ts";
2
3
  import { Database } from "./database.ts";
3
4
  import { check } from "./errors.ts";
4
5
  import {
@@ -52,6 +53,16 @@ Database.prototype.readScalarIntegers = function (
52
53
  return result;
53
54
  };
54
55
 
56
+ Database.prototype.readScalarBooleans = function (
57
+ this: Database,
58
+ collection: string,
59
+ attribute: string,
60
+ ): (boolean | null)[] {
61
+ return this.readScalarIntegers(collection, attribute).map((value) =>
62
+ integerToBoolean(value, collection, attribute),
63
+ );
64
+ };
65
+
55
66
  Database.prototype.readScalarFloats = function (
56
67
  this: Database,
57
68
  collection: string,
@@ -143,6 +154,19 @@ Database.prototype.readScalarIntegerById = function (
143
154
  return Number(new DataView(outValBuf.buffer).getBigInt64(0, true));
144
155
  };
145
156
 
157
+ Database.prototype.readScalarBooleanById = function (
158
+ this: Database,
159
+ collection: string,
160
+ attribute: string,
161
+ id: number,
162
+ ): boolean | null {
163
+ return integerToBoolean(
164
+ this.readScalarIntegerById(collection, attribute, id),
165
+ collection,
166
+ attribute,
167
+ );
168
+ };
169
+
146
170
  Database.prototype.readScalarFloatById = function (
147
171
  this: Database,
148
172
  collection: string,
@@ -342,6 +366,19 @@ Database.prototype.readVectorIntegers = function (
342
366
  attribute,
343
367
  );
344
368
  };
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
+ */
373
+ Database.prototype.readVectorBooleans = function (
374
+ this: Database,
375
+ collection: string,
376
+ attribute: string,
377
+ ): boolean[][] {
378
+ return this.readVectorIntegers(collection, attribute).map((values) =>
379
+ values.map((value) => integerToBoolean(value, collection, attribute)),
380
+ );
381
+ };
345
382
  Database.prototype.readVectorFloats = function (
346
383
  this: Database,
347
384
  collection: string,
@@ -381,6 +418,16 @@ Database.prototype.readSetIntegers = function (
381
418
  attribute,
382
419
  );
383
420
  };
421
+ /** Same alignment caveat as `readVectorBooleans`: NULL cells dropped, only ids that own rows. */
422
+ Database.prototype.readSetBooleans = function (
423
+ this: Database,
424
+ collection: string,
425
+ attribute: string,
426
+ ): boolean[][] {
427
+ return this.readSetIntegers(collection, attribute).map((values) =>
428
+ values.map((value) => integerToBoolean(value, collection, attribute)),
429
+ );
430
+ };
384
431
  Database.prototype.readSetFloats = function (
385
432
  this: Database,
386
433
  collection: string,
@@ -515,6 +562,16 @@ Database.prototype.readVectorIntegersById = function (
515
562
  id,
516
563
  );
517
564
  };
565
+ Database.prototype.readVectorBooleansById = function (
566
+ this: Database,
567
+ collection: string,
568
+ attribute: string,
569
+ id: number,
570
+ ): boolean[] {
571
+ return this.readVectorIntegersById(collection, attribute, id).map((value) =>
572
+ integerToBoolean(value, collection, attribute),
573
+ );
574
+ };
518
575
  Database.prototype.readVectorFloatsById = function (
519
576
  this: Database,
520
577
  collection: string,
@@ -560,6 +617,16 @@ Database.prototype.readSetIntegersById = function (
560
617
  id,
561
618
  );
562
619
  };
620
+ Database.prototype.readSetBooleansById = function (
621
+ this: Database,
622
+ collection: string,
623
+ attribute: string,
624
+ id: number,
625
+ ): boolean[] {
626
+ return this.readSetIntegersById(collection, attribute, id).map((value) =>
627
+ integerToBoolean(value, collection, attribute),
628
+ );
629
+ };
563
630
  Database.prototype.readSetFloatsById = function (
564
631
  this: Database,
565
632
  collection: string,
@@ -17,7 +17,7 @@ import {
17
17
  readUint64Out,
18
18
  toCString,
19
19
  } from "./ffi-helpers.ts";
20
- import { updateGroupColumns } from "./group-columns.ts";
20
+ import { type GroupColumns, updateGroupColumns } from "./group-columns.ts";
21
21
  import { getSymbols, type NativePointer } from "./loader.ts";
22
22
  import {
23
23
  type Allocation,
@@ -187,7 +187,7 @@ Database.prototype.updateTimeSeriesGroup = function (
187
187
  collection: string,
188
188
  group: string,
189
189
  id: number,
190
- data: TimeSeriesData,
190
+ data: GroupColumns,
191
191
  ): void {
192
192
  updateGroupColumns(
193
193
  this._handle,
@@ -206,7 +206,7 @@ Database.prototype.updateTimeSeriesGroupByLabel = function (
206
206
  collection: string,
207
207
  group: string,
208
208
  label: string,
209
- data: TimeSeriesData,
209
+ data: GroupColumns,
210
210
  ): void {
211
211
  updateGroupColumns(
212
212
  this._handle,
@@ -242,11 +242,12 @@ type UpsertRowFn = (
242
242
  */
243
243
  function upsertRowColumns(
244
244
  handle: NativePointer,
245
+ caller: string,
245
246
  upsert: UpsertRowFn,
246
247
  collection: string,
247
248
  group: string,
248
249
  key: number | string,
249
- row: Record<string, number | bigint | string>,
250
+ row: Record<string, number | bigint | string | boolean>,
250
251
  ): void {
251
252
  const collBuf = toCString(collection);
252
253
  const grpBuf = toCString(group);
@@ -264,7 +265,11 @@ function upsertRowColumns(
264
265
  const dataPtrs: (Pointer | null)[] = [];
265
266
 
266
267
  for (let c = 0; c < columnCount; c++) {
267
- const value = entries[c][1];
268
+ const [colName, raw] = entries[c];
269
+ // SQLite has no boolean type: a boolean is INTEGER 1/0. Normalized here rather than tested in
270
+ // the INTEGER condition below, because Number.isInteger(true) is false — a boolean otherwise
271
+ // reaches allocNativeFloat64 and lands in the column as FLOAT 1.0, silently the wrong type.
272
+ const value = typeof raw === "boolean" ? (raw ? 1 : 0) : raw;
268
273
  if (typeof value === "string") {
269
274
  typesDv.setInt32(c * 4, DATA_TYPE_STRING, true);
270
275
  const { table, keepalive: strPtrs } = allocNativeStringArray([value]);
@@ -275,11 +280,17 @@ function upsertRowColumns(
275
280
  const p = allocNativeInt64([value]);
276
281
  keepalive.push(p);
277
282
  dataPtrs.push(p.ptr);
278
- } else {
283
+ } else if (typeof value === "number") {
279
284
  typesDv.setInt32(c * 4, DATA_TYPE_FLOAT, true);
280
- const p = allocNativeFloat64([value as number]);
285
+ const p = allocNativeFloat64([value]);
281
286
  keepalive.push(p);
282
287
  dataPtrs.push(p.ptr);
288
+ } else {
289
+ // Mirrors updateGroupColumns: never let null/undefined/an object fall through to the FLOAT
290
+ // branch, where Number(null) is 0 and anything else is NaN — both written with no error.
291
+ throw new QuiverError(
292
+ `Cannot ${caller}: column '${colName}' has unsupported value type ${typeof value}`,
293
+ );
283
294
  }
284
295
  }
285
296
 
@@ -307,10 +318,11 @@ Database.prototype.upsertTimeSeriesRow = function (
307
318
  collection: string,
308
319
  group: string,
309
320
  id: number,
310
- row: Record<string, number | bigint | string>,
321
+ row: Record<string, number | bigint | string | boolean>,
311
322
  ): void {
312
323
  upsertRowColumns(
313
324
  this._handle,
325
+ "upsertTimeSeriesRow",
314
326
  getSymbols().quiver_database_upsert_time_series_row,
315
327
  collection,
316
328
  group,
@@ -325,10 +337,11 @@ Database.prototype.upsertTimeSeriesRowByLabel = function (
325
337
  collection: string,
326
338
  group: string,
327
339
  label: string,
328
- row: Record<string, number | bigint | string>,
340
+ row: Record<string, number | bigint | string | boolean>,
329
341
  ): void {
330
342
  upsertRowColumns(
331
343
  this._handle,
344
+ "upsertTimeSeriesRowByLabel",
332
345
  getSymbols().quiver_database_upsert_time_series_row_by_label,
333
346
  collection,
334
347
  group,
package/src/types.ts CHANGED
@@ -21,11 +21,12 @@ export type DatabaseOptions = {
21
21
  consoleLevel?: number;
22
22
  };
23
23
 
24
- export type ScalarValue = number | bigint | string | null;
25
- export type ArrayValue = number[] | bigint[] | string[];
24
+ /** A `boolean` is stored as the INTEGER 1 or 0 (see `readScalarBooleans` for the read side). */
25
+ export type ScalarValue = number | bigint | boolean | string | null;
26
+ export type ArrayValue = number[] | bigint[] | boolean[] | string[];
26
27
  export type Value = ScalarValue | ArrayValue;
27
28
  export type ElementData = Record<string, Value | undefined>;
28
- export type QueryParam = number | string | null;
29
+ export type QueryParam = number | boolean | string | null;
29
30
 
30
31
  /** Native memory allocation result. Callers MUST hold `buf` in scope to prevent GC. */
31
32
  export type Allocation = {