turbine-orm 0.49.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +122 -39
  2. package/dist/cjs/adapters/cockroachdb.d.ts +39 -0
  3. package/dist/cjs/adapters/index.d.ts +110 -0
  4. package/dist/cjs/adapters/yugabytedb.d.ts +51 -0
  5. package/dist/cjs/cli/config.d.ts +181 -0
  6. package/dist/cjs/cli/config.js +32 -6
  7. package/dist/cjs/cli/destructive.d.ts +38 -0
  8. package/dist/cjs/cli/index.d.ts +359 -0
  9. package/dist/cjs/cli/index.js +228 -56
  10. package/dist/cjs/cli/loader.d.ts +61 -0
  11. package/dist/cjs/cli/mcp.d.ts +42 -0
  12. package/dist/cjs/cli/migrate.d.ts +356 -0
  13. package/dist/cjs/cli/migrate.js +131 -40
  14. package/dist/cjs/cli/observe-ui.d.ts +1 -0
  15. package/dist/cjs/cli/observe-ui.js +14 -5
  16. package/dist/cjs/cli/observe.d.ts +25 -0
  17. package/dist/cjs/cli/observe.js +49 -12
  18. package/dist/cjs/cli/pii-tags.d.ts +53 -0
  19. package/dist/cjs/cli/prisma-report.d.ts +33 -0
  20. package/dist/cjs/cli/prisma-report.js +73 -0
  21. package/dist/cjs/cli/prisma-resolve.d.ts +106 -0
  22. package/dist/cjs/cli/prisma-resolve.js +1 -0
  23. package/dist/cjs/cli/prisma-schema.d.ts +176 -0
  24. package/dist/cjs/cli/prisma-schema.js +82 -4
  25. package/dist/cjs/cli/rate-limit.d.ts +32 -0
  26. package/dist/cjs/cli/rate-limit.js +45 -0
  27. package/dist/cjs/cli/studio-demo.d.ts +43 -0
  28. package/dist/cjs/cli/studio-ui.generated.d.ts +1 -0
  29. package/dist/cjs/cli/studio.d.ts +207 -0
  30. package/dist/cjs/cli/studio.js +136 -71
  31. package/dist/cjs/cli/ui.d.ts +73 -0
  32. package/dist/cjs/cli/ui.js +51 -9
  33. package/dist/cjs/client.d.ts +837 -0
  34. package/dist/cjs/client.js +3 -0
  35. package/dist/cjs/dialect.d.ts +516 -0
  36. package/dist/cjs/dialect.js +37 -12
  37. package/dist/cjs/errors.d.ts +370 -0
  38. package/dist/cjs/generate.d.ts +137 -0
  39. package/dist/cjs/generate.js +39 -6
  40. package/dist/cjs/index-advisor.d.ts +153 -0
  41. package/dist/cjs/index-stats.d.ts +384 -0
  42. package/dist/cjs/index.d.ts +55 -0
  43. package/dist/cjs/index.js +7 -2
  44. package/dist/cjs/introspect.d.ts +269 -0
  45. package/dist/cjs/mssql.d.ts +232 -0
  46. package/dist/cjs/mssql.js +6 -0
  47. package/dist/cjs/mysql.d.ts +173 -0
  48. package/dist/cjs/mysql.js +16 -0
  49. package/dist/cjs/nested-write.d.ts +96 -0
  50. package/dist/cjs/nested-write.js +414 -24
  51. package/dist/cjs/observe.d.ts +115 -0
  52. package/dist/cjs/optional-peer-import.d.cts +72 -0
  53. package/dist/cjs/pipeline-submittable.d.ts +93 -0
  54. package/dist/cjs/pipeline.d.ts +71 -0
  55. package/dist/cjs/powdb-introspect.d.ts +84 -0
  56. package/dist/cjs/powdb.d.ts +931 -0
  57. package/dist/cjs/powdb.js +106 -21
  58. package/dist/cjs/powql.d.ts +592 -0
  59. package/dist/cjs/powql.js +42 -6
  60. package/dist/cjs/prisma-compat.d.ts +283 -0
  61. package/dist/cjs/prisma-compat.js +167 -9
  62. package/dist/cjs/query/aggregates.d.ts +92 -0
  63. package/dist/cjs/query/aggregates.js +7 -3
  64. package/dist/cjs/query/batched-loader.d.ts +193 -0
  65. package/dist/cjs/query/builder.d.ts +849 -0
  66. package/dist/cjs/query/builder.js +571 -65
  67. package/dist/cjs/query/compound-unique.d.ts +51 -0
  68. package/dist/cjs/query/deferred.d.ts +223 -0
  69. package/dist/cjs/query/filters.d.ts +201 -0
  70. package/dist/cjs/query/index.d.ts +14 -0
  71. package/dist/cjs/query/index.js +6 -1
  72. package/dist/cjs/query/relations.d.ts +609 -0
  73. package/dist/cjs/query/relations.js +693 -46
  74. package/dist/cjs/query/types.d.ts +1300 -0
  75. package/dist/cjs/query/utils.d.ts +209 -0
  76. package/dist/cjs/query/utils.js +208 -1
  77. package/dist/cjs/query/warn-registry.d.ts +68 -0
  78. package/dist/cjs/query/warn-registry.js +9 -0
  79. package/dist/cjs/query/where-compile.d.ts +139 -0
  80. package/dist/cjs/query/where.d.ts +548 -0
  81. package/dist/cjs/query/where.js +58 -22
  82. package/dist/cjs/query/writes.d.ts +172 -0
  83. package/dist/cjs/query/writes.js +105 -12
  84. package/dist/cjs/realtime.d.ts +70 -0
  85. package/dist/cjs/schema-builder.d.ts +354 -0
  86. package/dist/cjs/schema-metadata.d.ts +83 -0
  87. package/dist/cjs/schema-sql.d.ts +217 -0
  88. package/dist/cjs/schema-sql.js +23 -5
  89. package/dist/cjs/schema.d.ts +356 -0
  90. package/dist/cjs/schema.js +125 -0
  91. package/dist/cjs/seed.d.ts +15 -0
  92. package/dist/cjs/serverless.d.ts +142 -0
  93. package/dist/cjs/sqlite.d.ts +143 -0
  94. package/dist/cjs/sqlite.js +4 -0
  95. package/dist/cjs/typed-sql.d.ts +102 -0
  96. package/dist/cli/config.d.ts +18 -4
  97. package/dist/cli/config.js +31 -6
  98. package/dist/cli/index.d.ts +123 -0
  99. package/dist/cli/index.js +223 -58
  100. package/dist/cli/migrate.d.ts +59 -10
  101. package/dist/cli/migrate.js +128 -41
  102. package/dist/cli/observe-ui.d.ts +1 -1
  103. package/dist/cli/observe-ui.js +14 -5
  104. package/dist/cli/observe.d.ts +7 -1
  105. package/dist/cli/observe.js +48 -12
  106. package/dist/cli/prisma-report.d.ts +14 -0
  107. package/dist/cli/prisma-report.js +72 -0
  108. package/dist/cli/prisma-resolve.d.ts +6 -0
  109. package/dist/cli/prisma-resolve.js +1 -0
  110. package/dist/cli/prisma-schema.d.ts +62 -2
  111. package/dist/cli/prisma-schema.js +81 -4
  112. package/dist/cli/rate-limit.d.ts +32 -0
  113. package/dist/cli/rate-limit.js +40 -0
  114. package/dist/cli/studio.d.ts +5 -5
  115. package/dist/cli/studio.js +135 -70
  116. package/dist/cli/ui.d.ts +1 -1
  117. package/dist/cli/ui.js +51 -9
  118. package/dist/client.d.ts +40 -0
  119. package/dist/client.js +3 -0
  120. package/dist/dialect.d.ts +17 -1
  121. package/dist/dialect.js +37 -12
  122. package/dist/generate.js +40 -7
  123. package/dist/index.d.ts +1 -1
  124. package/dist/index.js +1 -1
  125. package/dist/mssql.js +6 -0
  126. package/dist/mysql.js +16 -0
  127. package/dist/nested-write.d.ts +2 -0
  128. package/dist/nested-write.js +415 -25
  129. package/dist/powdb.d.ts +4 -2
  130. package/dist/powdb.js +106 -21
  131. package/dist/powql.d.ts +5 -0
  132. package/dist/powql.js +42 -6
  133. package/dist/prisma-compat.d.ts +2 -0
  134. package/dist/prisma-compat.js +166 -8
  135. package/dist/query/aggregates.js +7 -3
  136. package/dist/query/builder.d.ts +292 -21
  137. package/dist/query/builder.js +570 -64
  138. package/dist/query/deferred.d.ts +39 -0
  139. package/dist/query/index.d.ts +1 -1
  140. package/dist/query/index.js +1 -1
  141. package/dist/query/relations.d.ts +173 -5
  142. package/dist/query/relations.js +688 -47
  143. package/dist/query/types.d.ts +123 -39
  144. package/dist/query/utils.d.ts +116 -0
  145. package/dist/query/utils.js +198 -0
  146. package/dist/query/warn-registry.d.ts +9 -0
  147. package/dist/query/warn-registry.js +9 -0
  148. package/dist/query/where.d.ts +38 -1
  149. package/dist/query/where.js +58 -23
  150. package/dist/query/writes.d.ts +42 -1
  151. package/dist/query/writes.js +104 -13
  152. package/dist/schema-sql.d.ts +14 -0
  153. package/dist/schema-sql.js +23 -5
  154. package/dist/schema.d.ts +38 -0
  155. package/dist/schema.js +123 -0
  156. package/dist/sqlite.js +4 -0
  157. package/package.json +77 -28
@@ -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
- /** Detect legacy djb2 checksums (short alphanumeric strings, pre-v0.6) */
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 legacy djb2 checksums to SHA-256 without flagging as modified
619
- if (isLegacyChecksum(migration.checksum)) {
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 currentHash = checksum(readFileSync(file.path, 'utf-8'));
687
- if (currentHash !== migration.checksum && !isLegacyChecksum(migration.checksum)) {
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, options) {
799
+ export async function inspectMigrationDeploy(connectionString, migrationsDir) {
705
800
  const client = new pg.Client({ connectionString });
706
801
  await client.connect();
707
- const dialect = options?.dialect ?? postgresDialect;
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 = options?.dialect ?? postgresDialect;
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
- try {
849
- await client.query('BEGIN');
850
- await client.query(up);
851
- await client.query(insertApplied, [file.name, hash]);
852
- await client.query('COMMIT');
853
- results.push(file);
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 = options?.dialect ?? postgresDialect;
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
- try {
986
- await client.query('BEGIN');
987
- await client.query(down);
988
- await client.query(deleteApplied, [migration.name]);
989
- await client.query('COMMIT');
990
- results.push(file);
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
- * Includes checksum validation for applied migrations.
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, options) {
1102
+ export async function migrateStatus(connectionString, migrationsDir) {
1014
1103
  const client = new pg.Client({ connectionString });
1015
1104
  await client.connect();
1016
- const dialect = options?.dialect ?? postgresDialect;
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
- const content = readFileSync(file.path, 'utf-8');
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,
@@ -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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n }\n\n // Numeric sink: counts are integer aggregates, but they are still values\n // arriving over the wire, so coerce rather than interpolating them raw.\n function num(v) {\n const n = Number(v);\n return Number.isFinite(n) ? String(n) : '0';\n }\n\n function renderModels(data) {\n const el = document.getElementById('models-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Count</th><th class=\"num\">Avg (ms)</th><th class=\"num\">P95 (ms)</th><th class=\"num\">P99 (ms)</th></tr></thead><tbody>';\n for (const row of data) {\n html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'\n + '<td class=\"num\">' + num(row.count) + '</td>'\n + '<td class=\"num\">' + row.avg_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p95_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p99_ms.toFixed(1) + '</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n function renderErrors(data) {\n const el = document.getElementById('errors-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No errors</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Total</th><th class=\"num\">Errors</th><th class=\"num\">Rate</th></tr></thead><tbody>';\n for (const row of data) {\n const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';\n const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';\n html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'\n + '<td class=\"num\">' + num(row.count) + '</td>'\n + '<td class=\"num\">' + num(row.error_count) + '</td>'\n + '<td class=\"num ' + cls + '\">' + rate + '%</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n async function refresh() {\n const [latency, models] = await Promise.all([\n fetchJson('/api/latency?range=' + currentRange),\n fetchJson('/api/models?range=' + currentRange),\n ]);\n renderChart(latency);\n renderModels(models);\n // Derive errors from models data\n const withErrors = (models || []).filter(m => m.error_count > 0);\n renderErrors(withErrors);\n }\n\n refresh();\n setInterval(refresh, 60000);\n </script>\n</body>\n</html>";
@@ -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
- const token = document.cookie.match(/turbine_observe_token=([a-f0-9]+)/)?.[1] || '';
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, '&#39;');
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>';
@@ -1,10 +1,12 @@
1
1
  /**
2
- * turbine-orm CLI Observe
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>;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * turbine-orm CLI Observe
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
- if (!isAuthorized(req, authToken)) {
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
- const match = /turbine_observe_token=([a-f0-9]+)/.exec(cookieHeader);
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, { ...SECURITY_HEADERS, 'Content-Type': 'application/json' });
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
- res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'text/html; charset=utf-8' });
212
- res.end(html);
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
  }