@c9up/eon 0.1.0
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 +69 -0
- package/dist/EonProvider.d.ts +51 -0
- package/dist/EonProvider.d.ts.map +1 -0
- package/dist/EonProvider.js +154 -0
- package/dist/EonProvider.js.map +1 -0
- package/dist/connection/EonConnection.d.ts +116 -0
- package/dist/connection/EonConnection.d.ts.map +1 -0
- package/dist/connection/EonConnection.js +15 -0
- package/dist/connection/EonConnection.js.map +1 -0
- package/dist/connection/config.d.ts +56 -0
- package/dist/connection/config.d.ts.map +1 -0
- package/dist/connection/config.js +14 -0
- package/dist/connection/config.js.map +1 -0
- package/dist/connection/websocket.d.ts +16 -0
- package/dist/connection/websocket.d.ts.map +1 -0
- package/dist/connection/websocket.js +383 -0
- package/dist/connection/websocket.js.map +1 -0
- package/dist/decorators/superTable.d.ts +73 -0
- package/dist/decorators/superTable.d.ts.map +1 -0
- package/dist/decorators/superTable.js +98 -0
- package/dist/decorators/superTable.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest/schemaless.d.ts +22 -0
- package/dist/ingest/schemaless.d.ts.map +1 -0
- package/dist/ingest/schemaless.js +161 -0
- package/dist/ingest/schemaless.js.map +1 -0
- package/dist/ingest/sql.d.ts +23 -0
- package/dist/ingest/sql.d.ts.map +1 -0
- package/dist/ingest/sql.js +59 -0
- package/dist/ingest/sql.js.map +1 -0
- package/dist/ingest/stmt.d.ts +82 -0
- package/dist/ingest/stmt.d.ts.map +1 -0
- package/dist/ingest/stmt.js +246 -0
- package/dist/ingest/stmt.js.map +1 -0
- package/dist/metadata-keys.d.ts +17 -0
- package/dist/metadata-keys.d.ts.map +1 -0
- package/dist/metadata-keys.js +17 -0
- package/dist/metadata-keys.js.map +1 -0
- package/dist/query/TimeSeriesQuery.d.ts +121 -0
- package/dist/query/TimeSeriesQuery.d.ts.map +1 -0
- package/dist/query/TimeSeriesQuery.js +291 -0
- package/dist/query/TimeSeriesQuery.js.map +1 -0
- package/dist/query/native.d.ts +22 -0
- package/dist/query/native.d.ts.map +1 -0
- package/dist/query/native.js +135 -0
- package/dist/query/native.js.map +1 -0
- package/dist/repository/SuperTableRepository.d.ts +80 -0
- package/dist/repository/SuperTableRepository.d.ts.map +1 -0
- package/dist/repository/SuperTableRepository.js +232 -0
- package/dist/repository/SuperTableRepository.js.map +1 -0
- package/dist/schema/CreateStableSpec.d.ts +140 -0
- package/dist/schema/CreateStableSpec.d.ts.map +1 -0
- package/dist/schema/CreateStableSpec.js +41 -0
- package/dist/schema/CreateStableSpec.js.map +1 -0
- package/dist/schema/EonMigrationRunner.d.ts +71 -0
- package/dist/schema/EonMigrationRunner.d.ts.map +1 -0
- package/dist/schema/EonMigrationRunner.js +295 -0
- package/dist/schema/EonMigrationRunner.js.map +1 -0
- package/dist/schema/EonSchema.d.ts +128 -0
- package/dist/schema/EonSchema.d.ts.map +1 -0
- package/dist/schema/EonSchema.js +309 -0
- package/dist/schema/EonSchema.js.map +1 -0
- package/dist/schema/Migration.d.ts +47 -0
- package/dist/schema/Migration.d.ts.map +1 -0
- package/dist/schema/Migration.js +55 -0
- package/dist/schema/Migration.js.map +1 -0
- package/dist/schema/compile.d.ts +33 -0
- package/dist/schema/compile.d.ts.map +1 -0
- package/dist/schema/compile.js +70 -0
- package/dist/schema/compile.js.map +1 -0
- package/dist/schema/sync.d.ts +45 -0
- package/dist/schema/sync.d.ts.map +1 -0
- package/dist/schema/sync.js +102 -0
- package/dist/schema/sync.js.map +1 -0
- package/dist/services/connection.d.ts +33 -0
- package/dist/services/connection.d.ts.map +1 -0
- package/dist/services/connection.js +71 -0
- package/dist/services/connection.js.map +1 -0
- package/dist/testing/Factory.d.ts +43 -0
- package/dist/testing/Factory.d.ts.map +1 -0
- package/dist/testing/Factory.js +134 -0
- package/dist/testing/Factory.js.map +1 -0
- package/dist/testing/FakeEonConnection.d.ts +43 -0
- package/dist/testing/FakeEonConnection.d.ts.map +1 -0
- package/dist/testing/FakeEonConnection.js +315 -0
- package/dist/testing/FakeEonConnection.js.map +1 -0
- package/dist/testing/index.d.ts +34 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +67 -0
- package/dist/testing/index.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +65 -0
- package/scripts/copy-napi.mjs +72 -0
- package/src/EonProvider.ts +222 -0
- package/src/connection/EonConnection.ts +146 -0
- package/src/connection/config.ts +60 -0
- package/src/connection/websocket.ts +436 -0
- package/src/decorators/superTable.ts +169 -0
- package/src/index.ts +118 -0
- package/src/ingest/schemaless.ts +211 -0
- package/src/ingest/sql.ts +73 -0
- package/src/ingest/stmt.ts +335 -0
- package/src/metadata-keys.ts +20 -0
- package/src/query/TimeSeriesQuery.ts +480 -0
- package/src/query/native.ts +180 -0
- package/src/repository/SuperTableRepository.ts +347 -0
- package/src/schema/CreateStableSpec.ts +184 -0
- package/src/schema/EonMigrationRunner.ts +378 -0
- package/src/schema/EonSchema.ts +396 -0
- package/src/schema/Migration.ts +66 -0
- package/src/schema/compile.ts +109 -0
- package/src/schema/sync.ts +142 -0
- package/src/services/connection.ts +89 -0
- package/src/testing/Factory.ts +186 -0
- package/src/testing/FakeEonConnection.ts +351 -0
- package/src/testing/index.ts +81 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TimeSeriesQuery` — a fluent, Atlas-shaped time-series read builder for
|
|
3
|
+
* TDengine (story 58.5). It mirrors the `@c9up/atlas` `ModelQuery` SUBSET that
|
|
4
|
+
* makes sense for a super-table: filtering, projection (bare columns, aggregate
|
|
5
|
+
* / selector functions, window pseudo-columns), the TDengine window clauses
|
|
6
|
+
* (`INTERVAL` / `SLIDING` / `FILL` / `PARTITION BY`), ordering, paging, and the
|
|
7
|
+
* `.then` thenable ergonomic. It deliberately OMITS the Atlas surface TDengine
|
|
8
|
+
* has no analogue for (joins, CTEs, unions, preload, pivots, cursor-pagination).
|
|
9
|
+
*
|
|
10
|
+
* READ CRUX: `EonConnection.query(sql)` is literal-SQL-only — the ws transport
|
|
11
|
+
* binds `?` exclusively through the STMT/write path. So every spec this builder
|
|
12
|
+
* assembles carries `literal: true`, and the Rust compiler renders WHERE / IN /
|
|
13
|
+
* FILL(VALUE …) constants as inline SQL literals through the audited
|
|
14
|
+
* `render_literal` seam (never TS interpolation, memory `feedback_security_first`).
|
|
15
|
+
* `params` therefore comes back empty; a non-empty `params` is a literal-mode
|
|
16
|
+
* regression and is rejected before the SQL ever reaches the connection.
|
|
17
|
+
*
|
|
18
|
+
* The `.then` thenable makes `await query` ≡ `await query.exec()` (Lucid /
|
|
19
|
+
* atlas `ModelQuery` parity). A `then` member trips Biome's recommended
|
|
20
|
+
* `noThenProperty`, so — exactly as atlas does for the same reason — it carries
|
|
21
|
+
* a single targeted `biome-ignore`: the thenable IS the public API here, there
|
|
22
|
+
* is no trigger to refactor away.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { EonConnection } from "../connection/EonConnection.js";
|
|
26
|
+
import { compileStatementNative } from "./native.js";
|
|
27
|
+
|
|
28
|
+
/** A scalar bindable into a WHERE predicate or a FILL(VALUE …) constant. */
|
|
29
|
+
export type EonScalar = string | number | bigint | boolean | null;
|
|
30
|
+
|
|
31
|
+
/** A WHERE value — a scalar, or a list for `IN` / `NOT IN`. */
|
|
32
|
+
export type EonWhereValue = EonScalar | readonly EonScalar[];
|
|
33
|
+
|
|
34
|
+
/** WHERE operators (mirrors the Rust `validate_operator` allowlist). */
|
|
35
|
+
export type EonWhereOperator =
|
|
36
|
+
| "="
|
|
37
|
+
| "!="
|
|
38
|
+
| "<>"
|
|
39
|
+
| ">"
|
|
40
|
+
| ">="
|
|
41
|
+
| "<"
|
|
42
|
+
| "<="
|
|
43
|
+
| "LIKE"
|
|
44
|
+
| "NOT LIKE"
|
|
45
|
+
| "IN"
|
|
46
|
+
| "NOT IN"
|
|
47
|
+
| "IS NULL"
|
|
48
|
+
| "IS NOT NULL";
|
|
49
|
+
|
|
50
|
+
/** FILL modes in scope (58.5). `VALUE` carries the fill constants. */
|
|
51
|
+
export type EonFillMode =
|
|
52
|
+
| "none"
|
|
53
|
+
| "null"
|
|
54
|
+
| "prev"
|
|
55
|
+
| "next"
|
|
56
|
+
| "linear"
|
|
57
|
+
| "value";
|
|
58
|
+
|
|
59
|
+
/** ORDER BY direction. */
|
|
60
|
+
export type EonOrderDirection = "asc" | "desc";
|
|
61
|
+
|
|
62
|
+
/** An aggregate / selector function select item, e.g. `{ fn: "avg", column: "voltage" }`. */
|
|
63
|
+
export interface EonFunctionSelect {
|
|
64
|
+
/** Function name (allowlisted in Rust: avg/sum/min/max/count/first/last/last_row/spread/twa). */
|
|
65
|
+
readonly fn: string;
|
|
66
|
+
/** Column argument (or `"*"` for `count`). */
|
|
67
|
+
readonly column: string;
|
|
68
|
+
/** Optional `AS` alias. */
|
|
69
|
+
readonly as?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A window pseudo-column select item, e.g. `{ pseudo: "_wstart" }`. */
|
|
73
|
+
export interface EonPseudoSelect {
|
|
74
|
+
/** Pseudo-column: `_wstart` / `_wend` / `_wduration` / `tbname`. */
|
|
75
|
+
readonly pseudo: string;
|
|
76
|
+
/** Optional `AS` alias. */
|
|
77
|
+
readonly as?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A SELECT-list item: a bare column / pseudo-column name, or a structured expression. */
|
|
81
|
+
export type EonSelectItem = string | EonFunctionSelect | EonPseudoSelect;
|
|
82
|
+
|
|
83
|
+
// ─── Structural specs sent across the native boundary (serde camelCase) ───
|
|
84
|
+
|
|
85
|
+
type SelectItemSpec =
|
|
86
|
+
| string
|
|
87
|
+
| { function: string; column: string; alias?: string }
|
|
88
|
+
| { pseudo: string; alias?: string };
|
|
89
|
+
|
|
90
|
+
type IntervalSpec = string | { value: string; offset?: string };
|
|
91
|
+
|
|
92
|
+
interface FillSpec {
|
|
93
|
+
mode: string;
|
|
94
|
+
values?: readonly EonScalar[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface WhereSpec {
|
|
98
|
+
column: string;
|
|
99
|
+
operator: string;
|
|
100
|
+
value: EonWhereValue;
|
|
101
|
+
type: "and" | "or";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface OrderBySpec {
|
|
105
|
+
column: string;
|
|
106
|
+
direction: EonOrderDirection;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface SelectSpec {
|
|
110
|
+
kind: "select";
|
|
111
|
+
table: string;
|
|
112
|
+
select: SelectItemSpec[];
|
|
113
|
+
wheres: WhereSpec[];
|
|
114
|
+
partitionBy: string[];
|
|
115
|
+
interval?: IntervalSpec;
|
|
116
|
+
sliding?: string;
|
|
117
|
+
fill?: FillSpec;
|
|
118
|
+
orderBy: OrderBySpec[];
|
|
119
|
+
limit?: number;
|
|
120
|
+
offset?: number;
|
|
121
|
+
literal: true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The `where`/`andWhere`/`orWhere` argument tuples. A rest tuple (not an optional
|
|
126
|
+
* `value?`) so arity is exact: `where(col, op, undefined)` is a 3-element call and
|
|
127
|
+
* is rejected, never silently reinterpreted as the 2-arg `col = <operator>` form.
|
|
128
|
+
*/
|
|
129
|
+
type WhereArgs =
|
|
130
|
+
| readonly [column: string, value: EonWhereValue]
|
|
131
|
+
| readonly [column: string, operator: EonWhereOperator, value: EonWhereValue];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A fluent TDengine time-series query. Constructed by
|
|
135
|
+
* {@link SuperTableRepository.query}; also usable standalone with a connection,
|
|
136
|
+
* a hydrate closure, and the set of declared column names.
|
|
137
|
+
*
|
|
138
|
+
* @typeParam TPoint - the mapped row shape. Defaults to a column-keyed record;
|
|
139
|
+
* the repository binds it to the entity's columns via its hydrate closure.
|
|
140
|
+
*/
|
|
141
|
+
export class TimeSeriesQuery<TPoint extends object = Record<string, unknown>>
|
|
142
|
+
implements PromiseLike<TPoint[]>
|
|
143
|
+
{
|
|
144
|
+
readonly #table: string;
|
|
145
|
+
readonly #conn: EonConnection;
|
|
146
|
+
readonly #hydrate: (row: Record<string, unknown>) => TPoint;
|
|
147
|
+
readonly #knownColumns: ReadonlySet<string>;
|
|
148
|
+
|
|
149
|
+
readonly #select: EonSelectItem[] = [];
|
|
150
|
+
readonly #wheres: WhereSpec[] = [];
|
|
151
|
+
readonly #partitionBy: string[] = [];
|
|
152
|
+
readonly #orderBy: OrderBySpec[] = [];
|
|
153
|
+
#interval?: IntervalSpec;
|
|
154
|
+
#sliding?: string;
|
|
155
|
+
#fill?: FillSpec;
|
|
156
|
+
#limit?: number;
|
|
157
|
+
#offset?: number;
|
|
158
|
+
#cachedExec?: Promise<TPoint[]>;
|
|
159
|
+
|
|
160
|
+
constructor(
|
|
161
|
+
table: string,
|
|
162
|
+
conn: EonConnection,
|
|
163
|
+
hydrate: (row: Record<string, unknown>) => TPoint,
|
|
164
|
+
knownColumns: ReadonlySet<string>,
|
|
165
|
+
) {
|
|
166
|
+
this.#table = table;
|
|
167
|
+
this.#conn = conn;
|
|
168
|
+
this.#hydrate = hydrate;
|
|
169
|
+
this.#knownColumns = knownColumns;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Filtering ───
|
|
173
|
+
|
|
174
|
+
/** Add a WHERE predicate (AND-joined). Two-arg form is `(col, value)` with `=`. */
|
|
175
|
+
where(column: string, value: EonWhereValue): this;
|
|
176
|
+
where(column: string, operator: EonWhereOperator, value: EonWhereValue): this;
|
|
177
|
+
where(...args: WhereArgs): this {
|
|
178
|
+
return this.#pushWhere("and", args);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Alias of {@link where} — an explicit AND predicate. */
|
|
182
|
+
andWhere(column: string, value: EonWhereValue): this;
|
|
183
|
+
andWhere(
|
|
184
|
+
column: string,
|
|
185
|
+
operator: EonWhereOperator,
|
|
186
|
+
value: EonWhereValue,
|
|
187
|
+
): this;
|
|
188
|
+
andWhere(...args: WhereArgs): this {
|
|
189
|
+
return this.#pushWhere("and", args);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Add an OR-joined WHERE predicate. */
|
|
193
|
+
orWhere(column: string, value: EonWhereValue): this;
|
|
194
|
+
orWhere(
|
|
195
|
+
column: string,
|
|
196
|
+
operator: EonWhereOperator,
|
|
197
|
+
value: EonWhereValue,
|
|
198
|
+
): this;
|
|
199
|
+
orWhere(...args: WhereArgs): this {
|
|
200
|
+
return this.#pushWhere("or", args);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** `col IS NULL`. */
|
|
204
|
+
whereNull(column: string): this {
|
|
205
|
+
this.#cachedExec = undefined;
|
|
206
|
+
this.#wheres.push({
|
|
207
|
+
column,
|
|
208
|
+
operator: "IS NULL",
|
|
209
|
+
value: null,
|
|
210
|
+
type: "and",
|
|
211
|
+
});
|
|
212
|
+
return this;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** `col IS NOT NULL`. */
|
|
216
|
+
whereNotNull(column: string): this {
|
|
217
|
+
this.#cachedExec = undefined;
|
|
218
|
+
this.#wheres.push({
|
|
219
|
+
column,
|
|
220
|
+
operator: "IS NOT NULL",
|
|
221
|
+
value: null,
|
|
222
|
+
type: "and",
|
|
223
|
+
});
|
|
224
|
+
return this;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* `col >= lo AND col <= hi` — the idiomatic time-range helper for `ts`.
|
|
229
|
+
* Emitted as two scalar predicates (TDengine's operator set is covered by the
|
|
230
|
+
* `>=` / `<=` allowlist; no `BETWEEN` keyword is introduced).
|
|
231
|
+
*/
|
|
232
|
+
whereBetween(column: string, range: readonly [EonScalar, EonScalar]): this {
|
|
233
|
+
this.#cachedExec = undefined;
|
|
234
|
+
this.#wheres.push({ column, operator: ">=", value: range[0], type: "and" });
|
|
235
|
+
this.#wheres.push({ column, operator: "<=", value: range[1], type: "and" });
|
|
236
|
+
return this;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#pushWhere(type: "and" | "or", args: WhereArgs): this {
|
|
240
|
+
this.#cachedExec = undefined;
|
|
241
|
+
if (args.length === 2) {
|
|
242
|
+
const [column, value] = args;
|
|
243
|
+
this.#wheres.push(this.#nullFold(column, "=", value, type));
|
|
244
|
+
} else {
|
|
245
|
+
const [column, operator, value] = args;
|
|
246
|
+
// A 3-arg call whose value is runtime-`undefined` (a JS caller, or an
|
|
247
|
+
// `any` leak — `EonWhereValue` excludes `undefined`, so strict TS callers
|
|
248
|
+
// are already blocked) must NOT be reinterpreted as `col = <operator>`.
|
|
249
|
+
if (value === undefined) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
"[E_EON_INVALID_VALUE] where(column, operator, value): the value is undefined — pass a concrete value, or use whereNull()/whereNotNull() for NULL comparisons.",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
this.#wheres.push(this.#nullFold(column, operator, value, type));
|
|
255
|
+
}
|
|
256
|
+
return this;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Fold a `null` equality/inequality into `IS NULL` / `IS NOT NULL`, mirroring
|
|
261
|
+
* Knex/Lucid — `where(col, null)` never means `col = NULL` (which matches no
|
|
262
|
+
* row in SQL); a caller who wants that literal has no valid use for it.
|
|
263
|
+
*/
|
|
264
|
+
#nullFold(
|
|
265
|
+
column: string,
|
|
266
|
+
operator: string,
|
|
267
|
+
value: EonWhereValue,
|
|
268
|
+
type: "and" | "or",
|
|
269
|
+
): WhereSpec {
|
|
270
|
+
if (value === null && (operator === "=" || operator === "IS NULL")) {
|
|
271
|
+
return { column, operator: "IS NULL", value: null, type };
|
|
272
|
+
}
|
|
273
|
+
if (value === null && (operator === "!=" || operator === "<>")) {
|
|
274
|
+
return { column, operator: "IS NOT NULL", value: null, type };
|
|
275
|
+
}
|
|
276
|
+
return { column, operator, value, type };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─── Projection ───
|
|
280
|
+
|
|
281
|
+
/** Replace the SELECT list (bare columns, function items, pseudo-columns). */
|
|
282
|
+
select(items: readonly EonSelectItem[]): this {
|
|
283
|
+
this.#cachedExec = undefined;
|
|
284
|
+
this.#select.length = 0;
|
|
285
|
+
this.#select.push(...items);
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── Window ───
|
|
290
|
+
|
|
291
|
+
/** `INTERVAL(value[, offset])`. Duration tokens validated in Rust. */
|
|
292
|
+
interval(value: string, offset?: string): this {
|
|
293
|
+
this.#cachedExec = undefined;
|
|
294
|
+
this.#interval = offset === undefined ? value : { value, offset };
|
|
295
|
+
return this;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** `SLIDING(value)` — requires an `interval`. */
|
|
299
|
+
sliding(value: string): this {
|
|
300
|
+
this.#cachedExec = undefined;
|
|
301
|
+
this.#sliding = value;
|
|
302
|
+
return this;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** `FILL(mode[, ...values])` — requires an `interval`; `values` only for `value` mode. */
|
|
306
|
+
fill(mode: EonFillMode, ...values: readonly EonScalar[]): this {
|
|
307
|
+
this.#cachedExec = undefined;
|
|
308
|
+
this.#fill = { mode, values };
|
|
309
|
+
return this;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** `PARTITION BY col[, col]` — tags are quoted; `tbname` passes verbatim. */
|
|
313
|
+
partitionBy(...columns: readonly string[]): this {
|
|
314
|
+
this.#cachedExec = undefined;
|
|
315
|
+
this.#partitionBy.push(...columns);
|
|
316
|
+
return this;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── Ordering / paging ───
|
|
320
|
+
|
|
321
|
+
/** `ORDER BY col [asc|desc]` (default `asc`). */
|
|
322
|
+
orderBy(column: string, direction: EonOrderDirection = "asc"): this {
|
|
323
|
+
this.#cachedExec = undefined;
|
|
324
|
+
this.#orderBy.push({ column, direction });
|
|
325
|
+
return this;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** `LIMIT n` — `n` must be a non-negative safe integer. */
|
|
329
|
+
limit(n: number): this {
|
|
330
|
+
if (!Number.isSafeInteger(n) || n < 0) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`[E_EON_INVALID_LIMIT] limit must be a non-negative safe integer, got ${n}; a NaN/Infinity would silently drop the LIMIT (full scan).`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
this.#cachedExec = undefined;
|
|
336
|
+
this.#limit = n;
|
|
337
|
+
return this;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** `OFFSET m` — requires a `limit`; `m` must be a non-negative safe integer. */
|
|
341
|
+
offset(m: number): this {
|
|
342
|
+
if (!Number.isSafeInteger(m) || m < 0) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`[E_EON_INVALID_OFFSET] offset must be a non-negative safe integer, got ${m}.`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
this.#cachedExec = undefined;
|
|
348
|
+
this.#offset = m;
|
|
349
|
+
return this;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ─── Terminals ───
|
|
353
|
+
|
|
354
|
+
/** Build the `{ sql, params }` pair for debugging (params is always empty). */
|
|
355
|
+
toSQL(): { sql: string; params: unknown[] } {
|
|
356
|
+
const compiled = compileStatementNative(this.#buildSpec());
|
|
357
|
+
const [sql] = compiled.statements;
|
|
358
|
+
if (sql === undefined) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
"[E_EON_COMPILE_EMPTY] the compiler returned no SQL statement",
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
return { sql, params: compiled.params };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Execute and return all matching rows. Memoised: multiple awaits, `.then`
|
|
368
|
+
* probes, `Promise.resolve(query)` — all share one round-trip. Call the query
|
|
369
|
+
* afresh (a new builder) to re-execute.
|
|
370
|
+
*/
|
|
371
|
+
exec(): Promise<TPoint[]> {
|
|
372
|
+
this.#cachedExec ??= this.#runSpec(this.#buildSpec());
|
|
373
|
+
return this.#cachedExec;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Alias of {@link exec}. */
|
|
377
|
+
all(): Promise<TPoint[]> {
|
|
378
|
+
return this.exec();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Execute with an implicit `LIMIT 1` and return the first row, or `null`. */
|
|
382
|
+
async first(): Promise<TPoint | null> {
|
|
383
|
+
const rows = await this.#runSpec({ ...this.#buildSpec(), limit: 1 });
|
|
384
|
+
return rows[0] ?? null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Thenable: `await query` runs `exec()`. This is the documented ergonomic
|
|
389
|
+
* (Lucid / atlas parity) — the `then` member IS the public API, so the
|
|
390
|
+
* `noThenProperty` lint is suppressed rather than refactored away.
|
|
391
|
+
*/
|
|
392
|
+
// biome-ignore lint/suspicious/noThenProperty: the thenable IS the public API — `await query` is the documented ergonomic (atlas ModelQuery parity); removing `.then` breaks every `await query` call site.
|
|
393
|
+
then<TResult1 = TPoint[], TResult2 = never>(
|
|
394
|
+
onfulfilled?:
|
|
395
|
+
| ((value: TPoint[]) => TResult1 | PromiseLike<TResult1>)
|
|
396
|
+
| null
|
|
397
|
+
| undefined,
|
|
398
|
+
onrejected?:
|
|
399
|
+
| ((reason: unknown) => TResult2 | PromiseLike<TResult2>)
|
|
400
|
+
| null
|
|
401
|
+
| undefined,
|
|
402
|
+
): Promise<TResult1 | TResult2> {
|
|
403
|
+
return this.exec().then(onfulfilled, onrejected);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#buildSpec(): SelectSpec {
|
|
407
|
+
return {
|
|
408
|
+
kind: "select",
|
|
409
|
+
table: this.#table,
|
|
410
|
+
select: this.#select.map((item) => toSelectItemSpec(item)),
|
|
411
|
+
wheres: this.#wheres,
|
|
412
|
+
partitionBy: this.#partitionBy,
|
|
413
|
+
interval: this.#interval,
|
|
414
|
+
sliding: this.#sliding,
|
|
415
|
+
fill: this.#fill,
|
|
416
|
+
orderBy: this.#orderBy,
|
|
417
|
+
limit: this.#limit,
|
|
418
|
+
offset: this.#offset,
|
|
419
|
+
literal: true,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async #runSpec(spec: SelectSpec): Promise<TPoint[]> {
|
|
424
|
+
const compiled = compileStatementNative(spec);
|
|
425
|
+
const [sql] = compiled.statements;
|
|
426
|
+
if (sql === undefined) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
"[E_EON_COMPILE_EMPTY] the compiler returned no SQL statement",
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
// The literal-read contract: a builder SELECT must compile param-free, so
|
|
432
|
+
// running it through the literal-only `query(sql)` is safe. A non-empty
|
|
433
|
+
// `params` means literal mode did not hold — fail loud rather than run a
|
|
434
|
+
// statement whose `?` placeholders the connection would silently ignore.
|
|
435
|
+
if (compiled.params.length > 0) {
|
|
436
|
+
throw new Error(
|
|
437
|
+
"[E_EON_UNEXPECTED_PARAMS] a time-series builder SELECT must compile to literal-only SQL, but the compiler returned bound params — literal-mode regression",
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
const rawRows = await this.#conn.query<Record<string, unknown>>(sql);
|
|
441
|
+
return this.#mapRows(rawRows);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Map raw rows to points: the hydrate closure revives declared columns
|
|
446
|
+
* (bigint / timestamp → `bigint`) by metadata; any remaining key (a window
|
|
447
|
+
* pseudo-column or aggregate alias, not a declared column) is attached raw —
|
|
448
|
+
* the atlas `setExtra` escape for raw projections.
|
|
449
|
+
*/
|
|
450
|
+
#mapRows(rawRows: readonly Record<string, unknown>[]): TPoint[] {
|
|
451
|
+
return rawRows.map((row) => {
|
|
452
|
+
const point = this.#hydrate(row);
|
|
453
|
+
for (const key of Object.keys(row)) {
|
|
454
|
+
if (!this.#knownColumns.has(key)) {
|
|
455
|
+
// `defineProperty`, not `Reflect.set`/assignment: a raw column or
|
|
456
|
+
// alias literally named `__proto__`/`constructor` would otherwise
|
|
457
|
+
// invoke the inherited accessor and re-parent `point`. This defines
|
|
458
|
+
// an own data property under the exact key instead.
|
|
459
|
+
Object.defineProperty(point, key, {
|
|
460
|
+
value: row[key],
|
|
461
|
+
writable: true,
|
|
462
|
+
enumerable: true,
|
|
463
|
+
configurable: true,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return point;
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function toSelectItemSpec(item: EonSelectItem): SelectItemSpec {
|
|
473
|
+
if (typeof item === "string") {
|
|
474
|
+
return item;
|
|
475
|
+
}
|
|
476
|
+
if ("pseudo" in item) {
|
|
477
|
+
return { pseudo: item.pseudo, alias: item.as };
|
|
478
|
+
}
|
|
479
|
+
return { function: item.fn, column: item.column, alias: item.as };
|
|
480
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native query compiler loader — loads the Rust `eon-query` NAPI binary and
|
|
3
|
+
* exposes the JSON compile boundary to TypeScript.
|
|
4
|
+
*
|
|
5
|
+
* The binary is a single `index.<suffix>.node` at the package root (built by
|
|
6
|
+
* `pnpm --filter @c9up/eon build:napi`). Loading is lazy and fails loud: a miss
|
|
7
|
+
* throws a descriptive error naming the attempted path — never a silent `null`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { arch, platform } from "node:process";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const require2 = createRequire(import.meta.url);
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
const platformMap: Record<string, string> = {
|
|
20
|
+
"linux-x64": "linux-x64-gnu",
|
|
21
|
+
"linux-arm64": "linux-arm64-gnu",
|
|
22
|
+
"darwin-x64": "darwin-x64",
|
|
23
|
+
"darwin-arm64": "darwin-arm64",
|
|
24
|
+
"win32-x64": "win32-x64-msvc",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
interface NativeBinding {
|
|
28
|
+
compileStatement: (specJson: string, dialect: string) => string;
|
|
29
|
+
quoteIdent: (name: string) => string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The only dialect eon compiles today (AD10 single-variant seam). */
|
|
33
|
+
export type EonDialect = "tdengine";
|
|
34
|
+
|
|
35
|
+
export interface CompiledStatement {
|
|
36
|
+
statements: string[];
|
|
37
|
+
params: unknown[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let native: NativeBinding | undefined;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve and cache the NAPI binding. Throws a descriptive error on an
|
|
44
|
+
* unsupported platform or a missing/unloadable binary — never returns/stores
|
|
45
|
+
* `null`. Called lazily so importing the package does not require a built
|
|
46
|
+
* binary (typecheck, type-only consumers), but any actual compile fails loud.
|
|
47
|
+
*/
|
|
48
|
+
function loadNative(): NativeBinding {
|
|
49
|
+
if (native) return native;
|
|
50
|
+
const key = `${platform}-${arch}`;
|
|
51
|
+
const suffix = platformMap[key];
|
|
52
|
+
if (!suffix) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`[E_EON_NAPI_UNSUPPORTED] no eon-query native binary for platform '${key}'. Supported: ${Object.keys(platformMap).join(", ")}.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const binaryPath = join(here, `../../index.${suffix}.node`);
|
|
58
|
+
let binding: NativeBinding;
|
|
59
|
+
try {
|
|
60
|
+
binding = require2(binaryPath);
|
|
61
|
+
} catch (cause) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`[E_EON_NAPI_NOT_FOUND] eon-query native binary not found at '${binaryPath}'. Build it with: pnpm --filter @c9up/eon build:napi`,
|
|
64
|
+
cause !== undefined ? { cause } : undefined,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
native = binding;
|
|
68
|
+
return binding;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* i64 / nanosecond-timestamp precision guard for the JSON compile boundary.
|
|
73
|
+
*
|
|
74
|
+
* JS `number` cannot represent integers beyond 2^53 (`Number.MAX_SAFE_INTEGER`),
|
|
75
|
+
* yet a TDengine nanosecond `ts` (~1.7e18) or a `BIGINT` column needs full i64.
|
|
76
|
+
* A plain `JSON.stringify` throws on `bigint` and would silently truncate an
|
|
77
|
+
* unsafe-integer `number`; a plain `JSON.parse` truncates any i64 echoed back.
|
|
78
|
+
*
|
|
79
|
+
* Inbound — `bigint` params are carried as bare integer literals (`serde_json`
|
|
80
|
+
* reads them as i64); a `number` that is an unsafe integer is already
|
|
81
|
+
* lossy, so it is rejected loud rather than compiled corrupted.
|
|
82
|
+
* Outbound — integer literals beyond the safe range are revived as `bigint`
|
|
83
|
+
* using the `JSON.parse` source context (Node ≥22), so no echoed
|
|
84
|
+
* param is silently narrowed to a lossy double.
|
|
85
|
+
*/
|
|
86
|
+
function stringifyPrecisionSafe(spec: object): string {
|
|
87
|
+
// A per-call nonce makes the bigint carrier impossible to collide with — or
|
|
88
|
+
// forge from — caller-supplied string/key data: the marker only exists once
|
|
89
|
+
// this call mints it, so no inbound value can already contain it. (A fixed
|
|
90
|
+
// sentinel let a legitimate string param `"__eon_i64__42"` be silently
|
|
91
|
+
// rewritten to the integer 42, and a matching object key produced invalid
|
|
92
|
+
// JSON that hard-failed in serde.)
|
|
93
|
+
const open = `__eon_i64_${randomUUID().replace(/-/g, "")}__`;
|
|
94
|
+
let sawBigInt = false;
|
|
95
|
+
const json = JSON.stringify(spec, (_key, value: unknown) => {
|
|
96
|
+
if (typeof value === "bigint") {
|
|
97
|
+
if (value < -9223372036854775808n || value > 9223372036854775807n) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`[E_EON_PARAM_PRECISION] bigint param ${value} is outside the signed 64-bit range; TDengine BIGINT cannot represent it (it would silently narrow to a lossy float in the compiler).`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
sawBigInt = true;
|
|
103
|
+
return `${open}${value.toString()}__`;
|
|
104
|
+
}
|
|
105
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
106
|
+
// NaN / ±Infinity have no SQL-literal form: `JSON.stringify` maps them to
|
|
107
|
+
// `null`, which would silently become a NULL literal (a dropped LIMIT →
|
|
108
|
+
// full scan, a FILL(VALUE, NULL), or a never-matching WHERE). Reject loud.
|
|
109
|
+
throw new Error(
|
|
110
|
+
`[E_EON_PARAM_PRECISION] non-finite number param (${value}) has no SQL-literal representation; it would silently serialise to NULL.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (
|
|
114
|
+
typeof value === "number" &&
|
|
115
|
+
Number.isInteger(value) &&
|
|
116
|
+
!Number.isSafeInteger(value)
|
|
117
|
+
) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`[E_EON_PARAM_PRECISION] integer param ${value} exceeds the JS safe-integer range (2^53) and has already lost precision. Pass i64 / nanosecond-timestamp params as bigint.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
});
|
|
124
|
+
if (!sawBigInt) return json;
|
|
125
|
+
// Rewrite ONLY the nonced markers this call emitted → bare integer literals
|
|
126
|
+
// that serde_json reads as i64. Anchored to the nonce (hex, no regex
|
|
127
|
+
// metachars), so caller data of the shape `"__eon_i64__42"` is left intact.
|
|
128
|
+
const token = new RegExp(`"${open}(-?\\d+)__"`, "g");
|
|
129
|
+
return json.replace(token, "$1");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface ReviverContext {
|
|
133
|
+
source: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parsePrecisionSafe(json: string): CompiledStatement {
|
|
137
|
+
const parsed: CompiledStatement = JSON.parse(
|
|
138
|
+
json,
|
|
139
|
+
(_key, value: unknown, context?: ReviverContext) => {
|
|
140
|
+
if (
|
|
141
|
+
typeof value === "number" &&
|
|
142
|
+
Number.isInteger(value) &&
|
|
143
|
+
!Number.isSafeInteger(value)
|
|
144
|
+
) {
|
|
145
|
+
// An i64 the Rust compiler echoed back as a bare integer literal.
|
|
146
|
+
// Revive it losslessly from the raw source text (Node ≥22). If the
|
|
147
|
+
// runtime does not expose `context.source` the double is already
|
|
148
|
+
// lossy — fail loud rather than silently narrow it (`engines` is
|
|
149
|
+
// only advisory; a bundler/older runtime could reach here).
|
|
150
|
+
if (context !== undefined && /^-?\d+$/.test(context.source)) {
|
|
151
|
+
return BigInt(context.source);
|
|
152
|
+
}
|
|
153
|
+
throw new Error(
|
|
154
|
+
`[E_EON_PARAM_PRECISION] cannot losslessly revive the i64 value ${value}: this runtime does not expose the JSON.parse source context (Node ≥22 required); it would otherwise be silently narrowed to a lossy double.`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
return parsed;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Compile a tagged statement spec (`{ kind: 'select' | 'insert', ... }`) into
|
|
165
|
+
* `{ statements, params }` via the Rust compiler.
|
|
166
|
+
*/
|
|
167
|
+
export function compileStatementNative(
|
|
168
|
+
spec: object,
|
|
169
|
+
dialect: EonDialect = "tdengine",
|
|
170
|
+
): CompiledStatement {
|
|
171
|
+
const payload = stringifyPrecisionSafe(spec);
|
|
172
|
+
const binding = loadNative();
|
|
173
|
+
const json = binding.compileStatement(payload, dialect);
|
|
174
|
+
return parsePrecisionSafe(json);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Validate and backtick-quote a TDengine identifier via the Rust seam. */
|
|
178
|
+
export function quoteIdentNative(name: string): string {
|
|
179
|
+
return loadNative().quoteIdent(name);
|
|
180
|
+
}
|