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.
- package/README.md +122 -39
- package/dist/cjs/adapters/cockroachdb.d.ts +39 -0
- package/dist/cjs/adapters/index.d.ts +110 -0
- package/dist/cjs/adapters/yugabytedb.d.ts +51 -0
- package/dist/cjs/cli/config.d.ts +181 -0
- package/dist/cjs/cli/config.js +32 -6
- package/dist/cjs/cli/destructive.d.ts +38 -0
- package/dist/cjs/cli/index.d.ts +359 -0
- package/dist/cjs/cli/index.js +228 -56
- package/dist/cjs/cli/loader.d.ts +61 -0
- package/dist/cjs/cli/mcp.d.ts +42 -0
- package/dist/cjs/cli/migrate.d.ts +356 -0
- package/dist/cjs/cli/migrate.js +131 -40
- package/dist/cjs/cli/observe-ui.d.ts +1 -0
- package/dist/cjs/cli/observe-ui.js +14 -5
- package/dist/cjs/cli/observe.d.ts +25 -0
- package/dist/cjs/cli/observe.js +49 -12
- package/dist/cjs/cli/pii-tags.d.ts +53 -0
- package/dist/cjs/cli/prisma-report.d.ts +33 -0
- package/dist/cjs/cli/prisma-report.js +73 -0
- package/dist/cjs/cli/prisma-resolve.d.ts +106 -0
- package/dist/cjs/cli/prisma-resolve.js +1 -0
- package/dist/cjs/cli/prisma-schema.d.ts +176 -0
- package/dist/cjs/cli/prisma-schema.js +82 -4
- package/dist/cjs/cli/rate-limit.d.ts +32 -0
- package/dist/cjs/cli/rate-limit.js +45 -0
- package/dist/cjs/cli/studio-demo.d.ts +43 -0
- package/dist/cjs/cli/studio-ui.generated.d.ts +1 -0
- package/dist/cjs/cli/studio.d.ts +207 -0
- package/dist/cjs/cli/studio.js +136 -71
- package/dist/cjs/cli/ui.d.ts +73 -0
- package/dist/cjs/cli/ui.js +51 -9
- package/dist/cjs/client.d.ts +837 -0
- package/dist/cjs/client.js +3 -0
- package/dist/cjs/dialect.d.ts +516 -0
- package/dist/cjs/dialect.js +37 -12
- package/dist/cjs/errors.d.ts +370 -0
- package/dist/cjs/generate.d.ts +137 -0
- package/dist/cjs/generate.js +39 -6
- package/dist/cjs/index-advisor.d.ts +153 -0
- package/dist/cjs/index-stats.d.ts +384 -0
- package/dist/cjs/index.d.ts +55 -0
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/introspect.d.ts +269 -0
- package/dist/cjs/mssql.d.ts +232 -0
- package/dist/cjs/mssql.js +6 -0
- package/dist/cjs/mysql.d.ts +173 -0
- package/dist/cjs/mysql.js +16 -0
- package/dist/cjs/nested-write.d.ts +96 -0
- package/dist/cjs/nested-write.js +414 -24
- package/dist/cjs/observe.d.ts +115 -0
- package/dist/cjs/optional-peer-import.d.cts +72 -0
- package/dist/cjs/pipeline-submittable.d.ts +93 -0
- package/dist/cjs/pipeline.d.ts +71 -0
- package/dist/cjs/powdb-introspect.d.ts +84 -0
- package/dist/cjs/powdb.d.ts +931 -0
- package/dist/cjs/powdb.js +106 -21
- package/dist/cjs/powql.d.ts +592 -0
- package/dist/cjs/powql.js +42 -6
- package/dist/cjs/prisma-compat.d.ts +283 -0
- package/dist/cjs/prisma-compat.js +167 -9
- package/dist/cjs/query/aggregates.d.ts +92 -0
- package/dist/cjs/query/aggregates.js +7 -3
- package/dist/cjs/query/batched-loader.d.ts +193 -0
- package/dist/cjs/query/builder.d.ts +849 -0
- package/dist/cjs/query/builder.js +571 -65
- package/dist/cjs/query/compound-unique.d.ts +51 -0
- package/dist/cjs/query/deferred.d.ts +223 -0
- package/dist/cjs/query/filters.d.ts +201 -0
- package/dist/cjs/query/index.d.ts +14 -0
- package/dist/cjs/query/index.js +6 -1
- package/dist/cjs/query/relations.d.ts +609 -0
- package/dist/cjs/query/relations.js +693 -46
- package/dist/cjs/query/types.d.ts +1300 -0
- package/dist/cjs/query/utils.d.ts +209 -0
- package/dist/cjs/query/utils.js +208 -1
- package/dist/cjs/query/warn-registry.d.ts +68 -0
- package/dist/cjs/query/warn-registry.js +9 -0
- package/dist/cjs/query/where-compile.d.ts +139 -0
- package/dist/cjs/query/where.d.ts +548 -0
- package/dist/cjs/query/where.js +58 -22
- package/dist/cjs/query/writes.d.ts +172 -0
- package/dist/cjs/query/writes.js +105 -12
- package/dist/cjs/realtime.d.ts +70 -0
- package/dist/cjs/schema-builder.d.ts +354 -0
- package/dist/cjs/schema-metadata.d.ts +83 -0
- package/dist/cjs/schema-sql.d.ts +217 -0
- package/dist/cjs/schema-sql.js +23 -5
- package/dist/cjs/schema.d.ts +356 -0
- package/dist/cjs/schema.js +125 -0
- package/dist/cjs/seed.d.ts +15 -0
- package/dist/cjs/serverless.d.ts +142 -0
- package/dist/cjs/sqlite.d.ts +143 -0
- package/dist/cjs/sqlite.js +4 -0
- package/dist/cjs/typed-sql.d.ts +102 -0
- package/dist/cli/config.d.ts +18 -4
- package/dist/cli/config.js +31 -6
- package/dist/cli/index.d.ts +123 -0
- package/dist/cli/index.js +223 -58
- package/dist/cli/migrate.d.ts +59 -10
- package/dist/cli/migrate.js +128 -41
- package/dist/cli/observe-ui.d.ts +1 -1
- package/dist/cli/observe-ui.js +14 -5
- package/dist/cli/observe.d.ts +7 -1
- package/dist/cli/observe.js +48 -12
- package/dist/cli/prisma-report.d.ts +14 -0
- package/dist/cli/prisma-report.js +72 -0
- package/dist/cli/prisma-resolve.d.ts +6 -0
- package/dist/cli/prisma-resolve.js +1 -0
- package/dist/cli/prisma-schema.d.ts +62 -2
- package/dist/cli/prisma-schema.js +81 -4
- package/dist/cli/rate-limit.d.ts +32 -0
- package/dist/cli/rate-limit.js +40 -0
- package/dist/cli/studio.d.ts +5 -5
- package/dist/cli/studio.js +135 -70
- package/dist/cli/ui.d.ts +1 -1
- package/dist/cli/ui.js +51 -9
- package/dist/client.d.ts +40 -0
- package/dist/client.js +3 -0
- package/dist/dialect.d.ts +17 -1
- package/dist/dialect.js +37 -12
- package/dist/generate.js +40 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mssql.js +6 -0
- package/dist/mysql.js +16 -0
- package/dist/nested-write.d.ts +2 -0
- package/dist/nested-write.js +415 -25
- package/dist/powdb.d.ts +4 -2
- package/dist/powdb.js +106 -21
- package/dist/powql.d.ts +5 -0
- package/dist/powql.js +42 -6
- package/dist/prisma-compat.d.ts +2 -0
- package/dist/prisma-compat.js +166 -8
- package/dist/query/aggregates.js +7 -3
- package/dist/query/builder.d.ts +292 -21
- package/dist/query/builder.js +570 -64
- package/dist/query/deferred.d.ts +39 -0
- package/dist/query/index.d.ts +1 -1
- package/dist/query/index.js +1 -1
- package/dist/query/relations.d.ts +173 -5
- package/dist/query/relations.js +688 -47
- package/dist/query/types.d.ts +123 -39
- package/dist/query/utils.d.ts +116 -0
- package/dist/query/utils.js +198 -0
- package/dist/query/warn-registry.d.ts +9 -0
- package/dist/query/warn-registry.js +9 -0
- package/dist/query/where.d.ts +38 -1
- package/dist/query/where.js +58 -23
- package/dist/query/writes.d.ts +42 -1
- package/dist/query/writes.js +104 -13
- package/dist/schema-sql.d.ts +14 -0
- package/dist/schema-sql.js +23 -5
- package/dist/schema.d.ts +38 -0
- package/dist/schema.js +123 -0
- package/dist/sqlite.js +4 -0
- 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[]>;
|
package/dist/cjs/cli/migrate.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
645
|
-
|
|
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
|
|
713
|
-
|
|
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
|
|
829
|
+
async function inspectMigrationDeploy(connectionString, migrationsDir) {
|
|
731
830
|
const client = new pg_1.default.Client({ connectionString });
|
|
732
831
|
await client.connect();
|
|
733
|
-
const dialect =
|
|
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 =
|
|
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
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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 =
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
-
*
|
|
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
|
|
1132
|
+
async function migrateStatus(connectionString, migrationsDir) {
|
|
1040
1133
|
const client = new pg_1.default.Client({ connectionString });
|
|
1041
1134
|
await client.connect();
|
|
1042
|
-
const dialect =
|
|
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
|
-
|
|
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, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\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>";
|