@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,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default `connection` singleton — ergonomic access to the configured eon
|
|
3
|
+
* connection from anywhere, mirroring `@c9up/atlas/services/db`.
|
|
4
|
+
*
|
|
5
|
+
* import connection from '@c9up/eon/services/connection'
|
|
6
|
+
*
|
|
7
|
+
* const rows = await connection.query('SELECT server_version()')
|
|
8
|
+
*
|
|
9
|
+
* Populated by `EonProvider.boot()`. The instance is whatever
|
|
10
|
+
* `timeseries.connections[default]` resolves to — a ws `EonConnection` today, a
|
|
11
|
+
* future native impl behind the same seam tomorrow.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
EonColumnarIngest,
|
|
16
|
+
EonConnection,
|
|
17
|
+
EonSchemalessOptions,
|
|
18
|
+
} from "../connection/EonConnection.js";
|
|
19
|
+
|
|
20
|
+
let instance: EonConnection | undefined;
|
|
21
|
+
|
|
22
|
+
/** @internal Bind the singleton (called by EonProvider.boot). */
|
|
23
|
+
export function setConnection(connection: EonConnection): void {
|
|
24
|
+
instance = connection;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @internal Unbind the singleton IF it still points at `connection` (called by
|
|
29
|
+
* EonProvider.shutdown). Ownership-guarded: when a second provider rebound the
|
|
30
|
+
* singleton, an older provider's shutdown must not clear the newer binding —
|
|
31
|
+
* otherwise `connection.*` after shutdown would dereference a closed handle.
|
|
32
|
+
*/
|
|
33
|
+
export function clearConnection(connection: EonConnection): void {
|
|
34
|
+
if (instance === connection) instance = undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @internal Read the singleton (or `undefined` pre-boot). */
|
|
38
|
+
export function getConnection(): EonConnection | undefined {
|
|
39
|
+
return instance;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The bound connection, or a descriptive throw if accessed before boot. */
|
|
43
|
+
function requireInstance(): EonConnection {
|
|
44
|
+
if (!instance) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"[eon] connection singleton accessed before EonProvider.boot() ran. " +
|
|
47
|
+
"Check that `@c9up/eon/provider` is listed in your reamrc.ts providers " +
|
|
48
|
+
"and that `config/timeseries.ts` defines at least one connection.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return instance;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Forwards each `EonConnection` member to the bound instance (throwing until
|
|
56
|
+
* boot binds it). A plain forwarding object rather than a `Proxy` keeps the
|
|
57
|
+
* generic `query<T>` typed and cast-free (AC9) — the seam surface is small and
|
|
58
|
+
* explicit.
|
|
59
|
+
*/
|
|
60
|
+
const connection: EonConnection = {
|
|
61
|
+
get transport() {
|
|
62
|
+
return requireInstance().transport;
|
|
63
|
+
},
|
|
64
|
+
exec(sql: string): Promise<{ rowsAffected: number }> {
|
|
65
|
+
return requireInstance().exec(sql);
|
|
66
|
+
},
|
|
67
|
+
query<T = Record<string, unknown>>(sql: string): Promise<T[]> {
|
|
68
|
+
return requireInstance().query<T>(sql);
|
|
69
|
+
},
|
|
70
|
+
ping(): Promise<void> {
|
|
71
|
+
return requireInstance().ping();
|
|
72
|
+
},
|
|
73
|
+
ingestColumnar(
|
|
74
|
+
request: EonColumnarIngest,
|
|
75
|
+
): Promise<{ rowsAffected: number }> {
|
|
76
|
+
return requireInstance().ingestColumnar(request);
|
|
77
|
+
},
|
|
78
|
+
schemaless(
|
|
79
|
+
lines: readonly string[],
|
|
80
|
+
options?: EonSchemalessOptions,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
return requireInstance().schemaless(lines, options);
|
|
83
|
+
},
|
|
84
|
+
close(): Promise<void> {
|
|
85
|
+
return requireInstance().close();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export default connection;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `factory` / `FactoryBuilder` — Lucid/atlas-shaped test-data factory for
|
|
3
|
+
* time-series points (58.6), mirroring atlas `testing/Factory.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Named states, ad-hoc merges, stubbing (build without persisting), and a
|
|
6
|
+
* minimal `create`/`createMany` that persists through a literal `INSERT` via
|
|
7
|
+
* `EonConnection.exec` — working against a {@link FakeEonConnection} OR a real
|
|
8
|
+
* one. Hydration resolves columns/tags via the 58.3 metadata getters, NEVER
|
|
9
|
+
* `key in instance` (the `@Column() declare x` pitfall, memory
|
|
10
|
+
* `project_atlas_declare_hydration`).
|
|
11
|
+
*
|
|
12
|
+
* Named boundary: `create()` is a TEST-ONLY insert, NOT the 58.4
|
|
13
|
+
* `SuperTableRepository` ingest API — the two coexist (the factory stays
|
|
14
|
+
* test-scoped) once a live repository is in play.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { EonConnection } from "../connection/EonConnection.js";
|
|
18
|
+
import {
|
|
19
|
+
getColumnMetadata,
|
|
20
|
+
getTagMetadata,
|
|
21
|
+
getTimestampColumn,
|
|
22
|
+
} from "../decorators/superTable.js";
|
|
23
|
+
import { compileStatementNative } from "../query/native.js";
|
|
24
|
+
import {
|
|
25
|
+
orderTimestampFirst,
|
|
26
|
+
requireSuperTableName,
|
|
27
|
+
} from "../schema/compile.js";
|
|
28
|
+
import { childTableName } from "../schema/sync.js";
|
|
29
|
+
|
|
30
|
+
/** A concrete, `@SuperTable`-decorated point class (has a no-arg constructor). */
|
|
31
|
+
type PointConstructor<T extends object> = (new () => T) & {
|
|
32
|
+
readonly name: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** A named state — mutates an in-progress data object in place. */
|
|
36
|
+
type StateFn = (data: Record<string, unknown>) => void;
|
|
37
|
+
|
|
38
|
+
export interface FactoryBuilder<T extends object> {
|
|
39
|
+
/** Override fields for the NEXT build (reset after consumption). */
|
|
40
|
+
merge(overrides: Record<string, unknown>): FactoryBuilder<T>;
|
|
41
|
+
/** Declare a named variation, stored on the factory's state map. */
|
|
42
|
+
state(name: string, fn: StateFn): FactoryBuilder<T>;
|
|
43
|
+
/** Activate declared states for the NEXT build (compose in order; reset after). */
|
|
44
|
+
apply(...names: string[]): FactoryBuilder<T>;
|
|
45
|
+
/** Build a data object without persisting or instantiating. */
|
|
46
|
+
make(): Record<string, unknown>;
|
|
47
|
+
/** Build `count` data objects without persisting. */
|
|
48
|
+
makeMany(count: number): Record<string, unknown>[];
|
|
49
|
+
/** Build an INSTANCE without persisting (Lucid `makeStubbed`). */
|
|
50
|
+
makeStubbed(): T;
|
|
51
|
+
/** Build + persist a single point through `conn.exec` (literal INSERT). */
|
|
52
|
+
create(conn: EonConnection): Promise<T>;
|
|
53
|
+
/** Build + persist `count` points. */
|
|
54
|
+
createMany(count: number, conn: EonConnection): Promise<T[]>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function factory<T extends object>(
|
|
58
|
+
EntityClass: PointConstructor<T>,
|
|
59
|
+
defaults: () => Record<string, unknown>,
|
|
60
|
+
): FactoryBuilder<T> {
|
|
61
|
+
const states = new Map<string, StateFn>();
|
|
62
|
+
let pendingOverrides: Record<string, unknown> = {};
|
|
63
|
+
let pendingStates: string[] = [];
|
|
64
|
+
|
|
65
|
+
const buildData = (): Record<string, unknown> => {
|
|
66
|
+
const data: Record<string, unknown> = {
|
|
67
|
+
...defaults(),
|
|
68
|
+
...pendingOverrides,
|
|
69
|
+
};
|
|
70
|
+
for (const name of pendingStates) {
|
|
71
|
+
const fn = states.get(name);
|
|
72
|
+
if (!fn) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`[E_EON_FACTORY_STATE] state '${name}' is not defined on ${EntityClass.name}Factory`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
fn(data);
|
|
78
|
+
}
|
|
79
|
+
return data;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const resetPending = (): void => {
|
|
83
|
+
pendingOverrides = {};
|
|
84
|
+
pendingStates = [];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Hydrate an instance from data, assigning only declared columns/tags. */
|
|
88
|
+
const hydrate = (data: Record<string, unknown>): T => {
|
|
89
|
+
const instance = new EntityClass();
|
|
90
|
+
const known = new Set<string>([
|
|
91
|
+
...getColumnMetadata(EntityClass).map((c) => c.propertyKey),
|
|
92
|
+
...getTagMetadata(EntityClass).map((t) => t.propertyKey),
|
|
93
|
+
]);
|
|
94
|
+
const assignable: Record<string, unknown> = {};
|
|
95
|
+
for (const key of known) {
|
|
96
|
+
if (key in data) assignable[key] = data[key];
|
|
97
|
+
}
|
|
98
|
+
Object.assign(instance, assignable);
|
|
99
|
+
return instance;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const persist = async (
|
|
103
|
+
conn: EonConnection,
|
|
104
|
+
data: Record<string, unknown>,
|
|
105
|
+
): Promise<T> => {
|
|
106
|
+
const stable = requireSuperTableName(EntityClass);
|
|
107
|
+
const tsProperty = getTimestampColumn(EntityClass);
|
|
108
|
+
if (tsProperty === undefined) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`[E_EON_NO_TIMESTAMP] super-table '${stable}' has no @Timestamp column`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const columns = getColumnMetadata(EntityClass);
|
|
114
|
+
const tsColumn = columns.find((c) => c.propertyKey === tsProperty);
|
|
115
|
+
if (tsColumn === undefined) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`[E_EON_NO_TIMESTAMP] super-table '${stable}' @Timestamp column '${tsProperty}' is not registered`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const ordered = orderTimestampFirst(columns, tsColumn, tsProperty);
|
|
121
|
+
const tags = getTagMetadata(EntityClass);
|
|
122
|
+
const tagValues = tags.map((t) => data[t.propertyKey] ?? null);
|
|
123
|
+
const table = childTableName(stable, tagValues);
|
|
124
|
+
const row = ordered.map((c) => data[c.propertyKey] ?? null);
|
|
125
|
+
|
|
126
|
+
const { statements } = compileStatementNative(
|
|
127
|
+
{
|
|
128
|
+
kind: "insert",
|
|
129
|
+
table,
|
|
130
|
+
using: stable,
|
|
131
|
+
tags: tagValues,
|
|
132
|
+
columns: ordered.map((c) => c.propertyKey),
|
|
133
|
+
rows: [row],
|
|
134
|
+
literal: true,
|
|
135
|
+
},
|
|
136
|
+
"tdengine",
|
|
137
|
+
);
|
|
138
|
+
for (const sql of statements) await conn.exec(sql);
|
|
139
|
+
return hydrate(data);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const builder: FactoryBuilder<T> = {
|
|
143
|
+
merge(overrides) {
|
|
144
|
+
pendingOverrides = { ...pendingOverrides, ...overrides };
|
|
145
|
+
return builder;
|
|
146
|
+
},
|
|
147
|
+
state(name, fn) {
|
|
148
|
+
states.set(name, fn);
|
|
149
|
+
return builder;
|
|
150
|
+
},
|
|
151
|
+
apply(...names) {
|
|
152
|
+
pendingStates.push(...names);
|
|
153
|
+
return builder;
|
|
154
|
+
},
|
|
155
|
+
make() {
|
|
156
|
+
const data = buildData();
|
|
157
|
+
resetPending();
|
|
158
|
+
return data;
|
|
159
|
+
},
|
|
160
|
+
makeMany(count) {
|
|
161
|
+
const rows: Record<string, unknown>[] = [];
|
|
162
|
+
for (let i = 0; i < count; i++) rows.push(buildData());
|
|
163
|
+
resetPending();
|
|
164
|
+
return rows;
|
|
165
|
+
},
|
|
166
|
+
makeStubbed() {
|
|
167
|
+
const data = buildData();
|
|
168
|
+
resetPending();
|
|
169
|
+
return hydrate(data);
|
|
170
|
+
},
|
|
171
|
+
async create(conn) {
|
|
172
|
+
const data = buildData();
|
|
173
|
+
resetPending();
|
|
174
|
+
return persist(conn, data);
|
|
175
|
+
},
|
|
176
|
+
async createMany(count, conn) {
|
|
177
|
+
const rows: Record<string, unknown>[] = [];
|
|
178
|
+
for (let i = 0; i < count; i++) rows.push(buildData());
|
|
179
|
+
resetPending();
|
|
180
|
+
const created: T[] = [];
|
|
181
|
+
for (const data of rows) created.push(await persist(conn, data));
|
|
182
|
+
return created;
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
return builder;
|
|
186
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FakeEonConnection` — a hand-rolled in-memory `EonConnection` for fast,
|
|
3
|
+
* deterministic unit tests without a live TDengine (58.6). The atlas analogue is
|
|
4
|
+
* a REAL in-memory SQLite (`testing/TestDatabase.ts`); TDengine has no embeddable
|
|
5
|
+
* engine, so this is a deliberately narrow test double, NOT a SQL engine.
|
|
6
|
+
*
|
|
7
|
+
* ── Scope (named boundary) ─────────────────────────────────────────────────
|
|
8
|
+
* - `exec(sql)` RECORDS every statement (assertable via {@link statements}) and,
|
|
9
|
+
* for recognised `CREATE TABLE`/`CREATE STABLE`/`INSERT` forms, updates a
|
|
10
|
+
* per-table row store.
|
|
11
|
+
* - `query(sql)` answers ONLY flat `SELECT [cols] FROM t [WHERE col <op> val]
|
|
12
|
+
* [LIMIT n]`. Windowed / aggregate SQL (`INTERVAL`/`FILL`/`PARTITION BY`/
|
|
13
|
+
* `avg(...)`) is NOT interpreted — it throws `E_EON_FAKE_UNSUPPORTED` pointing
|
|
14
|
+
* the caller to the docker integration harness (`describeIfTdengine`).
|
|
15
|
+
* - `ingestColumnar`/`schemaless` are STMT/line-protocol paths that need a live
|
|
16
|
+
* connector — they throw `E_EON_FAKE_UNSUPPORTED`. (The 58.6 factory persists
|
|
17
|
+
* through literal `exec` INSERTs, which the store DOES handle.)
|
|
18
|
+
* - `ping()`/`close()` are no-ops.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
EonColumnarIngest,
|
|
23
|
+
EonConnection,
|
|
24
|
+
EonSchemalessOptions,
|
|
25
|
+
} from "../connection/EonConnection.js";
|
|
26
|
+
|
|
27
|
+
type Row = Record<string, unknown>;
|
|
28
|
+
|
|
29
|
+
const WINDOWED = /\b(INTERVAL|FILL|PARTITION\s+BY|SLIDING)\b/i;
|
|
30
|
+
|
|
31
|
+
export class FakeEonConnection implements EonConnection {
|
|
32
|
+
readonly transport: "fake" = "fake";
|
|
33
|
+
/** Every `exec`ed statement, in order — assert against this in tests. */
|
|
34
|
+
readonly statements: string[] = [];
|
|
35
|
+
readonly #store = new Map<string, Row[]>();
|
|
36
|
+
|
|
37
|
+
async exec(sql: string): Promise<{ rowsAffected: number }> {
|
|
38
|
+
this.statements.push(sql);
|
|
39
|
+
const trimmed = sql.trim();
|
|
40
|
+
if (/^CREATE\s+(STABLE|TABLE)\b/i.test(trimmed)) {
|
|
41
|
+
const table = extractCreateTarget(trimmed);
|
|
42
|
+
if (table !== undefined && !this.#store.has(table)) {
|
|
43
|
+
this.#store.set(table, []);
|
|
44
|
+
}
|
|
45
|
+
return { rowsAffected: 0 };
|
|
46
|
+
}
|
|
47
|
+
if (/^INSERT\s+INTO\b/i.test(trimmed)) {
|
|
48
|
+
const rows = parseInsert(trimmed);
|
|
49
|
+
if (rows !== undefined) {
|
|
50
|
+
const existing = this.#store.get(rows.table) ?? [];
|
|
51
|
+
existing.push(...rows.rows);
|
|
52
|
+
this.#store.set(rows.table, existing);
|
|
53
|
+
return { rowsAffected: rows.rows.length };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (/^DELETE\s+FROM\b/i.test(trimmed)) {
|
|
57
|
+
const affected = this.#applyDelete(trimmed);
|
|
58
|
+
return { rowsAffected: affected };
|
|
59
|
+
}
|
|
60
|
+
// DROP / USE / other DDL: recorded, no store change.
|
|
61
|
+
if (/^DROP\s+(STABLE|TABLE)\b/i.test(trimmed)) {
|
|
62
|
+
const table = extractDropTarget(trimmed);
|
|
63
|
+
if (table !== undefined) this.#store.delete(table);
|
|
64
|
+
}
|
|
65
|
+
return { rowsAffected: 0 };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
query<T = Row>(sql: string): Promise<T[]>;
|
|
69
|
+
async query(sql: string): Promise<Row[]> {
|
|
70
|
+
const trimmed = sql.trim();
|
|
71
|
+
if (WINDOWED.test(trimmed) || hasAggregate(trimmed)) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`[E_EON_FAKE_UNSUPPORTED] FakeEonConnection answers only flat SELECT; windowed/aggregate SQL needs a live TDengine (use describeIfTdengine). Query: ${trimmed}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const parsed = parseSelect(trimmed);
|
|
77
|
+
if (parsed === undefined) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`[E_EON_FAKE_UNSUPPORTED] FakeEonConnection could not parse this SELECT: ${trimmed}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
let rows = [...(this.#store.get(parsed.table) ?? [])];
|
|
83
|
+
if (parsed.where !== undefined) {
|
|
84
|
+
const { column, op, value } = parsed.where;
|
|
85
|
+
rows = rows.filter((r) => compareOp(r[column], op, value));
|
|
86
|
+
}
|
|
87
|
+
if (parsed.limit !== undefined) rows = rows.slice(0, parsed.limit);
|
|
88
|
+
if (parsed.columns === "*") return rows;
|
|
89
|
+
const cols = parsed.columns;
|
|
90
|
+
return rows.map((r) => {
|
|
91
|
+
const projected: Row = {};
|
|
92
|
+
for (const c of cols) projected[c] = r[c];
|
|
93
|
+
return projected;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async ingestColumnar(
|
|
98
|
+
request: EonColumnarIngest,
|
|
99
|
+
): Promise<{ rowsAffected: number }> {
|
|
100
|
+
void request;
|
|
101
|
+
throw new Error(
|
|
102
|
+
"[E_EON_FAKE_UNSUPPORTED] FakeEonConnection has no STMT columnar path; test bulk ingest against a live TDengine (describeIfTdengine).",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async schemaless(
|
|
107
|
+
lines: readonly string[],
|
|
108
|
+
options?: EonSchemalessOptions,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
void lines;
|
|
111
|
+
void options;
|
|
112
|
+
throw new Error(
|
|
113
|
+
"[E_EON_FAKE_UNSUPPORTED] FakeEonConnection has no line-protocol path; test schemaless ingest against a live TDengine (describeIfTdengine).",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async ping(): Promise<void> {}
|
|
118
|
+
async close(): Promise<void> {}
|
|
119
|
+
|
|
120
|
+
/** Clear recorded statements and the row store. */
|
|
121
|
+
reset(): void {
|
|
122
|
+
this.statements.length = 0;
|
|
123
|
+
this.#store.clear();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Read the current rows for a table (a copy) — a test-inspection helper. */
|
|
127
|
+
rows(table: string): Row[] {
|
|
128
|
+
return [...(this.#store.get(table) ?? [])];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#applyDelete(sql: string): number {
|
|
132
|
+
const match =
|
|
133
|
+
/^DELETE\s+FROM\s+`([^`]+)`\s+WHERE\s+`([^`]+)`\s*=\s*(.+)$/i.exec(sql);
|
|
134
|
+
if (match === null) return 0;
|
|
135
|
+
const [, table, column, rawValue] = match;
|
|
136
|
+
if (table === undefined || column === undefined || rawValue === undefined) {
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
const value = parseLiteral(rawValue.trim());
|
|
140
|
+
const existing = this.#store.get(table);
|
|
141
|
+
if (existing === undefined) return 0;
|
|
142
|
+
const kept = existing.filter((r) => r[column] !== value);
|
|
143
|
+
this.#store.set(table, kept);
|
|
144
|
+
return existing.length - kept.length;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Detect a function-call in the SELECT projection (`avg(...)`, `count(*)`, …). */
|
|
149
|
+
function hasAggregate(sql: string): boolean {
|
|
150
|
+
const match = /^SELECT\s+(.+?)\s+FROM\b/i.exec(sql);
|
|
151
|
+
if (match === null) return false;
|
|
152
|
+
const projection = match[1] ?? "";
|
|
153
|
+
return /[A-Za-z_][A-Za-z0-9_]*\s*\(/.test(projection);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function extractCreateTarget(sql: string): string | undefined {
|
|
157
|
+
const match =
|
|
158
|
+
/^CREATE\s+(?:STABLE|TABLE)\s+(?:IF\s+NOT\s+EXISTS\s+)?`([^`]+)`/i.exec(
|
|
159
|
+
sql,
|
|
160
|
+
);
|
|
161
|
+
return match?.[1];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function extractDropTarget(sql: string): string | undefined {
|
|
165
|
+
const match = /^DROP\s+(?:STABLE|TABLE)\s+(?:IF\s+EXISTS\s+)?`([^`]+)`/i.exec(
|
|
166
|
+
sql,
|
|
167
|
+
);
|
|
168
|
+
return match?.[1];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface ParsedInsert {
|
|
172
|
+
table: string;
|
|
173
|
+
rows: Row[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Parse a literal `INSERT` (plain or `USING … TAGS`) into rows keyed by column. */
|
|
177
|
+
function parseInsert(sql: string): ParsedInsert | undefined {
|
|
178
|
+
const tableMatch = /^INSERT\s+INTO\s+`([^`]+)`/i.exec(sql);
|
|
179
|
+
const table = tableMatch?.[1];
|
|
180
|
+
if (table === undefined) return undefined;
|
|
181
|
+
// Strip an optional child-create `USING `stb` TAGS (...)` clause.
|
|
182
|
+
const body = sql.replace(/\bUSING\s+`[^`]+`\s+TAGS\s*\([^)]*\)/i, "");
|
|
183
|
+
const colsMatch = /\(([^)]*)\)\s*VALUES/i.exec(body);
|
|
184
|
+
const valuesPart = body.slice(body.search(/VALUES/i) + "VALUES".length);
|
|
185
|
+
if (colsMatch === null) return undefined;
|
|
186
|
+
const columns = (colsMatch[1] ?? "")
|
|
187
|
+
.split(",")
|
|
188
|
+
.map((c) => c.trim().replace(/^`|`$/g, ""));
|
|
189
|
+
const rows: Row[] = [];
|
|
190
|
+
for (const tuple of splitTuples(valuesPart)) {
|
|
191
|
+
const values = splitValues(tuple).map(parseLiteral);
|
|
192
|
+
const row: Row = {};
|
|
193
|
+
columns.forEach((c, i) => {
|
|
194
|
+
row[c] = values[i];
|
|
195
|
+
});
|
|
196
|
+
rows.push(row);
|
|
197
|
+
}
|
|
198
|
+
return { table, rows };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface ParsedSelect {
|
|
202
|
+
columns: "*" | string[];
|
|
203
|
+
table: string;
|
|
204
|
+
where?: { column: string; op: string; value: unknown };
|
|
205
|
+
limit?: number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function parseSelect(sql: string): ParsedSelect | undefined {
|
|
209
|
+
const match =
|
|
210
|
+
/^SELECT\s+(.+?)\s+FROM\s+`([^`]+)`\s*(?:WHERE\s+(.+?))?\s*(?:LIMIT\s+(\d+))?\s*$/i.exec(
|
|
211
|
+
sql,
|
|
212
|
+
);
|
|
213
|
+
if (match === null) return undefined;
|
|
214
|
+
const [, rawCols, table, rawWhere, rawLimit] = match;
|
|
215
|
+
if (rawCols === undefined || table === undefined) return undefined;
|
|
216
|
+
const columns: "*" | string[] =
|
|
217
|
+
rawCols.trim() === "*"
|
|
218
|
+
? "*"
|
|
219
|
+
: rawCols.split(",").map((c) => c.trim().replace(/^`|`$/g, ""));
|
|
220
|
+
const result: ParsedSelect = { columns, table };
|
|
221
|
+
if (rawWhere !== undefined) {
|
|
222
|
+
const clause = rawWhere.trim();
|
|
223
|
+
// A multi-predicate WHERE (`a` = 1 AND `b` = 2) or an unparseable clause
|
|
224
|
+
// (non-backtick column, unknown operator) is NOT supported. Return
|
|
225
|
+
// undefined so the caller throws E_EON_FAKE_UNSUPPORTED rather than
|
|
226
|
+
// silently dropping the predicate and returning every row (fail-loud).
|
|
227
|
+
if (hasBooleanConnector(clause)) return undefined;
|
|
228
|
+
const w = /^`([^`]+)`\s*(=|!=|<>|>=|<=|>|<)\s*(.+)$/.exec(clause);
|
|
229
|
+
if (
|
|
230
|
+
w === null ||
|
|
231
|
+
w[1] === undefined ||
|
|
232
|
+
w[2] === undefined ||
|
|
233
|
+
w[3] === undefined
|
|
234
|
+
) {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
result.where = {
|
|
238
|
+
column: w[1],
|
|
239
|
+
op: w[2],
|
|
240
|
+
value: parseLiteral(w[3].trim()),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (rawLimit !== undefined) result.limit = Number(rawLimit);
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* True if a WHERE clause has a top-level `AND`/`OR` connector (a multi-predicate
|
|
249
|
+
* filter the flat store cannot evaluate). Single-quoted string literals are
|
|
250
|
+
* blanked first so an `AND` inside a value (`'a AND b'`) is not misread.
|
|
251
|
+
*/
|
|
252
|
+
function hasBooleanConnector(clause: string): boolean {
|
|
253
|
+
const unquoted = clause.replace(/'(?:\\.|[^'\\])*'/g, "''");
|
|
254
|
+
return /\b(?:AND|OR)\b/i.test(unquoted);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function compareOp(left: unknown, op: string, right: unknown): boolean {
|
|
258
|
+
switch (op) {
|
|
259
|
+
case "=":
|
|
260
|
+
return left === right;
|
|
261
|
+
case "!=":
|
|
262
|
+
case "<>":
|
|
263
|
+
return left !== right;
|
|
264
|
+
default: {
|
|
265
|
+
const l = Number(left);
|
|
266
|
+
const r = Number(right);
|
|
267
|
+
if (Number.isNaN(l) || Number.isNaN(r)) return false;
|
|
268
|
+
if (op === ">") return l > r;
|
|
269
|
+
if (op === ">=") return l >= r;
|
|
270
|
+
if (op === "<") return l < r;
|
|
271
|
+
if (op === "<=") return l <= r;
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Split a `VALUES` clause into per-row tuples, respecting quoted strings. */
|
|
278
|
+
function splitTuples(valuesPart: string): string[] {
|
|
279
|
+
const tuples: string[] = [];
|
|
280
|
+
let depth = 0;
|
|
281
|
+
let inString = false;
|
|
282
|
+
let start = -1;
|
|
283
|
+
for (let i = 0; i < valuesPart.length; i++) {
|
|
284
|
+
const ch = valuesPart[i];
|
|
285
|
+
if (inString) {
|
|
286
|
+
if (ch === "\\")
|
|
287
|
+
i++; // skip the escaped char
|
|
288
|
+
else if (ch === "'") inString = false;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (ch === "'") {
|
|
292
|
+
inString = true;
|
|
293
|
+
} else if (ch === "(") {
|
|
294
|
+
if (depth === 0) start = i + 1;
|
|
295
|
+
depth++;
|
|
296
|
+
} else if (ch === ")") {
|
|
297
|
+
depth--;
|
|
298
|
+
if (depth === 0 && start >= 0) {
|
|
299
|
+
tuples.push(valuesPart.slice(start, i));
|
|
300
|
+
start = -1;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return tuples;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Split a tuple body into top-level comma-separated value tokens. */
|
|
308
|
+
function splitValues(tuple: string): string[] {
|
|
309
|
+
const values: string[] = [];
|
|
310
|
+
let inString = false;
|
|
311
|
+
let current = "";
|
|
312
|
+
for (let i = 0; i < tuple.length; i++) {
|
|
313
|
+
const ch = tuple[i];
|
|
314
|
+
if (inString) {
|
|
315
|
+
current += ch;
|
|
316
|
+
if (ch === "\\") {
|
|
317
|
+
const next = tuple[i + 1];
|
|
318
|
+
if (next !== undefined) {
|
|
319
|
+
current += next;
|
|
320
|
+
i++;
|
|
321
|
+
}
|
|
322
|
+
} else if (ch === "'") {
|
|
323
|
+
inString = false;
|
|
324
|
+
}
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (ch === "'") {
|
|
328
|
+
inString = true;
|
|
329
|
+
current += ch;
|
|
330
|
+
} else if (ch === ",") {
|
|
331
|
+
values.push(current.trim());
|
|
332
|
+
current = "";
|
|
333
|
+
} else {
|
|
334
|
+
current += ch;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (current.trim().length > 0) values.push(current.trim());
|
|
338
|
+
return values;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Parse a single SQL literal token back to a JS value (inverse of `render_literal`). */
|
|
342
|
+
function parseLiteral(token: string): unknown {
|
|
343
|
+
if (token === "NULL") return null;
|
|
344
|
+
if (token === "true") return true;
|
|
345
|
+
if (token === "false") return false;
|
|
346
|
+
if (token.startsWith("'") && token.endsWith("'")) {
|
|
347
|
+
return token.slice(1, -1).replace(/\\(['\\])/g, "$1");
|
|
348
|
+
}
|
|
349
|
+
const num = Number(token);
|
|
350
|
+
return Number.isNaN(num) ? token : num;
|
|
351
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helpers for @c9up/eon — the REAL-connection harness for integration
|
|
3
|
+
* suites (58.2) PLUS the in-memory fake store + factory (58.6).
|
|
4
|
+
*
|
|
5
|
+
* `EON_TEST_URL` gates the live paths: unset → local dev without a server, so
|
|
6
|
+
* integration suites skip via `describeIfTdengine` (mirrors atlas's "no external
|
|
7
|
+
* DB required for unit tests" posture). CI sets it, so the roundtrip runs for
|
|
8
|
+
* real — no silent cap. The fake store + factory need NO server.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe } from "vitest";
|
|
12
|
+
import type { EonConnectionConfig } from "../connection/config.js";
|
|
13
|
+
import type { EonConnection } from "../connection/EonConnection.js";
|
|
14
|
+
import { connectWsEon } from "../connection/websocket.js";
|
|
15
|
+
|
|
16
|
+
export { type FactoryBuilder, factory } from "./Factory.js";
|
|
17
|
+
export { FakeEonConnection } from "./FakeEonConnection.js";
|
|
18
|
+
|
|
19
|
+
/** The live TDengine URL for integration tests (taosAdapter WS), or `undefined`. */
|
|
20
|
+
function testUrl(): string | undefined {
|
|
21
|
+
const url = process.env.EON_TEST_URL;
|
|
22
|
+
return typeof url === "string" && url.length > 0 ? url : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Whether an `EON_TEST_URL` is configured (a live TDengine is reachable). */
|
|
26
|
+
export function hasTestServer(): boolean {
|
|
27
|
+
return testUrl() !== undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register a suite that runs only when a test server is configured, else skips —
|
|
32
|
+
* so integration suites gate uniformly. CI (with `EON_TEST_URL`) runs them for
|
|
33
|
+
* real; local dev without a server skips them.
|
|
34
|
+
*/
|
|
35
|
+
export function describeIfTdengine(name: string, factory: () => void): void {
|
|
36
|
+
if (hasTestServer()) {
|
|
37
|
+
describe(name, factory);
|
|
38
|
+
} else {
|
|
39
|
+
describe.skip(name, factory);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Open a connection to the test server (`EON_TEST_URL`), applying root/taosdata
|
|
45
|
+
* defaults and a generous connect-retry for a cold docker server. Callers gate
|
|
46
|
+
* with `hasTestServer()` / `describeIfTdengine` first.
|
|
47
|
+
*/
|
|
48
|
+
export function connectTestEon(
|
|
49
|
+
overrides: Partial<EonConnectionConfig> = {},
|
|
50
|
+
): Promise<EonConnection> {
|
|
51
|
+
const url = testUrl();
|
|
52
|
+
if (!url) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"[eon] connectTestEon requires EON_TEST_URL (a live TDengine taosAdapter WS URL, e.g. ws://localhost:6041).",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return connectWsEon({
|
|
58
|
+
url,
|
|
59
|
+
user: "root",
|
|
60
|
+
password: "taosdata",
|
|
61
|
+
connectRetries: 10,
|
|
62
|
+
connectBackoffMs: 250,
|
|
63
|
+
...overrides,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reset a database for test isolation: drop it if present, then recreate it at
|
|
69
|
+
* millisecond precision. The name is validated against a safe alphabet before
|
|
70
|
+
* interpolation — a test-only helper, but no injection footgun.
|
|
71
|
+
*/
|
|
72
|
+
export async function resetDatabase(
|
|
73
|
+
conn: EonConnection,
|
|
74
|
+
db: string,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(db)) {
|
|
77
|
+
throw new Error(`[eon] resetDatabase: unsafe database name '${db}'`);
|
|
78
|
+
}
|
|
79
|
+
await conn.exec(`DROP DATABASE IF EXISTS ${db}`);
|
|
80
|
+
await conn.exec(`CREATE DATABASE ${db} PRECISION 'ms'`);
|
|
81
|
+
}
|