turbine-orm 0.49.0 → 0.50.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 (157) hide show
  1. package/README.md +122 -39
  2. package/dist/cjs/adapters/cockroachdb.d.ts +39 -0
  3. package/dist/cjs/adapters/index.d.ts +110 -0
  4. package/dist/cjs/adapters/yugabytedb.d.ts +51 -0
  5. package/dist/cjs/cli/config.d.ts +181 -0
  6. package/dist/cjs/cli/config.js +32 -6
  7. package/dist/cjs/cli/destructive.d.ts +38 -0
  8. package/dist/cjs/cli/index.d.ts +359 -0
  9. package/dist/cjs/cli/index.js +228 -56
  10. package/dist/cjs/cli/loader.d.ts +61 -0
  11. package/dist/cjs/cli/mcp.d.ts +42 -0
  12. package/dist/cjs/cli/migrate.d.ts +356 -0
  13. package/dist/cjs/cli/migrate.js +131 -40
  14. package/dist/cjs/cli/observe-ui.d.ts +1 -0
  15. package/dist/cjs/cli/observe-ui.js +14 -5
  16. package/dist/cjs/cli/observe.d.ts +25 -0
  17. package/dist/cjs/cli/observe.js +49 -12
  18. package/dist/cjs/cli/pii-tags.d.ts +53 -0
  19. package/dist/cjs/cli/prisma-report.d.ts +33 -0
  20. package/dist/cjs/cli/prisma-report.js +73 -0
  21. package/dist/cjs/cli/prisma-resolve.d.ts +106 -0
  22. package/dist/cjs/cli/prisma-resolve.js +1 -0
  23. package/dist/cjs/cli/prisma-schema.d.ts +176 -0
  24. package/dist/cjs/cli/prisma-schema.js +82 -4
  25. package/dist/cjs/cli/rate-limit.d.ts +32 -0
  26. package/dist/cjs/cli/rate-limit.js +45 -0
  27. package/dist/cjs/cli/studio-demo.d.ts +43 -0
  28. package/dist/cjs/cli/studio-ui.generated.d.ts +1 -0
  29. package/dist/cjs/cli/studio.d.ts +207 -0
  30. package/dist/cjs/cli/studio.js +136 -71
  31. package/dist/cjs/cli/ui.d.ts +73 -0
  32. package/dist/cjs/cli/ui.js +51 -9
  33. package/dist/cjs/client.d.ts +837 -0
  34. package/dist/cjs/client.js +3 -0
  35. package/dist/cjs/dialect.d.ts +516 -0
  36. package/dist/cjs/dialect.js +37 -12
  37. package/dist/cjs/errors.d.ts +370 -0
  38. package/dist/cjs/generate.d.ts +137 -0
  39. package/dist/cjs/generate.js +39 -6
  40. package/dist/cjs/index-advisor.d.ts +153 -0
  41. package/dist/cjs/index-stats.d.ts +384 -0
  42. package/dist/cjs/index.d.ts +55 -0
  43. package/dist/cjs/index.js +7 -2
  44. package/dist/cjs/introspect.d.ts +269 -0
  45. package/dist/cjs/mssql.d.ts +232 -0
  46. package/dist/cjs/mssql.js +6 -0
  47. package/dist/cjs/mysql.d.ts +173 -0
  48. package/dist/cjs/mysql.js +16 -0
  49. package/dist/cjs/nested-write.d.ts +96 -0
  50. package/dist/cjs/nested-write.js +414 -24
  51. package/dist/cjs/observe.d.ts +115 -0
  52. package/dist/cjs/optional-peer-import.d.cts +72 -0
  53. package/dist/cjs/pipeline-submittable.d.ts +93 -0
  54. package/dist/cjs/pipeline.d.ts +71 -0
  55. package/dist/cjs/powdb-introspect.d.ts +84 -0
  56. package/dist/cjs/powdb.d.ts +931 -0
  57. package/dist/cjs/powdb.js +106 -21
  58. package/dist/cjs/powql.d.ts +592 -0
  59. package/dist/cjs/powql.js +42 -6
  60. package/dist/cjs/prisma-compat.d.ts +283 -0
  61. package/dist/cjs/prisma-compat.js +167 -9
  62. package/dist/cjs/query/aggregates.d.ts +92 -0
  63. package/dist/cjs/query/aggregates.js +7 -3
  64. package/dist/cjs/query/batched-loader.d.ts +193 -0
  65. package/dist/cjs/query/builder.d.ts +849 -0
  66. package/dist/cjs/query/builder.js +571 -65
  67. package/dist/cjs/query/compound-unique.d.ts +51 -0
  68. package/dist/cjs/query/deferred.d.ts +223 -0
  69. package/dist/cjs/query/filters.d.ts +201 -0
  70. package/dist/cjs/query/index.d.ts +14 -0
  71. package/dist/cjs/query/index.js +6 -1
  72. package/dist/cjs/query/relations.d.ts +609 -0
  73. package/dist/cjs/query/relations.js +693 -46
  74. package/dist/cjs/query/types.d.ts +1300 -0
  75. package/dist/cjs/query/utils.d.ts +209 -0
  76. package/dist/cjs/query/utils.js +208 -1
  77. package/dist/cjs/query/warn-registry.d.ts +68 -0
  78. package/dist/cjs/query/warn-registry.js +9 -0
  79. package/dist/cjs/query/where-compile.d.ts +139 -0
  80. package/dist/cjs/query/where.d.ts +548 -0
  81. package/dist/cjs/query/where.js +58 -22
  82. package/dist/cjs/query/writes.d.ts +172 -0
  83. package/dist/cjs/query/writes.js +105 -12
  84. package/dist/cjs/realtime.d.ts +70 -0
  85. package/dist/cjs/schema-builder.d.ts +354 -0
  86. package/dist/cjs/schema-metadata.d.ts +83 -0
  87. package/dist/cjs/schema-sql.d.ts +217 -0
  88. package/dist/cjs/schema-sql.js +23 -5
  89. package/dist/cjs/schema.d.ts +356 -0
  90. package/dist/cjs/schema.js +125 -0
  91. package/dist/cjs/seed.d.ts +15 -0
  92. package/dist/cjs/serverless.d.ts +142 -0
  93. package/dist/cjs/sqlite.d.ts +143 -0
  94. package/dist/cjs/sqlite.js +4 -0
  95. package/dist/cjs/typed-sql.d.ts +102 -0
  96. package/dist/cli/config.d.ts +18 -4
  97. package/dist/cli/config.js +31 -6
  98. package/dist/cli/index.d.ts +123 -0
  99. package/dist/cli/index.js +223 -58
  100. package/dist/cli/migrate.d.ts +59 -10
  101. package/dist/cli/migrate.js +128 -41
  102. package/dist/cli/observe-ui.d.ts +1 -1
  103. package/dist/cli/observe-ui.js +14 -5
  104. package/dist/cli/observe.d.ts +7 -1
  105. package/dist/cli/observe.js +48 -12
  106. package/dist/cli/prisma-report.d.ts +14 -0
  107. package/dist/cli/prisma-report.js +72 -0
  108. package/dist/cli/prisma-resolve.d.ts +6 -0
  109. package/dist/cli/prisma-resolve.js +1 -0
  110. package/dist/cli/prisma-schema.d.ts +62 -2
  111. package/dist/cli/prisma-schema.js +81 -4
  112. package/dist/cli/rate-limit.d.ts +32 -0
  113. package/dist/cli/rate-limit.js +40 -0
  114. package/dist/cli/studio.d.ts +5 -5
  115. package/dist/cli/studio.js +135 -70
  116. package/dist/cli/ui.d.ts +1 -1
  117. package/dist/cli/ui.js +51 -9
  118. package/dist/client.d.ts +40 -0
  119. package/dist/client.js +3 -0
  120. package/dist/dialect.d.ts +17 -1
  121. package/dist/dialect.js +37 -12
  122. package/dist/generate.js +40 -7
  123. package/dist/index.d.ts +1 -1
  124. package/dist/index.js +1 -1
  125. package/dist/mssql.js +6 -0
  126. package/dist/mysql.js +16 -0
  127. package/dist/nested-write.d.ts +2 -0
  128. package/dist/nested-write.js +415 -25
  129. package/dist/powdb.d.ts +4 -2
  130. package/dist/powdb.js +106 -21
  131. package/dist/powql.d.ts +5 -0
  132. package/dist/powql.js +42 -6
  133. package/dist/prisma-compat.d.ts +2 -0
  134. package/dist/prisma-compat.js +166 -8
  135. package/dist/query/aggregates.js +7 -3
  136. package/dist/query/builder.d.ts +292 -21
  137. package/dist/query/builder.js +570 -64
  138. package/dist/query/deferred.d.ts +39 -0
  139. package/dist/query/index.d.ts +1 -1
  140. package/dist/query/index.js +1 -1
  141. package/dist/query/relations.d.ts +173 -5
  142. package/dist/query/relations.js +688 -47
  143. package/dist/query/types.d.ts +123 -39
  144. package/dist/query/utils.d.ts +116 -0
  145. package/dist/query/utils.js +198 -0
  146. package/dist/query/warn-registry.d.ts +9 -0
  147. package/dist/query/warn-registry.js +9 -0
  148. package/dist/query/where.d.ts +38 -1
  149. package/dist/query/where.js +58 -23
  150. package/dist/query/writes.d.ts +42 -1
  151. package/dist/query/writes.js +104 -13
  152. package/dist/schema-sql.d.ts +14 -0
  153. package/dist/schema-sql.js +23 -5
  154. package/dist/schema.d.ts +38 -0
  155. package/dist/schema.js +123 -0
  156. package/dist/sqlite.js +4 -0
  157. package/package.json +77 -28
@@ -0,0 +1,356 @@
1
+ /**
2
+ * turbine-orm CLI — Migration system
3
+ *
4
+ * SQL-first migrations with UP/DOWN sections, tracked in _turbine_migrations.
5
+ * Migration files are timestamp-prefixed .sql files.
6
+ *
7
+ * File format:
8
+ * -- UP
9
+ * CREATE TABLE users (...);
10
+ *
11
+ * -- DOWN
12
+ * DROP TABLE users;
13
+ */
14
+ import pg from 'pg';
15
+ import type { DatabaseAdapter } from '../adapters/index.js';
16
+ import { type Dialect } from '../dialect.js';
17
+ import { type DestructiveStatement } from './destructive.js';
18
+ export interface MigrationFile {
19
+ /** Full filename (e.g. "20260325120000_create_users.sql") */
20
+ filename: string;
21
+ /** Absolute path to the file */
22
+ path: string;
23
+ /** Extracted name portion (e.g. "20260325120000_create_users") */
24
+ name: string;
25
+ /** Timestamp prefix (e.g. "20260325120000") — YYYYMMDDHHMMSS */
26
+ timestamp: string;
27
+ }
28
+ export interface AppliedMigration {
29
+ id: number;
30
+ name: string;
31
+ applied_at: Date;
32
+ checksum: string;
33
+ }
34
+ export interface MigrationStatus {
35
+ file: MigrationFile;
36
+ applied: boolean;
37
+ appliedAt?: Date;
38
+ /** True if the file checksum matches the stored checksum (only set for applied migrations) */
39
+ checksumValid?: boolean;
40
+ /** True when the migration was applied but its file is missing from disk. */
41
+ missingFile?: boolean;
42
+ }
43
+ /** A pending migration whose UP section contains data-destroying statements. */
44
+ export interface DestructiveOffender {
45
+ file: string;
46
+ hits: DestructiveStatement[];
47
+ }
48
+ /**
49
+ * A migration that was applied even though an already-applied migration carries
50
+ * a newer timestamp prefix, i.e. history was written out of order.
51
+ */
52
+ export interface OutOfOrderApply {
53
+ /** The out-of-order migration that was just applied. */
54
+ applied: string;
55
+ /** The newest previously-applied migration it landed behind. */
56
+ newestPrior: string;
57
+ }
58
+ export interface MigrationRunResult {
59
+ applied: MigrationFile[];
60
+ errors: Array<{
61
+ file: MigrationFile;
62
+ error: string;
63
+ }>;
64
+ /**
65
+ * Destructive statements found in the pending batch. Populated whether or not
66
+ * the run was allowed to proceed, so a caller (deploy) can print a notice even
67
+ * when it applies them by design.
68
+ */
69
+ destructive: DestructiveOffender[];
70
+ /** Migrations applied with a timestamp older than an already-applied one. */
71
+ outOfOrder: OutOfOrderApply[];
72
+ /**
73
+ * Migrations applied WITHOUT a transaction (they carried the
74
+ * `-- turbine:no-transaction` directive). The CLI prints a loud notice for
75
+ * each: a mid-file failure leaves earlier statements applied and the migration
76
+ * unrecorded, so every statement in one of these must be idempotent.
77
+ */
78
+ noTransaction: MigrationFile[];
79
+ }
80
+ /** Extract the YYYYMMDDHHMMSS timestamp prefix from a migration name, or null. */
81
+ export declare function migrationTimestamp(name: string): string | null;
82
+ /** Scan a set of migration files' UP sections for data-destroying statements. */
83
+ export declare function collectUpDestructive(files: MigrationFile[]): DestructiveOffender[];
84
+ /**
85
+ * Parse a migration filename into its components.
86
+ * Expected format: YYYYMMDDHHMMSS_description.sql
87
+ */
88
+ export declare function parseMigrationFilename(filename: string): MigrationFile | null;
89
+ /**
90
+ * Sanitize a migration name: lowercase, replace non-alnum with _, collapse duplicates, trim.
91
+ */
92
+ export declare function sanitizeName(name: string): string;
93
+ /**
94
+ * Generate a YYYYMMDDHHMMSS timestamp string from a Date.
95
+ */
96
+ export declare function formatTimestamp(date: Date): string;
97
+ /**
98
+ * Get pending migration files — those not yet applied.
99
+ * Returns files sorted by timestamp (ascending).
100
+ */
101
+ export declare function getPendingMigrations(migrationsDir: string, applied: string[]): MigrationFile[];
102
+ /**
103
+ * List all migration files in the migrations directory, sorted by name.
104
+ */
105
+ export declare function listMigrationFiles(migrationsDir: string): MigrationFile[];
106
+ /** The parsed sections of a migration file plus its execution directives. */
107
+ export interface ParsedMigration {
108
+ up: string;
109
+ down: string;
110
+ /** True when the `-- turbine:no-transaction` directive is present in the header. */
111
+ noTransaction: boolean;
112
+ }
113
+ /**
114
+ * Parse migration content string into UP and DOWN sections plus directives.
115
+ * Exported for unit testing.
116
+ */
117
+ export declare function parseMigrationContent(content: string): ParsedMigration;
118
+ /**
119
+ * Split a SQL script into individual statements on top-level semicolons.
120
+ *
121
+ * A correct tokenizer, not a `split(';')`: a semicolon inside a single-quoted
122
+ * string (including a backslash-escaping `E'...'` string), a double-quoted
123
+ * identifier, a dollar-quoted body, a line comment
124
+ * (`--`), or a block comment (`/* *\/`, which Postgres allows to nest) must NOT
125
+ * split. This is the one production-destroying failure mode of no-transaction
126
+ * migrations (a partial statement executed against production), so the behavior
127
+ * is pinned by exhaustive unit tests.
128
+ *
129
+ * Comment-only fragments are dropped; every returned statement is trimmed and
130
+ * carries no trailing semicolon.
131
+ */
132
+ export declare function splitSqlStatements(sql: string): string[];
133
+ /**
134
+ * Parse a migration file into UP and DOWN sections.
135
+ */
136
+ export declare function parseMigrationSQL(filePath: string): ParsedMigration;
137
+ /**
138
+ * Can a stored pre-v0.6 checksum be safely upgraded to SHA-256?
139
+ *
140
+ * Only when the LEGACY algorithm, run over the file's CURRENT content,
141
+ * reproduces the stored value: that is what proves the file has not changed
142
+ * since it was applied. Upgrading without this proof blesses whatever the file
143
+ * says today and permanently disables drift detection for that migration.
144
+ *
145
+ * @internal exported for tests.
146
+ */
147
+ export declare function canUpgradeLegacyChecksum(stored: string, content: string): boolean;
148
+ /**
149
+ * Is an applied migration's stored checksum still valid for the file on disk?
150
+ *
151
+ * A SHA-256 match is the normal case. A pre-v0.6 row that {@link
152
+ * canUpgradeLegacyChecksum} would upgrade counts as valid too: `migrate up`
153
+ * accepts and upgrades it, so reporting it as invalid in `migrate status` would
154
+ * have the two commands disagree about the same unchanged file. Genuine drift
155
+ * (a legacy hash the current content no longer reproduces) still reports false.
156
+ *
157
+ * @internal exported for tests.
158
+ */
159
+ export declare function isChecksumValid(stored: string, content: string): boolean;
160
+ /**
161
+ * A named migration scaffold. `build()` returns the commented-SQL UP/DOWN body
162
+ * for the recipe; `createMigration({ recipe })` wraps it in the file header.
163
+ */
164
+ export interface MigrationRecipe {
165
+ /** One-line description shown in CLI help. */
166
+ description: string;
167
+ /** Build the UP/DOWN body (commented scaffold with placeholders). */
168
+ build(name: string): {
169
+ up: string;
170
+ down: string;
171
+ };
172
+ }
173
+ /**
174
+ * Registry of migration recipes, keyed by `--recipe <name>`. New recipes slot
175
+ * in here without touching {@link createMigration} or the CLI handler.
176
+ */
177
+ export declare const MIGRATION_RECIPES: Record<string, MigrationRecipe>;
178
+ /**
179
+ * The UP/DOWN body produced by {@link buildDiffMigrationBody}, plus the
180
+ * destructive statements found in each direction so the CLI can warn loudly.
181
+ */
182
+ export interface DiffMigrationBody {
183
+ /** Annotated UP SQL (destructive statements flagged with loud comments). */
184
+ up: string;
185
+ /** Annotated DOWN SQL, or an irreversible placeholder when none is derivable. */
186
+ down: string;
187
+ /** Destructive statements detected in the UP direction. */
188
+ destructiveUp: DestructiveStatement[];
189
+ /** Destructive statements detected in the DOWN direction. */
190
+ destructiveDown: DestructiveStatement[];
191
+ }
192
+ /**
193
+ * Build a migration UP/DOWN body from a `schemaDiff()` result.
194
+ *
195
+ * - UP is the diff's forward statements. DOWN is the diff's reverse statements
196
+ * when derivable, otherwise a clearly-commented "irreversible" placeholder.
197
+ * - Destructive statements in EITHER direction (a lossy `ALTER COLUMN ... TYPE`
198
+ * in UP, a `DROP TABLE`/`DROP COLUMN` reverse in DOWN) are flagged inline and,
199
+ * when any exist, a loud file-level banner is prepended to UP.
200
+ * - Any diff `warnings` (changes the diff refuses to apply automatically, e.g.
201
+ * enum value removals) are surfaced as `-- NOTE:` comments in UP.
202
+ *
203
+ * Pure and DB-free, so it is unit-testable from a synthesized diff.
204
+ */
205
+ export declare function buildDiffMigrationBody(diff: {
206
+ statements: string[];
207
+ reverseStatements: string[];
208
+ warnings?: string[];
209
+ }): DiffMigrationBody;
210
+ /**
211
+ * Create a new migration file.
212
+ *
213
+ * - `autoContent`: pre-populate UP/DOWN from a schema diff.
214
+ * - `options.recipe`: scaffold a named recipe (see {@link MIGRATION_RECIPES}).
215
+ * Mutually exclusive with `autoContent`; an unknown recipe throws.
216
+ * - `options.header`: extra header line(s) injected BEFORE `-- UP` (the only
217
+ * place a `-- turbine:no-transaction` directive is honored). Used with
218
+ * `autoContent`.
219
+ */
220
+ export declare function createMigration(migrationsDir: string, name: string, autoContent?: {
221
+ up: string;
222
+ down: string;
223
+ }, options?: {
224
+ recipe?: string;
225
+ header?: string;
226
+ }): MigrationFile;
227
+ /**
228
+ * Derive a Postgres advisory lock ID (positive int4) from the database name.
229
+ *
230
+ * Uses FNV-1a 32-bit hash — a well-known, stable, non-cryptographic hash with
231
+ * excellent distribution over short strings (database names are typically <64
232
+ * chars). Chosen over alternatives because it's:
233
+ * - deterministic (same input → same output, across processes/machines)
234
+ * - tiny (two lines, no allocations, no imports)
235
+ * - well-distributed (low collision rate for typical DB-name distributions)
236
+ *
237
+ * The top bit is cleared so the result fits in a positive int4, which is the
238
+ * range `pg_advisory_lock` expects for the single-argument form. Two databases
239
+ * in the same Postgres cluster can now run `turbine migrate` concurrently
240
+ * without contending on a single hardcoded lock ID.
241
+ */
242
+ export declare function deriveLockId(databaseName: string): number;
243
+ /**
244
+ * The minimal query surface a transactional migration body needs.
245
+ * `pg.Client` satisfies it; tests supply a fake.
246
+ */
247
+ export interface MigrationTxClient {
248
+ query(sql: string, params?: unknown[]): Promise<unknown>;
249
+ }
250
+ /**
251
+ * Run one migration body (UP or DOWN) plus its tracking-table write inside a
252
+ * single transaction. Returns `null` on success, or the error message on
253
+ * failure, leaving the caller to record it and stop.
254
+ *
255
+ * The ROLLBACK is best-effort: if the connection died, ROLLBACK throws too, and
256
+ * letting that escape would replace the real migration failure with a
257
+ * connection error. Same guard as `client.ts`, `query/builder.ts`, `dialect.ts`.
258
+ *
259
+ * @internal exported for tests; not part of the CLI's public surface.
260
+ */
261
+ export declare function runMigrationInTransaction(client: MigrationTxClient, body: string, tracking: {
262
+ sql: string;
263
+ params: unknown[];
264
+ }): Promise<string | null>;
265
+ export interface ChecksumMismatch {
266
+ name: string;
267
+ expected: string;
268
+ actual: string;
269
+ /** 'modified' if file changed, 'missing' if file deleted */
270
+ type: 'modified' | 'missing';
271
+ }
272
+ export interface MigrationDeployPlan {
273
+ pending: MigrationFile[];
274
+ mismatches: ChecksumMismatch[];
275
+ }
276
+ /**
277
+ * Validate that applied migration files have not been modified or deleted since they were run.
278
+ * Returns an array of mismatched migrations (empty if all are clean).
279
+ *
280
+ * @internal exported for tests; not part of the CLI's public surface.
281
+ */
282
+ export declare function validateChecksums(client: pg.Client, migrationsDir: string, dialect?: Dialect): Promise<ChecksumMismatch[]>;
283
+ export declare function formatChecksumMismatchError(mismatches: ChecksumMismatch[]): string;
284
+ /**
285
+ * Build a deploy plan from local migration files and applied migration rows.
286
+ * This is pure file-system planning; callers with a database connection should
287
+ * use `inspectMigrationDeploy()` to preserve legacy checksum upgrades.
288
+ */
289
+ export declare function planMigrationDeploy(migrationsDir: string, applied: AppliedMigration[]): MigrationDeployPlan;
290
+ /**
291
+ * Inspect deploy status without applying migrations.
292
+ */
293
+ export declare function inspectMigrationDeploy(connectionString: string, migrationsDir: string): Promise<MigrationDeployPlan>;
294
+ /**
295
+ * Apply all pending migrations (UP).
296
+ *
297
+ * Features:
298
+ * - Idempotent: running twice is safe (already-applied migrations are skipped)
299
+ * - Advisory lock: prevents concurrent migration runs
300
+ * - Checksum validation: detects modified migration files (BLOCKING — use
301
+ * `allowDrift: true` to bypass when intentionally rewriting history)
302
+ * - Each migration runs in its own transaction
303
+ *
304
+ * Throws `MigrationError` if any applied migration has been modified or deleted
305
+ * on disk, listing the offending files. Pass `{ allowDrift: true }` to bypass
306
+ * this check (the CLI exposes this as `--allow-drift`).
307
+ */
308
+ export declare function migrateUp(connectionString: string, migrationsDir: string, options?: {
309
+ step?: number;
310
+ allowDrift?: boolean;
311
+ force?: boolean /** @deprecated use allowDrift */;
312
+ /** Run migrations even when they contain data-destroying statements. Default false. */
313
+ allowDestructive?: boolean;
314
+ adapter?: DatabaseAdapter;
315
+ /**
316
+ * Called right before a `-- turbine:no-transaction` migration runs, so the
317
+ * CLI can print its loud pre-run notice (a concurrent index build can wait a
318
+ * long time on other transactions and otherwise looks hung).
319
+ */
320
+ onNoTransaction?: (file: MigrationFile) => void;
321
+ }): Promise<MigrationRunResult>;
322
+ /**
323
+ * Production migration apply. This intentionally applies files as written and
324
+ * never performs interactive destructive confirmation.
325
+ */
326
+ export declare function migrateDeploy(connectionString: string, migrationsDir: string, options?: {
327
+ adapter?: DatabaseAdapter;
328
+ allowDrift?: boolean;
329
+ }): Promise<MigrationRunResult>;
330
+ /**
331
+ * Rollback the last N migrations (DOWN).
332
+ *
333
+ * Features:
334
+ * - Advisory lock: prevents concurrent rollback runs
335
+ * - Each rollback runs in its own transaction
336
+ * - Properly reverses changes in reverse application order
337
+ */
338
+ export declare function migrateDown(connectionString: string, migrationsDir: string, options?: {
339
+ step?: number;
340
+ allowDestructive?: boolean;
341
+ adapter?: DatabaseAdapter;
342
+ }): Promise<{
343
+ rolledBack: MigrationFile[];
344
+ errors: Array<{
345
+ file: MigrationFile;
346
+ error: string;
347
+ }>;
348
+ }>;
349
+ /**
350
+ * Get the status of all migrations (applied vs pending).
351
+ *
352
+ * Applied rows carry `checksumValid`, decided by {@link isChecksumValid} so an
353
+ * unchanged pre-v0.6 row reports the same way `migrate up` treats it (valid,
354
+ * pending an in-place hash upgrade) rather than looking like drift.
355
+ */
356
+ export declare function migrateStatus(connectionString: string, migrationsDir: string): Promise<MigrationStatus[]>;
@@ -27,9 +27,13 @@ exports.listMigrationFiles = listMigrationFiles;
27
27
  exports.parseMigrationContent = parseMigrationContent;
28
28
  exports.splitSqlStatements = splitSqlStatements;
29
29
  exports.parseMigrationSQL = parseMigrationSQL;
30
+ exports.canUpgradeLegacyChecksum = canUpgradeLegacyChecksum;
31
+ exports.isChecksumValid = isChecksumValid;
30
32
  exports.buildDiffMigrationBody = buildDiffMigrationBody;
31
33
  exports.createMigration = createMigration;
32
34
  exports.deriveLockId = deriveLockId;
35
+ exports.runMigrationInTransaction = runMigrationInTransaction;
36
+ exports.validateChecksums = validateChecksums;
33
37
  exports.formatChecksumMismatchError = formatChecksumMismatchError;
34
38
  exports.planMigrationDeploy = planMigrationDeploy;
35
39
  exports.inspectMigrationDeploy = inspectMigrationDeploy;
@@ -67,6 +71,20 @@ function collectUpDestructive(files) {
67
71
  // Tracking table management
68
72
  // ---------------------------------------------------------------------------
69
73
  const TRACKING_TABLE = '_turbine_migrations';
74
+ /**
75
+ * The dialect the migration runner speaks.
76
+ *
77
+ * The runner connects with `pg.Client`, so Postgres (and the Postgres-compatible
78
+ * engines behind `adapters/`) is the only thing it can actually reach. The
79
+ * dialect is still threaded through every tracking-table statement, so making
80
+ * migrations dialect-aware is a matter of teaching this layer to build the
81
+ * engine's own client. Until then there is deliberately NO caller-supplied
82
+ * dialect option: accepting one would advertise sqlite/mysql/mssql migrations
83
+ * that silently run their SQL against Postgres.
84
+ */
85
+ function migrationDialect() {
86
+ return dialect_js_1.postgresDialect;
87
+ }
70
88
  function quotedTrackingTable(dialect) {
71
89
  return dialect.quoteIdentifier(TRACKING_TABLE);
72
90
  }
@@ -368,9 +386,53 @@ function parseMigrationSQL(filePath) {
368
386
  function checksum(content) {
369
387
  return (0, node_crypto_1.createHash)('sha256').update(content, 'utf-8').digest('hex');
370
388
  }
371
- /** Detect legacy djb2 checksums (short alphanumeric strings, pre-v0.6) */
389
+ /**
390
+ * The pre-v0.6 checksum algorithm (a 32-bit rolling hash rendered as base36).
391
+ * Kept verbatim so a legacy stored value can be RE-DERIVED from the current
392
+ * file content before we upgrade the row to SHA-256. Never used for new rows.
393
+ */
394
+ function legacyChecksum(content) {
395
+ let hash = 0;
396
+ for (let i = 0; i < content.length; i++) {
397
+ const chr = content.charCodeAt(i);
398
+ hash = ((hash << 5) - hash + chr) | 0;
399
+ }
400
+ return Math.abs(hash).toString(36);
401
+ }
402
+ /**
403
+ * Detect legacy checksums (short alphanumeric strings, pre-v0.6).
404
+ * An EMPTY checksum is never legacy: blessing it would silently disarm drift
405
+ * detection for a row whose stored hash was lost or never written.
406
+ */
372
407
  function isLegacyChecksum(hash) {
373
- return hash.length < 64;
408
+ return hash.length > 0 && hash.length < 64;
409
+ }
410
+ /**
411
+ * Can a stored pre-v0.6 checksum be safely upgraded to SHA-256?
412
+ *
413
+ * Only when the LEGACY algorithm, run over the file's CURRENT content,
414
+ * reproduces the stored value: that is what proves the file has not changed
415
+ * since it was applied. Upgrading without this proof blesses whatever the file
416
+ * says today and permanently disables drift detection for that migration.
417
+ *
418
+ * @internal exported for tests.
419
+ */
420
+ function canUpgradeLegacyChecksum(stored, content) {
421
+ return isLegacyChecksum(stored) && legacyChecksum(content) === stored;
422
+ }
423
+ /**
424
+ * Is an applied migration's stored checksum still valid for the file on disk?
425
+ *
426
+ * A SHA-256 match is the normal case. A pre-v0.6 row that {@link
427
+ * canUpgradeLegacyChecksum} would upgrade counts as valid too: `migrate up`
428
+ * accepts and upgrades it, so reporting it as invalid in `migrate status` would
429
+ * have the two commands disagree about the same unchanged file. Genuine drift
430
+ * (a legacy hash the current content no longer reproduces) still reports false.
431
+ *
432
+ * @internal exported for tests.
433
+ */
434
+ function isChecksumValid(stored, content) {
435
+ return checksum(content) === stored || canUpgradeLegacyChecksum(stored, content);
374
436
  }
375
437
  /** The sanctioned two-phase (add nullable, batched backfill, swap) recipe. */
376
438
  function buildBackfillRecipe() {
@@ -618,9 +680,40 @@ async function releaseLock(client, lockId, adapter) {
618
680
  const a = adapter ?? index_js_1.postgresql;
619
681
  await a.releaseLock(client, lockId);
620
682
  }
683
+ /**
684
+ * Run one migration body (UP or DOWN) plus its tracking-table write inside a
685
+ * single transaction. Returns `null` on success, or the error message on
686
+ * failure, leaving the caller to record it and stop.
687
+ *
688
+ * The ROLLBACK is best-effort: if the connection died, ROLLBACK throws too, and
689
+ * letting that escape would replace the real migration failure with a
690
+ * connection error. Same guard as `client.ts`, `query/builder.ts`, `dialect.ts`.
691
+ *
692
+ * @internal exported for tests; not part of the CLI's public surface.
693
+ */
694
+ async function runMigrationInTransaction(client, body, tracking) {
695
+ try {
696
+ await client.query('BEGIN');
697
+ await client.query(body);
698
+ await client.query(tracking.sql, tracking.params);
699
+ await client.query('COMMIT');
700
+ return null;
701
+ }
702
+ catch (err) {
703
+ try {
704
+ await client.query('ROLLBACK');
705
+ }
706
+ catch {
707
+ // Best effort: the original error below is what the user needs to see.
708
+ }
709
+ return err instanceof Error ? err.message : String(err);
710
+ }
711
+ }
621
712
  /**
622
713
  * Validate that applied migration files have not been modified or deleted since they were run.
623
714
  * Returns an array of mismatched migrations (empty if all are clean).
715
+ *
716
+ * @internal exported for tests; not part of the CLI's public surface.
624
717
  */
625
718
  async function validateChecksums(client, migrationsDir, dialect = dialect_js_1.postgresDialect) {
626
719
  const applied = await getAppliedMigrations(client, dialect);
@@ -641,8 +734,11 @@ async function validateChecksums(client, migrationsDir, dialect = dialect_js_1.p
641
734
  const content = (0, node_fs_1.readFileSync)(file.path, 'utf-8');
642
735
  const currentHash = checksum(content);
643
736
  if (currentHash !== migration.checksum) {
644
- // Auto-upgrade legacy djb2 checksums to SHA-256 without flagging as modified
645
- if (isLegacyChecksum(migration.checksum)) {
737
+ // Auto-upgrade a pre-v0.6 checksum to SHA-256 without flagging it as
738
+ // modified, but ONLY when the legacy hash of the current content still
739
+ // matches what was stored. A legacy row whose file HAS changed falls
740
+ // through to the mismatch path below (bypassable with --allow-drift).
741
+ if (canUpgradeLegacyChecksum(migration.checksum, content)) {
646
742
  await client.query(dialect.buildMigrationUpdateChecksum(quotedTrackingTable(dialect)), [
647
743
  currentHash,
648
744
  migration.name,
@@ -709,8 +805,11 @@ function planMigrationDeploy(migrationsDir, applied) {
709
805
  });
710
806
  continue;
711
807
  }
712
- const currentHash = checksum((0, node_fs_1.readFileSync)(file.path, 'utf-8'));
713
- if (currentHash !== migration.checksum && !isLegacyChecksum(migration.checksum)) {
808
+ const content = (0, node_fs_1.readFileSync)(file.path, 'utf-8');
809
+ const currentHash = checksum(content);
810
+ // A pre-v0.6 checksum is only forgiven when the legacy algorithm over the
811
+ // current content still reproduces it: otherwise the file really has drifted.
812
+ if (currentHash !== migration.checksum && !canUpgradeLegacyChecksum(migration.checksum, content)) {
714
813
  mismatches.push({
715
814
  name: migration.name,
716
815
  expected: migration.checksum,
@@ -727,10 +826,10 @@ function planMigrationDeploy(migrationsDir, applied) {
727
826
  /**
728
827
  * Inspect deploy status without applying migrations.
729
828
  */
730
- async function inspectMigrationDeploy(connectionString, migrationsDir, options) {
829
+ async function inspectMigrationDeploy(connectionString, migrationsDir) {
731
830
  const client = new pg_1.default.Client({ connectionString });
732
831
  await client.connect();
733
- const dialect = options?.dialect ?? dialect_js_1.postgresDialect;
832
+ const dialect = migrationDialect();
734
833
  try {
735
834
  await ensureTrackingTable(client, dialect);
736
835
  const mismatches = await validateChecksums(client, migrationsDir, dialect);
@@ -764,7 +863,7 @@ async function migrateUp(connectionString, migrationsDir, options) {
764
863
  await client.connect();
765
864
  // Treat `force` as an alias for `allowDrift` for backwards compatibility.
766
865
  const allowDrift = options?.allowDrift === true || options?.force === true;
767
- const dialect = options?.dialect ?? dialect_js_1.postgresDialect;
866
+ const dialect = migrationDialect();
768
867
  try {
769
868
  // Derive an advisory lock ID per-database so concurrent migrations in
770
869
  // sibling databases on the same Postgres cluster do not contend.
@@ -871,21 +970,17 @@ async function migrateUp(connectionString, migrationsDir, options) {
871
970
  }
872
971
  continue;
873
972
  }
874
- try {
875
- await client.query('BEGIN');
876
- await client.query(up);
877
- await client.query(insertApplied, [file.name, hash]);
878
- await client.query('COMMIT');
879
- results.push(file);
880
- flagOutOfOrder(file);
881
- }
882
- catch (err) {
883
- await client.query('ROLLBACK');
884
- const msg = err instanceof Error ? err.message : String(err);
885
- errors.push({ file, error: msg });
973
+ const failure = await runMigrationInTransaction(client, up, {
974
+ sql: insertApplied,
975
+ params: [file.name, hash],
976
+ });
977
+ if (failure !== null) {
978
+ errors.push({ file, error: failure });
886
979
  // Stop on first error
887
980
  break;
888
981
  }
982
+ results.push(file);
983
+ flagOutOfOrder(file);
889
984
  }
890
985
  return { applied: results, errors, destructive, outOfOrder, noTransaction: noTransactionApplied };
891
986
  }
@@ -908,7 +1003,6 @@ async function migrateDeploy(connectionString, migrationsDir, options) {
908
1003
  allowDrift: options?.allowDrift === true,
909
1004
  allowDestructive: true,
910
1005
  adapter: options?.adapter,
911
- dialect: options?.dialect,
912
1006
  });
913
1007
  }
914
1008
  /**
@@ -922,7 +1016,7 @@ async function migrateDeploy(connectionString, migrationsDir, options) {
922
1016
  async function migrateDown(connectionString, migrationsDir, options) {
923
1017
  const client = new pg_1.default.Client({ connectionString });
924
1018
  await client.connect();
925
- const dialect = options?.dialect ?? dialect_js_1.postgresDialect;
1019
+ const dialect = migrationDialect();
926
1020
  try {
927
1021
  // Derive a per-database advisory lock ID so concurrent migrations in
928
1022
  // sibling databases on the same cluster do not contend.
@@ -1008,19 +1102,15 @@ async function migrateDown(connectionString, migrationsDir, options) {
1008
1102
  }
1009
1103
  continue;
1010
1104
  }
1011
- try {
1012
- await client.query('BEGIN');
1013
- await client.query(down);
1014
- await client.query(deleteApplied, [migration.name]);
1015
- await client.query('COMMIT');
1016
- results.push(file);
1017
- }
1018
- catch (err) {
1019
- await client.query('ROLLBACK');
1020
- const msg = err instanceof Error ? err.message : String(err);
1021
- errors.push({ file, error: msg });
1105
+ const failure = await runMigrationInTransaction(client, down, {
1106
+ sql: deleteApplied,
1107
+ params: [migration.name],
1108
+ });
1109
+ if (failure !== null) {
1110
+ errors.push({ file, error: failure });
1022
1111
  break;
1023
1112
  }
1113
+ results.push(file);
1024
1114
  }
1025
1115
  return { rolledBack: results, errors };
1026
1116
  }
@@ -1034,12 +1124,15 @@ async function migrateDown(connectionString, migrationsDir, options) {
1034
1124
  }
1035
1125
  /**
1036
1126
  * Get the status of all migrations (applied vs pending).
1037
- * Includes checksum validation for applied migrations.
1127
+ *
1128
+ * Applied rows carry `checksumValid`, decided by {@link isChecksumValid} so an
1129
+ * unchanged pre-v0.6 row reports the same way `migrate up` treats it (valid,
1130
+ * pending an in-place hash upgrade) rather than looking like drift.
1038
1131
  */
1039
- async function migrateStatus(connectionString, migrationsDir, options) {
1132
+ async function migrateStatus(connectionString, migrationsDir) {
1040
1133
  const client = new pg_1.default.Client({ connectionString });
1041
1134
  await client.connect();
1042
- const dialect = options?.dialect ?? dialect_js_1.postgresDialect;
1135
+ const dialect = migrationDialect();
1043
1136
  try {
1044
1137
  await ensureTrackingTable(client, dialect);
1045
1138
  const applied = await getAppliedMigrations(client, dialect);
@@ -1050,9 +1143,7 @@ async function migrateStatus(connectionString, migrationsDir, options) {
1050
1143
  const record = appliedMap.get(file.name);
1051
1144
  let checksumValid;
1052
1145
  if (record) {
1053
- const content = (0, node_fs_1.readFileSync)(file.path, 'utf-8');
1054
- const currentHash = checksum(content);
1055
- checksumValid = currentHash === record.checksum;
1146
+ checksumValid = isChecksumValid(record.checksum, (0, node_fs_1.readFileSync)(file.path, 'utf-8'));
1056
1147
  }
1057
1148
  return {
1058
1149
  file,
@@ -0,0 +1 @@
1
+ export declare const OBSERVE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark\" />\n <title>Turbine Observe</title>\n <style>\n :root {\n --bg: #0a0a0b;\n --bg-elev: #111113;\n --bg-hover: #1a1a1d;\n --border: #26262b;\n --text: #e6e6ea;\n --text-dim: #8a8a93;\n --accent: #60a5fa;\n --green: #4ade80;\n --red: #f87171;\n --orange: #fb923c;\n --purple: #a78bfa;\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n --sans: system-ui, -apple-system, sans-serif;\n --radius: 6px;\n }\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; padding: 24px; }\n h1 { font-size: 20px; margin-bottom: 4px; }\n .subtitle { color: var(--text-dim); margin-bottom: 24px; }\n .controls { display: flex; gap: 8px; margin-bottom: 24px; }\n .controls button {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n color: var(--text); padding: 6px 12px; cursor: pointer; font-size: 13px;\n }\n .controls button.active { border-color: var(--accent); color: var(--accent); }\n .card {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n padding: 16px; margin-bottom: 16px;\n }\n .card h2 { font-size: 14px; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }\n table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }\n th { text-align: left; padding: 6px 8px; color: var(--text-dim); border-bottom: 1px solid var(--border); }\n td { padding: 6px 8px; border-bottom: 1px solid var(--border); }\n .num { text-align: right; }\n .error-rate { color: var(--red); }\n .low-error { color: var(--green); }\n svg { width: 100%; height: 200px; }\n .chart-line { fill: none; stroke-width: 1.5; }\n .line-avg { stroke: var(--accent); }\n .line-p95 { stroke: var(--orange); }\n .line-p99 { stroke: var(--red); }\n .legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--text-dim); }\n .legend span::before { content: ''; display: inline-block; width: 12px; height: 2px; margin-right: 4px; vertical-align: middle; }\n .legend .l-avg::before { background: var(--accent); }\n .legend .l-p95::before { background: var(--orange); }\n .legend .l-p99::before { background: var(--red); }\n .empty { color: var(--text-dim); text-align: center; padding: 40px; }\n </style>\n</head>\n<body>\n <h1>Turbine Observe</h1>\n <p class=\"subtitle\">Query performance metrics</p>\n <div class=\"controls\">\n <button data-range=\"1h\" class=\"active\">1h</button>\n <button data-range=\"6h\">6h</button>\n <button data-range=\"24h\">24h</button>\n <button data-range=\"7d\">7d</button>\n </div>\n <div class=\"card\" id=\"latency-card\">\n <h2>Latency over time</h2>\n <div id=\"chart\"></div>\n <div class=\"legend\">\n <span class=\"l-avg\">avg</span>\n <span class=\"l-p95\">p95</span>\n <span class=\"l-p99\">p99</span>\n </div>\n </div>\n <div class=\"card\" id=\"models-card\">\n <h2>Top models</h2>\n <div id=\"models-table\"></div>\n </div>\n <div class=\"card\" id=\"errors-card\">\n <h2>Error rates</h2>\n <div id=\"errors-table\"></div>\n </div>\n <script nonce=\"__CSP_NONCE__\">\n let currentRange = '1h';\n // Anchored on a cookie boundary so a decoy cookie whose name ends with this\n // one cannot shadow the real token.\n const token = document.cookie.match(/(?:^|;\\s*)turbine_observe_token=([a-f0-9]+)/)?.[1] || '';\n const headers = { 'x-turbine-token': token };\n\n document.querySelector('.controls').addEventListener('click', e => {\n if (e.target.tagName !== 'BUTTON') return;\n document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));\n e.target.classList.add('active');\n currentRange = e.target.dataset.range;\n refresh();\n });\n\n async function fetchJson(path) {\n const res = await fetch(path, { headers });\n if (!res.ok) return null;\n return res.json();\n }\n\n function buildSvgPath(points, width, height, maxY) {\n if (points.length === 0) return '';\n const xStep = width / Math.max(points.length - 1, 1);\n return points.map((y, i) => {\n const px = i * xStep;\n const py = height - (y / maxY) * height;\n return (i === 0 ? 'M' : 'L') + px.toFixed(1) + ',' + py.toFixed(1);\n }).join(' ');\n }\n\n function renderChart(data) {\n const el = document.getElementById('chart');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n const width = 800; const height = 180;\n const allVals = data.flatMap(d => [d.avg_ms, d.p95_ms, d.p99_ms]);\n const maxY = Math.max(...allVals, 1) * 1.1;\n const avgPath = buildSvgPath(data.map(d => d.avg_ms), width, height, maxY);\n const p95Path = buildSvgPath(data.map(d => d.p95_ms), width, height, maxY);\n const p99Path = buildSvgPath(data.map(d => d.p99_ms), width, height, maxY);\n el.innerHTML = '<svg viewBox=\"0 0 ' + width + ' ' + height + '\" preserveAspectRatio=\"none\">'\n + '<path class=\"chart-line line-avg\" d=\"' + avgPath + '\"/>'\n + '<path class=\"chart-line line-p95\" d=\"' + p95Path + '\"/>'\n + '<path class=\"chart-line line-p99\" d=\"' + p99Path + '\"/>'\n + '</svg>';\n }\n\n function escapeHtml(s) {\n if (s == null) return '';\n return String(s)\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n }\n\n // Numeric sink: counts are integer aggregates, but they are still values\n // arriving over the wire, so coerce rather than interpolating them raw.\n function num(v) {\n const n = Number(v);\n return Number.isFinite(n) ? String(n) : '0';\n }\n\n function renderModels(data) {\n const el = document.getElementById('models-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Count</th><th class=\"num\">Avg (ms)</th><th class=\"num\">P95 (ms)</th><th class=\"num\">P99 (ms)</th></tr></thead><tbody>';\n for (const row of data) {\n html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'\n + '<td class=\"num\">' + num(row.count) + '</td>'\n + '<td class=\"num\">' + row.avg_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p95_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p99_ms.toFixed(1) + '</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n function renderErrors(data) {\n const el = document.getElementById('errors-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No errors</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Total</th><th class=\"num\">Errors</th><th class=\"num\">Rate</th></tr></thead><tbody>';\n for (const row of data) {\n const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';\n const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';\n html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'\n + '<td class=\"num\">' + num(row.count) + '</td>'\n + '<td class=\"num\">' + num(row.error_count) + '</td>'\n + '<td class=\"num ' + cls + '\">' + rate + '%</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n async function refresh() {\n const [latency, models] = await Promise.all([\n fetchJson('/api/latency?range=' + currentRange),\n fetchJson('/api/models?range=' + currentRange),\n ]);\n renderChart(latency);\n renderModels(models);\n // Derive errors from models data\n const withErrors = (models || []).filter(m => m.error_count > 0);\n renderErrors(withErrors);\n }\n\n refresh();\n setInterval(refresh, 60000);\n </script>\n</body>\n</html>";