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