@c9up/eon 0.1.0 → 0.2.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 +5 -0
- package/dist/EonProvider.d.ts +6 -0
- package/dist/EonProvider.d.ts.map +1 -1
- package/dist/EonProvider.js +43 -0
- package/dist/EonProvider.js.map +1 -1
- package/dist/configure.d.ts +17 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +19 -0
- package/dist/configure.js.map +1 -0
- package/dist/connection/config.d.ts +8 -0
- package/dist/connection/config.d.ts.map +1 -1
- package/dist/connection/config.js.map +1 -1
- package/dist/connection/websocket.d.ts +13 -0
- package/dist/connection/websocket.d.ts.map +1 -1
- package/dist/connection/websocket.js +16 -1
- package/dist/connection/websocket.js.map +1 -1
- package/dist/health.d.ts +50 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +73 -0
- package/dist/health.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/query/TimeSeriesQuery.d.ts +12 -1
- package/dist/query/TimeSeriesQuery.d.ts.map +1 -1
- package/dist/query/TimeSeriesQuery.js +12 -1
- package/dist/query/TimeSeriesQuery.js.map +1 -1
- package/dist/schema/EonMigrationRunner.d.ts +35 -3
- package/dist/schema/EonMigrationRunner.d.ts.map +1 -1
- package/dist/schema/EonMigrationRunner.js +169 -7
- package/dist/schema/EonMigrationRunner.js.map +1 -1
- package/dist/schema/index.d.ts +15 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +15 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/testing/FakeEonConnection.d.ts +1 -1
- package/dist/testing/FakeEonConnection.d.ts.map +1 -1
- package/dist/testing/FakeEonConnection.js +27 -3
- package/dist/testing/FakeEonConnection.js.map +1 -1
- package/dist/testing/FakeEonConsumer.d.ts +28 -0
- package/dist/testing/FakeEonConsumer.d.ts.map +1 -0
- package/dist/testing/FakeEonConsumer.js +66 -0
- package/dist/testing/FakeEonConsumer.js.map +1 -0
- package/dist/testing/cleanup.d.ts +50 -0
- package/dist/testing/cleanup.d.ts.map +1 -0
- package/dist/testing/cleanup.js +88 -0
- package/dist/testing/cleanup.js.map +1 -0
- package/dist/testing/index.d.ts +6 -6
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +6 -14
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/vitest.d.ts +7 -0
- package/dist/testing/vitest.d.ts.map +1 -0
- package/dist/testing/vitest.js +28 -0
- package/dist/testing/vitest.js.map +1 -0
- package/dist/tmq/EonConsumer.d.ts +99 -0
- package/dist/tmq/EonConsumer.d.ts.map +1 -0
- package/dist/tmq/EonConsumer.js +27 -0
- package/dist/tmq/EonConsumer.js.map +1 -0
- package/dist/tmq/index.d.ts +4 -0
- package/dist/tmq/index.d.ts.map +1 -0
- package/dist/tmq/index.js +4 -0
- package/dist/tmq/index.js.map +1 -0
- package/dist/tmq/websocket.d.ts +11 -0
- package/dist/tmq/websocket.d.ts.map +1 -0
- package/dist/tmq/websocket.js +189 -0
- package/dist/tmq/websocket.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 +12 -3
- package/src/EonProvider.ts +61 -0
- package/src/configure.ts +38 -0
- package/src/connection/config.ts +8 -0
- package/src/connection/websocket.ts +17 -0
- package/src/health.ts +99 -0
- package/src/index.ts +17 -1
- package/src/query/TimeSeriesQuery.ts +12 -1
- package/src/schema/EonMigrationRunner.ts +205 -9
- package/src/schema/index.ts +28 -0
- package/src/testing/FakeEonConnection.ts +37 -7
- package/src/testing/FakeEonConsumer.ts +93 -0
- package/src/testing/cleanup.ts +120 -0
- package/src/testing/index.ts +11 -14
- package/src/testing/vitest.ts +27 -0
- package/src/tmq/EonConsumer.ts +111 -0
- package/src/tmq/index.ts +10 -0
- package/src/tmq/websocket.ts +217 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* sorted-file discovery, a `ream_`-prefixed tracking table, batch-based
|
|
5
5
|
* rollback, `init/status/migrate/rollback/reset/refresh/fresh/dryRun`.
|
|
6
6
|
*
|
|
7
|
-
* ──
|
|
7
|
+
* ── THREE named TDengine deviations from atlas (AC6) ────────────────────────
|
|
8
8
|
*
|
|
9
9
|
* 1. **No transactions / no engine rollback.** TDengine DDL is non-transactional
|
|
10
10
|
* — a batch CANNOT be applied atomically. The runner executes statements
|
|
@@ -19,17 +19,37 @@
|
|
|
19
19
|
* unique timestamp key (TDengine only allows a `DELETE` predicate on the
|
|
20
20
|
* primary timestamp column).
|
|
21
21
|
*
|
|
22
|
+
* 3. **The migration lock is a table's EXISTENCE, not a row's value.** atlas
|
|
23
|
+
* (Lucid/Knex parity) serialises concurrent runners with a conditional
|
|
24
|
+
* `UPDATE … WHERE is_locked = 0`; TDengine has no conditional UPDATE, so
|
|
25
|
+
* that exact shape is impossible. The equivalent guarantee comes from a
|
|
26
|
+
* different primitive: `CREATE TABLE` **without** `IF NOT EXISTS` is an
|
|
27
|
+
* atomic compare-and-swap — the mnode serialises metadata, so exactly ONE
|
|
28
|
+
* concurrent creator succeeds and every other gets code 1539. Creating the
|
|
29
|
+
* lock table IS taking the lock; dropping it is releasing it. Verified
|
|
30
|
+
* against a live TDengine 3.3.5.0: 12 racing connections, 1 winner, 11 ×
|
|
31
|
+
* 1539, three rounds. The API stays atlas's (`disableLocks`, `forceUnlock`,
|
|
32
|
+
* `E_..._LOCKED`) — only the primitive underneath differs.
|
|
33
|
+
*
|
|
22
34
|
* Agnostic leaf: takes the connection structurally and a plain directory path —
|
|
23
35
|
* no `@c9up/ream` import, no app helper (`project_package_extraction`).
|
|
24
36
|
*/
|
|
25
37
|
|
|
38
|
+
import { randomUUID } from "node:crypto";
|
|
26
39
|
import * as fsp from "node:fs/promises";
|
|
27
40
|
import * as path from "node:path";
|
|
28
41
|
import { pathToFileURL } from "node:url";
|
|
29
|
-
import
|
|
42
|
+
import {
|
|
43
|
+
type EonConnection,
|
|
44
|
+
EonConnectionError,
|
|
45
|
+
} from "../connection/EonConnection.js";
|
|
30
46
|
import { compileStatementNative } from "../query/native.js";
|
|
31
47
|
import type { Migration } from "./Migration.js";
|
|
32
48
|
|
|
49
|
+
/** TDengine's "Table already exists" code — the losing side of the lock race. */
|
|
50
|
+
const TABLE_ALREADY_EXISTS = 1539;
|
|
51
|
+
/** TDengine's "Table does not exist" code — nothing to force-unlock. */
|
|
52
|
+
const TABLE_DOES_NOT_EXIST = 9731;
|
|
33
53
|
const DEFAULT_DIR = "database/eon-migrations";
|
|
34
54
|
const DEFAULT_TABLE = "ream_eon_migrations";
|
|
35
55
|
const TABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -45,13 +65,26 @@ export interface EonMigrationOptions {
|
|
|
45
65
|
* table (`feedback_underscore_policy`) — keep it so cleanup helpers skip it.
|
|
46
66
|
*/
|
|
47
67
|
tableName?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Skip the migration lock (atlas / Lucid `disableLocks` parity). Only for a
|
|
70
|
+
* context where concurrent runs are impossible by construction — a single
|
|
71
|
+
* test process, or a one-off local run.
|
|
72
|
+
*/
|
|
73
|
+
disableLocks?: boolean;
|
|
48
74
|
}
|
|
49
75
|
|
|
50
76
|
export type MigrationState = "applied" | "pending";
|
|
51
77
|
|
|
52
78
|
export interface MigrationStatus {
|
|
53
79
|
name: string;
|
|
54
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Whether the migration has run.
|
|
82
|
+
*
|
|
83
|
+
* Spelled `status`, not `state`: it is what Lucid's `MigrationListNode`
|
|
84
|
+
* calls it and what atlas already called it, and ream's migration registry
|
|
85
|
+
* needs one shape across every store.
|
|
86
|
+
*/
|
|
87
|
+
status: MigrationState;
|
|
55
88
|
batch?: number;
|
|
56
89
|
}
|
|
57
90
|
|
|
@@ -66,6 +99,10 @@ export class EonMigrationRunner {
|
|
|
66
99
|
readonly #conn: EonConnection;
|
|
67
100
|
readonly #dir: string;
|
|
68
101
|
readonly #table: string;
|
|
102
|
+
readonly #lockTable: string;
|
|
103
|
+
readonly #disableLocks: boolean;
|
|
104
|
+
/** Set while THIS runner holds the lock, so we never drop someone else's. */
|
|
105
|
+
#lockToken: string | undefined;
|
|
69
106
|
|
|
70
107
|
constructor(conn: EonConnection, options: EonMigrationOptions = {}) {
|
|
71
108
|
this.#conn = conn;
|
|
@@ -77,6 +114,10 @@ export class EonMigrationRunner {
|
|
|
77
114
|
);
|
|
78
115
|
}
|
|
79
116
|
this.#table = table;
|
|
117
|
+
// Derived from the tracking table, so a custom `tableName` keeps its lock
|
|
118
|
+
// beside it; already validated by the pattern above, plus a literal suffix.
|
|
119
|
+
this.#lockTable = `${table}_lock`;
|
|
120
|
+
this.#disableLocks = options.disableLocks ?? false;
|
|
80
121
|
}
|
|
81
122
|
|
|
82
123
|
/** Create the tracking table (`IF NOT EXISTS`) via the basic-table DDL path. */
|
|
@@ -124,13 +165,17 @@ export class EonMigrationRunner {
|
|
|
124
165
|
return files.map((name) => {
|
|
125
166
|
const batch = byName.get(name);
|
|
126
167
|
return batch === undefined
|
|
127
|
-
? { name,
|
|
128
|
-
: { name,
|
|
168
|
+
? { name, status: "pending" }
|
|
169
|
+
: { name, status: "applied", batch };
|
|
129
170
|
});
|
|
130
171
|
}
|
|
131
172
|
|
|
132
173
|
/** Run every pending migration (filename order), recording each. */
|
|
133
174
|
async migrate(): Promise<string[]> {
|
|
175
|
+
return this.#withLock(() => this.#migrateLocked());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async #migrateLocked(): Promise<string[]> {
|
|
134
179
|
await this.init();
|
|
135
180
|
const applied = await this.#appliedRecords();
|
|
136
181
|
const appliedNames = new Set(applied.map((r) => r.name));
|
|
@@ -158,6 +203,10 @@ export class EonMigrationRunner {
|
|
|
158
203
|
|
|
159
204
|
/** Roll back the most-recent batch (files in reverse order), running `down()`. */
|
|
160
205
|
async rollback(): Promise<string[]> {
|
|
206
|
+
return this.#withLock(() => this.#rollbackLocked());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async #rollbackLocked(): Promise<string[]> {
|
|
161
210
|
await this.init();
|
|
162
211
|
const applied = await this.#appliedRecords();
|
|
163
212
|
if (applied.length === 0) return [];
|
|
@@ -186,11 +235,17 @@ export class EonMigrationRunner {
|
|
|
186
235
|
|
|
187
236
|
/** Roll back every applied batch (Lucid `migrate:reset`). */
|
|
188
237
|
async reset(): Promise<string[]> {
|
|
238
|
+
return this.#withLock(() => this.#resetLocked());
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async #resetLocked(): Promise<string[]> {
|
|
189
242
|
await this.init();
|
|
190
243
|
const all: string[] = [];
|
|
244
|
+
// Loops on the LOCKED variant: `rollback()` would try to take a lock this
|
|
245
|
+
// runner already holds, and the CREATE would fail with 1539 against itself.
|
|
191
246
|
// Guard against a stuck loop: each rollback must shrink the applied set.
|
|
192
247
|
for (;;) {
|
|
193
|
-
const rolled = await this
|
|
248
|
+
const rolled = await this.#rollbackLocked();
|
|
194
249
|
if (rolled.length === 0) break;
|
|
195
250
|
all.push(...rolled);
|
|
196
251
|
}
|
|
@@ -199,9 +254,13 @@ export class EonMigrationRunner {
|
|
|
199
254
|
|
|
200
255
|
/** Reset then re-run every migration (Lucid `migrate:refresh`). */
|
|
201
256
|
async refresh(): Promise<{ rolled: string[]; executed: string[] }> {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return
|
|
257
|
+
// ONE lock held across the WHOLE rollback + re-migrate, so no other run can
|
|
258
|
+
// slip into the free window between reset and migrate (atlas does the same).
|
|
259
|
+
return this.#withLock(async () => {
|
|
260
|
+
const rolled = await this.#resetLocked();
|
|
261
|
+
const executed = await this.#migrateLocked();
|
|
262
|
+
return { rolled, executed };
|
|
263
|
+
});
|
|
205
264
|
}
|
|
206
265
|
|
|
207
266
|
/** Alias of {@link refresh} (Lucid `migrate:fresh`). */
|
|
@@ -249,6 +308,143 @@ export class EonMigrationRunner {
|
|
|
249
308
|
}));
|
|
250
309
|
}
|
|
251
310
|
|
|
311
|
+
// ── Migration lock ─────────────────────────────────────────────────────────
|
|
312
|
+
// Deviation 3 (see the file header): the lock is the lock table's EXISTENCE.
|
|
313
|
+
// `CREATE TABLE` without `IF NOT EXISTS` is TDengine's atomic compare-and-swap.
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Take the migration lock so two processes cannot migrate concurrently
|
|
317
|
+
* (atlas / Lucid parity — a lock TABLE; here its existence, not a row value).
|
|
318
|
+
*
|
|
319
|
+
* ATOMIC: the mnode serialises table creation, so of N concurrent creators
|
|
320
|
+
* exactly ONE succeeds and the rest get code 1539. Winning the CREATE IS
|
|
321
|
+
* holding the lock — there is no separate read-back to race against.
|
|
322
|
+
*
|
|
323
|
+
* Any OTHER failure propagates untouched: we must never read an unknown error
|
|
324
|
+
* as "someone else holds it" (or as "we hold it"). Either way we do not migrate.
|
|
325
|
+
*/
|
|
326
|
+
async #acquireLock(): Promise<void> {
|
|
327
|
+
if (this.#disableLocks) return;
|
|
328
|
+
const { statements } = compileStatementNative(
|
|
329
|
+
{
|
|
330
|
+
kind: "createTable",
|
|
331
|
+
name: this.#lockTable,
|
|
332
|
+
ifNotExists: false,
|
|
333
|
+
columns: [
|
|
334
|
+
{
|
|
335
|
+
name: "locked_at",
|
|
336
|
+
kind: "timestamp",
|
|
337
|
+
length: null,
|
|
338
|
+
precision: null,
|
|
339
|
+
scale: null,
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
name: "token",
|
|
343
|
+
kind: "varchar",
|
|
344
|
+
length: 64,
|
|
345
|
+
precision: null,
|
|
346
|
+
scale: null,
|
|
347
|
+
},
|
|
348
|
+
],
|
|
349
|
+
},
|
|
350
|
+
"tdengine",
|
|
351
|
+
);
|
|
352
|
+
const token = randomUUID();
|
|
353
|
+
try {
|
|
354
|
+
for (const sql of statements) await this.#conn.exec(sql);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (
|
|
357
|
+
error instanceof EonConnectionError &&
|
|
358
|
+
error.code === TABLE_ALREADY_EXISTS
|
|
359
|
+
) {
|
|
360
|
+
throw new Error(
|
|
361
|
+
`[E_EON_MIGRATION_LOCKED] could not acquire the migration lock — another migration is already running. Wait for it to finish, or clear a stuck lock with forceUnlock() (drops '${this.#lockTable}').`,
|
|
362
|
+
{ cause: error },
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
this.#lockToken = token;
|
|
368
|
+
// Written for diagnosis only (who holds it, since when) — the lock is
|
|
369
|
+
// already ours the moment the CREATE returned, so a failure to record it
|
|
370
|
+
// must not look like a failure to acquire.
|
|
371
|
+
await this.#writeLockRecord(token);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Best-effort ownership stamp inside the lock table. Never fails acquisition. */
|
|
375
|
+
async #writeLockRecord(token: string): Promise<void> {
|
|
376
|
+
try {
|
|
377
|
+
const { statements } = compileStatementNative(
|
|
378
|
+
{
|
|
379
|
+
kind: "insert",
|
|
380
|
+
table: this.#lockTable,
|
|
381
|
+
columns: ["locked_at", "token"],
|
|
382
|
+
rows: [[Date.now(), token]],
|
|
383
|
+
literal: true,
|
|
384
|
+
},
|
|
385
|
+
"tdengine",
|
|
386
|
+
);
|
|
387
|
+
for (const sql of statements) await this.#conn.exec(sql);
|
|
388
|
+
} catch {
|
|
389
|
+
// Diagnostic only — swallowed on purpose.
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Run `fn` holding the migration lock; always release, even on throw. */
|
|
394
|
+
async #withLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
395
|
+
await this.#acquireLock();
|
|
396
|
+
try {
|
|
397
|
+
return await fn();
|
|
398
|
+
} finally {
|
|
399
|
+
await this.#releaseLock();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Release the lock — only if WE took it, so we never drop someone else's. */
|
|
404
|
+
async #releaseLock(): Promise<void> {
|
|
405
|
+
if (this.#disableLocks || this.#lockToken === undefined) return;
|
|
406
|
+
this.#lockToken = undefined;
|
|
407
|
+
await this.#dropLockTable();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async #dropLockTable(): Promise<void> {
|
|
411
|
+
const { statements } = compileStatementNative(
|
|
412
|
+
{ kind: "dropTable", name: this.#lockTable, ifExists: true },
|
|
413
|
+
"tdengine",
|
|
414
|
+
);
|
|
415
|
+
for (const sql of statements) await this.#conn.exec(sql);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Force-clear a stuck migration lock (atlas `forceUnlock`, Lucid
|
|
420
|
+
* `migration:unlock`). A process killed mid-migrate leaves the lock table
|
|
421
|
+
* behind with no owner alive to drop it, and every later run would fail to
|
|
422
|
+
* acquire. Returns `true` if a held lock was cleared.
|
|
423
|
+
*/
|
|
424
|
+
async forceUnlock(): Promise<boolean> {
|
|
425
|
+
// Symmetric to acquisition: a `DROP` that omits `IF EXISTS` reports whether
|
|
426
|
+
// anything was there, in one atomic statement. No separate existence probe,
|
|
427
|
+
// which would both race and (on a missing table) read an unrelated failure
|
|
428
|
+
// as "no lock held".
|
|
429
|
+
const { statements } = compileStatementNative(
|
|
430
|
+
{ kind: "dropTable", name: this.#lockTable, ifExists: false },
|
|
431
|
+
"tdengine",
|
|
432
|
+
);
|
|
433
|
+
try {
|
|
434
|
+
for (const sql of statements) await this.#conn.exec(sql);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (
|
|
437
|
+
error instanceof EonConnectionError &&
|
|
438
|
+
error.code === TABLE_DOES_NOT_EXIST
|
|
439
|
+
) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
this.#lockToken = undefined;
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
|
|
252
448
|
#maxBatch(records: readonly MigrationRecord[]): number {
|
|
253
449
|
return records.reduce((max, r) => Math.max(max, r.batch), 0);
|
|
254
450
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@c9up/eon/schema` — everything a migration file needs, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* The root barrel re-exports `connectWsEon`, so importing `Migration` from
|
|
5
|
+
* `@c9up/eon` loads `@tdengine/websocket` as a side effect: a migration that
|
|
6
|
+
* only describes a schema pays for the driver. Nothing under this path touches
|
|
7
|
+
* the driver — `EonMigrationRunner` takes its connection structurally.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors `@c9up/atlas/schema`.
|
|
10
|
+
*/
|
|
11
|
+
export {
|
|
12
|
+
type CreateStableSpec,
|
|
13
|
+
type CreateTableSpec,
|
|
14
|
+
TYPE_KIND_MAP,
|
|
15
|
+
} from "./CreateStableSpec.js";
|
|
16
|
+
export {
|
|
17
|
+
type EonMigrationOptions,
|
|
18
|
+
EonMigrationRunner,
|
|
19
|
+
type MigrationState,
|
|
20
|
+
type MigrationStatus,
|
|
21
|
+
} from "./EonMigrationRunner.js";
|
|
22
|
+
export {
|
|
23
|
+
AlterStableBuilder,
|
|
24
|
+
BasicTableBuilder,
|
|
25
|
+
EonSchema,
|
|
26
|
+
StableBuilder,
|
|
27
|
+
} from "./EonSchema.js";
|
|
28
|
+
export { Migration } from "./Migration.js";
|
|
@@ -18,15 +18,22 @@
|
|
|
18
18
|
* - `ping()`/`close()` are no-ops.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import
|
|
22
|
-
EonColumnarIngest,
|
|
23
|
-
EonConnection,
|
|
24
|
-
|
|
21
|
+
import {
|
|
22
|
+
type EonColumnarIngest,
|
|
23
|
+
type EonConnection,
|
|
24
|
+
EonConnectionError,
|
|
25
|
+
type EonSchemalessOptions,
|
|
25
26
|
} from "../connection/EonConnection.js";
|
|
26
27
|
|
|
27
28
|
type Row = Record<string, unknown>;
|
|
28
29
|
|
|
29
30
|
const WINDOWED = /\b(INTERVAL|FILL|PARTITION\s+BY|SLIDING)\b/i;
|
|
31
|
+
/** TDengine's "Table already exists" error code. */
|
|
32
|
+
const TABLE_ALREADY_EXISTS = 1539;
|
|
33
|
+
const IF_NOT_EXISTS = /\bIF\s+NOT\s+EXISTS\b/i;
|
|
34
|
+
/** TDengine's "Table does not exist" error code. */
|
|
35
|
+
const TABLE_DOES_NOT_EXIST = 9731;
|
|
36
|
+
const IF_EXISTS = /\bIF\s+EXISTS\b/i;
|
|
30
37
|
|
|
31
38
|
export class FakeEonConnection implements EonConnection {
|
|
32
39
|
readonly transport: "fake" = "fake";
|
|
@@ -39,8 +46,19 @@ export class FakeEonConnection implements EonConnection {
|
|
|
39
46
|
const trimmed = sql.trim();
|
|
40
47
|
if (/^CREATE\s+(STABLE|TABLE)\b/i.test(trimmed)) {
|
|
41
48
|
const table = extractCreateTarget(trimmed);
|
|
42
|
-
if (table !== undefined
|
|
43
|
-
|
|
49
|
+
if (table !== undefined) {
|
|
50
|
+
// A real server REJECTS a duplicate CREATE that omits IF NOT EXISTS,
|
|
51
|
+
// with code 1539 — the atomic "one winner" primitive the migration
|
|
52
|
+
// lock is built on (measured against a live 3.3.5.0 server: 12 racing
|
|
53
|
+
// connections, 1 winner, 11 × 1539). The fake has to reject it too,
|
|
54
|
+
// or a lock test would pass here and deadlock against a real server.
|
|
55
|
+
if (this.#store.has(table) && !IF_NOT_EXISTS.test(trimmed)) {
|
|
56
|
+
throw new EonConnectionError(
|
|
57
|
+
`eon: exec failed for [${trimmed}]: Table already exists`,
|
|
58
|
+
{ code: TABLE_ALREADY_EXISTS },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (!this.#store.has(table)) this.#store.set(table, []);
|
|
44
62
|
}
|
|
45
63
|
return { rowsAffected: 0 };
|
|
46
64
|
}
|
|
@@ -60,7 +78,19 @@ export class FakeEonConnection implements EonConnection {
|
|
|
60
78
|
// DROP / USE / other DDL: recorded, no store change.
|
|
61
79
|
if (/^DROP\s+(STABLE|TABLE)\b/i.test(trimmed)) {
|
|
62
80
|
const table = extractDropTarget(trimmed);
|
|
63
|
-
if (table !== undefined)
|
|
81
|
+
if (table !== undefined) {
|
|
82
|
+
// Mirror of the CREATE rule above: a real server rejects a DROP that
|
|
83
|
+
// omits IF EXISTS when the table is absent (code 9731, measured on
|
|
84
|
+
// 3.3.5.0). `forceUnlock` reads exactly that code to report whether a
|
|
85
|
+
// lock was actually held.
|
|
86
|
+
if (!this.#store.has(table) && !IF_EXISTS.test(trimmed)) {
|
|
87
|
+
throw new EonConnectionError(
|
|
88
|
+
`eon: exec failed for [${trimmed}]: Table does not exist`,
|
|
89
|
+
{ code: TABLE_DOES_NOT_EXIST },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
this.#store.delete(table);
|
|
93
|
+
}
|
|
64
94
|
}
|
|
65
95
|
return { rowsAffected: 0 };
|
|
66
96
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory {@link EonConsumer}, so a subscription handler can be tested
|
|
3
|
+
* without a server.
|
|
4
|
+
*
|
|
5
|
+
* The fake models the two things that actually bite: `poll` drains a queue
|
|
6
|
+
* rather than replaying it, and `commit` only acknowledges what was polled. A
|
|
7
|
+
* fake that returns the same batch forever hides the bug where a handler never
|
|
8
|
+
* advances.
|
|
9
|
+
*/
|
|
10
|
+
import type {
|
|
11
|
+
EonConsumer,
|
|
12
|
+
EonMessage,
|
|
13
|
+
EonTopicPartition,
|
|
14
|
+
} from "../tmq/EonConsumer.js";
|
|
15
|
+
import { EonConsumerError } from "../tmq/EonConsumer.js";
|
|
16
|
+
|
|
17
|
+
export class FakeEonConsumer implements EonConsumer {
|
|
18
|
+
#topics: string[] = [];
|
|
19
|
+
#queue: EonMessage[] = [];
|
|
20
|
+
#polled = 0;
|
|
21
|
+
#committed = 0;
|
|
22
|
+
#closed = false;
|
|
23
|
+
|
|
24
|
+
/** Queue messages a later `poll()` will return, in order. */
|
|
25
|
+
push(...messages: EonMessage[]): this {
|
|
26
|
+
this.#queue.push(...messages);
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** How many messages have been acknowledged. */
|
|
31
|
+
get committedCount(): number {
|
|
32
|
+
return this.#committed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether `close()` has run. */
|
|
36
|
+
get isClosed(): boolean {
|
|
37
|
+
return this.#closed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#ensureOpen(): void {
|
|
41
|
+
if (this.#closed) throw new EonConsumerError("eon: consumer is closed");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async subscribe(topics: string[]): Promise<void> {
|
|
45
|
+
this.#ensureOpen();
|
|
46
|
+
if (topics.length === 0) {
|
|
47
|
+
throw new EonConsumerError("eon: subscribe() needs at least one topic");
|
|
48
|
+
}
|
|
49
|
+
this.#topics = [...topics];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async subscription(): Promise<string[]> {
|
|
53
|
+
this.#ensureOpen();
|
|
54
|
+
return [...this.#topics];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async poll<T = Record<string, unknown>>(
|
|
58
|
+
_timeoutMs: number,
|
|
59
|
+
): Promise<EonMessage<T>[]> {
|
|
60
|
+
this.#ensureOpen();
|
|
61
|
+
// Drain, don't replay: a real poll never hands back the same rows twice.
|
|
62
|
+
const batch = this.#queue.splice(0, this.#queue.length);
|
|
63
|
+
this.#polled += batch.length;
|
|
64
|
+
return batch as EonMessage<T>[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async commit(): Promise<EonTopicPartition[]> {
|
|
68
|
+
this.#ensureOpen();
|
|
69
|
+
this.#committed = this.#polled;
|
|
70
|
+
return this.#topics.map((topic) => ({ topic, vgroupId: 0 }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async assignment(topics?: string[]): Promise<EonTopicPartition[]> {
|
|
74
|
+
this.#ensureOpen();
|
|
75
|
+
return (topics ?? this.#topics).map((topic) => ({ topic, vgroupId: 0 }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async seek(partition: EonTopicPartition): Promise<void> {
|
|
79
|
+
this.#ensureOpen();
|
|
80
|
+
if (partition.offset === undefined) {
|
|
81
|
+
throw new EonConsumerError("eon: seek() needs an offset");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async unsubscribe(): Promise<void> {
|
|
86
|
+
this.#ensureOpen();
|
|
87
|
+
this.#topics = [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async close(): Promise<void> {
|
|
91
|
+
this.#closed = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-database lifecycle for eon integration suites.
|
|
3
|
+
*
|
|
4
|
+
* Every integration file used to spell its own
|
|
5
|
+
* `await conn.exec("DROP DATABASE IF EXISTS " + TEST_DB)` in `afterAll` —
|
|
6
|
+
* seven copies of one rule, which is how a cleanup quietly stops matching its
|
|
7
|
+
* setup. Atlas ships `truncateAll` / `useTransaction` for the same reason;
|
|
8
|
+
* TDengine has no transactions, so the unit here is the database.
|
|
9
|
+
*/
|
|
10
|
+
import type { EonConnection } from "../connection/EonConnection.js";
|
|
11
|
+
|
|
12
|
+
/** TDengine identifiers: a letter or underscore, then letters, digits, underscores. */
|
|
13
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Guard an identifier before it is interpolated into DDL.
|
|
17
|
+
*
|
|
18
|
+
* `exec` takes literal SQL — TDengine binds no `?` in DDL — so a database name
|
|
19
|
+
* reaching these helpers is concatenated. A test name is not attacker-supplied,
|
|
20
|
+
* but a typo'd one silently becomes a different statement, and `DROP DATABASE`
|
|
21
|
+
* is the statement you least want to be surprising.
|
|
22
|
+
*/
|
|
23
|
+
function assertIdentifier(name: string, what: string): void {
|
|
24
|
+
if (!IDENTIFIER.test(name)) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`[E_EON_TEST_IDENTIFIER] invalid ${what} '${name}'; must match ${IDENTIFIER}`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Drop a subscription topic if it exists. Safe to call when it never existed.
|
|
33
|
+
*
|
|
34
|
+
* Order matters: TDengine REFUSES to drop a database that still carries a
|
|
35
|
+
* topic ("Topic must be dropped first"), so a suite that creates one must drop
|
|
36
|
+
* it before {@link dropTestDatabase} or {@link createTestDatabase} — including
|
|
37
|
+
* on the setup path, where a topic left by a crashed previous run is exactly
|
|
38
|
+
* what breaks the next one.
|
|
39
|
+
*/
|
|
40
|
+
export async function dropTestTopic(
|
|
41
|
+
conn: EonConnection,
|
|
42
|
+
topic: string,
|
|
43
|
+
options: { retries?: number; delayMs?: number } = {},
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
assertIdentifier(topic, "topic name");
|
|
46
|
+
const retries = options.retries ?? 10;
|
|
47
|
+
const delayMs = options.delayMs ?? 300;
|
|
48
|
+
|
|
49
|
+
// TDengine rebalances a topic's partitions for a moment after a consumer
|
|
50
|
+
// disconnects, and refuses DROP TOPIC with "Topic being rebalanced" until it
|
|
51
|
+
// settles. Any suite that subscribed hits this, so the wait belongs here
|
|
52
|
+
// rather than copy-pasted into each one — and it must be BOUNDED: retrying
|
|
53
|
+
// forever would turn a real failure into a hang.
|
|
54
|
+
for (let attempt = 0; ; attempt++) {
|
|
55
|
+
try {
|
|
56
|
+
await conn.exec(`DROP TOPIC IF EXISTS ${topic}`);
|
|
57
|
+
return;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const rebalancing = /being rebalanced/i.test(
|
|
60
|
+
error instanceof Error ? error.message : String(error),
|
|
61
|
+
);
|
|
62
|
+
if (!rebalancing || attempt >= retries) throw error;
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Drop a test database if it exists. Safe to call when it never existed.
|
|
70
|
+
*
|
|
71
|
+
* Fails if the database still carries a topic — see {@link dropTestTopic}.
|
|
72
|
+
*/
|
|
73
|
+
export async function dropTestDatabase(
|
|
74
|
+
conn: EonConnection,
|
|
75
|
+
database: string,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
assertIdentifier(database, "database name");
|
|
78
|
+
await conn.exec(`DROP DATABASE IF EXISTS ${database}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Drop, recreate and `USE` a test database — the setup half of a suite.
|
|
83
|
+
*
|
|
84
|
+
* Dropping FIRST is the point: a suite that crashed mid-run leaves rows behind,
|
|
85
|
+
* and a `CREATE DATABASE IF NOT EXISTS` would then run against them. A test
|
|
86
|
+
* that passes only because the previous run left the right data is worse than
|
|
87
|
+
* one that fails.
|
|
88
|
+
*/
|
|
89
|
+
export async function createTestDatabase(
|
|
90
|
+
conn: EonConnection,
|
|
91
|
+
database: string,
|
|
92
|
+
options: { keepMs?: number } = {},
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
assertIdentifier(database, "database name");
|
|
95
|
+
await conn.exec(`DROP DATABASE IF EXISTS ${database}`);
|
|
96
|
+
const keep = options.keepMs === undefined ? "" : ` KEEP ${options.keepMs}`;
|
|
97
|
+
await conn.exec(`CREATE DATABASE ${database}${keep}`);
|
|
98
|
+
await conn.exec(`USE ${database}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run a body against a freshly created test database, dropping it afterwards
|
|
103
|
+
* whether the body threw or not.
|
|
104
|
+
*
|
|
105
|
+
* The `finally` is what the hand-written `afterAll` blocks kept getting right
|
|
106
|
+
* by accident: a failing assertion must still leave the server clean, or the
|
|
107
|
+
* next run inherits the wreckage of this one.
|
|
108
|
+
*/
|
|
109
|
+
export async function withTestDatabase<T>(
|
|
110
|
+
conn: EonConnection,
|
|
111
|
+
database: string,
|
|
112
|
+
body: () => Promise<T>,
|
|
113
|
+
): Promise<T> {
|
|
114
|
+
await createTestDatabase(conn, database);
|
|
115
|
+
try {
|
|
116
|
+
return await body();
|
|
117
|
+
} finally {
|
|
118
|
+
await dropTestDatabase(conn, database);
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/testing/index.ts
CHANGED
|
@@ -6,15 +6,25 @@
|
|
|
6
6
|
* integration suites skip via `describeIfTdengine` (mirrors atlas's "no external
|
|
7
7
|
* DB required for unit tests" posture). CI sets it, so the roundtrip runs for
|
|
8
8
|
* real — no silent cap. The fake store + factory need NO server.
|
|
9
|
+
*
|
|
10
|
+
* This module is RUNNER-AGNOSTIC and must stay that way: it is a shipped export
|
|
11
|
+
* path, so anything it imports becomes a hard requirement for every consumer.
|
|
12
|
+
* `describeIfTdengine` lives in `./vitest.js` for that reason.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
|
-
import { describe } from "vitest";
|
|
12
15
|
import type { EonConnectionConfig } from "../connection/config.js";
|
|
13
16
|
import type { EonConnection } from "../connection/EonConnection.js";
|
|
14
17
|
import { connectWsEon } from "../connection/websocket.js";
|
|
15
18
|
|
|
19
|
+
export {
|
|
20
|
+
createTestDatabase,
|
|
21
|
+
dropTestDatabase,
|
|
22
|
+
dropTestTopic,
|
|
23
|
+
withTestDatabase,
|
|
24
|
+
} from "./cleanup.js";
|
|
16
25
|
export { type FactoryBuilder, factory } from "./Factory.js";
|
|
17
26
|
export { FakeEonConnection } from "./FakeEonConnection.js";
|
|
27
|
+
export { FakeEonConsumer } from "./FakeEonConsumer.js";
|
|
18
28
|
|
|
19
29
|
/** The live TDengine URL for integration tests (taosAdapter WS), or `undefined`. */
|
|
20
30
|
function testUrl(): string | undefined {
|
|
@@ -27,19 +37,6 @@ export function hasTestServer(): boolean {
|
|
|
27
37
|
return testUrl() !== undefined;
|
|
28
38
|
}
|
|
29
39
|
|
|
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
40
|
/**
|
|
44
41
|
* Open a connection to the test server (`EON_TEST_URL`), applying root/taosdata
|
|
45
42
|
* defaults and a generous connect-retry for a cold docker server. Callers gate
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vitest-flavoured half of eon's test helpers.
|
|
3
|
+
*
|
|
4
|
+
* Split out on purpose. `@c9up/eon/testing` is a SHIPPED export path, and a
|
|
5
|
+
* static `import { describe } from "vitest"` made merely importing it require
|
|
6
|
+
* vitest — which eon carries as a devDependency only. An app on helix (ream's
|
|
7
|
+
* own runner) reaching for `factory` or `FakeEonConnection` would have died on
|
|
8
|
+
* `Cannot find package 'vitest'`, the same shape as the @c9up/comet bug.
|
|
9
|
+
*
|
|
10
|
+
* Everything runner-agnostic stays in `./index.ts`. Only this file needs the
|
|
11
|
+
* runner, and vitest is declared an OPTIONAL peer for it.
|
|
12
|
+
*/
|
|
13
|
+
import { describe } from "vitest";
|
|
14
|
+
import { hasTestServer } from "./index.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register a suite that runs only when a test server is configured, else skips —
|
|
18
|
+
* so integration suites gate uniformly. CI (with `EON_TEST_URL`) runs them for
|
|
19
|
+
* real; local dev without a server skips them.
|
|
20
|
+
*/
|
|
21
|
+
export function describeIfTdengine(name: string, factory: () => void): void {
|
|
22
|
+
if (hasTestServer()) {
|
|
23
|
+
describe(name, factory);
|
|
24
|
+
} else {
|
|
25
|
+
describe.skip(name, factory);
|
|
26
|
+
}
|
|
27
|
+
}
|