turbine-orm 0.59.2 → 0.60.1

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 CHANGED
@@ -1038,6 +1038,13 @@ const prisma = createPrismaCompatClient(db, PRISMA_MAP);
1038
1038
  const users = await prisma.User.findMany({ include: { posts: { take: 5 } } });
1039
1039
  ```
1040
1040
 
1041
+ Because nothing re-runs the generator, the map records a fingerprint of the
1042
+ `schema.prisma` it was built from, and `createPrismaCompatClient` warns once at startup
1043
+ (development only, never in production, silent when the file is absent) if that file has
1044
+ since changed. Put `turbine migrate-from-prisma --if-db` in `postinstall` next to
1045
+ `prisma generate` so it does not depend on anyone remembering; `--if-db` exits 0 when no
1046
+ database is reachable, so a build image with no `DATABASE_URL` still installs.
1047
+
1041
1048
  Turbine-only query options (`forceCustomPlan`, `skipGlobalFilters`, `allowFullTableScan`,
1042
1049
  `warnOnUnlimited`, `timeout`, `optimisticLock`, `distinctOn`, …) pass through the compat
1043
1050
  delegates, and an unrecognized query-level key logs a one-time dev warning naming the
@@ -98,6 +98,13 @@ export interface CliArgs {
98
98
  allowPartial?: boolean;
99
99
  /** `migrate-from-prisma --no-db`: parse-only, skip database resolution. */
100
100
  noDb?: boolean;
101
+ /**
102
+ * `migrate-from-prisma --if-db`: when no connection string can be resolved,
103
+ * print a notice and exit 0 rather than failing. For the postinstall hook,
104
+ * where an `npm ci` in a build image legitimately has no database and must
105
+ * not be turned into a failed install.
106
+ */
107
+ ifDb?: boolean;
101
108
  }
102
109
  export declare function parseArgs(argv?: string[]): CliArgs;
103
110
  /**
@@ -84,6 +84,7 @@ const index_stats_js_1 = require("../index-stats.js");
84
84
  const introspect_js_1 = require("../introspect.js");
85
85
  const plan_divergence_js_1 = require("../plan-divergence.js");
86
86
  const plan_flip_probe_js_1 = require("../plan-flip-probe.js");
87
+ const prisma_schema_fingerprint_js_1 = require("../prisma-schema-fingerprint.js");
87
88
  const schema_js_1 = require("../schema.js");
88
89
  const schema_sql_js_1 = require("../schema-sql.js");
89
90
  const config_js_1 = require("./config.js");
@@ -274,6 +275,9 @@ function parseArgs(argv = process.argv.slice(2)) {
274
275
  case '--no-db':
275
276
  result.noDb = true;
276
277
  break;
278
+ case '--if-db':
279
+ result.ifDb = true;
280
+ break;
277
281
  default:
278
282
  if (!arg.startsWith('-')) {
279
283
  result.positional.push(arg);
@@ -1292,6 +1296,17 @@ async function cmdMigrateFromPrisma(args, config) {
1292
1296
  const origin = variable ? `${(0, ui_js_1.cyan)(variable)} via datasource "${dsName}"` : `datasource "${dsName}" ${key}`;
1293
1297
  (0, ui_js_1.info)(`Using the connection string declared by your Prisma schema (${origin}).`);
1294
1298
  }
1299
+ if (!resolvedUrl.url && args.ifDb) {
1300
+ // `--if-db`: the caller is a postinstall hook. A build image with no
1301
+ // database is the expected case there, and failing the install over it
1302
+ // would make the hook worse than not having one. Leave every existing
1303
+ // artifact untouched and say plainly that nothing was regenerated, so a
1304
+ // stale map is never mistaken for a fresh one.
1305
+ (0, ui_js_1.newline)();
1306
+ (0, ui_js_1.info)(`No connection string resolved and ${(0, ui_js_1.cyan)('--if-db')} was passed: skipping, nothing regenerated.`);
1307
+ (0, ui_js_1.newline)();
1308
+ return;
1309
+ }
1295
1310
  url = resolvedUrl.url ?? requireUrl(config, { datasourceVars: resolvedUrl.missingVariables });
1296
1311
  (0, ui_js_1.label)('Database', (0, ui_js_1.redactUrl)(url));
1297
1312
  const spinner = new ui_js_1.Spinner('Introspecting database schema').start();
@@ -1333,6 +1348,13 @@ async function cmdMigrateFromPrisma(args, config) {
1333
1348
  (0, node_fs_1.writeFileSync)(reportPath, (0, prisma_report_js_1.formatPrismaReport)(result, { schemaPath: prismaPath, noTimestamp: args.noTimestamp }), 'utf-8');
1334
1349
  console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.cyan)(reportPath)} ${(0, ui_js_1.dim)('(report)')}`);
1335
1350
  if (!args.noDb && schemaMeta) {
1351
+ // Record WHAT WAS READ, so the runtime adapter can report a stale map
1352
+ // instead of quietly translating names from a schema that has moved on.
1353
+ // The path is stored relative to the directory this ran in: the runtime
1354
+ // resolves it against `process.cwd()`, and an absolute path would both
1355
+ // break for everyone else and commit a home directory to the repo.
1356
+ const sourcePath = (0, node_path_1.relative)(process.cwd(), prismaPath).split(node_path_1.sep).join('/');
1357
+ result.map.source = { path: sourcePath, hash: (0, prisma_schema_fingerprint_js_1.fingerprintPrismaSchema)(source) };
1336
1358
  const mapPath = (0, node_path_1.join)(outDir, 'prisma-map.ts');
1337
1359
  (0, node_fs_1.writeFileSync)(mapPath, (0, generate_js_1.generatePrismaMap)(result.map, { noTimestamp: args.noTimestamp }), 'utf-8');
1338
1360
  console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.cyan)(mapPath)} ${(0, ui_js_1.dim)('(typed name map)')}`);
@@ -3344,6 +3366,7 @@ function showMigrateFromPrismaHelp() {
3344
3366
  console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string ${(0, ui_js_1.dim)('(unless --no-db)')}`);
3345
3367
  console.log(` ${(0, ui_js_1.cyan)('--out, -o')} ${(0, ui_js_1.dim)('<dir>')} Output directory ${(0, ui_js_1.dim)('(default: ./generated/turbine)')}`);
3346
3368
  console.log(` ${(0, ui_js_1.cyan)('--no-db')} Parse-only: write the report without resolving names`);
3369
+ console.log(` ${(0, ui_js_1.cyan)('--if-db')} Skip (exit 0) when no connection string resolves ${(0, ui_js_1.dim)('(postinstall)')}`);
3347
3370
  console.log(` ${(0, ui_js_1.cyan)('--allow-partial')} Exit 0 even when some items are UNRESOLVED`);
3348
3371
  console.log(` ${(0, ui_js_1.cyan)('--no-timestamp')} Omit the ${(0, ui_js_1.dim)('Generated:')} lines ${(0, ui_js_1.dim)('(reproducible output)')}`);
3349
3372
  (0, ui_js_1.newline)();
@@ -3351,6 +3374,11 @@ function showMigrateFromPrismaHelp() {
3351
3374
  console.log(` ${(0, ui_js_1.dim)('$')} DATABASE_URL=postgres://... npx turbine migrate-from-prisma --schema prisma/schema.prisma`);
3352
3375
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate-from-prisma --schema prisma/schema.prisma --no-db`);
3353
3376
  (0, ui_js_1.newline)();
3377
+ console.log(` ${(0, ui_js_1.bold)('Keeping it current:')}`);
3378
+ console.log(` ${(0, ui_js_1.dim)('Nothing re-runs this for you, and a stale prisma-map.ts fails silently, so')}`);
3379
+ console.log(` ${(0, ui_js_1.dim)('put it next to prisma generate:')}`);
3380
+ console.log(` ${(0, ui_js_1.dim)('"postinstall": "prisma generate && turbine migrate-from-prisma --if-db"')}`);
3381
+ (0, ui_js_1.newline)();
3354
3382
  }
3355
3383
  function showPushHelp() {
3356
3384
  (0, ui_js_1.banner)();
@@ -34,7 +34,7 @@ interface ForeignKeyRow {
34
34
  * naming to the SHARED introspection builder (`buildRelationsFromForeignKeys`
35
35
  * + `addAutoManyToManyRelations` in ../introspect.ts). MCP previously carried
36
36
  * a stale copy of a retired naming scheme, so `turbine mcp` and `turbine
37
- * generate` derived DIFFERENT relation names from the same database (N-3).
37
+ * generate` derived DIFFERENT relation names from the same database.
38
38
  * Exported for the parity unit test.
39
39
  */
40
40
  export declare function buildRelations(tableNames: string[], columnsByTable: Map<string, ColumnMetadata[]>, pkByTable: Map<string, string[]>, rows: ForeignKeyRow[], enums?: Record<string, string[]>): Map<string, Record<string, RelationDef>>;
@@ -630,7 +630,7 @@ async function loadSchemaMetadata(client, options) {
630
630
  * naming to the SHARED introspection builder (`buildRelationsFromForeignKeys`
631
631
  * + `addAutoManyToManyRelations` in ../introspect.ts). MCP previously carried
632
632
  * a stale copy of a retired naming scheme, so `turbine mcp` and `turbine
633
- * generate` derived DIFFERENT relation names from the same database (N-3).
633
+ * generate` derived DIFFERENT relation names from the same database.
634
634
  * Exported for the parity unit test.
635
635
  */
636
636
  function buildRelations(tableNames, columnsByTable, pkByTable, rows, enums = {}) {
@@ -25,7 +25,7 @@ export interface GenerateOptions {
25
25
  zod?: boolean;
26
26
  /**
27
27
  * Omit the `Generated at: <ISO timestamp>` line from every generated file
28
- * header (T-8b, reproducible codegen). With this set, byte-identical
28
+ * header (reproducible codegen). With this set, byte-identical
29
29
  * schemas regenerate to byte-identical output, so regens produce empty
30
30
  * diffs. Default: `false` (timestamp included, unchanged behavior).
31
31
  */
@@ -259,7 +259,7 @@ function stripJsonComments(text) {
259
259
  // types.ts generator
260
260
  // ---------------------------------------------------------------------------
261
261
  function generatedFileHeader(options) {
262
- // `noTimestamp` omits the volatile line entirely (T-8b) so regenerating an
262
+ // `noTimestamp` omits the volatile line entirely so regenerating an
263
263
  // unchanged schema produces byte-identical files.
264
264
  return [
265
265
  '/**',
@@ -996,6 +996,11 @@ function generatePrismaMap(map, options) {
996
996
  }
997
997
  lines.push(' },');
998
998
  lines.push(` enums: ${serializeStringRecord(map.enums)},`);
999
+ if (map.source) {
1000
+ // Provenance, so the runtime adapter can tell you this file is stale rather
1001
+ // than behaving as if the schema never changed.
1002
+ lines.push(` source: { path: '${escSQ(map.source.path)}', hash: '${escSQ(map.source.hash)}' },`);
1003
+ }
999
1004
  lines.push('};');
1000
1005
  lines.push('');
1001
1006
  return lines.join('\n');
@@ -1044,7 +1049,7 @@ function serializeColumn(col) {
1044
1049
  ];
1045
1050
  // Cross-schema type marker, introspection records it only for types living
1046
1051
  // outside the introspected schema; it must survive codegen or the runtime
1047
- // enum-cast guard in query/builder.ts loses the signal (N-5).
1052
+ // enum-cast guard in query/builder.ts loses the signal.
1048
1053
  if (col.pgTypeSchema !== undefined)
1049
1054
  parts.push(`pgTypeSchema: '${escSQ(col.pgTypeSchema)}'`);
1050
1055
  // Emit isGenerated only when set (server-generated serial/identity), so the
@@ -43,9 +43,10 @@ export { type IntrospectOptions, introspect } from './introspect.js';
43
43
  export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
44
44
  export { HttpJsonSink, type HttpJsonSinkOptions, type MetricsFlushBatch, type MetricsFlushRow, type ObserveConfig, type ObserveHandle, type ObserveSink, PgMetricsSink, type PgMetricsSinkOptions, } from './observe.js';
45
45
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
46
+ export { fingerprintPrismaSchema } from './prisma-schema-fingerprint.js';
46
47
  export { type AggregateArgs, type AggregateResult, type ArrayFilter, AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type GroupByResult, type HavingClause, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderByObject, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationLoadStrategy, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TemporalInfinityReading, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithOrderByObject, type WithResult, } from './query/index.js';
47
48
  export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
48
- export type { CheckMetadata, ColumnMetadata, IndexMetadata, PrismaCompatMap, PrismaModelMap, PrismaRelationMap, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
49
+ export type { CheckMetadata, ColumnMetadata, IndexMetadata, PrismaCompatMap, PrismaModelMap, PrismaRelationMap, PrismaSchemaSource, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
49
50
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, withDbFieldNames, } from './schema.js';
50
51
  export { applyManyToManyRelations, type CheckDef, ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnIndexDef, type ColumnType, type ColumnTypeName, column, type DefineSchemaOptions, type DocFieldIndexDef, defineSchema, isDocFieldIndexDef, type ManyToManyDef, type ReferenceDef, type SchemaDef, type SchemaIndexDef, type TableDef, table, } from './schema-builder.js';
51
52
  export { schemaDefToMetadata } from './schema-metadata.js';
package/dist/cjs/index.js CHANGED
@@ -34,8 +34,8 @@
34
34
  * ```
35
35
  */
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
- exports.camelToSnake = exports.validateChannel = exports.QueryInterface = exports.AUTO_TO_ONE_JOIN_ROWS_MIN = exports.AUTO_TO_ONE_JOIN_ROWS_MAX = exports.AUTO_TO_ONE_JOIN_MAX_ROWS = exports.AUTO_JOIN_PENALTY_MS_PER_ROW = exports.AUTO_COUNT_BATCH_MIN_PARENT_ROWS = exports.AUTO_ASSUMED_ROUND_TRIP_MS = exports.pipelineSupported = exports.executePipeline = exports.PgMetricsSink = exports.HttpJsonSink = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UnsupportedFeatureError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.ReadOnlyError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
38
- exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.DestructivePushRefusal = exports.schemaDefToMetadata = exports.table = exports.isDocFieldIndexDef = exports.defineSchema = exports.column = exports.ColumnBuilder = exports.applyManyToManyRelations = exports.withDbFieldNames = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = void 0;
37
+ exports.validateChannel = exports.QueryInterface = exports.AUTO_TO_ONE_JOIN_ROWS_MIN = exports.AUTO_TO_ONE_JOIN_ROWS_MAX = exports.AUTO_TO_ONE_JOIN_MAX_ROWS = exports.AUTO_JOIN_PENALTY_MS_PER_ROW = exports.AUTO_COUNT_BATCH_MIN_PARENT_ROWS = exports.AUTO_ASSUMED_ROUND_TRIP_MS = exports.fingerprintPrismaSchema = exports.pipelineSupported = exports.executePipeline = exports.PgMetricsSink = exports.HttpJsonSink = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UnsupportedFeatureError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.ReadOnlyError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
38
+ exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.DestructivePushRefusal = exports.schemaDefToMetadata = exports.table = exports.isDocFieldIndexDef = exports.defineSchema = exports.column = exports.ColumnBuilder = exports.applyManyToManyRelations = exports.withDbFieldNames = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = void 0;
39
39
  var index_js_1 = require("./adapters/index.js");
40
40
  Object.defineProperty(exports, "alloydb", { enumerable: true, get: function () { return index_js_1.alloydb; } });
41
41
  Object.defineProperty(exports, "cockroachdb", { enumerable: true, get: function () { return index_js_1.cockroachdb; } });
@@ -93,6 +93,9 @@ Object.defineProperty(exports, "PgMetricsSink", { enumerable: true, get: functio
93
93
  var pipeline_js_1 = require("./pipeline.js");
94
94
  Object.defineProperty(exports, "executePipeline", { enumerable: true, get: function () { return pipeline_js_1.executePipeline; } });
95
95
  Object.defineProperty(exports, "pipelineSupported", { enumerable: true, get: function () { return pipeline_js_1.pipelineSupported; } });
96
+ // Prisma-schema fingerprint (provenance on a generated PRISMA_MAP)
97
+ var prisma_schema_fingerprint_js_1 = require("./prisma-schema-fingerprint.js");
98
+ Object.defineProperty(exports, "fingerprintPrismaSchema", { enumerable: true, get: function () { return prisma_schema_fingerprint_js_1.fingerprintPrismaSchema; } });
96
99
  // Query builder
97
100
  var index_js_2 = require("./query/index.js");
98
101
  Object.defineProperty(exports, "AUTO_ASSUMED_ROUND_TRIP_MS", { enumerable: true, get: function () { return index_js_2.AUTO_ASSUMED_ROUND_TRIP_MS; } });
@@ -110,11 +110,45 @@
110
110
  * });
111
111
  * ```
112
112
  */
113
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
114
+ if (k2 === undefined) k2 = k;
115
+ var desc = Object.getOwnPropertyDescriptor(m, k);
116
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
117
+ desc = { enumerable: true, get: function() { return m[k]; } };
118
+ }
119
+ Object.defineProperty(o, k2, desc);
120
+ }) : (function(o, m, k, k2) {
121
+ if (k2 === undefined) k2 = k;
122
+ o[k2] = m[k];
123
+ }));
124
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
125
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
126
+ }) : function(o, v) {
127
+ o["default"] = v;
128
+ });
129
+ var __importStar = (this && this.__importStar) || (function () {
130
+ var ownKeys = function(o) {
131
+ ownKeys = Object.getOwnPropertyNames || function (o) {
132
+ var ar = [];
133
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
134
+ return ar;
135
+ };
136
+ return ownKeys(o);
137
+ };
138
+ return function (mod) {
139
+ if (mod && mod.__esModule) return mod;
140
+ var result = {};
141
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
142
+ __setModuleDefault(result, mod);
143
+ return result;
144
+ };
145
+ })();
113
146
  Object.defineProperty(exports, "__esModule", { value: true });
114
147
  exports.CLIENT_RESERVED_KEYS = exports.COMPAT_DEFERRED = exports.PRISMA_ARG_KEYS = exports.Prisma = void 0;
115
148
  exports.createPrismaCompatClient = createPrismaCompatClient;
116
149
  const errors_js_1 = require("./errors.js");
117
150
  const nested_write_js_1 = require("./nested-write.js");
151
+ const prisma_schema_fingerprint_js_1 = require("./prisma-schema-fingerprint.js");
118
152
  const index_js_1 = require("./query/index.js");
119
153
  const utils_js_1 = require("./query/utils.js");
120
154
  const warn_registry_js_1 = require("./query/warn-registry.js");
@@ -442,6 +476,55 @@ function warnUnknownQueryOptions(model, op, args) {
442
476
  // must never be the reason a query fails.
443
477
  }
444
478
  }
479
+ /**
480
+ * Dev-mode notice that the `prisma-map.ts` driving this client no longer matches
481
+ * the Prisma schema it was generated from.
482
+ *
483
+ * Nothing re-runs `turbine migrate-from-prisma`. When the schema moves on, the
484
+ * map does not, and the adapter keeps translating the names it has: a model
485
+ * added last week is simply not on the client, a renamed field is quietly
486
+ * absent from results. Both read as adapter bugs. This turns them into one line
487
+ * naming the file and the command that fixes it.
488
+ *
489
+ * Everything about the check is subordinate to "never break an app that works":
490
+ *
491
+ * - **Async and unawaited.** Client construction does not wait on a file read;
492
+ * the warning lands a tick later or not at all.
493
+ * - **Any failure is silence.** A missing file is the NORMAL deployed state
494
+ * (nobody ships `prisma/` to production) and so are a bundled runtime with no
495
+ * `node:fs` and a read that is refused. None of them is evidence of drift.
496
+ * - **Production is skipped entirely**, before anything is imported or read.
497
+ * - **Once per process per path**, via the shared registry, so a process that
498
+ * builds a client per request says it once.
499
+ *
500
+ * @param map - the map under test; a map with no `source` (hand-written, or
501
+ * emitted before 0.60) is skipped, since absence carries no information.
502
+ */
503
+ function warnStalePrismaMap(map) {
504
+ const source = map.source;
505
+ if (!source || typeof source.path !== 'string' || typeof source.hash !== 'string')
506
+ return;
507
+ if (typeof process === 'undefined' || process.env?.NODE_ENV === 'production')
508
+ return;
509
+ void (async () => {
510
+ try {
511
+ // Dynamic so a bundle that never reaches this line never needs `node:fs`.
512
+ const { readFile } = await Promise.resolve().then(() => __importStar(require('node:fs/promises')));
513
+ const onDisk = await readFile(source.path, 'utf8');
514
+ if ((0, prisma_schema_fingerprint_js_1.fingerprintPrismaSchema)(onDisk) === source.hash)
515
+ return;
516
+ if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.stalePrismaMap, source.path))
517
+ return;
518
+ console.warn(`[turbine] prisma-compat: ${source.path} has changed since prisma-map.ts was generated,` +
519
+ ' so any model, field, relation or compound-unique added or renamed since then is missing' +
520
+ ' from the compat client. Re-run: turbine migrate-from-prisma');
521
+ }
522
+ catch {
523
+ // No file, no `node:fs`, no permission. None of these means the map is
524
+ // stale, and a diagnostic must never be the reason a client fails.
525
+ }
526
+ })();
527
+ }
445
528
  /**
446
529
  * Prisma 6.7+ accepts `limit` on `updateMany` / `deleteMany` to BOUND how many
447
530
  * rows a mass mutation touches. Turbine has no row-bounded mass mutation, so
@@ -1844,6 +1927,9 @@ function createPrismaCompatClient(client, map, options = {}) {
1844
1927
  // the real client satisfies it structurally (the cast just relaxes the strict
1845
1928
  // callback-param variance on `$transaction`).
1846
1929
  const db = client;
1930
+ // Fire-and-forget, dev-only, once per process: is this map still describing
1931
+ // the schema on disk? Nothing else in the system ever asks.
1932
+ warnStalePrismaMap(map);
1847
1933
  const tableToModel = new Map();
1848
1934
  for (const [prismaModel, mm] of Object.entries(map.models))
1849
1935
  tableToModel.set(mm.table, prismaModel);
@@ -0,0 +1,61 @@
1
+ /**
2
+ * turbine-orm, fingerprint of the Prisma schema a `PRISMA_MAP` was generated
3
+ * from.
4
+ *
5
+ * ## The failure this exists for
6
+ *
7
+ * `turbine migrate-from-prisma` reads `prisma/schema.prisma` and emits a
8
+ * `prisma-map.ts` that the `turbine-orm/prisma-compat` adapter is driven by.
9
+ * Nothing re-runs it. Add a model, rename a field, change a `@@unique` name, and
10
+ * the map keeps describing the schema as it was: the adapter goes on translating
11
+ * the names it knows and simply has no entry for the new ones. That surfaces as
12
+ * "this model is not on the compat client" or a field silently absent from a
13
+ * result, which reads like an adapter bug rather than a stale artifact, and
14
+ * which is exactly the failure mode a code generator ought to be able to
15
+ * announce about itself.
16
+ *
17
+ * So the generator records what it read, and the adapter checks that record
18
+ * against what is on disk. This is the same shape as the unknown-config-key
19
+ * check in `client.ts` and the parser-overwrite warning in `query/utils.ts`,
20
+ * with a higher severity than either: those two produce visible breakage on
21
+ * their own, and this one produces none at all.
22
+ *
23
+ * ## Why FNV-1a and not a cryptographic digest
24
+ *
25
+ * The question asked is "is this the same file", not "did an adversary craft a
26
+ * collision". A collision costs one missed warning, so a 64-bit non-crypto hash
27
+ * is the right instrument, and it keeps this module pure: no `node:crypto`, no
28
+ * new dependency, and it is importable from the runtime adapter, from the CLI,
29
+ * and from an edge bundle alike.
30
+ *
31
+ * ## Normalization
32
+ *
33
+ * Deliberately minimal, and only for differences that are not edits:
34
+ *
35
+ * - a leading UTF-8 BOM, which some editors add and remove without being asked;
36
+ * - CRLF and lone CR line endings, so a Windows checkout of an unchanged file
37
+ * is not reported as drift (`core.autocrlf` rewrites on checkout);
38
+ * - whitespace at the very END of the file, for the same reason.
39
+ *
40
+ * Note the last one is end-of-FILE only, not per line. Trailing whitespace on an
41
+ * individual line is hashed, and changing it is reported as drift. That is the
42
+ * intended reading: nothing in a checkout rewrites it, so it got there because
43
+ * someone edited the line, and the cost of an unnecessary regeneration is lower
44
+ * than the cost of a missed one.
45
+ *
46
+ * Nothing else is normalized. Comments and blank lines are hashed as-is: a
47
+ * "harmless" edit is still an edit, and reporting one costs a regeneration
48
+ * nobody regrets, while normalizing one away risks silence on a real change
49
+ * whose only visible trace happened to be a comment.
50
+ */
51
+ /**
52
+ * Fingerprint the TEXT of a Prisma schema file. Stable across line-ending and
53
+ * BOM differences that a checkout can introduce (see the module doc), sensitive
54
+ * to every other byte.
55
+ *
56
+ * The returned string is opaque and versioned by its `v1:` prefix: if the
57
+ * normalization ever has to change, old maps carry the old prefix and can be
58
+ * recognized rather than reported as drift against a rule they were not
59
+ * generated under.
60
+ */
61
+ export declare function fingerprintPrismaSchema(source: string): string;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm, fingerprint of the Prisma schema a `PRISMA_MAP` was generated
4
+ * from.
5
+ *
6
+ * ## The failure this exists for
7
+ *
8
+ * `turbine migrate-from-prisma` reads `prisma/schema.prisma` and emits a
9
+ * `prisma-map.ts` that the `turbine-orm/prisma-compat` adapter is driven by.
10
+ * Nothing re-runs it. Add a model, rename a field, change a `@@unique` name, and
11
+ * the map keeps describing the schema as it was: the adapter goes on translating
12
+ * the names it knows and simply has no entry for the new ones. That surfaces as
13
+ * "this model is not on the compat client" or a field silently absent from a
14
+ * result, which reads like an adapter bug rather than a stale artifact, and
15
+ * which is exactly the failure mode a code generator ought to be able to
16
+ * announce about itself.
17
+ *
18
+ * So the generator records what it read, and the adapter checks that record
19
+ * against what is on disk. This is the same shape as the unknown-config-key
20
+ * check in `client.ts` and the parser-overwrite warning in `query/utils.ts`,
21
+ * with a higher severity than either: those two produce visible breakage on
22
+ * their own, and this one produces none at all.
23
+ *
24
+ * ## Why FNV-1a and not a cryptographic digest
25
+ *
26
+ * The question asked is "is this the same file", not "did an adversary craft a
27
+ * collision". A collision costs one missed warning, so a 64-bit non-crypto hash
28
+ * is the right instrument, and it keeps this module pure: no `node:crypto`, no
29
+ * new dependency, and it is importable from the runtime adapter, from the CLI,
30
+ * and from an edge bundle alike.
31
+ *
32
+ * ## Normalization
33
+ *
34
+ * Deliberately minimal, and only for differences that are not edits:
35
+ *
36
+ * - a leading UTF-8 BOM, which some editors add and remove without being asked;
37
+ * - CRLF and lone CR line endings, so a Windows checkout of an unchanged file
38
+ * is not reported as drift (`core.autocrlf` rewrites on checkout);
39
+ * - whitespace at the very END of the file, for the same reason.
40
+ *
41
+ * Note the last one is end-of-FILE only, not per line. Trailing whitespace on an
42
+ * individual line is hashed, and changing it is reported as drift. That is the
43
+ * intended reading: nothing in a checkout rewrites it, so it got there because
44
+ * someone edited the line, and the cost of an unnecessary regeneration is lower
45
+ * than the cost of a missed one.
46
+ *
47
+ * Nothing else is normalized. Comments and blank lines are hashed as-is: a
48
+ * "harmless" edit is still an edit, and reporting one costs a regeneration
49
+ * nobody regrets, while normalizing one away risks silence on a real change
50
+ * whose only visible trace happened to be a comment.
51
+ */
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ exports.fingerprintPrismaSchema = fingerprintPrismaSchema;
54
+ const utils_js_1 = require("./query/utils.js");
55
+ /**
56
+ * Fingerprint the TEXT of a Prisma schema file. Stable across line-ending and
57
+ * BOM differences that a checkout can introduce (see the module doc), sensitive
58
+ * to every other byte.
59
+ *
60
+ * The returned string is opaque and versioned by its `v1:` prefix: if the
61
+ * normalization ever has to change, old maps carry the old prefix and can be
62
+ * recognized rather than reported as drift against a rule they were not
63
+ * generated under.
64
+ */
65
+ function fingerprintPrismaSchema(source) {
66
+ const normalized = source
67
+ // Written as an escape, not the literal character: a bare BOM in source is
68
+ // invisible in a diff and some tools strip it from the file it lives in.
69
+ .replace(/^\uFEFF/, '')
70
+ .replace(/\r\n?/g, '\n')
71
+ .replace(/\s+$/, '');
72
+ return `v1:${(0, utils_js_1.fnv1a64Hex)(normalized)}`;
73
+ }
@@ -104,6 +104,13 @@ export declare const WARN_NS: {
104
104
  * a million executions of one call site is one.
105
105
  */
106
106
  readonly unknownQueryOption: "unknownQueryOption";
107
+ /**
108
+ * The `prisma-map.ts` driving a `turbine-orm/prisma-compat` client no longer
109
+ * matches the Prisma schema it was generated from (prisma-compat.ts
110
+ * `warnStalePrismaMap`). Keyed on the schema path, so a process that builds a
111
+ * compat client per request reports it once.
112
+ */
113
+ readonly stalePrismaMap: "stalePrismaMap";
107
114
  /**
108
115
  * `planCacheMode` was set on a client given an EXTERNAL pool, where Turbine
109
116
  * runs no connection setup, so the option is a no-op (client.ts constructor).
@@ -145,6 +145,13 @@ exports.WARN_NS = {
145
145
  * a million executions of one call site is one.
146
146
  */
147
147
  unknownQueryOption: 'unknownQueryOption',
148
+ /**
149
+ * The `prisma-map.ts` driving a `turbine-orm/prisma-compat` client no longer
150
+ * matches the Prisma schema it was generated from (prisma-compat.ts
151
+ * `warnStalePrismaMap`). Keyed on the schema path, so a process that builds a
152
+ * compat client per request reports it once.
153
+ */
154
+ stalePrismaMap: 'stalePrismaMap',
148
155
  /**
149
156
  * `planCacheMode` was set on a client given an EXTERNAL pool, where Turbine
150
157
  * runs no connection setup, so the option is a no-op (client.ts constructor).
@@ -1064,7 +1064,7 @@ function enumTypeForColumn(qi, column) {
1064
1064
  const enums = qi.schema.enums;
1065
1065
  if (!enums)
1066
1066
  return null;
1067
- // Cross-schema guard (N-5): introspection records pgTypeSchema ONLY when
1067
+ // Cross-schema guard: introspection records pgTypeSchema ONLY when
1068
1068
  // the column's type lives OUTSIDE the introspected schema. A same-named
1069
1069
  // enum in another schema must not get this schema's cast, search_path
1070
1070
  // would resolve `::"status"` to the wrong type. Skipping the cast restores
@@ -252,7 +252,7 @@ function schemaDefToMetadata(def) {
252
252
  // local reimplementation had NO collision guard, `posts.user` (text) +
253
253
  // `userId references users.id` produced a relation `user` that shadowed the
254
254
  // scalar, and two FKs deriving the same name silently clobbered each other
255
- // (N-4).
255
+ //.
256
256
  //
257
257
  // Constraint names are synthesized in pg's default `<table>_<column>_fkey`
258
258
  // form; they only feed the referential-action lookup and composite-FK
@@ -325,6 +325,30 @@ export interface PrismaCompatMap {
325
325
  models: Record<string, PrismaModelMap>;
326
326
  /** Prisma enum name → resolved database enum-type name. */
327
327
  enums: Record<string, string>;
328
+ /**
329
+ * The Prisma schema file this map was generated from. Present on maps emitted
330
+ * by `turbine migrate-from-prisma` since 0.60; absent on older ones, and on a
331
+ * map assembled by hand.
332
+ *
333
+ * `turbine-orm/prisma-compat` uses it for a once-per-process, dev-only staleness
334
+ * check: nothing re-runs the generator, so a map can silently fall behind the
335
+ * schema it describes and the adapter has no way to know. See
336
+ * {@link fingerprintPrismaSchema}.
337
+ */
338
+ source?: PrismaSchemaSource;
339
+ }
340
+ /** Provenance of a generated {@link PrismaCompatMap}. */
341
+ export interface PrismaSchemaSource {
342
+ /**
343
+ * The Prisma schema file, POSIX-separated and relative to the directory the
344
+ * generator ran in (which for the documented workflow is the project root, so
345
+ * it resolves against `process.cwd()` at runtime). Relative rather than
346
+ * absolute so the value is identical for every developer and in CI, and so a
347
+ * generated file never carries someone's home directory into version control.
348
+ */
349
+ path: string;
350
+ /** {@link fingerprintPrismaSchema} of that file's contents at generation time. */
351
+ hash: string;
328
352
  }
329
353
  /** One Prisma model's resolved mapping onto a Turbine table + client accessor. */
330
354
  export interface PrismaModelMap {
@@ -98,6 +98,13 @@ export interface CliArgs {
98
98
  allowPartial?: boolean;
99
99
  /** `migrate-from-prisma --no-db`: parse-only, skip database resolution. */
100
100
  noDb?: boolean;
101
+ /**
102
+ * `migrate-from-prisma --if-db`: when no connection string can be resolved,
103
+ * print a notice and exit 0 rather than failing. For the postinstall hook,
104
+ * where an `npm ci` in a build image legitimately has no database and must
105
+ * not be turned into a failed install.
106
+ */
107
+ ifDb?: boolean;
101
108
  }
102
109
  export declare function parseArgs(argv?: string[]): CliArgs;
103
110
  /**
package/dist/cli/index.js CHANGED
@@ -26,7 +26,7 @@
26
26
  */
27
27
  import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
28
28
  import { tmpdir } from 'node:os';
29
- import { basename, dirname, extname, join, relative, resolve } from 'node:path';
29
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
30
30
  import { pathToFileURL } from 'node:url';
31
31
  import { generate, generatePrismaMap } from '../generate.js';
32
32
  import { buildCreateIndexSql, buildDropIndexSql, collectDoctorProbeIndexNames, collectRelationProbeColumns, findMissingRelationIndexes, } from '../index-advisor.js';
@@ -34,6 +34,7 @@ import { auditDoctorIndexes, collectStatsSnapshot, collectTableHeat, findInvalid
34
34
  import { introspect } from '../introspect.js';
35
35
  import { collectDivergenceCandidateColumns, collectDivergenceOrderColumns, findPlanDivergence, PLAN_DIVERGENCE_THRESHOLDS, } from '../plan-divergence.js';
36
36
  import { applyFlipVerdicts, emptyFlipProbeResult, needsFlipProbe, probePlanFlips } from '../plan-flip-probe.js';
37
+ import { fingerprintPrismaSchema } from '../prisma-schema-fingerprint.js';
37
38
  import { snakeToCamel } from '../schema.js';
38
39
  import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
39
40
  import { configTemplate, DEFAULT_INIT_SEED_FILE, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
@@ -224,6 +225,9 @@ export function parseArgs(argv = process.argv.slice(2)) {
224
225
  case '--no-db':
225
226
  result.noDb = true;
226
227
  break;
228
+ case '--if-db':
229
+ result.ifDb = true;
230
+ break;
227
231
  default:
228
232
  if (!arg.startsWith('-')) {
229
233
  result.positional.push(arg);
@@ -1242,6 +1246,17 @@ async function cmdMigrateFromPrisma(args, config) {
1242
1246
  const origin = variable ? `${cyan(variable)} via datasource "${dsName}"` : `datasource "${dsName}" ${key}`;
1243
1247
  info(`Using the connection string declared by your Prisma schema (${origin}).`);
1244
1248
  }
1249
+ if (!resolvedUrl.url && args.ifDb) {
1250
+ // `--if-db`: the caller is a postinstall hook. A build image with no
1251
+ // database is the expected case there, and failing the install over it
1252
+ // would make the hook worse than not having one. Leave every existing
1253
+ // artifact untouched and say plainly that nothing was regenerated, so a
1254
+ // stale map is never mistaken for a fresh one.
1255
+ newline();
1256
+ info(`No connection string resolved and ${cyan('--if-db')} was passed: skipping, nothing regenerated.`);
1257
+ newline();
1258
+ return;
1259
+ }
1245
1260
  url = resolvedUrl.url ?? requireUrl(config, { datasourceVars: resolvedUrl.missingVariables });
1246
1261
  label('Database', redactUrl(url));
1247
1262
  const spinner = new Spinner('Introspecting database schema').start();
@@ -1283,6 +1298,13 @@ async function cmdMigrateFromPrisma(args, config) {
1283
1298
  writeFileSync(reportPath, formatPrismaReport(result, { schemaPath: prismaPath, noTimestamp: args.noTimestamp }), 'utf-8');
1284
1299
  console.log(` ${dim(symbols.teeEnd)} ${cyan(reportPath)} ${dim('(report)')}`);
1285
1300
  if (!args.noDb && schemaMeta) {
1301
+ // Record WHAT WAS READ, so the runtime adapter can report a stale map
1302
+ // instead of quietly translating names from a schema that has moved on.
1303
+ // The path is stored relative to the directory this ran in: the runtime
1304
+ // resolves it against `process.cwd()`, and an absolute path would both
1305
+ // break for everyone else and commit a home directory to the repo.
1306
+ const sourcePath = relative(process.cwd(), prismaPath).split(sep).join('/');
1307
+ result.map.source = { path: sourcePath, hash: fingerprintPrismaSchema(source) };
1286
1308
  const mapPath = join(outDir, 'prisma-map.ts');
1287
1309
  writeFileSync(mapPath, generatePrismaMap(result.map, { noTimestamp: args.noTimestamp }), 'utf-8');
1288
1310
  console.log(` ${dim(symbols.teeEnd)} ${cyan(mapPath)} ${dim('(typed name map)')}`);
@@ -3294,6 +3316,7 @@ function showMigrateFromPrismaHelp() {
3294
3316
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string ${dim('(unless --no-db)')}`);
3295
3317
  console.log(` ${cyan('--out, -o')} ${dim('<dir>')} Output directory ${dim('(default: ./generated/turbine)')}`);
3296
3318
  console.log(` ${cyan('--no-db')} Parse-only: write the report without resolving names`);
3319
+ console.log(` ${cyan('--if-db')} Skip (exit 0) when no connection string resolves ${dim('(postinstall)')}`);
3297
3320
  console.log(` ${cyan('--allow-partial')} Exit 0 even when some items are UNRESOLVED`);
3298
3321
  console.log(` ${cyan('--no-timestamp')} Omit the ${dim('Generated:')} lines ${dim('(reproducible output)')}`);
3299
3322
  newline();
@@ -3301,6 +3324,11 @@ function showMigrateFromPrismaHelp() {
3301
3324
  console.log(` ${dim('$')} DATABASE_URL=postgres://... npx turbine migrate-from-prisma --schema prisma/schema.prisma`);
3302
3325
  console.log(` ${dim('$')} npx turbine migrate-from-prisma --schema prisma/schema.prisma --no-db`);
3303
3326
  newline();
3327
+ console.log(` ${bold('Keeping it current:')}`);
3328
+ console.log(` ${dim('Nothing re-runs this for you, and a stale prisma-map.ts fails silently, so')}`);
3329
+ console.log(` ${dim('put it next to prisma generate:')}`);
3330
+ console.log(` ${dim('"postinstall": "prisma generate && turbine migrate-from-prisma --if-db"')}`);
3331
+ newline();
3304
3332
  }
3305
3333
  function showPushHelp() {
3306
3334
  banner();
package/dist/cli/mcp.d.ts CHANGED
@@ -34,7 +34,7 @@ interface ForeignKeyRow {
34
34
  * naming to the SHARED introspection builder (`buildRelationsFromForeignKeys`
35
35
  * + `addAutoManyToManyRelations` in ../introspect.ts). MCP previously carried
36
36
  * a stale copy of a retired naming scheme, so `turbine mcp` and `turbine
37
- * generate` derived DIFFERENT relation names from the same database (N-3).
37
+ * generate` derived DIFFERENT relation names from the same database.
38
38
  * Exported for the parity unit test.
39
39
  */
40
40
  export declare function buildRelations(tableNames: string[], columnsByTable: Map<string, ColumnMetadata[]>, pkByTable: Map<string, string[]>, rows: ForeignKeyRow[], enums?: Record<string, string[]>): Map<string, Record<string, RelationDef>>;
package/dist/cli/mcp.js CHANGED
@@ -622,7 +622,7 @@ async function loadSchemaMetadata(client, options) {
622
622
  * naming to the SHARED introspection builder (`buildRelationsFromForeignKeys`
623
623
  * + `addAutoManyToManyRelations` in ../introspect.ts). MCP previously carried
624
624
  * a stale copy of a retired naming scheme, so `turbine mcp` and `turbine
625
- * generate` derived DIFFERENT relation names from the same database (N-3).
625
+ * generate` derived DIFFERENT relation names from the same database.
626
626
  * Exported for the parity unit test.
627
627
  */
628
628
  export function buildRelations(tableNames, columnsByTable, pkByTable, rows, enums = {}) {
@@ -25,7 +25,7 @@ export interface GenerateOptions {
25
25
  zod?: boolean;
26
26
  /**
27
27
  * Omit the `Generated at: <ISO timestamp>` line from every generated file
28
- * header (T-8b, reproducible codegen). With this set, byte-identical
28
+ * header (reproducible codegen). With this set, byte-identical
29
29
  * schemas regenerate to byte-identical output, so regens produce empty
30
30
  * diffs. Default: `false` (timestamp included, unchanged behavior).
31
31
  */
package/dist/generate.js CHANGED
@@ -247,7 +247,7 @@ export function stripJsonComments(text) {
247
247
  // types.ts generator
248
248
  // ---------------------------------------------------------------------------
249
249
  function generatedFileHeader(options) {
250
- // `noTimestamp` omits the volatile line entirely (T-8b) so regenerating an
250
+ // `noTimestamp` omits the volatile line entirely so regenerating an
251
251
  // unchanged schema produces byte-identical files.
252
252
  return [
253
253
  '/**',
@@ -984,6 +984,11 @@ export function generatePrismaMap(map, options) {
984
984
  }
985
985
  lines.push(' },');
986
986
  lines.push(` enums: ${serializeStringRecord(map.enums)},`);
987
+ if (map.source) {
988
+ // Provenance, so the runtime adapter can tell you this file is stale rather
989
+ // than behaving as if the schema never changed.
990
+ lines.push(` source: { path: '${escSQ(map.source.path)}', hash: '${escSQ(map.source.hash)}' },`);
991
+ }
987
992
  lines.push('};');
988
993
  lines.push('');
989
994
  return lines.join('\n');
@@ -1032,7 +1037,7 @@ function serializeColumn(col) {
1032
1037
  ];
1033
1038
  // Cross-schema type marker, introspection records it only for types living
1034
1039
  // outside the introspected schema; it must survive codegen or the runtime
1035
- // enum-cast guard in query/builder.ts loses the signal (N-5).
1040
+ // enum-cast guard in query/builder.ts loses the signal.
1036
1041
  if (col.pgTypeSchema !== undefined)
1037
1042
  parts.push(`pgTypeSchema: '${escSQ(col.pgTypeSchema)}'`);
1038
1043
  // Emit isGenerated only when set (server-generated serial/identity), so the
package/dist/index.d.ts CHANGED
@@ -43,9 +43,10 @@ export { type IntrospectOptions, introspect } from './introspect.js';
43
43
  export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
44
44
  export { HttpJsonSink, type HttpJsonSinkOptions, type MetricsFlushBatch, type MetricsFlushRow, type ObserveConfig, type ObserveHandle, type ObserveSink, PgMetricsSink, type PgMetricsSinkOptions, } from './observe.js';
45
45
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
46
+ export { fingerprintPrismaSchema } from './prisma-schema-fingerprint.js';
46
47
  export { type AggregateArgs, type AggregateResult, type ArrayFilter, AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type GroupByResult, type HavingClause, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderByObject, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationLoadStrategy, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TemporalInfinityReading, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithOrderByObject, type WithResult, } from './query/index.js';
47
48
  export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
48
- export type { CheckMetadata, ColumnMetadata, IndexMetadata, PrismaCompatMap, PrismaModelMap, PrismaRelationMap, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
49
+ export type { CheckMetadata, ColumnMetadata, IndexMetadata, PrismaCompatMap, PrismaModelMap, PrismaRelationMap, PrismaSchemaSource, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
49
50
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, withDbFieldNames, } from './schema.js';
50
51
  export { applyManyToManyRelations, type CheckDef, ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnIndexDef, type ColumnType, type ColumnTypeName, column, type DefineSchemaOptions, type DocFieldIndexDef, defineSchema, isDocFieldIndexDef, type ManyToManyDef, type ReferenceDef, type SchemaDef, type SchemaIndexDef, type TableDef, table, } from './schema-builder.js';
51
52
  export { schemaDefToMetadata } from './schema-metadata.js';
package/dist/index.js CHANGED
@@ -48,6 +48,8 @@ export { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './
48
48
  export { HttpJsonSink, PgMetricsSink, } from './observe.js';
49
49
  // Pipeline
50
50
  export { executePipeline, pipelineSupported } from './pipeline.js';
51
+ // Prisma-schema fingerprint (provenance on a generated PRISMA_MAP)
52
+ export { fingerprintPrismaSchema } from './prisma-schema-fingerprint.js';
51
53
  // Query builder
52
54
  export { AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, QueryInterface, } from './query/index.js';
53
55
  // Realtime, LISTEN/NOTIFY pub/sub
@@ -111,6 +111,7 @@
111
111
  */
112
112
  import { TurbineError, TurbineErrorCode, UnsupportedFeatureError, ValidationError, wrapPgError } from './errors.js';
113
113
  import { createManyShapeRuns } from './nested-write.js';
114
+ import { fingerprintPrismaSchema } from './prisma-schema-fingerprint.js';
114
115
  import { AGGREGATE_OPTIONS, applyNativeOptions, COUNT_OPTIONS, CREATE_MANY_OPTIONS, CREATE_OPTIONS, DELETE_MANY_OPTIONS, DELETE_OPTIONS, FIND_MANY_OPTIONS, FIND_UNIQUE_OPTIONS, GROUP_BY_OPTIONS, optionKeysOfKind, UPDATE_MANY_OPTIONS, UPDATE_OPTIONS, UPSERT_OPTIONS, } from './query/index.js';
115
116
  import { suggestKey } from './query/utils.js';
116
117
  import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
@@ -438,6 +439,55 @@ function warnUnknownQueryOptions(model, op, args) {
438
439
  // must never be the reason a query fails.
439
440
  }
440
441
  }
442
+ /**
443
+ * Dev-mode notice that the `prisma-map.ts` driving this client no longer matches
444
+ * the Prisma schema it was generated from.
445
+ *
446
+ * Nothing re-runs `turbine migrate-from-prisma`. When the schema moves on, the
447
+ * map does not, and the adapter keeps translating the names it has: a model
448
+ * added last week is simply not on the client, a renamed field is quietly
449
+ * absent from results. Both read as adapter bugs. This turns them into one line
450
+ * naming the file and the command that fixes it.
451
+ *
452
+ * Everything about the check is subordinate to "never break an app that works":
453
+ *
454
+ * - **Async and unawaited.** Client construction does not wait on a file read;
455
+ * the warning lands a tick later or not at all.
456
+ * - **Any failure is silence.** A missing file is the NORMAL deployed state
457
+ * (nobody ships `prisma/` to production) and so are a bundled runtime with no
458
+ * `node:fs` and a read that is refused. None of them is evidence of drift.
459
+ * - **Production is skipped entirely**, before anything is imported or read.
460
+ * - **Once per process per path**, via the shared registry, so a process that
461
+ * builds a client per request says it once.
462
+ *
463
+ * @param map - the map under test; a map with no `source` (hand-written, or
464
+ * emitted before 0.60) is skipped, since absence carries no information.
465
+ */
466
+ function warnStalePrismaMap(map) {
467
+ const source = map.source;
468
+ if (!source || typeof source.path !== 'string' || typeof source.hash !== 'string')
469
+ return;
470
+ if (typeof process === 'undefined' || process.env?.NODE_ENV === 'production')
471
+ return;
472
+ void (async () => {
473
+ try {
474
+ // Dynamic so a bundle that never reaches this line never needs `node:fs`.
475
+ const { readFile } = await import('node:fs/promises');
476
+ const onDisk = await readFile(source.path, 'utf8');
477
+ if (fingerprintPrismaSchema(onDisk) === source.hash)
478
+ return;
479
+ if (!shouldWarnOnce(WARN_NS.stalePrismaMap, source.path))
480
+ return;
481
+ console.warn(`[turbine] prisma-compat: ${source.path} has changed since prisma-map.ts was generated,` +
482
+ ' so any model, field, relation or compound-unique added or renamed since then is missing' +
483
+ ' from the compat client. Re-run: turbine migrate-from-prisma');
484
+ }
485
+ catch {
486
+ // No file, no `node:fs`, no permission. None of these means the map is
487
+ // stale, and a diagnostic must never be the reason a client fails.
488
+ }
489
+ })();
490
+ }
441
491
  /**
442
492
  * Prisma 6.7+ accepts `limit` on `updateMany` / `deleteMany` to BOUND how many
443
493
  * rows a mass mutation touches. Turbine has no row-bounded mass mutation, so
@@ -1840,6 +1890,9 @@ export function createPrismaCompatClient(client, map, options = {}) {
1840
1890
  // the real client satisfies it structurally (the cast just relaxes the strict
1841
1891
  // callback-param variance on `$transaction`).
1842
1892
  const db = client;
1893
+ // Fire-and-forget, dev-only, once per process: is this map still describing
1894
+ // the schema on disk? Nothing else in the system ever asks.
1895
+ warnStalePrismaMap(map);
1843
1896
  const tableToModel = new Map();
1844
1897
  for (const [prismaModel, mm] of Object.entries(map.models))
1845
1898
  tableToModel.set(mm.table, prismaModel);
@@ -0,0 +1,61 @@
1
+ /**
2
+ * turbine-orm, fingerprint of the Prisma schema a `PRISMA_MAP` was generated
3
+ * from.
4
+ *
5
+ * ## The failure this exists for
6
+ *
7
+ * `turbine migrate-from-prisma` reads `prisma/schema.prisma` and emits a
8
+ * `prisma-map.ts` that the `turbine-orm/prisma-compat` adapter is driven by.
9
+ * Nothing re-runs it. Add a model, rename a field, change a `@@unique` name, and
10
+ * the map keeps describing the schema as it was: the adapter goes on translating
11
+ * the names it knows and simply has no entry for the new ones. That surfaces as
12
+ * "this model is not on the compat client" or a field silently absent from a
13
+ * result, which reads like an adapter bug rather than a stale artifact, and
14
+ * which is exactly the failure mode a code generator ought to be able to
15
+ * announce about itself.
16
+ *
17
+ * So the generator records what it read, and the adapter checks that record
18
+ * against what is on disk. This is the same shape as the unknown-config-key
19
+ * check in `client.ts` and the parser-overwrite warning in `query/utils.ts`,
20
+ * with a higher severity than either: those two produce visible breakage on
21
+ * their own, and this one produces none at all.
22
+ *
23
+ * ## Why FNV-1a and not a cryptographic digest
24
+ *
25
+ * The question asked is "is this the same file", not "did an adversary craft a
26
+ * collision". A collision costs one missed warning, so a 64-bit non-crypto hash
27
+ * is the right instrument, and it keeps this module pure: no `node:crypto`, no
28
+ * new dependency, and it is importable from the runtime adapter, from the CLI,
29
+ * and from an edge bundle alike.
30
+ *
31
+ * ## Normalization
32
+ *
33
+ * Deliberately minimal, and only for differences that are not edits:
34
+ *
35
+ * - a leading UTF-8 BOM, which some editors add and remove without being asked;
36
+ * - CRLF and lone CR line endings, so a Windows checkout of an unchanged file
37
+ * is not reported as drift (`core.autocrlf` rewrites on checkout);
38
+ * - whitespace at the very END of the file, for the same reason.
39
+ *
40
+ * Note the last one is end-of-FILE only, not per line. Trailing whitespace on an
41
+ * individual line is hashed, and changing it is reported as drift. That is the
42
+ * intended reading: nothing in a checkout rewrites it, so it got there because
43
+ * someone edited the line, and the cost of an unnecessary regeneration is lower
44
+ * than the cost of a missed one.
45
+ *
46
+ * Nothing else is normalized. Comments and blank lines are hashed as-is: a
47
+ * "harmless" edit is still an edit, and reporting one costs a regeneration
48
+ * nobody regrets, while normalizing one away risks silence on a real change
49
+ * whose only visible trace happened to be a comment.
50
+ */
51
+ /**
52
+ * Fingerprint the TEXT of a Prisma schema file. Stable across line-ending and
53
+ * BOM differences that a checkout can introduce (see the module doc), sensitive
54
+ * to every other byte.
55
+ *
56
+ * The returned string is opaque and versioned by its `v1:` prefix: if the
57
+ * normalization ever has to change, old maps carry the old prefix and can be
58
+ * recognized rather than reported as drift against a rule they were not
59
+ * generated under.
60
+ */
61
+ export declare function fingerprintPrismaSchema(source: string): string;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * turbine-orm, fingerprint of the Prisma schema a `PRISMA_MAP` was generated
3
+ * from.
4
+ *
5
+ * ## The failure this exists for
6
+ *
7
+ * `turbine migrate-from-prisma` reads `prisma/schema.prisma` and emits a
8
+ * `prisma-map.ts` that the `turbine-orm/prisma-compat` adapter is driven by.
9
+ * Nothing re-runs it. Add a model, rename a field, change a `@@unique` name, and
10
+ * the map keeps describing the schema as it was: the adapter goes on translating
11
+ * the names it knows and simply has no entry for the new ones. That surfaces as
12
+ * "this model is not on the compat client" or a field silently absent from a
13
+ * result, which reads like an adapter bug rather than a stale artifact, and
14
+ * which is exactly the failure mode a code generator ought to be able to
15
+ * announce about itself.
16
+ *
17
+ * So the generator records what it read, and the adapter checks that record
18
+ * against what is on disk. This is the same shape as the unknown-config-key
19
+ * check in `client.ts` and the parser-overwrite warning in `query/utils.ts`,
20
+ * with a higher severity than either: those two produce visible breakage on
21
+ * their own, and this one produces none at all.
22
+ *
23
+ * ## Why FNV-1a and not a cryptographic digest
24
+ *
25
+ * The question asked is "is this the same file", not "did an adversary craft a
26
+ * collision". A collision costs one missed warning, so a 64-bit non-crypto hash
27
+ * is the right instrument, and it keeps this module pure: no `node:crypto`, no
28
+ * new dependency, and it is importable from the runtime adapter, from the CLI,
29
+ * and from an edge bundle alike.
30
+ *
31
+ * ## Normalization
32
+ *
33
+ * Deliberately minimal, and only for differences that are not edits:
34
+ *
35
+ * - a leading UTF-8 BOM, which some editors add and remove without being asked;
36
+ * - CRLF and lone CR line endings, so a Windows checkout of an unchanged file
37
+ * is not reported as drift (`core.autocrlf` rewrites on checkout);
38
+ * - whitespace at the very END of the file, for the same reason.
39
+ *
40
+ * Note the last one is end-of-FILE only, not per line. Trailing whitespace on an
41
+ * individual line is hashed, and changing it is reported as drift. That is the
42
+ * intended reading: nothing in a checkout rewrites it, so it got there because
43
+ * someone edited the line, and the cost of an unnecessary regeneration is lower
44
+ * than the cost of a missed one.
45
+ *
46
+ * Nothing else is normalized. Comments and blank lines are hashed as-is: a
47
+ * "harmless" edit is still an edit, and reporting one costs a regeneration
48
+ * nobody regrets, while normalizing one away risks silence on a real change
49
+ * whose only visible trace happened to be a comment.
50
+ */
51
+ import { fnv1a64Hex } from './query/utils.js';
52
+ /**
53
+ * Fingerprint the TEXT of a Prisma schema file. Stable across line-ending and
54
+ * BOM differences that a checkout can introduce (see the module doc), sensitive
55
+ * to every other byte.
56
+ *
57
+ * The returned string is opaque and versioned by its `v1:` prefix: if the
58
+ * normalization ever has to change, old maps carry the old prefix and can be
59
+ * recognized rather than reported as drift against a rule they were not
60
+ * generated under.
61
+ */
62
+ export function fingerprintPrismaSchema(source) {
63
+ const normalized = source
64
+ // Written as an escape, not the literal character: a bare BOM in source is
65
+ // invisible in a diff and some tools strip it from the file it lives in.
66
+ .replace(/^\uFEFF/, '')
67
+ .replace(/\r\n?/g, '\n')
68
+ .replace(/\s+$/, '');
69
+ return `v1:${fnv1a64Hex(normalized)}`;
70
+ }
@@ -104,6 +104,13 @@ export declare const WARN_NS: {
104
104
  * a million executions of one call site is one.
105
105
  */
106
106
  readonly unknownQueryOption: "unknownQueryOption";
107
+ /**
108
+ * The `prisma-map.ts` driving a `turbine-orm/prisma-compat` client no longer
109
+ * matches the Prisma schema it was generated from (prisma-compat.ts
110
+ * `warnStalePrismaMap`). Keyed on the schema path, so a process that builds a
111
+ * compat client per request reports it once.
112
+ */
113
+ readonly stalePrismaMap: "stalePrismaMap";
107
114
  /**
108
115
  * `planCacheMode` was set on a client given an EXTERNAL pool, where Turbine
109
116
  * runs no connection setup, so the option is a no-op (client.ts constructor).
@@ -139,6 +139,13 @@ export const WARN_NS = {
139
139
  * a million executions of one call site is one.
140
140
  */
141
141
  unknownQueryOption: 'unknownQueryOption',
142
+ /**
143
+ * The `prisma-map.ts` driving a `turbine-orm/prisma-compat` client no longer
144
+ * matches the Prisma schema it was generated from (prisma-compat.ts
145
+ * `warnStalePrismaMap`). Keyed on the schema path, so a process that builds a
146
+ * compat client per request reports it once.
147
+ */
148
+ stalePrismaMap: 'stalePrismaMap',
142
149
  /**
143
150
  * `planCacheMode` was set on a client given an EXTERNAL pool, where Turbine
144
151
  * runs no connection setup, so the option is a no-op (client.ts constructor).
@@ -999,7 +999,7 @@ export function enumTypeForColumn(qi, column) {
999
999
  const enums = qi.schema.enums;
1000
1000
  if (!enums)
1001
1001
  return null;
1002
- // Cross-schema guard (N-5): introspection records pgTypeSchema ONLY when
1002
+ // Cross-schema guard: introspection records pgTypeSchema ONLY when
1003
1003
  // the column's type lives OUTSIDE the introspected schema. A same-named
1004
1004
  // enum in another schema must not get this schema's cast, search_path
1005
1005
  // would resolve `::"status"` to the wrong type. Skipping the cast restores
@@ -249,7 +249,7 @@ export function schemaDefToMetadata(def) {
249
249
  // local reimplementation had NO collision guard, `posts.user` (text) +
250
250
  // `userId references users.id` produced a relation `user` that shadowed the
251
251
  // scalar, and two FKs deriving the same name silently clobbered each other
252
- // (N-4).
252
+ //.
253
253
  //
254
254
  // Constraint names are synthesized in pg's default `<table>_<column>_fkey`
255
255
  // form; they only feed the referential-action lookup and composite-FK
package/dist/schema.d.ts CHANGED
@@ -325,6 +325,30 @@ export interface PrismaCompatMap {
325
325
  models: Record<string, PrismaModelMap>;
326
326
  /** Prisma enum name → resolved database enum-type name. */
327
327
  enums: Record<string, string>;
328
+ /**
329
+ * The Prisma schema file this map was generated from. Present on maps emitted
330
+ * by `turbine migrate-from-prisma` since 0.60; absent on older ones, and on a
331
+ * map assembled by hand.
332
+ *
333
+ * `turbine-orm/prisma-compat` uses it for a once-per-process, dev-only staleness
334
+ * check: nothing re-runs the generator, so a map can silently fall behind the
335
+ * schema it describes and the adapter has no way to know. See
336
+ * {@link fingerprintPrismaSchema}.
337
+ */
338
+ source?: PrismaSchemaSource;
339
+ }
340
+ /** Provenance of a generated {@link PrismaCompatMap}. */
341
+ export interface PrismaSchemaSource {
342
+ /**
343
+ * The Prisma schema file, POSIX-separated and relative to the directory the
344
+ * generator ran in (which for the documented workflow is the project root, so
345
+ * it resolves against `process.cwd()` at runtime). Relative rather than
346
+ * absolute so the value is identical for every developer and in CI, and so a
347
+ * generated file never carries someone's home directory into version control.
348
+ */
349
+ path: string;
350
+ /** {@link fingerprintPrismaSchema} of that file's contents at generation time. */
351
+ hash: string;
328
352
  }
329
353
  /** One Prisma model's resolved mapping onto a Turbine table + client accessor. */
330
354
  export interface PrismaModelMap {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.59.2",
3
+ "version": "0.60.1",
4
4
  "description": "Postgres-native TypeScript ORM, runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "//exports": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",