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
package/dist/cli/migrate.js
CHANGED
|
@@ -41,6 +41,20 @@ export function collectUpDestructive(files) {
|
|
|
41
41
|
// Tracking table management
|
|
42
42
|
// ---------------------------------------------------------------------------
|
|
43
43
|
const TRACKING_TABLE = '_turbine_migrations';
|
|
44
|
+
/**
|
|
45
|
+
* The dialect the migration runner speaks.
|
|
46
|
+
*
|
|
47
|
+
* The runner connects with `pg.Client`, so Postgres (and the Postgres-compatible
|
|
48
|
+
* engines behind `adapters/`) is the only thing it can actually reach. The
|
|
49
|
+
* dialect is still threaded through every tracking-table statement, so making
|
|
50
|
+
* migrations dialect-aware is a matter of teaching this layer to build the
|
|
51
|
+
* engine's own client. Until then there is deliberately NO caller-supplied
|
|
52
|
+
* dialect option: accepting one would advertise sqlite/mysql/mssql migrations
|
|
53
|
+
* that silently run their SQL against Postgres.
|
|
54
|
+
*/
|
|
55
|
+
function migrationDialect() {
|
|
56
|
+
return postgresDialect;
|
|
57
|
+
}
|
|
44
58
|
function quotedTrackingTable(dialect) {
|
|
45
59
|
return dialect.quoteIdentifier(TRACKING_TABLE);
|
|
46
60
|
}
|
|
@@ -342,9 +356,53 @@ export function parseMigrationSQL(filePath) {
|
|
|
342
356
|
function checksum(content) {
|
|
343
357
|
return createHash('sha256').update(content, 'utf-8').digest('hex');
|
|
344
358
|
}
|
|
345
|
-
/**
|
|
359
|
+
/**
|
|
360
|
+
* The pre-v0.6 checksum algorithm (a 32-bit rolling hash rendered as base36).
|
|
361
|
+
* Kept verbatim so a legacy stored value can be RE-DERIVED from the current
|
|
362
|
+
* file content before we upgrade the row to SHA-256. Never used for new rows.
|
|
363
|
+
*/
|
|
364
|
+
function legacyChecksum(content) {
|
|
365
|
+
let hash = 0;
|
|
366
|
+
for (let i = 0; i < content.length; i++) {
|
|
367
|
+
const chr = content.charCodeAt(i);
|
|
368
|
+
hash = ((hash << 5) - hash + chr) | 0;
|
|
369
|
+
}
|
|
370
|
+
return Math.abs(hash).toString(36);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Detect legacy checksums (short alphanumeric strings, pre-v0.6).
|
|
374
|
+
* An EMPTY checksum is never legacy: blessing it would silently disarm drift
|
|
375
|
+
* detection for a row whose stored hash was lost or never written.
|
|
376
|
+
*/
|
|
346
377
|
function isLegacyChecksum(hash) {
|
|
347
|
-
return hash.length < 64;
|
|
378
|
+
return hash.length > 0 && hash.length < 64;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Can a stored pre-v0.6 checksum be safely upgraded to SHA-256?
|
|
382
|
+
*
|
|
383
|
+
* Only when the LEGACY algorithm, run over the file's CURRENT content,
|
|
384
|
+
* reproduces the stored value: that is what proves the file has not changed
|
|
385
|
+
* since it was applied. Upgrading without this proof blesses whatever the file
|
|
386
|
+
* says today and permanently disables drift detection for that migration.
|
|
387
|
+
*
|
|
388
|
+
* @internal exported for tests.
|
|
389
|
+
*/
|
|
390
|
+
export function canUpgradeLegacyChecksum(stored, content) {
|
|
391
|
+
return isLegacyChecksum(stored) && legacyChecksum(content) === stored;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Is an applied migration's stored checksum still valid for the file on disk?
|
|
395
|
+
*
|
|
396
|
+
* A SHA-256 match is the normal case. A pre-v0.6 row that {@link
|
|
397
|
+
* canUpgradeLegacyChecksum} would upgrade counts as valid too: `migrate up`
|
|
398
|
+
* accepts and upgrades it, so reporting it as invalid in `migrate status` would
|
|
399
|
+
* have the two commands disagree about the same unchanged file. Genuine drift
|
|
400
|
+
* (a legacy hash the current content no longer reproduces) still reports false.
|
|
401
|
+
*
|
|
402
|
+
* @internal exported for tests.
|
|
403
|
+
*/
|
|
404
|
+
export function isChecksumValid(stored, content) {
|
|
405
|
+
return checksum(content) === stored || canUpgradeLegacyChecksum(stored, content);
|
|
348
406
|
}
|
|
349
407
|
/** The sanctioned two-phase (add nullable, batched backfill, swap) recipe. */
|
|
350
408
|
function buildBackfillRecipe() {
|
|
@@ -592,11 +650,42 @@ async function releaseLock(client, lockId, adapter) {
|
|
|
592
650
|
const a = adapter ?? postgresql;
|
|
593
651
|
await a.releaseLock(client, lockId);
|
|
594
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* Run one migration body (UP or DOWN) plus its tracking-table write inside a
|
|
655
|
+
* single transaction. Returns `null` on success, or the error message on
|
|
656
|
+
* failure, leaving the caller to record it and stop.
|
|
657
|
+
*
|
|
658
|
+
* The ROLLBACK is best-effort: if the connection died, ROLLBACK throws too, and
|
|
659
|
+
* letting that escape would replace the real migration failure with a
|
|
660
|
+
* connection error. Same guard as `client.ts`, `query/builder.ts`, `dialect.ts`.
|
|
661
|
+
*
|
|
662
|
+
* @internal exported for tests; not part of the CLI's public surface.
|
|
663
|
+
*/
|
|
664
|
+
export async function runMigrationInTransaction(client, body, tracking) {
|
|
665
|
+
try {
|
|
666
|
+
await client.query('BEGIN');
|
|
667
|
+
await client.query(body);
|
|
668
|
+
await client.query(tracking.sql, tracking.params);
|
|
669
|
+
await client.query('COMMIT');
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
catch (err) {
|
|
673
|
+
try {
|
|
674
|
+
await client.query('ROLLBACK');
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
// Best effort: the original error below is what the user needs to see.
|
|
678
|
+
}
|
|
679
|
+
return err instanceof Error ? err.message : String(err);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
595
682
|
/**
|
|
596
683
|
* Validate that applied migration files have not been modified or deleted since they were run.
|
|
597
684
|
* Returns an array of mismatched migrations (empty if all are clean).
|
|
685
|
+
*
|
|
686
|
+
* @internal exported for tests; not part of the CLI's public surface.
|
|
598
687
|
*/
|
|
599
|
-
async function validateChecksums(client, migrationsDir, dialect = postgresDialect) {
|
|
688
|
+
export async function validateChecksums(client, migrationsDir, dialect = postgresDialect) {
|
|
600
689
|
const applied = await getAppliedMigrations(client, dialect);
|
|
601
690
|
const allFiles = listMigrationFiles(migrationsDir);
|
|
602
691
|
const fileMap = new Map(allFiles.map((f) => [f.name, f]));
|
|
@@ -615,8 +704,11 @@ async function validateChecksums(client, migrationsDir, dialect = postgresDialec
|
|
|
615
704
|
const content = readFileSync(file.path, 'utf-8');
|
|
616
705
|
const currentHash = checksum(content);
|
|
617
706
|
if (currentHash !== migration.checksum) {
|
|
618
|
-
// Auto-upgrade
|
|
619
|
-
|
|
707
|
+
// Auto-upgrade a pre-v0.6 checksum to SHA-256 without flagging it as
|
|
708
|
+
// modified, but ONLY when the legacy hash of the current content still
|
|
709
|
+
// matches what was stored. A legacy row whose file HAS changed falls
|
|
710
|
+
// through to the mismatch path below (bypassable with --allow-drift).
|
|
711
|
+
if (canUpgradeLegacyChecksum(migration.checksum, content)) {
|
|
620
712
|
await client.query(dialect.buildMigrationUpdateChecksum(quotedTrackingTable(dialect)), [
|
|
621
713
|
currentHash,
|
|
622
714
|
migration.name,
|
|
@@ -683,8 +775,11 @@ export function planMigrationDeploy(migrationsDir, applied) {
|
|
|
683
775
|
});
|
|
684
776
|
continue;
|
|
685
777
|
}
|
|
686
|
-
const
|
|
687
|
-
|
|
778
|
+
const content = readFileSync(file.path, 'utf-8');
|
|
779
|
+
const currentHash = checksum(content);
|
|
780
|
+
// A pre-v0.6 checksum is only forgiven when the legacy algorithm over the
|
|
781
|
+
// current content still reproduces it: otherwise the file really has drifted.
|
|
782
|
+
if (currentHash !== migration.checksum && !canUpgradeLegacyChecksum(migration.checksum, content)) {
|
|
688
783
|
mismatches.push({
|
|
689
784
|
name: migration.name,
|
|
690
785
|
expected: migration.checksum,
|
|
@@ -701,10 +796,10 @@ export function planMigrationDeploy(migrationsDir, applied) {
|
|
|
701
796
|
/**
|
|
702
797
|
* Inspect deploy status without applying migrations.
|
|
703
798
|
*/
|
|
704
|
-
export async function inspectMigrationDeploy(connectionString, migrationsDir
|
|
799
|
+
export async function inspectMigrationDeploy(connectionString, migrationsDir) {
|
|
705
800
|
const client = new pg.Client({ connectionString });
|
|
706
801
|
await client.connect();
|
|
707
|
-
const dialect =
|
|
802
|
+
const dialect = migrationDialect();
|
|
708
803
|
try {
|
|
709
804
|
await ensureTrackingTable(client, dialect);
|
|
710
805
|
const mismatches = await validateChecksums(client, migrationsDir, dialect);
|
|
@@ -738,7 +833,7 @@ export async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
738
833
|
await client.connect();
|
|
739
834
|
// Treat `force` as an alias for `allowDrift` for backwards compatibility.
|
|
740
835
|
const allowDrift = options?.allowDrift === true || options?.force === true;
|
|
741
|
-
const dialect =
|
|
836
|
+
const dialect = migrationDialect();
|
|
742
837
|
try {
|
|
743
838
|
// Derive an advisory lock ID per-database so concurrent migrations in
|
|
744
839
|
// sibling databases on the same Postgres cluster do not contend.
|
|
@@ -845,21 +940,17 @@ export async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
845
940
|
}
|
|
846
941
|
continue;
|
|
847
942
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
flagOutOfOrder(file);
|
|
855
|
-
}
|
|
856
|
-
catch (err) {
|
|
857
|
-
await client.query('ROLLBACK');
|
|
858
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
859
|
-
errors.push({ file, error: msg });
|
|
943
|
+
const failure = await runMigrationInTransaction(client, up, {
|
|
944
|
+
sql: insertApplied,
|
|
945
|
+
params: [file.name, hash],
|
|
946
|
+
});
|
|
947
|
+
if (failure !== null) {
|
|
948
|
+
errors.push({ file, error: failure });
|
|
860
949
|
// Stop on first error
|
|
861
950
|
break;
|
|
862
951
|
}
|
|
952
|
+
results.push(file);
|
|
953
|
+
flagOutOfOrder(file);
|
|
863
954
|
}
|
|
864
955
|
return { applied: results, errors, destructive, outOfOrder, noTransaction: noTransactionApplied };
|
|
865
956
|
}
|
|
@@ -882,7 +973,6 @@ export async function migrateDeploy(connectionString, migrationsDir, options) {
|
|
|
882
973
|
allowDrift: options?.allowDrift === true,
|
|
883
974
|
allowDestructive: true,
|
|
884
975
|
adapter: options?.adapter,
|
|
885
|
-
dialect: options?.dialect,
|
|
886
976
|
});
|
|
887
977
|
}
|
|
888
978
|
/**
|
|
@@ -896,7 +986,7 @@ export async function migrateDeploy(connectionString, migrationsDir, options) {
|
|
|
896
986
|
export async function migrateDown(connectionString, migrationsDir, options) {
|
|
897
987
|
const client = new pg.Client({ connectionString });
|
|
898
988
|
await client.connect();
|
|
899
|
-
const dialect =
|
|
989
|
+
const dialect = migrationDialect();
|
|
900
990
|
try {
|
|
901
991
|
// Derive a per-database advisory lock ID so concurrent migrations in
|
|
902
992
|
// sibling databases on the same cluster do not contend.
|
|
@@ -982,19 +1072,15 @@ export async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
982
1072
|
}
|
|
983
1073
|
continue;
|
|
984
1074
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
}
|
|
992
|
-
catch (err) {
|
|
993
|
-
await client.query('ROLLBACK');
|
|
994
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
995
|
-
errors.push({ file, error: msg });
|
|
1075
|
+
const failure = await runMigrationInTransaction(client, down, {
|
|
1076
|
+
sql: deleteApplied,
|
|
1077
|
+
params: [migration.name],
|
|
1078
|
+
});
|
|
1079
|
+
if (failure !== null) {
|
|
1080
|
+
errors.push({ file, error: failure });
|
|
996
1081
|
break;
|
|
997
1082
|
}
|
|
1083
|
+
results.push(file);
|
|
998
1084
|
}
|
|
999
1085
|
return { rolledBack: results, errors };
|
|
1000
1086
|
}
|
|
@@ -1008,12 +1094,15 @@ export async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
1008
1094
|
}
|
|
1009
1095
|
/**
|
|
1010
1096
|
* Get the status of all migrations (applied vs pending).
|
|
1011
|
-
*
|
|
1097
|
+
*
|
|
1098
|
+
* Applied rows carry `checksumValid`, decided by {@link isChecksumValid} so an
|
|
1099
|
+
* unchanged pre-v0.6 row reports the same way `migrate up` treats it (valid,
|
|
1100
|
+
* pending an in-place hash upgrade) rather than looking like drift.
|
|
1012
1101
|
*/
|
|
1013
|
-
export async function migrateStatus(connectionString, migrationsDir
|
|
1102
|
+
export async function migrateStatus(connectionString, migrationsDir) {
|
|
1014
1103
|
const client = new pg.Client({ connectionString });
|
|
1015
1104
|
await client.connect();
|
|
1016
|
-
const dialect =
|
|
1105
|
+
const dialect = migrationDialect();
|
|
1017
1106
|
try {
|
|
1018
1107
|
await ensureTrackingTable(client, dialect);
|
|
1019
1108
|
const applied = await getAppliedMigrations(client, dialect);
|
|
@@ -1024,9 +1113,7 @@ export async function migrateStatus(connectionString, migrationsDir, options) {
|
|
|
1024
1113
|
const record = appliedMap.get(file.name);
|
|
1025
1114
|
let checksumValid;
|
|
1026
1115
|
if (record) {
|
|
1027
|
-
|
|
1028
|
-
const currentHash = checksum(content);
|
|
1029
|
-
checksumValid = currentHash === record.checksum;
|
|
1116
|
+
checksumValid = isChecksumValid(record.checksum, readFileSync(file.path, 'utf-8'));
|
|
1030
1117
|
}
|
|
1031
1118
|
return {
|
|
1032
1119
|
file,
|
package/dist/cli/observe-ui.d.ts
CHANGED
|
@@ -1 +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>\n let currentRange = '1h';\n const token = document.cookie.match(/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 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\">' + 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\">' + row.count + '</td>'\n + '<td class=\"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>";
|
|
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>";
|
package/dist/cli/observe-ui.js
CHANGED
|
@@ -84,9 +84,11 @@ export const OBSERVE_HTML = `<!doctype html>
|
|
|
84
84
|
<h2>Error rates</h2>
|
|
85
85
|
<div id="errors-table"></div>
|
|
86
86
|
</div>
|
|
87
|
-
<script>
|
|
87
|
+
<script nonce="__CSP_NONCE__">
|
|
88
88
|
let currentRange = '1h';
|
|
89
|
-
|
|
89
|
+
// Anchored on a cookie boundary so a decoy cookie whose name ends with this
|
|
90
|
+
// one cannot shadow the real token.
|
|
91
|
+
const token = document.cookie.match(/(?:^|;\\s*)turbine_observe_token=([a-f0-9]+)/)?.[1] || '';
|
|
90
92
|
const headers = { 'x-turbine-token': token };
|
|
91
93
|
|
|
92
94
|
document.querySelector('.controls').addEventListener('click', e => {
|
|
@@ -139,13 +141,20 @@ export const OBSERVE_HTML = `<!doctype html>
|
|
|
139
141
|
.replace(/'/g, ''');
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
// Numeric sink: counts are integer aggregates, but they are still values
|
|
145
|
+
// arriving over the wire, so coerce rather than interpolating them raw.
|
|
146
|
+
function num(v) {
|
|
147
|
+
const n = Number(v);
|
|
148
|
+
return Number.isFinite(n) ? String(n) : '0';
|
|
149
|
+
}
|
|
150
|
+
|
|
142
151
|
function renderModels(data) {
|
|
143
152
|
const el = document.getElementById('models-table');
|
|
144
153
|
if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No data yet</p>'; return; }
|
|
145
154
|
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>';
|
|
146
155
|
for (const row of data) {
|
|
147
156
|
html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'
|
|
148
|
-
+ '<td class="num">' + row.count + '</td>'
|
|
157
|
+
+ '<td class="num">' + num(row.count) + '</td>'
|
|
149
158
|
+ '<td class="num">' + row.avg_ms.toFixed(1) + '</td>'
|
|
150
159
|
+ '<td class="num">' + row.p95_ms.toFixed(1) + '</td>'
|
|
151
160
|
+ '<td class="num">' + row.p99_ms.toFixed(1) + '</td></tr>';
|
|
@@ -162,8 +171,8 @@ export const OBSERVE_HTML = `<!doctype html>
|
|
|
162
171
|
const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';
|
|
163
172
|
const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';
|
|
164
173
|
html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'
|
|
165
|
-
+ '<td class="num">' + row.count + '</td>'
|
|
166
|
-
+ '<td class="num">' + row.error_count + '</td>'
|
|
174
|
+
+ '<td class="num">' + num(row.count) + '</td>'
|
|
175
|
+
+ '<td class="num">' + num(row.error_count) + '</td>'
|
|
167
176
|
+ '<td class="num ' + cls + '">' + rate + '%</td></tr>';
|
|
168
177
|
}
|
|
169
178
|
html += '</tbody></table>';
|
package/dist/cli/observe.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* turbine-orm CLI
|
|
2
|
+
* turbine-orm CLI: Observe
|
|
3
3
|
*
|
|
4
4
|
* A local, read-only dashboard for viewing query metrics stored in
|
|
5
5
|
* _turbine_metrics. Same security model as Studio: loopback binding,
|
|
6
6
|
* random token, HttpOnly cookie, CSP headers, read-only transactions.
|
|
7
7
|
*/
|
|
8
|
+
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
9
|
+
import pg from 'pg';
|
|
8
10
|
export interface ObserveOptions {
|
|
9
11
|
url: string;
|
|
10
12
|
port: number;
|
|
@@ -17,3 +19,7 @@ export interface ObserveServerHandle {
|
|
|
17
19
|
url: string;
|
|
18
20
|
}
|
|
19
21
|
export declare function startObserve(options: ObserveOptions): Promise<ObserveServerHandle>;
|
|
22
|
+
export declare function handleRequest(req: IncomingMessage, res: ServerResponse, pool: pg.Pool, options: ObserveOptions, authToken: string, rateLimiter: Map<string, {
|
|
23
|
+
count: number;
|
|
24
|
+
resetAt: number;
|
|
25
|
+
}>): Promise<void>;
|
package/dist/cli/observe.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* turbine-orm CLI
|
|
2
|
+
* turbine-orm CLI: Observe
|
|
3
3
|
*
|
|
4
4
|
* A local, read-only dashboard for viewing query metrics stored in
|
|
5
5
|
* _turbine_metrics. Same security model as Studio: loopback binding,
|
|
@@ -9,6 +9,7 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
9
9
|
import { createServer } from 'node:http';
|
|
10
10
|
import pg from 'pg';
|
|
11
11
|
import { OBSERVE_HTML } from './observe-ui.js';
|
|
12
|
+
import { callerKey, checkRateLimit } from './rate-limit.js';
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
// Main entry point
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
@@ -26,8 +27,9 @@ export async function startObserve(options) {
|
|
|
26
27
|
probe.release();
|
|
27
28
|
}
|
|
28
29
|
const authToken = randomBytes(24).toString('hex');
|
|
30
|
+
const rateLimiter = new Map();
|
|
29
31
|
const server = createServer((req, res) => {
|
|
30
|
-
handleRequest(req, res, pool, options, authToken).catch((err) => {
|
|
32
|
+
handleRequest(req, res, pool, options, authToken, rateLimiter).catch((err) => {
|
|
31
33
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
32
34
|
});
|
|
33
35
|
});
|
|
@@ -55,7 +57,7 @@ export async function startObserve(options) {
|
|
|
55
57
|
// ---------------------------------------------------------------------------
|
|
56
58
|
// Request routing
|
|
57
59
|
// ---------------------------------------------------------------------------
|
|
58
|
-
async function handleRequest(req, res, pool, options, authToken) {
|
|
60
|
+
export async function handleRequest(req, res, pool, options, authToken, rateLimiter) {
|
|
59
61
|
const hostPart = options.host.includes(':') && !options.host.startsWith('[') ? `[${options.host}]` : options.host;
|
|
60
62
|
const expectedOrigin = `http://${hostPart}:${options.port}`;
|
|
61
63
|
const origin = req.headers.origin;
|
|
@@ -79,10 +81,21 @@ async function handleRequest(req, res, pool, options, authToken) {
|
|
|
79
81
|
res.end();
|
|
80
82
|
return;
|
|
81
83
|
}
|
|
82
|
-
sendHtml(res, 200, OBSERVE_HTML);
|
|
84
|
+
sendHtml(res, 200, OBSERVE_HTML, cspNonce());
|
|
83
85
|
return;
|
|
84
86
|
}
|
|
85
|
-
|
|
87
|
+
// Rate limiting: the same fixed window as Studio (shared implementation), applied
|
|
88
|
+
// before the auth gate so unauthenticated probing is throttled too, and keyed
|
|
89
|
+
// per caller so the two never share a bucket.
|
|
90
|
+
const authorized = isAuthorized(req, authToken);
|
|
91
|
+
const rateLimitResult = checkRateLimit(rateLimiter, `${authorized ? 'session' : 'anon'}:${callerKey(req)}`);
|
|
92
|
+
if (!rateLimitResult.allowed) {
|
|
93
|
+
const retryAfter = Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000);
|
|
94
|
+
res.setHeader('Retry-After', String(retryAfter));
|
|
95
|
+
sendJson(res, 429, { error: 'Rate limit exceeded', retryAfter });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!authorized) {
|
|
86
99
|
sendJson(res, 401, { error: 'unauthorized' });
|
|
87
100
|
return;
|
|
88
101
|
}
|
|
@@ -103,7 +116,10 @@ function isAuthorized(req, expectedToken) {
|
|
|
103
116
|
return true;
|
|
104
117
|
}
|
|
105
118
|
const cookieHeader = req.headers.cookie ?? '';
|
|
106
|
-
|
|
119
|
+
// Anchored on a cookie boundary: unanchored, a decoy cookie whose name merely
|
|
120
|
+
// ENDS with this one (`x_turbine_observe_token=...`) matched first and the real
|
|
121
|
+
// token was never compared, denying the legitimate session.
|
|
122
|
+
const match = /(?:^|;\s*)turbine_observe_token=([a-f0-9]+)/.exec(cookieHeader);
|
|
107
123
|
if (match?.[1] && constantTimeEqual(match[1], expectedToken)) {
|
|
108
124
|
return true;
|
|
109
125
|
}
|
|
@@ -200,19 +216,39 @@ const SECURITY_HEADERS = {
|
|
|
200
216
|
'X-Content-Type-Options': 'nosniff',
|
|
201
217
|
'X-Frame-Options': 'DENY',
|
|
202
218
|
'Referrer-Policy': 'no-referrer',
|
|
203
|
-
'Content-Security-Policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
|
|
204
219
|
};
|
|
220
|
+
/**
|
|
221
|
+
* A fresh CSP nonce for one HTML response, matching Studio's posture: the value
|
|
222
|
+
* is stamped into both the header and the inline `<script nonce="...">` tag so
|
|
223
|
+
* script-src drops `unsafe-inline`.
|
|
224
|
+
*/
|
|
225
|
+
function cspNonce() {
|
|
226
|
+
return randomBytes(16).toString('base64');
|
|
227
|
+
}
|
|
228
|
+
/** Non-document responses render no markup, so no inline script is allowed at all. */
|
|
229
|
+
const JSON_CSP = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'";
|
|
205
230
|
function sendJson(res, status, body) {
|
|
206
231
|
const payload = JSON.stringify(body);
|
|
207
|
-
res.writeHead(status, {
|
|
232
|
+
res.writeHead(status, {
|
|
233
|
+
...SECURITY_HEADERS,
|
|
234
|
+
'Content-Security-Policy': JSON_CSP,
|
|
235
|
+
'Content-Type': 'application/json',
|
|
236
|
+
});
|
|
208
237
|
res.end(payload);
|
|
209
238
|
}
|
|
210
|
-
function sendHtml(res, status, html) {
|
|
211
|
-
|
|
212
|
-
res.
|
|
239
|
+
function sendHtml(res, status, html, nonce) {
|
|
240
|
+
const body = html.replaceAll('__CSP_NONCE__', nonce);
|
|
241
|
+
res.writeHead(status, {
|
|
242
|
+
...SECURITY_HEADERS,
|
|
243
|
+
// style-src keeps 'unsafe-inline' (the dashboard styles inline, and nonces
|
|
244
|
+
// do not cover style attributes); script-src moves to the per-request nonce.
|
|
245
|
+
'Content-Security-Policy': `default-src 'none'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'`,
|
|
246
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
247
|
+
});
|
|
248
|
+
res.end(body);
|
|
213
249
|
}
|
|
214
250
|
function sendText(res, status, text) {
|
|
215
|
-
res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' });
|
|
251
|
+
res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Security-Policy': JSON_CSP, 'Content-Type': 'text/plain' });
|
|
216
252
|
res.end(text);
|
|
217
253
|
}
|
|
218
254
|
// ---------------------------------------------------------------------------
|
|
@@ -13,7 +13,21 @@ export interface ReportOptions {
|
|
|
13
13
|
* Build the full Markdown migration report.
|
|
14
14
|
*/
|
|
15
15
|
export declare function formatPrismaReport(result: ResolutionResult, options?: ReportOptions): string;
|
|
16
|
+
/** One resolved many-to-many relation, named from both sides. */
|
|
17
|
+
interface ManyToManyCallSite {
|
|
18
|
+
/** `Model.field` as written in schema.prisma (what application code says). */
|
|
19
|
+
prismaPath: string;
|
|
20
|
+
/** The Prisma field name on its own (what to grep for). */
|
|
21
|
+
prismaField: string;
|
|
22
|
+
/** The Turbine relation name (what `generated/metadata.ts` says). */
|
|
23
|
+
turbineName: string;
|
|
24
|
+
/** Junction table, when it could be named. */
|
|
25
|
+
junction?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Every resolved manyToMany relation in the result, in model/field order. */
|
|
28
|
+
export declare function manyToManyCallSites(result: ResolutionResult): ManyToManyCallSite[];
|
|
16
29
|
/** Flat list of unresolved item descriptions across the whole result. */
|
|
17
30
|
export declare function collectUnresolved(result: ResolutionResult): string[];
|
|
18
31
|
/** A one-line-per-model console summary for the CLI. */
|
|
19
32
|
export declare function summaryLines(result: ResolutionResult): string[];
|
|
33
|
+
export {};
|
|
@@ -107,6 +107,8 @@ export function formatPrismaReport(result, options = {}) {
|
|
|
107
107
|
L.push('');
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
|
+
// ---- Many-to-many call sites -----------------------------------------
|
|
111
|
+
L.push(...manyToManySection(result));
|
|
110
112
|
// ---- Junction tables --------------------------------------------------
|
|
111
113
|
const junctions = new Set();
|
|
112
114
|
for (const m of result.models) {
|
|
@@ -156,6 +158,71 @@ export function formatPrismaReport(result, options = {}) {
|
|
|
156
158
|
L.push(SEMANTIC_DIVERGENCE);
|
|
157
159
|
return `${L.join('\n')}\n`;
|
|
158
160
|
}
|
|
161
|
+
/** Every resolved manyToMany relation in the result, in model/field order. */
|
|
162
|
+
export function manyToManyCallSites(result) {
|
|
163
|
+
const out = [];
|
|
164
|
+
for (const m of result.models) {
|
|
165
|
+
for (const r of m.relations) {
|
|
166
|
+
if (r.status !== 'resolved' || !r.turbineName)
|
|
167
|
+
continue;
|
|
168
|
+
if (!r.manyToMany && !r.junction)
|
|
169
|
+
continue;
|
|
170
|
+
out.push({
|
|
171
|
+
prismaPath: `${m.prismaName}.${r.prismaName}`,
|
|
172
|
+
prismaField: r.prismaName,
|
|
173
|
+
turbineName: r.turbineName,
|
|
174
|
+
junction: r.junction,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The many-to-many audit section.
|
|
182
|
+
*
|
|
183
|
+
* A migration audit that greps the TURBINE relation names cannot find anything:
|
|
184
|
+
* application code written against the compat client uses the PRISMA field
|
|
185
|
+
* names, and the two are related only through `PRISMA_MAP`. That is true of
|
|
186
|
+
* every compat integration, so the report resolves the pairing itself rather
|
|
187
|
+
* than leaving it to a recipe the reader has to get right.
|
|
188
|
+
*/
|
|
189
|
+
function manyToManySection(result) {
|
|
190
|
+
const sites = manyToManyCallSites(result);
|
|
191
|
+
if (sites.length === 0) {
|
|
192
|
+
if (!result.noDb)
|
|
193
|
+
return [];
|
|
194
|
+
return [
|
|
195
|
+
'## Many-to-many relations (audit these call sites)',
|
|
196
|
+
'',
|
|
197
|
+
'Not determined: many-to-many relations are recognized from the live database.',
|
|
198
|
+
'Re-run without `--no-db` to get the audit list.',
|
|
199
|
+
'',
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
const L = [];
|
|
203
|
+
L.push('## Many-to-many relations (audit these call sites)');
|
|
204
|
+
L.push('');
|
|
205
|
+
L.push('Turbine and Prisma name these relations differently, and your application code');
|
|
206
|
+
L.push('uses the PRISMA name. Grepping the Turbine relation name (for example');
|
|
207
|
+
L.push('`grep -rn "manyToMany" generated/`, then searching for the names it prints) finds');
|
|
208
|
+
L.push('nothing and silently reports a clean audit. Both names are paired below.');
|
|
209
|
+
L.push('');
|
|
210
|
+
L.push('| Prisma call site | Turbine relation | Junction table |');
|
|
211
|
+
L.push('| --- | --- | --- |');
|
|
212
|
+
for (const s of sites) {
|
|
213
|
+
L.push(`| \`${s.prismaPath}\` | \`${s.turbineName}\` | ${s.junction ? `\`${s.junction}\`` : '-'} |`);
|
|
214
|
+
}
|
|
215
|
+
L.push('');
|
|
216
|
+
L.push('Audit every write whose `data` nests one of the Prisma field names above');
|
|
217
|
+
L.push('(`connect`, `disconnect`, `set`, `connectOrCreate`, `create`, `update`, `upsert`,');
|
|
218
|
+
L.push('`delete`): those are the many-to-many writes in your codebase.');
|
|
219
|
+
L.push('');
|
|
220
|
+
L.push('```bash');
|
|
221
|
+
L.push(`grep -rEn "\\b(${[...new Set(sites.map((s) => s.prismaField))].sort().join('|')})\\b" src`);
|
|
222
|
+
L.push('```');
|
|
223
|
+
L.push('');
|
|
224
|
+
return L;
|
|
225
|
+
}
|
|
159
226
|
/** Flat list of unresolved item descriptions across the whole result. */
|
|
160
227
|
export function collectUnresolved(result) {
|
|
161
228
|
const out = [];
|
|
@@ -195,6 +262,11 @@ phase-2 \`turbine-orm/prisma-compat\` adapter handles most of these translations
|
|
|
195
262
|
- Aggregate / groupBy \`_count\`. Prisma returns \`_count\` as a record
|
|
196
263
|
(\`{ _all: n }\` / per-field counts). Turbine's scalar \`_count: true\` returns a
|
|
197
264
|
number. Reshape as needed (the phase-2 adapter does this both directions).
|
|
265
|
+
- Paginated reads. Prisma appends an implicit \`ORDER BY <primary key> ASC\` to a
|
|
266
|
+
\`findMany\` with \`take\`/\`skip\`; core Turbine emits a bare \`LIMIT\`, which is not
|
|
267
|
+
deterministic (a row can appear on two pages or on none). The phase-2
|
|
268
|
+
\`prisma-compat\` adapter restores Prisma's ordering; on the core client, pass an
|
|
269
|
+
explicit \`orderBy\` or set \`implicitPkOrdering: true\`.
|
|
198
270
|
- Relation-array order. Without an \`orderBy\` on a \`with\`/\`include\` clause, the
|
|
199
271
|
order of a to-many relation array is unspecified in Turbine (\`json_agg\` order).
|
|
200
272
|
Add an explicit \`orderBy\` where order matters.
|
|
@@ -34,6 +34,12 @@ export interface ResolvedRelation {
|
|
|
34
34
|
targetModel: string;
|
|
35
35
|
/** Junction table for an m2m relation, if resolved. */
|
|
36
36
|
junction?: string;
|
|
37
|
+
/**
|
|
38
|
+
* True when the resolved Turbine relation is `manyToMany`. Distinct from
|
|
39
|
+
* {@link ResolvedRelation.junction}, which is absent when the junction table
|
|
40
|
+
* itself could not be named.
|
|
41
|
+
*/
|
|
42
|
+
manyToMany?: boolean;
|
|
37
43
|
status: ResolveStatus;
|
|
38
44
|
reason?: string;
|
|
39
45
|
}
|
|
@@ -341,6 +341,7 @@ function resolveRelation(model, fieldName, targetModelName, isList, modelTable,
|
|
|
341
341
|
turbineName: def.name,
|
|
342
342
|
cardinality,
|
|
343
343
|
junction: def.type === 'manyToMany' ? def.through?.table : undefined,
|
|
344
|
+
manyToMany: def.type === 'manyToMany' ? true : undefined,
|
|
344
345
|
status: 'resolved',
|
|
345
346
|
};
|
|
346
347
|
}
|