@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.
Files changed (123) hide show
  1. package/README.md +69 -0
  2. package/dist/EonProvider.d.ts +51 -0
  3. package/dist/EonProvider.d.ts.map +1 -0
  4. package/dist/EonProvider.js +154 -0
  5. package/dist/EonProvider.js.map +1 -0
  6. package/dist/connection/EonConnection.d.ts +116 -0
  7. package/dist/connection/EonConnection.d.ts.map +1 -0
  8. package/dist/connection/EonConnection.js +15 -0
  9. package/dist/connection/EonConnection.js.map +1 -0
  10. package/dist/connection/config.d.ts +56 -0
  11. package/dist/connection/config.d.ts.map +1 -0
  12. package/dist/connection/config.js +14 -0
  13. package/dist/connection/config.js.map +1 -0
  14. package/dist/connection/websocket.d.ts +16 -0
  15. package/dist/connection/websocket.d.ts.map +1 -0
  16. package/dist/connection/websocket.js +383 -0
  17. package/dist/connection/websocket.js.map +1 -0
  18. package/dist/decorators/superTable.d.ts +73 -0
  19. package/dist/decorators/superTable.d.ts.map +1 -0
  20. package/dist/decorators/superTable.js +98 -0
  21. package/dist/decorators/superTable.js.map +1 -0
  22. package/dist/index.d.ts +20 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +19 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/ingest/schemaless.d.ts +22 -0
  27. package/dist/ingest/schemaless.d.ts.map +1 -0
  28. package/dist/ingest/schemaless.js +161 -0
  29. package/dist/ingest/schemaless.js.map +1 -0
  30. package/dist/ingest/sql.d.ts +23 -0
  31. package/dist/ingest/sql.d.ts.map +1 -0
  32. package/dist/ingest/sql.js +59 -0
  33. package/dist/ingest/sql.js.map +1 -0
  34. package/dist/ingest/stmt.d.ts +82 -0
  35. package/dist/ingest/stmt.d.ts.map +1 -0
  36. package/dist/ingest/stmt.js +246 -0
  37. package/dist/ingest/stmt.js.map +1 -0
  38. package/dist/metadata-keys.d.ts +17 -0
  39. package/dist/metadata-keys.d.ts.map +1 -0
  40. package/dist/metadata-keys.js +17 -0
  41. package/dist/metadata-keys.js.map +1 -0
  42. package/dist/query/TimeSeriesQuery.d.ts +121 -0
  43. package/dist/query/TimeSeriesQuery.d.ts.map +1 -0
  44. package/dist/query/TimeSeriesQuery.js +291 -0
  45. package/dist/query/TimeSeriesQuery.js.map +1 -0
  46. package/dist/query/native.d.ts +22 -0
  47. package/dist/query/native.d.ts.map +1 -0
  48. package/dist/query/native.js +135 -0
  49. package/dist/query/native.js.map +1 -0
  50. package/dist/repository/SuperTableRepository.d.ts +80 -0
  51. package/dist/repository/SuperTableRepository.d.ts.map +1 -0
  52. package/dist/repository/SuperTableRepository.js +232 -0
  53. package/dist/repository/SuperTableRepository.js.map +1 -0
  54. package/dist/schema/CreateStableSpec.d.ts +140 -0
  55. package/dist/schema/CreateStableSpec.d.ts.map +1 -0
  56. package/dist/schema/CreateStableSpec.js +41 -0
  57. package/dist/schema/CreateStableSpec.js.map +1 -0
  58. package/dist/schema/EonMigrationRunner.d.ts +71 -0
  59. package/dist/schema/EonMigrationRunner.d.ts.map +1 -0
  60. package/dist/schema/EonMigrationRunner.js +295 -0
  61. package/dist/schema/EonMigrationRunner.js.map +1 -0
  62. package/dist/schema/EonSchema.d.ts +128 -0
  63. package/dist/schema/EonSchema.d.ts.map +1 -0
  64. package/dist/schema/EonSchema.js +309 -0
  65. package/dist/schema/EonSchema.js.map +1 -0
  66. package/dist/schema/Migration.d.ts +47 -0
  67. package/dist/schema/Migration.d.ts.map +1 -0
  68. package/dist/schema/Migration.js +55 -0
  69. package/dist/schema/Migration.js.map +1 -0
  70. package/dist/schema/compile.d.ts +33 -0
  71. package/dist/schema/compile.d.ts.map +1 -0
  72. package/dist/schema/compile.js +70 -0
  73. package/dist/schema/compile.js.map +1 -0
  74. package/dist/schema/sync.d.ts +45 -0
  75. package/dist/schema/sync.d.ts.map +1 -0
  76. package/dist/schema/sync.js +102 -0
  77. package/dist/schema/sync.js.map +1 -0
  78. package/dist/services/connection.d.ts +33 -0
  79. package/dist/services/connection.d.ts.map +1 -0
  80. package/dist/services/connection.js +71 -0
  81. package/dist/services/connection.js.map +1 -0
  82. package/dist/testing/Factory.d.ts +43 -0
  83. package/dist/testing/Factory.d.ts.map +1 -0
  84. package/dist/testing/Factory.js +134 -0
  85. package/dist/testing/Factory.js.map +1 -0
  86. package/dist/testing/FakeEonConnection.d.ts +43 -0
  87. package/dist/testing/FakeEonConnection.d.ts.map +1 -0
  88. package/dist/testing/FakeEonConnection.js +315 -0
  89. package/dist/testing/FakeEonConnection.js.map +1 -0
  90. package/dist/testing/index.d.ts +34 -0
  91. package/dist/testing/index.d.ts.map +1 -0
  92. package/dist/testing/index.js +67 -0
  93. package/dist/testing/index.js.map +1 -0
  94. package/index.darwin-arm64.node +0 -0
  95. package/index.darwin-x64.node +0 -0
  96. package/index.linux-arm64-gnu.node +0 -0
  97. package/index.linux-x64-gnu.node +0 -0
  98. package/index.win32-x64-msvc.node +0 -0
  99. package/package.json +65 -0
  100. package/scripts/copy-napi.mjs +72 -0
  101. package/src/EonProvider.ts +222 -0
  102. package/src/connection/EonConnection.ts +146 -0
  103. package/src/connection/config.ts +60 -0
  104. package/src/connection/websocket.ts +436 -0
  105. package/src/decorators/superTable.ts +169 -0
  106. package/src/index.ts +118 -0
  107. package/src/ingest/schemaless.ts +211 -0
  108. package/src/ingest/sql.ts +73 -0
  109. package/src/ingest/stmt.ts +335 -0
  110. package/src/metadata-keys.ts +20 -0
  111. package/src/query/TimeSeriesQuery.ts +480 -0
  112. package/src/query/native.ts +180 -0
  113. package/src/repository/SuperTableRepository.ts +347 -0
  114. package/src/schema/CreateStableSpec.ts +184 -0
  115. package/src/schema/EonMigrationRunner.ts +378 -0
  116. package/src/schema/EonSchema.ts +396 -0
  117. package/src/schema/Migration.ts +66 -0
  118. package/src/schema/compile.ts +109 -0
  119. package/src/schema/sync.ts +142 -0
  120. package/src/services/connection.ts +89 -0
  121. package/src/testing/Factory.ts +186 -0
  122. package/src/testing/FakeEonConnection.ts +351 -0
  123. package/src/testing/index.ts +81 -0
@@ -0,0 +1,378 @@
1
+ /**
2
+ * `EonMigrationRunner` — discovers, executes, and tracks eon (TDengine)
3
+ * migrations. Mirrors atlas `MigrationRunner` (`schema/MigrationRunner.ts`):
4
+ * sorted-file discovery, a `ream_`-prefixed tracking table, batch-based
5
+ * rollback, `init/status/migrate/rollback/reset/refresh/fresh/dryRun`.
6
+ *
7
+ * ── TWO named TDengine deviations from atlas (AC6) ──────────────────────────
8
+ *
9
+ * 1. **No transactions / no engine rollback.** TDengine DDL is non-transactional
10
+ * — a batch CANNOT be applied atomically. The runner executes statements
11
+ * **sequentially**; a mid-migration failure leaves earlier statements
12
+ * applied. The mitigation is idempotent DDL (`IF (NOT) EXISTS`, the
13
+ * `EonSchema` default) so a re-run converges. There is NO `runInTransaction`.
14
+ *
15
+ * 2. **No `UNIQUE`, no auto-increment.** The tracking table is a basic table
16
+ * `ream_eon_migrations(executed_at TIMESTAMP, name VARCHAR(255), batch INT)`;
17
+ * the applied-set is de-duped **by name in JS**, and each record is written
18
+ * with a strictly-increasing `executed_at` so rollback can delete it by that
19
+ * unique timestamp key (TDengine only allows a `DELETE` predicate on the
20
+ * primary timestamp column).
21
+ *
22
+ * Agnostic leaf: takes the connection structurally and a plain directory path —
23
+ * no `@c9up/ream` import, no app helper (`project_package_extraction`).
24
+ */
25
+
26
+ import * as fsp from "node:fs/promises";
27
+ import * as path from "node:path";
28
+ import { pathToFileURL } from "node:url";
29
+ import type { EonConnection } from "../connection/EonConnection.js";
30
+ import { compileStatementNative } from "../query/native.js";
31
+ import type { Migration } from "./Migration.js";
32
+
33
+ const DEFAULT_DIR = "database/eon-migrations";
34
+ const DEFAULT_TABLE = "ream_eon_migrations";
35
+ const TABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
36
+ /** Migration filename (no extension): no path separators, no `..`. */
37
+ const MIGRATION_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
38
+
39
+ export interface EonMigrationOptions {
40
+ /** Directory holding migration files. Default `database/eon-migrations`. */
41
+ migrationsDir?: string;
42
+ /**
43
+ * Tracking-table name. Default `ream_eon_migrations`. Must match
44
+ * `/^[A-Za-z_][A-Za-z0-9_]*$/`. The `ream_` prefix marks it a framework/system
45
+ * table (`feedback_underscore_policy`) — keep it so cleanup helpers skip it.
46
+ */
47
+ tableName?: string;
48
+ }
49
+
50
+ export type MigrationState = "applied" | "pending";
51
+
52
+ export interface MigrationStatus {
53
+ name: string;
54
+ state: MigrationState;
55
+ batch?: number;
56
+ }
57
+
58
+ /** One tracking record row as read back from the connection. */
59
+ interface MigrationRecord {
60
+ name: string;
61
+ batch: number;
62
+ executed_at: number;
63
+ }
64
+
65
+ export class EonMigrationRunner {
66
+ readonly #conn: EonConnection;
67
+ readonly #dir: string;
68
+ readonly #table: string;
69
+
70
+ constructor(conn: EonConnection, options: EonMigrationOptions = {}) {
71
+ this.#conn = conn;
72
+ this.#dir = options.migrationsDir ?? DEFAULT_DIR;
73
+ const table = options.tableName ?? DEFAULT_TABLE;
74
+ if (!TABLE_NAME_PATTERN.test(table)) {
75
+ throw new Error(
76
+ `[E_EON_MIGRATION_INVALID] invalid tracking-table name '${table}'; must match ${TABLE_NAME_PATTERN}`,
77
+ );
78
+ }
79
+ this.#table = table;
80
+ }
81
+
82
+ /** Create the tracking table (`IF NOT EXISTS`) via the basic-table DDL path. */
83
+ async init(): Promise<void> {
84
+ const { statements } = compileStatementNative(
85
+ {
86
+ kind: "createTable",
87
+ name: this.#table,
88
+ ifNotExists: true,
89
+ columns: [
90
+ {
91
+ name: "executed_at",
92
+ kind: "timestamp",
93
+ length: null,
94
+ precision: null,
95
+ scale: null,
96
+ },
97
+ {
98
+ name: "name",
99
+ kind: "varchar",
100
+ length: 255,
101
+ precision: null,
102
+ scale: null,
103
+ },
104
+ {
105
+ name: "batch",
106
+ kind: "int",
107
+ length: null,
108
+ precision: null,
109
+ scale: null,
110
+ },
111
+ ],
112
+ },
113
+ "tdengine",
114
+ );
115
+ for (const sql of statements) await this.#conn.exec(sql);
116
+ }
117
+
118
+ /** The status of every discovered migration (applied vs pending). */
119
+ async status(): Promise<MigrationStatus[]> {
120
+ await this.init();
121
+ const applied = await this.#appliedRecords();
122
+ const byName = new Map(applied.map((r) => [r.name, r.batch]));
123
+ const files = await this.#discoverFiles();
124
+ return files.map((name) => {
125
+ const batch = byName.get(name);
126
+ return batch === undefined
127
+ ? { name, state: "pending" }
128
+ : { name, state: "applied", batch };
129
+ });
130
+ }
131
+
132
+ /** Run every pending migration (filename order), recording each. */
133
+ async migrate(): Promise<string[]> {
134
+ await this.init();
135
+ const applied = await this.#appliedRecords();
136
+ const appliedNames = new Set(applied.map((r) => r.name));
137
+ const files = await this.#discoverFiles();
138
+ const pending = files.filter((f) => !appliedNames.has(f));
139
+ if (pending.length === 0) return [];
140
+
141
+ const batch = this.#maxBatch(applied) + 1;
142
+ // Strictly-increasing executed_at (unique key for rollback deletes) — never
143
+ // below any existing record, never colliding within this run.
144
+ let clock = Math.max(Date.now(), this.#maxExecutedAt(applied) + 1);
145
+ const executed: string[] = [];
146
+
147
+ for (const name of pending) {
148
+ const migration = await this.#loadMigration(name);
149
+ for (const sql of await migration.getUpSQL()) {
150
+ await this.#conn.exec(sql);
151
+ }
152
+ await this.#writeRecord(name, batch, clock);
153
+ clock += 1;
154
+ executed.push(name);
155
+ }
156
+ return executed;
157
+ }
158
+
159
+ /** Roll back the most-recent batch (files in reverse order), running `down()`. */
160
+ async rollback(): Promise<string[]> {
161
+ await this.init();
162
+ const applied = await this.#appliedRecords();
163
+ if (applied.length === 0) return [];
164
+ const batch = this.#maxBatch(applied);
165
+
166
+ const inBatch = new Map(
167
+ applied.filter((r) => r.batch === batch).map((r) => [r.name, r]),
168
+ );
169
+ const files = await this.#discoverFiles();
170
+ // Reverse of application order = reverse of filename order within the batch.
171
+ const ordered = files.filter((f) => inBatch.has(f)).reverse();
172
+
173
+ const rolled: string[] = [];
174
+ for (const name of ordered) {
175
+ const record = inBatch.get(name);
176
+ if (record === undefined) continue;
177
+ const migration = await this.#loadMigration(name);
178
+ for (const sql of await migration.getDownSQL()) {
179
+ await this.#conn.exec(sql);
180
+ }
181
+ await this.#deleteRecord(record.executed_at);
182
+ rolled.push(name);
183
+ }
184
+ return rolled;
185
+ }
186
+
187
+ /** Roll back every applied batch (Lucid `migrate:reset`). */
188
+ async reset(): Promise<string[]> {
189
+ await this.init();
190
+ const all: string[] = [];
191
+ // Guard against a stuck loop: each rollback must shrink the applied set.
192
+ for (;;) {
193
+ const rolled = await this.rollback();
194
+ if (rolled.length === 0) break;
195
+ all.push(...rolled);
196
+ }
197
+ return all;
198
+ }
199
+
200
+ /** Reset then re-run every migration (Lucid `migrate:refresh`). */
201
+ async refresh(): Promise<{ rolled: string[]; executed: string[] }> {
202
+ const rolled = await this.reset();
203
+ const executed = await this.migrate();
204
+ return { rolled, executed };
205
+ }
206
+
207
+ /** Alias of {@link refresh} (Lucid `migrate:fresh`). */
208
+ fresh(): Promise<{ rolled: string[]; executed: string[] }> {
209
+ return this.refresh();
210
+ }
211
+
212
+ /** Compute the SQL each pending migration would emit, WITHOUT executing it. */
213
+ async dryRun(): Promise<Array<{ name: string; sql: string[] }>> {
214
+ await this.init();
215
+ const applied = await this.#appliedRecords();
216
+ const appliedNames = new Set(applied.map((r) => r.name));
217
+ const pending = (await this.#discoverFiles()).filter(
218
+ (f) => !appliedNames.has(f),
219
+ );
220
+ const result: Array<{ name: string; sql: string[] }> = [];
221
+ for (const name of pending) {
222
+ const migration = await this.#loadMigration(name);
223
+ result.push({ name, sql: await migration.getUpSQL() });
224
+ }
225
+ return result;
226
+ }
227
+
228
+ /** Read every tracking record (name, batch, executed_at). */
229
+ async #appliedRecords(): Promise<MigrationRecord[]> {
230
+ const { statements } = compileStatementNative(
231
+ {
232
+ kind: "select",
233
+ table: this.#table,
234
+ select: ["name", "batch", "executed_at"],
235
+ },
236
+ "tdengine",
237
+ );
238
+ const sql = statements[0];
239
+ if (sql === undefined) return [];
240
+ const rows = await this.#conn.query<{
241
+ name: unknown;
242
+ batch: unknown;
243
+ executed_at: unknown;
244
+ }>(sql);
245
+ return rows.map((r) => ({
246
+ name: String(r.name),
247
+ batch: Number(r.batch),
248
+ executed_at: Number(r.executed_at),
249
+ }));
250
+ }
251
+
252
+ #maxBatch(records: readonly MigrationRecord[]): number {
253
+ return records.reduce((max, r) => Math.max(max, r.batch), 0);
254
+ }
255
+
256
+ #maxExecutedAt(records: readonly MigrationRecord[]): number {
257
+ return records.reduce((max, r) => Math.max(max, r.executed_at), 0);
258
+ }
259
+
260
+ /** Insert a tracking record via the literal INSERT compiler (never TS interpolation). */
261
+ async #writeRecord(
262
+ name: string,
263
+ batch: number,
264
+ executedAt: number,
265
+ ): Promise<void> {
266
+ const { statements } = compileStatementNative(
267
+ {
268
+ kind: "insert",
269
+ table: this.#table,
270
+ columns: ["executed_at", "name", "batch"],
271
+ rows: [[executedAt, name, batch]],
272
+ literal: true,
273
+ },
274
+ "tdengine",
275
+ );
276
+ for (const sql of statements) await this.#conn.exec(sql);
277
+ }
278
+
279
+ /** Delete a tracking record by its unique `executed_at` key (compiled in Rust). */
280
+ async #deleteRecord(executedAt: number): Promise<void> {
281
+ const { statements } = compileStatementNative(
282
+ {
283
+ kind: "delete",
284
+ table: this.#table,
285
+ column: "executed_at",
286
+ value: executedAt,
287
+ },
288
+ "tdengine",
289
+ );
290
+ for (const sql of statements) await this.#conn.exec(sql);
291
+ }
292
+
293
+ /** Discover migration files (`.ts`/`.js`), sorted, extension stripped. */
294
+ async #discoverFiles(): Promise<string[]> {
295
+ try {
296
+ const entries = await fsp.readdir(this.#dir);
297
+ return entries
298
+ .filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
299
+ .sort()
300
+ .map((f) => f.replace(/\.(ts|js)$/, ""));
301
+ } catch (err) {
302
+ // A missing migrations directory → no migrations. Anything else propagates.
303
+ if (
304
+ typeof err === "object" &&
305
+ err !== null &&
306
+ Reflect.get(err, "code") === "ENOENT"
307
+ ) {
308
+ return [];
309
+ }
310
+ throw err;
311
+ }
312
+ }
313
+
314
+ /** Load + instantiate a migration's default-export class. */
315
+ async #loadMigration(name: string): Promise<Migration> {
316
+ if (!MIGRATION_NAME_PATTERN.test(name) || name.includes("..")) {
317
+ throw new Error(
318
+ `[E_EON_MIGRATION_INVALID] unsafe migration name '${name}'`,
319
+ );
320
+ }
321
+ const tsPath = path.join(this.#dir, `${name}.ts`);
322
+ const jsPath = path.join(this.#dir, `${name}.js`);
323
+ const tsExists = await pathExists(tsPath);
324
+ // Fail loud with the runner's own error (atlas parity) instead of letting
325
+ // `import` throw a raw ERR_MODULE_NOT_FOUND on a missing/renamed file.
326
+ if (!tsExists && !(await pathExists(jsPath))) {
327
+ throw new Error(
328
+ `[E_EON_MIGRATION_INVALID] migration '${name}' not found (no .ts or .js file in ${this.#dir})`,
329
+ );
330
+ }
331
+ const filePath = tsExists ? tsPath : jsPath;
332
+ // ESM dynamic import needs a file:// URL (Windows rejects a bare path).
333
+ const mod: { default?: unknown } = await import(
334
+ pathToFileURL(path.resolve(filePath)).href
335
+ );
336
+ const MigrationClass = mod.default;
337
+ if (!isConstructor(MigrationClass)) {
338
+ throw new Error(
339
+ `[E_EON_MIGRATION_INVALID] migration '${name}' must export a default class`,
340
+ );
341
+ }
342
+ const instance: unknown = new MigrationClass();
343
+ if (!isMigration(instance)) {
344
+ throw new Error(
345
+ `[E_EON_MIGRATION_INVALID] migration '${name}' default export is not a Migration (missing up/down)`,
346
+ );
347
+ }
348
+ return instance;
349
+ }
350
+ }
351
+
352
+ /** A no-arg constructor — narrows an `unknown` default export before `new`. */
353
+ type NoArgConstructor = new () => unknown;
354
+
355
+ function isConstructor(value: unknown): value is NoArgConstructor {
356
+ return typeof value === "function";
357
+ }
358
+
359
+ /**
360
+ * Structural Migration guard — avoids importing the class value for an
361
+ * `instanceof`. Reads through the prototype chain (methods live on the
362
+ * prototype, not as own enumerable props) via `Reflect.get`.
363
+ */
364
+ function isMigration(value: unknown): value is Migration {
365
+ if (typeof value !== "object" || value === null) return false;
366
+ const up = Reflect.get(value, "getUpSQL");
367
+ const down = Reflect.get(value, "getDownSQL");
368
+ return typeof up === "function" && typeof down === "function";
369
+ }
370
+
371
+ async function pathExists(p: string): Promise<boolean> {
372
+ try {
373
+ await fsp.access(p);
374
+ return true;
375
+ } catch {
376
+ return false;
377
+ }
378
+ }