@suss/contract-prisma 0.34.1 → 0.35.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
@@ -17,15 +17,37 @@ One `library`-kind summary per `model` and per `view`, with a storage boundary b
17
17
  The behavior is in `metadata.storageContract`:
18
18
 
19
19
  - `fieldSet: "exhaustive"`. A Prisma model declares every column its table has, so a column the code touches that is missing from this list is unknown, and not merely unlisted.
20
- - `fields`, one entry per column, with the Prisma type, whether the field is optional (`nullable`), and `primary` / `unique` from the `@id` and `@unique` field attributes. Scalar fields (`Int`, `BigInt`, `Float`, `Decimal`, `String`, `Boolean`, `DateTime`, `Json`, `Bytes`) and enum fields are columns.
20
+ - `fields`, one entry per column. Scalar fields (`Int`, `BigInt`, `Float`, `Decimal`, `String`, `Boolean`, `DateTime`, `Json`, `Bytes`), enum fields, and lists of either are columns. Each column has:
21
+ - `name`, the field name the client uses, and `column`, the name `@map("...")` gives the column in the database, when that is different.
22
+ - `type`, the Prisma type, or the native type when the field has one, such as `@db.VarChar` for `String @db.VarChar(255)`. The length is left out. Every enum field has the type `enum`, and a list adds `[]`, as in `String[]`.
23
+ - `nullable`, from whether the field is optional. A list works differently, as described below.
24
+ - `primary` from `@id`, or from being listed in the model's `@@id([...])`, and `unique` from `@unique`.
25
+ - `default`, whether the database fills the column when a create leaves it out. See below.
26
+ - `foreignKey`, the table of the model a relation reaches, on each column its `@relation(fields: [...])` lists. The table is the target's `@@map` name, or the model name when it has none.
21
27
  - Relation fields appear too, marked `derived`, because the Prisma client accepts them in an `include` or a `select` even though no column of that name exists. Leaving them out of a contract marked exhaustive would report working code as reading an undeclared field. When the model stores the foreign key, the columns listed in `@relation(fields: [...])` are included as `relationKey`. Prisma only allows that argument on one side of a relation, so the other side, and every implicit many-to-many, has no key.
22
28
  - A many-to-many relation with no explicit join model gets a `joinContainer` entry instead, which gives the table Prisma manages for it. See below.
23
29
  - A model with any relation also gets a derived `_count` entry, since the client exposes relation counts under that name.
24
30
  - `indexes`, from `@@index([...])` (not unique), `@@unique([...])` and `@@id([...])` (both unique).
25
31
  - `physicalTable`, when `@@map("...")` gives the table a SQL name different from the model name. That lets it pair with code that uses SQL names directly, such as Drizzle's `pgTable("users")` or a raw query.
32
+ - `origin: "migrations"`, when a folder next to the schema has Prisma Migrate's `migration_lock.toml` in it. For a schema split over a folder, suss looks in the folder above it too. It means Prisma's own migrations made the tables. When another declaration of the same table disagrees with this one, the comparison takes this schema as the provider, unless the other one is a dump of the database.
26
33
 
27
34
  These summaries have no inputs and no transitions. A table is a set of columns the code has to agree with, and it does not return anything the way a function does. Confidence is `declared` at `high`, since the schema states all of this explicitly.
28
35
 
36
+ ### Defaults, and who fills them
37
+
38
+ `@default(...)` means different things depending on what it calls. Prisma's migrations turn some of them into a database default, and the client fills some of them in itself before it sends the insert:
39
+
40
+ | `@default` | In the migration SQL | Sent by the client on create | `default` |
41
+ |---|---|---|---|
42
+ | `autoincrement()`, `dbgenerated(...)` | `SERIAL`, `DEFAULT <expression>` | no | `true` |
43
+ | `uuid()`, `cuid()`, `nanoid()`, `ulid()` | nothing | yes | `false` |
44
+ | `now()`, a literal such as `false` or `"pending"` | `DEFAULT CURRENT_TIMESTAMP`, `DEFAULT false` | yes | `true` with migrations, otherwise left out |
45
+ | none, or only `@updatedAt` | nothing | `@updatedAt` only | `false` |
46
+
47
+ A literal or `now()` is a database default only when Prisma's migrations made the table. In a schema written to match a database something else manages, the client sends the value on every create, so the schema says nothing about whether the database has a default there. The reader leaves `default` out on those columns, and a comparison skips it.
48
+
49
+ A scalar list has the same problem with `nullable`. Prisma's migrations make a list column nullable, and the client reads a NULL there as an empty list. So a list column has `nullable: true` when Prisma's migrations made the table, and no `nullable` otherwise.
50
+
29
51
  ### Implicit many-to-many join tables
30
52
 
31
53
  When two list fields point at each other and neither side declares `@relation(fields: [...])`, that is Prisma's implicit many-to-many. Prisma creates and manages the join table itself, and the schema never declares a model for it. This reader produces a summary for that table too, with the boundary Prisma would create. The container is `_Name` when the fields have `@relation("Name")`, and otherwise `_FirstToSecond`, with the two model names in alphabetical order. The table has two columns, `A` and `B`, keyed to whichever model sorts first and second. Each side of the relation gets `joinContainer` set to that table's container name, so a `connect`, `disconnect` or `set` through the field counts as a write there and is not lost.
@@ -36,8 +58,9 @@ An explicit join model, the kind written out by hand to add extra columns, alrea
36
58
 
37
59
  - **Non-relational providers.** A MongoDB schema comes back empty. Document storage is separate work.
38
60
  - **Composite types and unsupported field types.** A field whose type is not a scalar, an enum or another model is skipped, so the reader never guesses. That covers `Unsupported(...)`, a Mongo composite type, and a typo.
39
- - **Array scalar fields.** `String[]` is dropped along with relation lists, because the walk treats every array field as a relation array.
40
- - **Native type attributes.** `@db.VarChar(255)` and similar are not recorded. The field's Prisma type is what comes through.
61
+ - **Native type arguments and enum values.** `@db.VarChar(255)` comes through as `@db.VarChar`, and an enum field as `enum`, without its values.
62
+ - **Postgres schemas other than the default.** `@@schema("...")` is not read, so a model in another schema has its bare table name.
63
+ - **`migrations.path` in `prisma.config.ts`.** Only a migrations folder next to the schema sets `origin`.
41
64
  - **`generator` blocks, the datasource URL, and relation cardinality** as behavior of their own.
42
65
 
43
66
  ## Worked example
@@ -64,7 +87,9 @@ suss contract --from prisma prisma/schema.prisma -o summaries/db.json
64
87
  suss check summaries/db.json summaries/app.json
65
88
  ```
66
89
 
67
- The `User` summary declares `id` (primary), `email` (unique), a nullable `name`, the derived `posts` relation, a derived `_count`, an index on `email`, and `physicalTable: "users"`.
90
+ The `User` summary declares `id` (primary, with a database default), `email` (unique), a nullable `name`, the derived `posts` relation, a derived `_count`, an index on `email`, and `physicalTable: "users"`.
91
+
92
+ A schema split over several files is read as one, the way Prisma reads it. Pass the folder, and every `.prisma` file below it is read. Pass one file, and when it is the only file in its folder with a datasource, the other `.prisma` files in that folder are read with it, since a file with no datasource cannot be a schema on its own. Each model's summary records the file the model is in, and every model gets the storage system of the one datasource.
68
93
 
69
94
  Or from code:
70
95
 
package/dist/index.cjs CHANGED
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  prismaSchemaFileToSummaries: () => prismaSchemaFileToSummaries,
34
+ prismaSchemaFiles: () => prismaSchemaFiles,
34
35
  prismaSchemaToSummaries: () => prismaSchemaToSummaries,
35
36
  prismaStorageSystem: () => prismaStorageSystem
36
37
  });
@@ -39,6 +40,7 @@ var import_node_fs = __toESM(require("fs"), 1);
39
40
  var import_node_path = __toESM(require("path"), 1);
40
41
  var import_prisma_ast = require("@mrleebo/prisma-ast");
41
42
  var import_behavioral_ir = require("@suss/behavioral-ir");
43
+ var import_files = require("@suss/behavioral-ir/files");
42
44
  var PRISMA_SCALARS = /* @__PURE__ */ new Set([
43
45
  "Int",
44
46
  "BigInt",
@@ -57,8 +59,20 @@ var PROVIDER_TO_SYSTEM = {
57
59
  sqlite: "sqlite"
58
60
  };
59
61
  function prismaSchemaToSummaries(source, options = {}) {
60
- const ast = (0, import_prisma_ast.getSchema)(source);
61
- const list = ast.list;
62
+ return summariesOfSchema(
63
+ [{ file: options.source ?? "schema.prisma", text: source }],
64
+ options.scope ?? "default",
65
+ options.origin
66
+ );
67
+ }
68
+ function summariesOfSchema(sources, scope, origin) {
69
+ const nodes = sources.flatMap(
70
+ ({ file, text }) => (0, import_prisma_ast.getSchema)(text).list.map((node) => ({
71
+ node,
72
+ file
73
+ }))
74
+ );
75
+ const list = nodes.map(({ node }) => node);
62
76
  const modelNames = /* @__PURE__ */ new Set();
63
77
  const enumNames = /* @__PURE__ */ new Set();
64
78
  const storageSystem = storageSystemIn(list);
@@ -77,13 +91,17 @@ function prismaSchemaToSummaries(source, options = {}) {
77
91
  if (storageSystem === null) {
78
92
  return [];
79
93
  }
80
- const sourceFile = options.source ?? "schema.prisma";
81
- const scope = options.scope ?? "default";
82
94
  const summaries = [];
83
95
  const models = list.filter(
84
96
  (node) => node.type === "model"
85
97
  );
98
+ const fileOfModel = new Map(
99
+ nodes.map(({ node, file }) => [node.name, file])
100
+ );
86
101
  const relations = implicitManyToManyRelations(models, modelNames);
102
+ const tableOfModel = new Map(
103
+ models.map((model) => [model.name, physicalTableOf(model) ?? model.name])
104
+ );
87
105
  const joinContainerByField = /* @__PURE__ */ new Map();
88
106
  for (const relation of relations) {
89
107
  joinContainerByField.set(
@@ -95,7 +113,7 @@ function prismaSchemaToSummaries(source, options = {}) {
95
113
  relation.joinTable
96
114
  );
97
115
  }
98
- for (const node of list) {
116
+ for (const { node, file } of nodes) {
99
117
  const n = node;
100
118
  if (n.type !== "model" && n.type !== "view") {
101
119
  continue;
@@ -106,16 +124,25 @@ function prismaSchemaToSummaries(source, options = {}) {
106
124
  model,
107
125
  modelNames,
108
126
  enumNames,
127
+ tableOfModel,
109
128
  storageSystem,
110
129
  scope,
111
- sourceFile,
130
+ origin,
131
+ sourceFile: file,
112
132
  joinContainerByField
113
133
  })
114
134
  );
115
135
  }
116
136
  for (const relation of relations) {
117
137
  summaries.push(
118
- buildJoinTableSummary({ relation, storageSystem, scope, sourceFile })
138
+ buildJoinTableSummary({
139
+ relation,
140
+ tableOfModel,
141
+ storageSystem,
142
+ scope,
143
+ origin,
144
+ sourceFile: fileOfModel.get(relation.leftModel) ?? "schema.prisma"
145
+ })
119
146
  );
120
147
  }
121
148
  return summaries;
@@ -198,6 +225,14 @@ function buildJoinTableSummary(opts) {
198
225
  opts.relation.leftModel,
199
226
  opts.relation.rightModel
200
227
  ].sort();
228
+ const keyColumn = (name, model) => ({
229
+ name,
230
+ type: model,
231
+ nullable: false,
232
+ primary: true,
233
+ default: false,
234
+ foreignKey: opts.tableOfModel.get(model) ?? model
235
+ });
201
236
  return {
202
237
  kind: "library",
203
238
  location: {
@@ -222,14 +257,12 @@ function buildJoinTableSummary(opts) {
222
257
  metadata: {
223
258
  storageContract: {
224
259
  fieldSet: "exhaustive",
225
- fields: [
226
- { name: "A", type: modelA, nullable: false, primary: true },
227
- { name: "B", type: modelB, nullable: false, primary: true }
228
- ],
260
+ fields: [keyColumn("A", modelA), keyColumn("B", modelB)],
229
261
  indexes: [
230
262
  { fields: ["A", "B"], unique: true },
231
263
  { fields: ["B"], unique: false }
232
- ]
264
+ ],
265
+ ...originOf(opts.origin)
233
266
  }
234
267
  }
235
268
  };
@@ -238,21 +271,94 @@ function prismaSchemaFileToSummaries(schemaPath, options = {}) {
238
271
  if (!import_node_fs.default.existsSync(schemaPath)) {
239
272
  throw new Error(`Prisma schema not found: ${schemaPath}`);
240
273
  }
241
- const source = import_node_fs.default.readFileSync(schemaPath, "utf-8");
242
- return prismaSchemaToSummaries(source, {
243
- ...options,
244
- source: options.source ?? import_node_path.default.relative(process.cwd(), schemaPath)
245
- });
274
+ const root = options.projectRoot ?? (0, import_files.defaultProjectRoot)(schemaPath);
275
+ const files = prismaSchemaFiles(schemaPath);
276
+ const sources = files.map((file) => ({
277
+ file: options.source ?? (0, import_files.pathInProject)(file, root),
278
+ text: import_node_fs.default.readFileSync(file, "utf-8")
279
+ }));
280
+ return summariesOfSchema(
281
+ sources,
282
+ options.scope ?? "default",
283
+ options.origin ?? migrationsOriginOf(schemaPath, files)
284
+ );
285
+ }
286
+ function migrationsOriginOf(schemaPath, files) {
287
+ const folder = statOf(schemaPath)?.isDirectory() === true;
288
+ const own = folder ? import_node_path.default.resolve(schemaPath) : import_node_path.default.dirname(import_node_path.default.resolve(schemaPath));
289
+ const searched = folder || files.length > 1 ? [own, import_node_path.default.dirname(own)] : [own];
290
+ return searched.some(hasMigrationsFolder) ? "migrations" : void 0;
291
+ }
292
+ function hasMigrationsFolder(dir) {
293
+ return foldersIn(dir).some(
294
+ (folder) => import_node_fs.default.existsSync(import_node_path.default.join(dir, folder, "migration_lock.toml"))
295
+ );
296
+ }
297
+ function foldersIn(dir) {
298
+ try {
299
+ return import_node_fs.default.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
300
+ } catch {
301
+ return [];
302
+ }
303
+ }
304
+ function originOf(origin) {
305
+ return origin === void 0 ? {} : { origin };
306
+ }
307
+ function prismaSchemaFiles(schemaPath) {
308
+ const stat = statOf(schemaPath);
309
+ if (stat?.isDirectory() === true) {
310
+ return prismaFilesIn(schemaPath, true);
311
+ }
312
+ if (stat?.isFile() !== true) {
313
+ return [];
314
+ }
315
+ const beside = prismaFilesIn(import_node_path.default.dirname(schemaPath), false).flatMap(
316
+ (file) => {
317
+ const list = schemaListOf(file);
318
+ return list === null ? [] : [{ file, list }];
319
+ }
320
+ );
321
+ const datasources = beside.filter(
322
+ ({ list }) => list.some(isDatasource)
323
+ ).length;
324
+ const given = import_node_path.default.resolve(schemaPath);
325
+ return datasources === 1 && beside.some(({ file }) => import_node_path.default.resolve(file) === given) ? beside.map(({ file }) => file) : [schemaPath];
326
+ }
327
+ function statOf(file) {
328
+ try {
329
+ return import_node_fs.default.statSync(file);
330
+ } catch {
331
+ return null;
332
+ }
333
+ }
334
+ function prismaFilesIn(dir, below) {
335
+ return import_node_fs.default.readdirSync(dir, { recursive: below, encoding: "utf8" }).filter((name) => name.endsWith(".prisma")).map((name) => import_node_path.default.join(dir, name)).filter((file) => statOf(file)?.isFile() === true).sort();
336
+ }
337
+ function schemaListOf(file) {
338
+ try {
339
+ return (0, import_prisma_ast.getSchema)(import_node_fs.default.readFileSync(file, "utf8")).list;
340
+ } catch {
341
+ return null;
342
+ }
343
+ }
344
+ function isDatasource(node) {
345
+ return node.type === "datasource";
246
346
  }
247
347
  function buildModelSummary(opts) {
248
348
  const columns = [];
249
349
  const indexes = [];
250
350
  const physicalTable = physicalTableOf(opts.model);
351
+ const context = {
352
+ enumNames: opts.enumNames,
353
+ foreignKeys: foreignKeysOf(opts.model, opts.modelNames, opts.tableOfModel),
354
+ compositeKey: compositeKeyOf(opts.model),
355
+ migrated: opts.origin === "migrations"
356
+ };
251
357
  let hasRelation = false;
252
358
  for (const property of opts.model.properties) {
253
359
  if (property.type === "field") {
254
360
  const field = property;
255
- const column = fieldToColumn(field, opts.modelNames, opts.enumNames);
361
+ const column = fieldToColumn(field, opts.modelNames, context);
256
362
  if (column !== null) {
257
363
  columns.push(column);
258
364
  }
@@ -306,7 +412,8 @@ function buildModelSummary(opts) {
306
412
  fieldSet: "exhaustive",
307
413
  fields: columns,
308
414
  indexes,
309
- ...physicalTable !== null && physicalTable !== opts.model.name ? { physicalTable } : {}
415
+ ...physicalTable !== null && physicalTable !== opts.model.name ? { physicalTable } : {},
416
+ ...originOf(opts.origin)
310
417
  }
311
418
  }
312
419
  };
@@ -336,37 +443,110 @@ function relationNameOf(field) {
336
443
  const relation = relationAttributeOf(field);
337
444
  return relation === void 0 ? null : readStringArg(relation);
338
445
  }
339
- function fieldToColumn(field, modelNames, enumNames) {
340
- if (field.array === true) {
341
- return null;
342
- }
446
+ function fieldToColumn(field, modelNames, context) {
343
447
  const ft = field.fieldType;
344
- const isScalar = PRISMA_SCALARS.has(ft);
345
- const isEnum = enumNames.has(ft);
346
- const isRelation = modelNames.has(ft);
347
- if (isRelation) {
448
+ if (modelNames.has(ft)) {
348
449
  return null;
349
450
  }
350
- if (!isScalar && !isEnum) {
451
+ if (!PRISMA_SCALARS.has(ft) && !context.enumNames.has(ft)) {
351
452
  return null;
352
453
  }
353
- let primary = false;
354
- let unique = false;
355
- for (const attr of field.attributes ?? []) {
356
- if (attr.name === "id") {
357
- primary = true;
358
- } else if (attr.name === "unique") {
359
- unique = true;
360
- }
361
- }
454
+ const column = stringArgOf(fieldAttribute(field, "map"));
455
+ const nullable = nullableOf(field, context.migrated);
456
+ const databaseDefault = databaseDefaultOf(field, context.migrated);
457
+ const foreignKey = context.foreignKeys.get(field.name);
458
+ const primary = fieldAttribute(field, "id") !== void 0 || context.compositeKey.has(field.name);
362
459
  return {
363
460
  name: field.name,
364
- type: ft,
365
- nullable: field.optional === true,
461
+ ...column !== null && column !== field.name ? { column } : {},
462
+ type: columnTypeOf(field, context.enumNames),
463
+ ...nullable === void 0 ? {} : { nullable },
366
464
  ...primary ? { primary: true } : {},
367
- ...unique ? { unique: true } : {}
465
+ ...fieldAttribute(field, "unique") !== void 0 ? { unique: true } : {},
466
+ ...databaseDefault === void 0 ? {} : { default: databaseDefault },
467
+ ...foreignKey === void 0 ? {} : { foreignKey }
368
468
  };
369
469
  }
470
+ function fieldAttribute(field, name) {
471
+ return (field.attributes ?? []).find(
472
+ (attr) => attr.name === name && attr.group === void 0
473
+ );
474
+ }
475
+ function columnTypeOf(field, enumNames) {
476
+ const element = elementTypeOf(field, enumNames);
477
+ return field.array === true ? `${element}[]` : element;
478
+ }
479
+ function elementTypeOf(field, enumNames) {
480
+ const native = (field.attributes ?? []).find((attr) => attr.group === "db");
481
+ if (native !== void 0) {
482
+ return `@db.${native.name}`;
483
+ }
484
+ return enumNames.has(field.fieldType) ? "enum" : field.fieldType;
485
+ }
486
+ function nullableOf(field, migrated) {
487
+ if (field.array === true) {
488
+ return migrated ? true : void 0;
489
+ }
490
+ return field.optional === true;
491
+ }
492
+ var DEFAULT_FUNCTION_FILLERS = {
493
+ autoincrement: "database",
494
+ dbgenerated: "database",
495
+ now: "both",
496
+ uuid: "client",
497
+ cuid: "client",
498
+ nanoid: "client",
499
+ ulid: "client"
500
+ };
501
+ var DATABASE_DEFAULT = {
502
+ nobody: () => false,
503
+ database: () => true,
504
+ client: () => false,
505
+ both: (migrated) => migrated ? true : void 0,
506
+ unknown: () => void 0
507
+ };
508
+ function databaseDefaultOf(field, migrated) {
509
+ return DATABASE_DEFAULT[defaultFillerOf(field)](migrated);
510
+ }
511
+ function defaultFillerOf(field) {
512
+ const value = (fieldAttribute(field, "default")?.args ?? []).find(
513
+ (arg) => arg.value?.type !== "keyValue"
514
+ )?.value;
515
+ if (value === void 0) {
516
+ return "nobody";
517
+ }
518
+ const call = value;
519
+ if (call?.type !== "function") {
520
+ return "both";
521
+ }
522
+ return DEFAULT_FUNCTION_FILLERS[call.name ?? ""] ?? "unknown";
523
+ }
524
+ function foreignKeysOf(model, modelNames, tableOfModel) {
525
+ const targets = /* @__PURE__ */ new Map();
526
+ for (const property of model.properties) {
527
+ if (property.type !== "field") {
528
+ continue;
529
+ }
530
+ const field = property;
531
+ if (!modelNames.has(field.fieldType)) {
532
+ continue;
533
+ }
534
+ const table = tableOfModel.get(field.fieldType) ?? field.fieldType;
535
+ for (const column of relationKeyOf(field) ?? []) {
536
+ targets.set(column, table);
537
+ }
538
+ }
539
+ return targets;
540
+ }
541
+ function compositeKeyOf(model) {
542
+ for (const property of model.properties) {
543
+ const attr = property;
544
+ if (attr.type === "attribute" && attr.name === "id") {
545
+ return new Set(blockFieldsOf(attr) ?? []);
546
+ }
547
+ }
548
+ return /* @__PURE__ */ new Set();
549
+ }
370
550
  function physicalTableOf(model) {
371
551
  for (const property of model.properties) {
372
552
  if (property.type !== "attribute") {
@@ -376,13 +556,29 @@ function physicalTableOf(model) {
376
556
  if (attr.name !== "map") {
377
557
  continue;
378
558
  }
379
- const name = readStringArg(attr);
559
+ const name = stringArgOf(attr);
380
560
  if (name !== null) {
381
561
  return name;
382
562
  }
383
563
  }
384
564
  return null;
385
565
  }
566
+ function stringArgOf(attr) {
567
+ if (attr === void 0) {
568
+ return null;
569
+ }
570
+ const positional = readStringArg(attr);
571
+ if (positional !== null) {
572
+ return positional;
573
+ }
574
+ for (const arg of attr.args ?? []) {
575
+ const value = arg.value;
576
+ if (value?.type === "keyValue" && value.key === "name" && typeof value.value === "string") {
577
+ return value.value.replace(/^"|"$/g, "");
578
+ }
579
+ }
580
+ return null;
581
+ }
386
582
  function readStringArg(attr) {
387
583
  for (const arg of attr.args ?? []) {
388
584
  const value = arg.value;
@@ -396,12 +592,15 @@ function blockAttributeToIndex(attr) {
396
592
  if (attr.name !== "index" && attr.name !== "unique" && attr.name !== "id") {
397
593
  return null;
398
594
  }
399
- const fields = readArrayArg(attr);
595
+ const fields = blockFieldsOf(attr);
400
596
  if (fields === null) {
401
597
  return null;
402
598
  }
403
599
  return { fields, unique: attr.name !== "index" };
404
600
  }
601
+ function blockFieldsOf(attr) {
602
+ return readArrayArg(attr) ?? readKeyedArrayArg(attr, "fields");
603
+ }
405
604
  function readArrayArg(attr) {
406
605
  for (const arg of attr.args ?? []) {
407
606
  const names = arrayNames(arg.value);
@@ -469,6 +668,7 @@ function readProviderString(ds) {
469
668
  // Annotate the CommonJS export names for ESM import in node:
470
669
  0 && (module.exports = {
471
670
  prismaSchemaFileToSummaries,
671
+ prismaSchemaFiles,
472
672
  prismaSchemaToSummaries,
473
673
  prismaStorageSystem
474
674
  });
package/dist/index.d.cts CHANGED
@@ -18,6 +18,12 @@ interface PrismaSchemaToSummariesOptions {
18
18
  * each its own scope so their pairings stay separate.
19
19
  */
20
20
  scope?: string;
21
+ /** `"migrations"` when Prisma's own migrations made the database. */
22
+ origin?: "migrations";
23
+ }
24
+ interface PrismaSchemaFileToSummariesOptions extends PrismaSchemaToSummariesOptions {
25
+ /** Absolute. The directory each schema file's path is measured from. */
26
+ projectRoot?: string;
21
27
  }
22
28
  /**
23
29
  * Converts Prisma schema text into summaries. Returns an empty array when
@@ -25,10 +31,20 @@ interface PrismaSchemaToSummariesOptions {
25
31
  */
26
32
  declare function prismaSchemaToSummaries(source: string, options?: PrismaSchemaToSummariesOptions): BehavioralSummary[];
27
33
  /**
28
- * Reads a Prisma schema file and converts it. Throws when the file does
29
- * not exist.
34
+ * Reads a Prisma schema from disk and converts it: a file, a schema
35
+ * folder, or one file of a multi-file schema, which brings the rest of
36
+ * its files with it. Throws when the path does not exist. Unless the
37
+ * options say otherwise, the origin is `"migrations"` when a Prisma
38
+ * migrations folder is next to the schema.
39
+ */
40
+ declare function prismaSchemaFileToSummaries(schemaPath: string, options?: PrismaSchemaFileToSummariesOptions): BehavioralSummary[];
41
+ /**
42
+ * The files Prisma reads as one schema, given a schema file or folder.
43
+ * Prisma reads every `.prisma` file below a folder. A file with no
44
+ * datasource cannot be a schema on its own, so when only one file in a
45
+ * folder has the datasource, the folder's files are one schema.
30
46
  */
31
- declare function prismaSchemaFileToSummaries(schemaPath: string, options?: PrismaSchemaToSummariesOptions): BehavioralSummary[];
47
+ declare function prismaSchemaFiles(schemaPath: string): string[];
32
48
  type PrismaStorageSystem = "postgresql" | "mysql" | "sqlite";
33
49
  /**
34
50
  * The storage system the datasource provider says, or null for MongoDB or
@@ -36,4 +52,4 @@ type PrismaStorageSystem = "postgresql" | "mysql" | "sqlite";
36
52
  */
37
53
  declare function prismaStorageSystem(source: string): PrismaStorageSystem | null;
38
54
 
39
- export { type PrismaSchemaToSummariesOptions, type PrismaStorageSystem, prismaSchemaFileToSummaries, prismaSchemaToSummaries, prismaStorageSystem };
55
+ export { type PrismaSchemaFileToSummariesOptions, type PrismaSchemaToSummariesOptions, type PrismaStorageSystem, prismaSchemaFileToSummaries, prismaSchemaFiles, prismaSchemaToSummaries, prismaStorageSystem };
package/dist/index.d.ts CHANGED
@@ -18,6 +18,12 @@ interface PrismaSchemaToSummariesOptions {
18
18
  * each its own scope so their pairings stay separate.
19
19
  */
20
20
  scope?: string;
21
+ /** `"migrations"` when Prisma's own migrations made the database. */
22
+ origin?: "migrations";
23
+ }
24
+ interface PrismaSchemaFileToSummariesOptions extends PrismaSchemaToSummariesOptions {
25
+ /** Absolute. The directory each schema file's path is measured from. */
26
+ projectRoot?: string;
21
27
  }
22
28
  /**
23
29
  * Converts Prisma schema text into summaries. Returns an empty array when
@@ -25,10 +31,20 @@ interface PrismaSchemaToSummariesOptions {
25
31
  */
26
32
  declare function prismaSchemaToSummaries(source: string, options?: PrismaSchemaToSummariesOptions): BehavioralSummary[];
27
33
  /**
28
- * Reads a Prisma schema file and converts it. Throws when the file does
29
- * not exist.
34
+ * Reads a Prisma schema from disk and converts it: a file, a schema
35
+ * folder, or one file of a multi-file schema, which brings the rest of
36
+ * its files with it. Throws when the path does not exist. Unless the
37
+ * options say otherwise, the origin is `"migrations"` when a Prisma
38
+ * migrations folder is next to the schema.
39
+ */
40
+ declare function prismaSchemaFileToSummaries(schemaPath: string, options?: PrismaSchemaFileToSummariesOptions): BehavioralSummary[];
41
+ /**
42
+ * The files Prisma reads as one schema, given a schema file or folder.
43
+ * Prisma reads every `.prisma` file below a folder. A file with no
44
+ * datasource cannot be a schema on its own, so when only one file in a
45
+ * folder has the datasource, the folder's files are one schema.
30
46
  */
31
- declare function prismaSchemaFileToSummaries(schemaPath: string, options?: PrismaSchemaToSummariesOptions): BehavioralSummary[];
47
+ declare function prismaSchemaFiles(schemaPath: string): string[];
32
48
  type PrismaStorageSystem = "postgresql" | "mysql" | "sqlite";
33
49
  /**
34
50
  * The storage system the datasource provider says, or null for MongoDB or
@@ -36,4 +52,4 @@ type PrismaStorageSystem = "postgresql" | "mysql" | "sqlite";
36
52
  */
37
53
  declare function prismaStorageSystem(source: string): PrismaStorageSystem | null;
38
54
 
39
- export { type PrismaSchemaToSummariesOptions, type PrismaStorageSystem, prismaSchemaFileToSummaries, prismaSchemaToSummaries, prismaStorageSystem };
55
+ export { type PrismaSchemaFileToSummariesOptions, type PrismaSchemaToSummariesOptions, type PrismaStorageSystem, prismaSchemaFileToSummaries, prismaSchemaFiles, prismaSchemaToSummaries, prismaStorageSystem };
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import fs from "fs";
3
3
  import path from "path";
4
4
  import { getSchema } from "@mrleebo/prisma-ast";
5
5
  import { storageBinding } from "@suss/behavioral-ir";
6
+ import { defaultProjectRoot, pathInProject } from "@suss/behavioral-ir/files";
6
7
  var PRISMA_SCALARS = /* @__PURE__ */ new Set([
7
8
  "Int",
8
9
  "BigInt",
@@ -21,8 +22,20 @@ var PROVIDER_TO_SYSTEM = {
21
22
  sqlite: "sqlite"
22
23
  };
23
24
  function prismaSchemaToSummaries(source, options = {}) {
24
- const ast = getSchema(source);
25
- const list = ast.list;
25
+ return summariesOfSchema(
26
+ [{ file: options.source ?? "schema.prisma", text: source }],
27
+ options.scope ?? "default",
28
+ options.origin
29
+ );
30
+ }
31
+ function summariesOfSchema(sources, scope, origin) {
32
+ const nodes = sources.flatMap(
33
+ ({ file, text }) => getSchema(text).list.map((node) => ({
34
+ node,
35
+ file
36
+ }))
37
+ );
38
+ const list = nodes.map(({ node }) => node);
26
39
  const modelNames = /* @__PURE__ */ new Set();
27
40
  const enumNames = /* @__PURE__ */ new Set();
28
41
  const storageSystem = storageSystemIn(list);
@@ -41,13 +54,17 @@ function prismaSchemaToSummaries(source, options = {}) {
41
54
  if (storageSystem === null) {
42
55
  return [];
43
56
  }
44
- const sourceFile = options.source ?? "schema.prisma";
45
- const scope = options.scope ?? "default";
46
57
  const summaries = [];
47
58
  const models = list.filter(
48
59
  (node) => node.type === "model"
49
60
  );
61
+ const fileOfModel = new Map(
62
+ nodes.map(({ node, file }) => [node.name, file])
63
+ );
50
64
  const relations = implicitManyToManyRelations(models, modelNames);
65
+ const tableOfModel = new Map(
66
+ models.map((model) => [model.name, physicalTableOf(model) ?? model.name])
67
+ );
51
68
  const joinContainerByField = /* @__PURE__ */ new Map();
52
69
  for (const relation of relations) {
53
70
  joinContainerByField.set(
@@ -59,7 +76,7 @@ function prismaSchemaToSummaries(source, options = {}) {
59
76
  relation.joinTable
60
77
  );
61
78
  }
62
- for (const node of list) {
79
+ for (const { node, file } of nodes) {
63
80
  const n = node;
64
81
  if (n.type !== "model" && n.type !== "view") {
65
82
  continue;
@@ -70,16 +87,25 @@ function prismaSchemaToSummaries(source, options = {}) {
70
87
  model,
71
88
  modelNames,
72
89
  enumNames,
90
+ tableOfModel,
73
91
  storageSystem,
74
92
  scope,
75
- sourceFile,
93
+ origin,
94
+ sourceFile: file,
76
95
  joinContainerByField
77
96
  })
78
97
  );
79
98
  }
80
99
  for (const relation of relations) {
81
100
  summaries.push(
82
- buildJoinTableSummary({ relation, storageSystem, scope, sourceFile })
101
+ buildJoinTableSummary({
102
+ relation,
103
+ tableOfModel,
104
+ storageSystem,
105
+ scope,
106
+ origin,
107
+ sourceFile: fileOfModel.get(relation.leftModel) ?? "schema.prisma"
108
+ })
83
109
  );
84
110
  }
85
111
  return summaries;
@@ -162,6 +188,14 @@ function buildJoinTableSummary(opts) {
162
188
  opts.relation.leftModel,
163
189
  opts.relation.rightModel
164
190
  ].sort();
191
+ const keyColumn = (name, model) => ({
192
+ name,
193
+ type: model,
194
+ nullable: false,
195
+ primary: true,
196
+ default: false,
197
+ foreignKey: opts.tableOfModel.get(model) ?? model
198
+ });
165
199
  return {
166
200
  kind: "library",
167
201
  location: {
@@ -186,14 +220,12 @@ function buildJoinTableSummary(opts) {
186
220
  metadata: {
187
221
  storageContract: {
188
222
  fieldSet: "exhaustive",
189
- fields: [
190
- { name: "A", type: modelA, nullable: false, primary: true },
191
- { name: "B", type: modelB, nullable: false, primary: true }
192
- ],
223
+ fields: [keyColumn("A", modelA), keyColumn("B", modelB)],
193
224
  indexes: [
194
225
  { fields: ["A", "B"], unique: true },
195
226
  { fields: ["B"], unique: false }
196
- ]
227
+ ],
228
+ ...originOf(opts.origin)
197
229
  }
198
230
  }
199
231
  };
@@ -202,21 +234,94 @@ function prismaSchemaFileToSummaries(schemaPath, options = {}) {
202
234
  if (!fs.existsSync(schemaPath)) {
203
235
  throw new Error(`Prisma schema not found: ${schemaPath}`);
204
236
  }
205
- const source = fs.readFileSync(schemaPath, "utf-8");
206
- return prismaSchemaToSummaries(source, {
207
- ...options,
208
- source: options.source ?? path.relative(process.cwd(), schemaPath)
209
- });
237
+ const root = options.projectRoot ?? defaultProjectRoot(schemaPath);
238
+ const files = prismaSchemaFiles(schemaPath);
239
+ const sources = files.map((file) => ({
240
+ file: options.source ?? pathInProject(file, root),
241
+ text: fs.readFileSync(file, "utf-8")
242
+ }));
243
+ return summariesOfSchema(
244
+ sources,
245
+ options.scope ?? "default",
246
+ options.origin ?? migrationsOriginOf(schemaPath, files)
247
+ );
248
+ }
249
+ function migrationsOriginOf(schemaPath, files) {
250
+ const folder = statOf(schemaPath)?.isDirectory() === true;
251
+ const own = folder ? path.resolve(schemaPath) : path.dirname(path.resolve(schemaPath));
252
+ const searched = folder || files.length > 1 ? [own, path.dirname(own)] : [own];
253
+ return searched.some(hasMigrationsFolder) ? "migrations" : void 0;
254
+ }
255
+ function hasMigrationsFolder(dir) {
256
+ return foldersIn(dir).some(
257
+ (folder) => fs.existsSync(path.join(dir, folder, "migration_lock.toml"))
258
+ );
259
+ }
260
+ function foldersIn(dir) {
261
+ try {
262
+ return fs.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
263
+ } catch {
264
+ return [];
265
+ }
266
+ }
267
+ function originOf(origin) {
268
+ return origin === void 0 ? {} : { origin };
269
+ }
270
+ function prismaSchemaFiles(schemaPath) {
271
+ const stat = statOf(schemaPath);
272
+ if (stat?.isDirectory() === true) {
273
+ return prismaFilesIn(schemaPath, true);
274
+ }
275
+ if (stat?.isFile() !== true) {
276
+ return [];
277
+ }
278
+ const beside = prismaFilesIn(path.dirname(schemaPath), false).flatMap(
279
+ (file) => {
280
+ const list = schemaListOf(file);
281
+ return list === null ? [] : [{ file, list }];
282
+ }
283
+ );
284
+ const datasources = beside.filter(
285
+ ({ list }) => list.some(isDatasource)
286
+ ).length;
287
+ const given = path.resolve(schemaPath);
288
+ return datasources === 1 && beside.some(({ file }) => path.resolve(file) === given) ? beside.map(({ file }) => file) : [schemaPath];
289
+ }
290
+ function statOf(file) {
291
+ try {
292
+ return fs.statSync(file);
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+ function prismaFilesIn(dir, below) {
298
+ return fs.readdirSync(dir, { recursive: below, encoding: "utf8" }).filter((name) => name.endsWith(".prisma")).map((name) => path.join(dir, name)).filter((file) => statOf(file)?.isFile() === true).sort();
299
+ }
300
+ function schemaListOf(file) {
301
+ try {
302
+ return getSchema(fs.readFileSync(file, "utf8")).list;
303
+ } catch {
304
+ return null;
305
+ }
306
+ }
307
+ function isDatasource(node) {
308
+ return node.type === "datasource";
210
309
  }
211
310
  function buildModelSummary(opts) {
212
311
  const columns = [];
213
312
  const indexes = [];
214
313
  const physicalTable = physicalTableOf(opts.model);
314
+ const context = {
315
+ enumNames: opts.enumNames,
316
+ foreignKeys: foreignKeysOf(opts.model, opts.modelNames, opts.tableOfModel),
317
+ compositeKey: compositeKeyOf(opts.model),
318
+ migrated: opts.origin === "migrations"
319
+ };
215
320
  let hasRelation = false;
216
321
  for (const property of opts.model.properties) {
217
322
  if (property.type === "field") {
218
323
  const field = property;
219
- const column = fieldToColumn(field, opts.modelNames, opts.enumNames);
324
+ const column = fieldToColumn(field, opts.modelNames, context);
220
325
  if (column !== null) {
221
326
  columns.push(column);
222
327
  }
@@ -270,7 +375,8 @@ function buildModelSummary(opts) {
270
375
  fieldSet: "exhaustive",
271
376
  fields: columns,
272
377
  indexes,
273
- ...physicalTable !== null && physicalTable !== opts.model.name ? { physicalTable } : {}
378
+ ...physicalTable !== null && physicalTable !== opts.model.name ? { physicalTable } : {},
379
+ ...originOf(opts.origin)
274
380
  }
275
381
  }
276
382
  };
@@ -300,37 +406,110 @@ function relationNameOf(field) {
300
406
  const relation = relationAttributeOf(field);
301
407
  return relation === void 0 ? null : readStringArg(relation);
302
408
  }
303
- function fieldToColumn(field, modelNames, enumNames) {
304
- if (field.array === true) {
305
- return null;
306
- }
409
+ function fieldToColumn(field, modelNames, context) {
307
410
  const ft = field.fieldType;
308
- const isScalar = PRISMA_SCALARS.has(ft);
309
- const isEnum = enumNames.has(ft);
310
- const isRelation = modelNames.has(ft);
311
- if (isRelation) {
411
+ if (modelNames.has(ft)) {
312
412
  return null;
313
413
  }
314
- if (!isScalar && !isEnum) {
414
+ if (!PRISMA_SCALARS.has(ft) && !context.enumNames.has(ft)) {
315
415
  return null;
316
416
  }
317
- let primary = false;
318
- let unique = false;
319
- for (const attr of field.attributes ?? []) {
320
- if (attr.name === "id") {
321
- primary = true;
322
- } else if (attr.name === "unique") {
323
- unique = true;
324
- }
325
- }
417
+ const column = stringArgOf(fieldAttribute(field, "map"));
418
+ const nullable = nullableOf(field, context.migrated);
419
+ const databaseDefault = databaseDefaultOf(field, context.migrated);
420
+ const foreignKey = context.foreignKeys.get(field.name);
421
+ const primary = fieldAttribute(field, "id") !== void 0 || context.compositeKey.has(field.name);
326
422
  return {
327
423
  name: field.name,
328
- type: ft,
329
- nullable: field.optional === true,
424
+ ...column !== null && column !== field.name ? { column } : {},
425
+ type: columnTypeOf(field, context.enumNames),
426
+ ...nullable === void 0 ? {} : { nullable },
330
427
  ...primary ? { primary: true } : {},
331
- ...unique ? { unique: true } : {}
428
+ ...fieldAttribute(field, "unique") !== void 0 ? { unique: true } : {},
429
+ ...databaseDefault === void 0 ? {} : { default: databaseDefault },
430
+ ...foreignKey === void 0 ? {} : { foreignKey }
332
431
  };
333
432
  }
433
+ function fieldAttribute(field, name) {
434
+ return (field.attributes ?? []).find(
435
+ (attr) => attr.name === name && attr.group === void 0
436
+ );
437
+ }
438
+ function columnTypeOf(field, enumNames) {
439
+ const element = elementTypeOf(field, enumNames);
440
+ return field.array === true ? `${element}[]` : element;
441
+ }
442
+ function elementTypeOf(field, enumNames) {
443
+ const native = (field.attributes ?? []).find((attr) => attr.group === "db");
444
+ if (native !== void 0) {
445
+ return `@db.${native.name}`;
446
+ }
447
+ return enumNames.has(field.fieldType) ? "enum" : field.fieldType;
448
+ }
449
+ function nullableOf(field, migrated) {
450
+ if (field.array === true) {
451
+ return migrated ? true : void 0;
452
+ }
453
+ return field.optional === true;
454
+ }
455
+ var DEFAULT_FUNCTION_FILLERS = {
456
+ autoincrement: "database",
457
+ dbgenerated: "database",
458
+ now: "both",
459
+ uuid: "client",
460
+ cuid: "client",
461
+ nanoid: "client",
462
+ ulid: "client"
463
+ };
464
+ var DATABASE_DEFAULT = {
465
+ nobody: () => false,
466
+ database: () => true,
467
+ client: () => false,
468
+ both: (migrated) => migrated ? true : void 0,
469
+ unknown: () => void 0
470
+ };
471
+ function databaseDefaultOf(field, migrated) {
472
+ return DATABASE_DEFAULT[defaultFillerOf(field)](migrated);
473
+ }
474
+ function defaultFillerOf(field) {
475
+ const value = (fieldAttribute(field, "default")?.args ?? []).find(
476
+ (arg) => arg.value?.type !== "keyValue"
477
+ )?.value;
478
+ if (value === void 0) {
479
+ return "nobody";
480
+ }
481
+ const call = value;
482
+ if (call?.type !== "function") {
483
+ return "both";
484
+ }
485
+ return DEFAULT_FUNCTION_FILLERS[call.name ?? ""] ?? "unknown";
486
+ }
487
+ function foreignKeysOf(model, modelNames, tableOfModel) {
488
+ const targets = /* @__PURE__ */ new Map();
489
+ for (const property of model.properties) {
490
+ if (property.type !== "field") {
491
+ continue;
492
+ }
493
+ const field = property;
494
+ if (!modelNames.has(field.fieldType)) {
495
+ continue;
496
+ }
497
+ const table = tableOfModel.get(field.fieldType) ?? field.fieldType;
498
+ for (const column of relationKeyOf(field) ?? []) {
499
+ targets.set(column, table);
500
+ }
501
+ }
502
+ return targets;
503
+ }
504
+ function compositeKeyOf(model) {
505
+ for (const property of model.properties) {
506
+ const attr = property;
507
+ if (attr.type === "attribute" && attr.name === "id") {
508
+ return new Set(blockFieldsOf(attr) ?? []);
509
+ }
510
+ }
511
+ return /* @__PURE__ */ new Set();
512
+ }
334
513
  function physicalTableOf(model) {
335
514
  for (const property of model.properties) {
336
515
  if (property.type !== "attribute") {
@@ -340,13 +519,29 @@ function physicalTableOf(model) {
340
519
  if (attr.name !== "map") {
341
520
  continue;
342
521
  }
343
- const name = readStringArg(attr);
522
+ const name = stringArgOf(attr);
344
523
  if (name !== null) {
345
524
  return name;
346
525
  }
347
526
  }
348
527
  return null;
349
528
  }
529
+ function stringArgOf(attr) {
530
+ if (attr === void 0) {
531
+ return null;
532
+ }
533
+ const positional = readStringArg(attr);
534
+ if (positional !== null) {
535
+ return positional;
536
+ }
537
+ for (const arg of attr.args ?? []) {
538
+ const value = arg.value;
539
+ if (value?.type === "keyValue" && value.key === "name" && typeof value.value === "string") {
540
+ return value.value.replace(/^"|"$/g, "");
541
+ }
542
+ }
543
+ return null;
544
+ }
350
545
  function readStringArg(attr) {
351
546
  for (const arg of attr.args ?? []) {
352
547
  const value = arg.value;
@@ -360,12 +555,15 @@ function blockAttributeToIndex(attr) {
360
555
  if (attr.name !== "index" && attr.name !== "unique" && attr.name !== "id") {
361
556
  return null;
362
557
  }
363
- const fields = readArrayArg(attr);
558
+ const fields = blockFieldsOf(attr);
364
559
  if (fields === null) {
365
560
  return null;
366
561
  }
367
562
  return { fields, unique: attr.name !== "index" };
368
563
  }
564
+ function blockFieldsOf(attr) {
565
+ return readArrayArg(attr) ?? readKeyedArrayArg(attr, "fields");
566
+ }
369
567
  function readArrayArg(attr) {
370
568
  for (const arg of attr.args ?? []) {
371
569
  const names = arrayNames(arg.value);
@@ -432,6 +630,7 @@ function readProviderString(ds) {
432
630
  }
433
631
  export {
434
632
  prismaSchemaFileToSummaries,
633
+ prismaSchemaFiles,
435
634
  prismaSchemaToSummaries,
436
635
  prismaStorageSystem
437
636
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suss/contract-prisma",
3
- "version": "0.34.1",
3
+ "version": "0.35.1",
4
4
  "type": "module",
5
5
  "description": "Turn a Prisma schema into a suss contract and check it against the queries your code runs on those tables.",
6
6
  "keywords": [
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@mrleebo/prisma-ast": "^0.15.0",
51
- "@suss/behavioral-ir": "0.34.1"
51
+ "@suss/behavioral-ir": "0.35.1"
52
52
  },
53
53
  "devDependencies": {
54
54
  "tsup": "*",