@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,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket implementation of `EonConnection` over `@tdengine/websocket` 3.5.0
|
|
3
|
+
* (ws-only via taosAdapter — no libtaos, no native FFI, D1). The spike-proven
|
|
4
|
+
* recipe: `WSConfig` + setters → `sqlConnect`; cursor-decode SELECT rows into
|
|
5
|
+
* column-name-keyed objects; TIMESTAMP / BIGINT stay `BigInt` epoch values (no
|
|
6
|
+
* lossy `Date` coercion — the consuming ORM layer decides, D4).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
Precision,
|
|
11
|
+
SchemalessProto,
|
|
12
|
+
type StmtBindParams,
|
|
13
|
+
sqlConnect,
|
|
14
|
+
type TDengineMeta,
|
|
15
|
+
TDWebSocketClientError,
|
|
16
|
+
WSConfig,
|
|
17
|
+
type WSRows,
|
|
18
|
+
type WsSql,
|
|
19
|
+
} from "@tdengine/websocket";
|
|
20
|
+
import type { EonConnectionConfig } from "./config.js";
|
|
21
|
+
import {
|
|
22
|
+
type EonBoundColumn,
|
|
23
|
+
type EonColumnarIngest,
|
|
24
|
+
type EonConnection,
|
|
25
|
+
EonConnectionError,
|
|
26
|
+
type EonLineProtocol,
|
|
27
|
+
type EonSchemalessOptions,
|
|
28
|
+
type EonSchemalessPrecision,
|
|
29
|
+
} from "./EonConnection.js";
|
|
30
|
+
|
|
31
|
+
/** Map an agnostic line-protocol name to the connector's `SchemalessProto`. */
|
|
32
|
+
function toSchemalessProto(protocol: EonLineProtocol): SchemalessProto {
|
|
33
|
+
switch (protocol) {
|
|
34
|
+
case "influxdb":
|
|
35
|
+
return SchemalessProto.InfluxDBLineProtocol;
|
|
36
|
+
case "opentsdb-telnet":
|
|
37
|
+
return SchemalessProto.OpenTSDBTelnetLineProtocol;
|
|
38
|
+
case "opentsdb-json":
|
|
39
|
+
return SchemalessProto.OpenTSDBJsonFormatProtocol;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Map an agnostic precision to the connector's `Precision`. */
|
|
44
|
+
function toPrecision(precision: EonSchemalessPrecision): Precision {
|
|
45
|
+
switch (precision) {
|
|
46
|
+
case "hours":
|
|
47
|
+
return Precision.HOURS;
|
|
48
|
+
case "minutes":
|
|
49
|
+
return Precision.MINUTES;
|
|
50
|
+
case "seconds":
|
|
51
|
+
return Precision.SECONDS;
|
|
52
|
+
case "milliseconds":
|
|
53
|
+
return Precision.MILLI_SECONDS;
|
|
54
|
+
case "microseconds":
|
|
55
|
+
return Precision.MICRO_SECONDS;
|
|
56
|
+
case "nanoseconds":
|
|
57
|
+
return Precision.NANO_SECONDS;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Bind one whole column (SoA) into a `StmtBindParams` via the setter its kind
|
|
63
|
+
* selects. The connector's setters take the entire column array in one call —
|
|
64
|
+
* this is the columnar boundary the locked 58.0 contract mandates (never a
|
|
65
|
+
* per-row object). The setter params are typed `any[]` by the connector; we
|
|
66
|
+
* pass our `unknown[]` column verbatim (no `any` enters eon).
|
|
67
|
+
*/
|
|
68
|
+
function bindColumn(params: StmtBindParams, column: EonBoundColumn): void {
|
|
69
|
+
const v = column.values;
|
|
70
|
+
switch (column.kind) {
|
|
71
|
+
case "timestamp":
|
|
72
|
+
params.setTimestamp(v);
|
|
73
|
+
return;
|
|
74
|
+
case "bool":
|
|
75
|
+
params.setBoolean(v);
|
|
76
|
+
return;
|
|
77
|
+
case "tinyInt":
|
|
78
|
+
params.setTinyInt(v);
|
|
79
|
+
return;
|
|
80
|
+
case "smallInt":
|
|
81
|
+
params.setSmallInt(v);
|
|
82
|
+
return;
|
|
83
|
+
case "int":
|
|
84
|
+
params.setInt(v);
|
|
85
|
+
return;
|
|
86
|
+
case "bigInt":
|
|
87
|
+
params.setBigint(v);
|
|
88
|
+
return;
|
|
89
|
+
case "float":
|
|
90
|
+
params.setFloat(v);
|
|
91
|
+
return;
|
|
92
|
+
case "double":
|
|
93
|
+
params.setDouble(v);
|
|
94
|
+
return;
|
|
95
|
+
case "varchar":
|
|
96
|
+
params.setVarchar(v);
|
|
97
|
+
return;
|
|
98
|
+
case "nchar":
|
|
99
|
+
params.setNchar(v);
|
|
100
|
+
return;
|
|
101
|
+
case "varbinary":
|
|
102
|
+
params.setVarBinary(v);
|
|
103
|
+
return;
|
|
104
|
+
case "json":
|
|
105
|
+
params.setJson(v);
|
|
106
|
+
return;
|
|
107
|
+
case "decimal":
|
|
108
|
+
params.setDecimal(v);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const DEFAULT_BACKOFF_MS = 200;
|
|
114
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
115
|
+
|
|
116
|
+
function delay(ms: number): Promise<void> {
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
setTimeout(resolve, ms);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Redact a statement for error/log messages: keep the leading clause (verb +
|
|
124
|
+
* quoted identifiers) but drop any value-bearing tail (`VALUES` / `WHERE` /
|
|
125
|
+
* `SET`, or a parenthesised list), so literal measurements/tags never leak into
|
|
126
|
+
* log sinks. Identifiers are backtick-quoted names, not sensitive data.
|
|
127
|
+
*/
|
|
128
|
+
function sqlHead(sql: string): string {
|
|
129
|
+
const cut = sql.search(/\b(?:VALUES|WHERE|SET)\b|\(/i);
|
|
130
|
+
const head = (cut === -1 ? sql : sql.slice(0, cut)).trim();
|
|
131
|
+
return head.length > 80 ? `${head.slice(0, 80)}…` : head;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Wrap a connector failure as an `EonConnectionError`, preserving `.code`. */
|
|
135
|
+
function wrapError(cause: unknown, message: string): EonConnectionError {
|
|
136
|
+
if (cause instanceof TDWebSocketClientError) {
|
|
137
|
+
return new EonConnectionError(`${message}: ${cause.message}`, {
|
|
138
|
+
cause,
|
|
139
|
+
code: cause.code,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return new EonConnectionError(message, { cause });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Race a connect against an overall deadline so a socket that accepts TCP but
|
|
147
|
+
* stalls the WS handshake can't wedge boot forever (`WSConfig.setTimeOut` bounds
|
|
148
|
+
* requests, not necessarily the handshake). A timeout rejects → the retry loop
|
|
149
|
+
* treats it like any other connect failure. The timer is always cleared.
|
|
150
|
+
*/
|
|
151
|
+
async function withConnectTimeout(
|
|
152
|
+
connect: Promise<WsSql>,
|
|
153
|
+
ms: number | undefined,
|
|
154
|
+
): Promise<WsSql> {
|
|
155
|
+
if (ms === undefined) return connect;
|
|
156
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
157
|
+
let timedOut = false;
|
|
158
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
159
|
+
timer = setTimeout(() => {
|
|
160
|
+
timedOut = true;
|
|
161
|
+
reject(
|
|
162
|
+
new EonConnectionError(
|
|
163
|
+
`eon: connect did not complete within ${ms}ms (WS handshake stalled)`,
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
}, ms);
|
|
167
|
+
});
|
|
168
|
+
// If the deadline wins the race, the connect promise is abandoned but still
|
|
169
|
+
// pending — a handshake that completes late resolves to a live WsSql nobody
|
|
170
|
+
// holds or closes (an orphaned socket per timed-out attempt, worst under a
|
|
171
|
+
// retry loop). Tear it down when it eventually settles.
|
|
172
|
+
connect
|
|
173
|
+
.then((ws) => {
|
|
174
|
+
if (timedOut) void ws.close().catch(() => {});
|
|
175
|
+
})
|
|
176
|
+
.catch(() => {});
|
|
177
|
+
try {
|
|
178
|
+
return await Promise.race([connect, deadline]);
|
|
179
|
+
} finally {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Open the WS connection, retrying only the INITIAL connect (docker/k8s
|
|
186
|
+
* cold-start race — D7 / atlas `createNapiConnection` parity). Backoff grows
|
|
187
|
+
* exponentially, capped at 30s. This is NOT a per-query reconnect.
|
|
188
|
+
*/
|
|
189
|
+
async function connectWithRetry(config: EonConnectionConfig): Promise<WsSql> {
|
|
190
|
+
if (typeof config.url !== "string" || config.url.trim() === "") {
|
|
191
|
+
throw new EonConnectionError(
|
|
192
|
+
"eon: connection 'url' is required and must be a non-empty WebSocket URL (e.g. 'ws://localhost:6041')",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
// Clamp: a negative, fractional, or NaN `connectRetries` must still make one
|
|
196
|
+
// attempt, never zero — a zero-iteration loop would skip connecting entirely
|
|
197
|
+
// and then throw with an `undefined` cause, hiding the real config mistake.
|
|
198
|
+
// `Math.max(0, NaN)` is `NaN`, so guard non-finite before the arithmetic.
|
|
199
|
+
const configured = config.connectRetries;
|
|
200
|
+
const retries =
|
|
201
|
+
typeof configured === "number" && Number.isFinite(configured)
|
|
202
|
+
? Math.max(0, Math.floor(configured))
|
|
203
|
+
: 0;
|
|
204
|
+
const attempts = retries + 1;
|
|
205
|
+
// Clamp like `connectRetries`: a NaN/negative backoff (`??` only catches
|
|
206
|
+
// null/undefined) would make `delay()` fire immediately, turning the retry
|
|
207
|
+
// loop into a tight no-delay hammer against the server.
|
|
208
|
+
const configuredBackoff = config.connectBackoffMs;
|
|
209
|
+
const baseBackoff =
|
|
210
|
+
typeof configuredBackoff === "number" && Number.isFinite(configuredBackoff)
|
|
211
|
+
? Math.max(0, configuredBackoff)
|
|
212
|
+
: DEFAULT_BACKOFF_MS;
|
|
213
|
+
let lastError: unknown;
|
|
214
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
215
|
+
try {
|
|
216
|
+
const conf = new WSConfig(config.url);
|
|
217
|
+
if (config.user !== undefined) conf.setUser(config.user);
|
|
218
|
+
if (config.password !== undefined) conf.setPwd(config.password);
|
|
219
|
+
if (config.database !== undefined) conf.setDb(config.database);
|
|
220
|
+
if (config.token !== undefined) conf.setToken(config.token);
|
|
221
|
+
if (config.timeoutMs !== undefined) conf.setTimeOut(config.timeoutMs);
|
|
222
|
+
return await withConnectTimeout(sqlConnect(conf), config.timeoutMs);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
lastError = error;
|
|
225
|
+
if (attempt === attempts - 1) break;
|
|
226
|
+
await delay(Math.min(baseBackoff * 2 ** attempt, MAX_BACKOFF_MS));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
throw wrapError(
|
|
230
|
+
lastError,
|
|
231
|
+
`eon: failed to connect to '${config.url}' after ${attempts} attempt(s)`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Connect to TDengine over WebSocket and return a transport-agnostic
|
|
237
|
+
* `EonConnection`. Credentials go through `WSConfig` setters, never embedded in
|
|
238
|
+
* the URL. The returned object is a single long-lived connection (no pool, D7).
|
|
239
|
+
*/
|
|
240
|
+
export async function connectWsEon(
|
|
241
|
+
config: EonConnectionConfig,
|
|
242
|
+
): Promise<EonConnection> {
|
|
243
|
+
const wsSql = await connectWithRetry(config);
|
|
244
|
+
|
|
245
|
+
let closed = false;
|
|
246
|
+
// Serialize every operation on the single shared socket. `@tdengine/websocket`
|
|
247
|
+
// multiplexes request/response over one connection, so two overlapping calls
|
|
248
|
+
// could interleave frames and desync responses (rows attributed to the wrong
|
|
249
|
+
// query). A promise-chain mutex runs each op strictly after the previous one
|
|
250
|
+
// settles — success or failure (D7: one long-lived connection, no pool).
|
|
251
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
252
|
+
function serialize<T>(op: () => Promise<T>): Promise<T> {
|
|
253
|
+
const run = queue.then(op, op);
|
|
254
|
+
queue = run.then(
|
|
255
|
+
() => undefined,
|
|
256
|
+
() => undefined,
|
|
257
|
+
);
|
|
258
|
+
return run;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Reject use-after-close loudly instead of dereferencing the dead handle. */
|
|
262
|
+
function ensureOpen(): void {
|
|
263
|
+
if (closed) throw new EonConnectionError("eon: connection is closed");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function execStatement(sql: string): Promise<{ rowsAffected: number }> {
|
|
267
|
+
ensureOpen();
|
|
268
|
+
return serialize(async () => {
|
|
269
|
+
try {
|
|
270
|
+
const result = await wsSql.exec(sql);
|
|
271
|
+
return { rowsAffected: result.getAffectRows() ?? 0 };
|
|
272
|
+
} catch (error) {
|
|
273
|
+
throw wrapError(error, `eon: exec failed for [${sqlHead(sql)}]`);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Overload: the public seam is generic (`query<T>(): Promise<T[]>`), the
|
|
279
|
+
// implementation decodes into `Record<string, unknown>[]`. The overload lets
|
|
280
|
+
// the concrete impl satisfy the generic signature with no `as` cast (AC9) —
|
|
281
|
+
// narrowing to `T` is the caller's assertion at the call site.
|
|
282
|
+
function runQuery<T = Record<string, unknown>>(sql: string): Promise<T[]>;
|
|
283
|
+
async function runQuery(sql: string): Promise<Record<string, unknown>[]> {
|
|
284
|
+
ensureOpen();
|
|
285
|
+
return serialize(async () => {
|
|
286
|
+
let rows: WSRows;
|
|
287
|
+
try {
|
|
288
|
+
rows = await wsSql.query(sql);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
throw wrapError(error, `eon: query failed for [${sqlHead(sql)}]`);
|
|
291
|
+
}
|
|
292
|
+
let out: Record<string, unknown>[];
|
|
293
|
+
try {
|
|
294
|
+
// getMeta(): {name,type,length}[] | null — column order matches getData().
|
|
295
|
+
const meta = rows.getMeta() ?? [];
|
|
296
|
+
out = [];
|
|
297
|
+
while (await rows.next()) {
|
|
298
|
+
// A non-empty cursor with empty column metadata would decode every
|
|
299
|
+
// row to `{}` — silent data loss. Fail loud instead.
|
|
300
|
+
if (meta.length === 0) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`[E_EON_DECODE] query returned rows but no column metadata for [${sqlHead(sql)}]; cannot decode`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
// getData() is typed `any[]` by the connector; funnel it through
|
|
306
|
+
// `unknown[]` so no `any` leaks into eon (AC9). Values stay
|
|
307
|
+
// connector-native — TIMESTAMP/BIGINT are BigInt (D4).
|
|
308
|
+
const row: unknown[] = rows.getData() ?? [];
|
|
309
|
+
const record: Record<string, unknown> = {};
|
|
310
|
+
meta.forEach((column: TDengineMeta, index: number) => {
|
|
311
|
+
record[column.name] = row[index];
|
|
312
|
+
});
|
|
313
|
+
out.push(record);
|
|
314
|
+
}
|
|
315
|
+
} catch (error) {
|
|
316
|
+
// The decode error is the real one — close best-effort so a failing
|
|
317
|
+
// close on the dead socket never masks it.
|
|
318
|
+
await rows.close().catch(() => {});
|
|
319
|
+
throw wrapError(error, `eon: query failed for [${sqlHead(sql)}]`);
|
|
320
|
+
}
|
|
321
|
+
// Decode succeeded: here a close failure IS the primary error, surface it.
|
|
322
|
+
try {
|
|
323
|
+
await rows.close();
|
|
324
|
+
} catch (error) {
|
|
325
|
+
throw wrapError(
|
|
326
|
+
error,
|
|
327
|
+
`eon: cursor close failed for [${sqlHead(sql)}]`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function pingServer(): Promise<void> {
|
|
335
|
+
ensureOpen();
|
|
336
|
+
return serialize(async () => {
|
|
337
|
+
let rows: WSRows;
|
|
338
|
+
try {
|
|
339
|
+
rows = await wsSql.query("SELECT server_version()");
|
|
340
|
+
} catch (error) {
|
|
341
|
+
throw wrapError(error, "eon: ping failed");
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
await rows.close();
|
|
345
|
+
} catch (error) {
|
|
346
|
+
throw wrapError(error, "eon: ping failed");
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Columnar STMT bulk ingest (58.4). One `stmtInit` + `prepare` reused across
|
|
353
|
+
* every child batch: `setTableName` → columnar `setTags` → columnar `bind` →
|
|
354
|
+
* `batch` → `exec`. The STMT handle is always released in `finally`, even on
|
|
355
|
+
* the error path (AC6). Serialized on the shared socket like every other op.
|
|
356
|
+
*/
|
|
357
|
+
async function ingestColumnar(
|
|
358
|
+
request: EonColumnarIngest,
|
|
359
|
+
): Promise<{ rowsAffected: number }> {
|
|
360
|
+
ensureOpen();
|
|
361
|
+
return serialize(async () => {
|
|
362
|
+
// stmtInit sits before the try/finally that owns the handle; wrap its
|
|
363
|
+
// failure too so a stmtInit error carries the EonConnectionError code
|
|
364
|
+
// like every other op (it acquires no handle, so there is nothing to
|
|
365
|
+
// release on this path).
|
|
366
|
+
const stmt = await wsSql.stmtInit().catch((error) => {
|
|
367
|
+
throw wrapError(error, "eon: columnar STMT ingest failed (stmtInit)");
|
|
368
|
+
});
|
|
369
|
+
try {
|
|
370
|
+
await stmt.prepare(request.sql);
|
|
371
|
+
let rowsAffected = 0;
|
|
372
|
+
for (const child of request.children) {
|
|
373
|
+
await stmt.setTableName(child.table);
|
|
374
|
+
const tagParams = stmt.newStmtParam();
|
|
375
|
+
for (const tag of child.tags) bindColumn(tagParams, tag);
|
|
376
|
+
await stmt.setTags(tagParams);
|
|
377
|
+
const colParams = stmt.newStmtParam();
|
|
378
|
+
for (const column of child.columns) bindColumn(colParams, column);
|
|
379
|
+
await stmt.bind(colParams);
|
|
380
|
+
await stmt.batch();
|
|
381
|
+
await stmt.exec();
|
|
382
|
+
rowsAffected += stmt.getLastAffected() ?? 0;
|
|
383
|
+
}
|
|
384
|
+
return { rowsAffected };
|
|
385
|
+
} catch (error) {
|
|
386
|
+
throw wrapError(error, "eon: columnar STMT ingest failed");
|
|
387
|
+
} finally {
|
|
388
|
+
// Release the STMT resource unconditionally; a close failure here must
|
|
389
|
+
// not mask a real ingest error, so it is swallowed best-effort.
|
|
390
|
+
await stmt.close().catch(() => {});
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function schemalessInsert(
|
|
396
|
+
lines: readonly string[],
|
|
397
|
+
options?: EonSchemalessOptions,
|
|
398
|
+
): Promise<void> {
|
|
399
|
+
ensureOpen();
|
|
400
|
+
return serialize(async () => {
|
|
401
|
+
try {
|
|
402
|
+
await wsSql.schemalessInsert(
|
|
403
|
+
[...lines],
|
|
404
|
+
toSchemalessProto(options?.protocol ?? "influxdb"),
|
|
405
|
+
toPrecision(options?.precision ?? "milliseconds"),
|
|
406
|
+
options?.ttl ?? 0,
|
|
407
|
+
);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
throw wrapError(error, "eon: schemaless insert failed");
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function closeConnection(): Promise<void> {
|
|
415
|
+
if (closed) return; // idempotent — double-close is a no-op, not a connector error
|
|
416
|
+
closed = true;
|
|
417
|
+
// Drain any in-flight op (enqueued before `closed` was set) then close.
|
|
418
|
+
return serialize(async () => {
|
|
419
|
+
try {
|
|
420
|
+
await wsSql.close();
|
|
421
|
+
} catch (error) {
|
|
422
|
+
throw wrapError(error, "eon: close failed");
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
transport: "websocket",
|
|
429
|
+
exec: execStatement,
|
|
430
|
+
query: runQuery,
|
|
431
|
+
ping: pingServer,
|
|
432
|
+
ingestColumnar,
|
|
433
|
+
schemaless: schemalessInsert,
|
|
434
|
+
close: closeConnection,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Super-table decorators — the Atlas-shaped declarative schema layer for
|
|
3
|
+
* TDengine, adapted to the STable / tag / child-table model.
|
|
4
|
+
*
|
|
5
|
+
* `@SuperTable` marks a class (mirror atlas `@Entity`); `@Timestamp` is the
|
|
6
|
+
* mandatory first `TIMESTAMP` primary column (the eon analogue of atlas
|
|
7
|
+
* `@PrimaryKey`, which likewise registers a column); `@Column` is a metric
|
|
8
|
+
* column; `@Tag` registers into a SEPARATE tag registry (TDengine's tags have
|
|
9
|
+
* no atlas analogue — this is the one intrinsic divergence, D2).
|
|
10
|
+
*
|
|
11
|
+
* Because eon entities use `@Column() declare x` (memory
|
|
12
|
+
* `project_atlas_declare_hydration`), `x in instance` is ALWAYS false at
|
|
13
|
+
* runtime — every consumer reads schema through the getters below, NEVER from
|
|
14
|
+
* instance keys.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import "reflect-metadata";
|
|
18
|
+
import {
|
|
19
|
+
COLUMNS_KEY,
|
|
20
|
+
SUPER_TABLE_KEY,
|
|
21
|
+
TAGS_KEY,
|
|
22
|
+
TIMESTAMP_KEY,
|
|
23
|
+
} from "../metadata-keys.js";
|
|
24
|
+
|
|
25
|
+
/** Options for `@Timestamp` / `@Column`. `type` is a LOGICAL type string. */
|
|
26
|
+
export interface EonColumnOptions {
|
|
27
|
+
/** Logical type (e.g. `"float"`, `"int"`, `"varchar"`). Mapped to a TDengine physical type in the compiler. */
|
|
28
|
+
type?: string;
|
|
29
|
+
/** Length for `varchar` / `nchar` / `varbinary` — required for those types. */
|
|
30
|
+
length?: number;
|
|
31
|
+
/** Precision for `decimal` — required for `decimal`. */
|
|
32
|
+
precision?: number;
|
|
33
|
+
/** Scale for `decimal`. */
|
|
34
|
+
scale?: number;
|
|
35
|
+
/** Whether the column is nullable (metadata only — TDengine columns are nullable by default). */
|
|
36
|
+
nullable?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Options for `@Tag` — same as a column minus `nullable` (tags are always nullable-on-partial-insert). */
|
|
40
|
+
export type EonTagOptions = Omit<EonColumnOptions, "nullable">;
|
|
41
|
+
|
|
42
|
+
/** Metric-column metadata (declaration order preserved). */
|
|
43
|
+
export interface EonColumnMetadata {
|
|
44
|
+
propertyKey: string;
|
|
45
|
+
type?: string;
|
|
46
|
+
length?: number;
|
|
47
|
+
precision?: number;
|
|
48
|
+
scale?: number;
|
|
49
|
+
nullable?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Tag metadata (declaration order preserved). */
|
|
53
|
+
export interface EonTagMetadata {
|
|
54
|
+
propertyKey: string;
|
|
55
|
+
type?: string;
|
|
56
|
+
length?: number;
|
|
57
|
+
precision?: number;
|
|
58
|
+
scale?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Super-table descriptor. */
|
|
62
|
+
export interface SuperTableMetadata {
|
|
63
|
+
name: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `@SuperTable('name')` — marks a class as a TDengine super-table. */
|
|
67
|
+
export function SuperTable(name: string): ClassDecorator {
|
|
68
|
+
return (target) => {
|
|
69
|
+
Reflect.defineMetadata(SUPER_TABLE_KEY, { name }, target);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Push a metric column onto the registry, deduping on re-decoration (mirror atlas `@Column`). */
|
|
74
|
+
function pushColumn(
|
|
75
|
+
ctor: object,
|
|
76
|
+
propertyKey: string,
|
|
77
|
+
options: EonColumnOptions | undefined,
|
|
78
|
+
): void {
|
|
79
|
+
const columns: EonColumnMetadata[] =
|
|
80
|
+
Reflect.getOwnMetadata(COLUMNS_KEY, ctor) ?? [];
|
|
81
|
+
if (!columns.some((c) => c.propertyKey === propertyKey)) {
|
|
82
|
+
columns.push({
|
|
83
|
+
propertyKey,
|
|
84
|
+
type: options?.type,
|
|
85
|
+
length: options?.length,
|
|
86
|
+
precision: options?.precision,
|
|
87
|
+
scale: options?.scale,
|
|
88
|
+
nullable: options?.nullable,
|
|
89
|
+
});
|
|
90
|
+
Reflect.defineMetadata(COLUMNS_KEY, columns, ctor);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `@Timestamp()` — the mandatory first `TIMESTAMP` primary column. Exactly one
|
|
96
|
+
* per super-table (enforced by the compiler, AC5). Registers a
|
|
97
|
+
* `type: "timestamp"` metric column AND records the ts property name.
|
|
98
|
+
*/
|
|
99
|
+
export function Timestamp(options?: EonColumnOptions): PropertyDecorator {
|
|
100
|
+
return (target, propertyKey) => {
|
|
101
|
+
const key = String(propertyKey);
|
|
102
|
+
Reflect.defineMetadata(TIMESTAMP_KEY, key, target.constructor);
|
|
103
|
+
pushColumn(target.constructor, key, {
|
|
104
|
+
...options,
|
|
105
|
+
type: options?.type ?? "timestamp",
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `@Column()` — a metric column. */
|
|
111
|
+
export function Column(options?: EonColumnOptions): PropertyDecorator {
|
|
112
|
+
return (target, propertyKey) => {
|
|
113
|
+
pushColumn(target.constructor, String(propertyKey), options);
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `@Tag()` — a tag column, stored in a SEPARATE registry from metrics (D2). */
|
|
118
|
+
export function Tag(options?: EonTagOptions): PropertyDecorator {
|
|
119
|
+
return (target, propertyKey) => {
|
|
120
|
+
const ctor = target.constructor;
|
|
121
|
+
const tags: EonTagMetadata[] = Reflect.getOwnMetadata(TAGS_KEY, ctor) ?? [];
|
|
122
|
+
const key = String(propertyKey);
|
|
123
|
+
if (!tags.some((t) => t.propertyKey === key)) {
|
|
124
|
+
tags.push({
|
|
125
|
+
propertyKey: key,
|
|
126
|
+
type: options?.type,
|
|
127
|
+
length: options?.length,
|
|
128
|
+
precision: options?.precision,
|
|
129
|
+
scale: options?.scale,
|
|
130
|
+
});
|
|
131
|
+
Reflect.defineMetadata(TAGS_KEY, tags, ctor);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Read the super-table descriptor for a class (`undefined` if undecorated). */
|
|
137
|
+
export function getSuperTableMetadata(
|
|
138
|
+
target: object,
|
|
139
|
+
): SuperTableMetadata | undefined {
|
|
140
|
+
const meta: SuperTableMetadata | undefined = Reflect.getMetadata(
|
|
141
|
+
SUPER_TABLE_KEY,
|
|
142
|
+
target,
|
|
143
|
+
);
|
|
144
|
+
return meta;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Read the timestamp column's property name (`undefined` if none). */
|
|
148
|
+
export function getTimestampColumn(target: object): string | undefined {
|
|
149
|
+
const key: string | undefined = Reflect.getMetadata(TIMESTAMP_KEY, target);
|
|
150
|
+
return key;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Read the metric-column metadata (declaration order), returning a copy. */
|
|
154
|
+
export function getColumnMetadata(target: object): EonColumnMetadata[] {
|
|
155
|
+
const columns: EonColumnMetadata[] | undefined = Reflect.getMetadata(
|
|
156
|
+
COLUMNS_KEY,
|
|
157
|
+
target,
|
|
158
|
+
);
|
|
159
|
+
return [...(columns ?? [])];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Read the tag metadata (declaration order), returning a copy. */
|
|
163
|
+
export function getTagMetadata(target: object): EonTagMetadata[] {
|
|
164
|
+
const tags: EonTagMetadata[] | undefined = Reflect.getMetadata(
|
|
165
|
+
TAGS_KEY,
|
|
166
|
+
target,
|
|
167
|
+
);
|
|
168
|
+
return [...(tags ?? [])];
|
|
169
|
+
}
|