@c9up/eon 0.1.0 → 0.2.1

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.
Files changed (91) hide show
  1. package/README.md +5 -0
  2. package/dist/EonProvider.d.ts +6 -0
  3. package/dist/EonProvider.d.ts.map +1 -1
  4. package/dist/EonProvider.js +43 -0
  5. package/dist/EonProvider.js.map +1 -1
  6. package/dist/configure.d.ts +17 -0
  7. package/dist/configure.d.ts.map +1 -0
  8. package/dist/configure.js +19 -0
  9. package/dist/configure.js.map +1 -0
  10. package/dist/connection/config.d.ts +8 -0
  11. package/dist/connection/config.d.ts.map +1 -1
  12. package/dist/connection/config.js.map +1 -1
  13. package/dist/connection/websocket.d.ts +13 -0
  14. package/dist/connection/websocket.d.ts.map +1 -1
  15. package/dist/connection/websocket.js +16 -1
  16. package/dist/connection/websocket.js.map +1 -1
  17. package/dist/health.d.ts +50 -0
  18. package/dist/health.d.ts.map +1 -0
  19. package/dist/health.js +73 -0
  20. package/dist/health.js.map +1 -0
  21. package/dist/index.d.ts +4 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/query/TimeSeriesQuery.d.ts +12 -1
  26. package/dist/query/TimeSeriesQuery.d.ts.map +1 -1
  27. package/dist/query/TimeSeriesQuery.js +12 -1
  28. package/dist/query/TimeSeriesQuery.js.map +1 -1
  29. package/dist/schema/EonMigrationRunner.d.ts +35 -3
  30. package/dist/schema/EonMigrationRunner.d.ts.map +1 -1
  31. package/dist/schema/EonMigrationRunner.js +169 -7
  32. package/dist/schema/EonMigrationRunner.js.map +1 -1
  33. package/dist/schema/index.d.ts +15 -0
  34. package/dist/schema/index.d.ts.map +1 -0
  35. package/dist/schema/index.js +15 -0
  36. package/dist/schema/index.js.map +1 -0
  37. package/dist/testing/FakeEonConnection.d.ts +1 -1
  38. package/dist/testing/FakeEonConnection.d.ts.map +1 -1
  39. package/dist/testing/FakeEonConnection.js +27 -3
  40. package/dist/testing/FakeEonConnection.js.map +1 -1
  41. package/dist/testing/FakeEonConsumer.d.ts +28 -0
  42. package/dist/testing/FakeEonConsumer.d.ts.map +1 -0
  43. package/dist/testing/FakeEonConsumer.js +66 -0
  44. package/dist/testing/FakeEonConsumer.js.map +1 -0
  45. package/dist/testing/cleanup.d.ts +50 -0
  46. package/dist/testing/cleanup.d.ts.map +1 -0
  47. package/dist/testing/cleanup.js +88 -0
  48. package/dist/testing/cleanup.js.map +1 -0
  49. package/dist/testing/index.d.ts +6 -6
  50. package/dist/testing/index.d.ts.map +1 -1
  51. package/dist/testing/index.js +6 -14
  52. package/dist/testing/index.js.map +1 -1
  53. package/dist/testing/vitest.d.ts +7 -0
  54. package/dist/testing/vitest.d.ts.map +1 -0
  55. package/dist/testing/vitest.js +28 -0
  56. package/dist/testing/vitest.js.map +1 -0
  57. package/dist/tmq/EonConsumer.d.ts +99 -0
  58. package/dist/tmq/EonConsumer.d.ts.map +1 -0
  59. package/dist/tmq/EonConsumer.js +27 -0
  60. package/dist/tmq/EonConsumer.js.map +1 -0
  61. package/dist/tmq/index.d.ts +4 -0
  62. package/dist/tmq/index.d.ts.map +1 -0
  63. package/dist/tmq/index.js +4 -0
  64. package/dist/tmq/index.js.map +1 -0
  65. package/dist/tmq/websocket.d.ts +11 -0
  66. package/dist/tmq/websocket.d.ts.map +1 -0
  67. package/dist/tmq/websocket.js +189 -0
  68. package/dist/tmq/websocket.js.map +1 -0
  69. package/index.darwin-arm64.node +0 -0
  70. package/index.darwin-x64.node +0 -0
  71. package/index.linux-arm64-gnu.node +0 -0
  72. package/index.linux-x64-gnu.node +0 -0
  73. package/index.win32-x64-msvc.node +0 -0
  74. package/package.json +32 -3
  75. package/src/EonProvider.ts +61 -0
  76. package/src/configure.ts +38 -0
  77. package/src/connection/config.ts +8 -0
  78. package/src/connection/websocket.ts +17 -0
  79. package/src/health.ts +99 -0
  80. package/src/index.ts +17 -1
  81. package/src/query/TimeSeriesQuery.ts +12 -1
  82. package/src/schema/EonMigrationRunner.ts +205 -9
  83. package/src/schema/index.ts +28 -0
  84. package/src/testing/FakeEonConnection.ts +37 -7
  85. package/src/testing/FakeEonConsumer.ts +93 -0
  86. package/src/testing/cleanup.ts +120 -0
  87. package/src/testing/index.ts +11 -14
  88. package/src/testing/vitest.ts +27 -0
  89. package/src/tmq/EonConsumer.ts +111 -0
  90. package/src/tmq/index.ts +10 -0
  91. 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
- * ── TWO named TDengine deviations from atlas (AC6) ──────────────────────────
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 type { EonConnection } from "../connection/EonConnection.js";
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
- state: MigrationState;
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, state: "pending" }
128
- : { name, state: "applied", batch };
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.rollback();
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
- const rolled = await this.reset();
203
- const executed = await this.migrate();
204
- return { rolled, executed };
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 type {
22
- EonColumnarIngest,
23
- EonConnection,
24
- EonSchemalessOptions,
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 && !this.#store.has(table)) {
43
- this.#store.set(table, []);
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) this.#store.delete(table);
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
+ }
@@ -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
+ }