@suss/contract-prisma 0.22.3 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,13 +16,20 @@ The behavior is in `metadata.storageContract`:
16
16
 
17
17
  - `fieldSet: "exhaustive"`. A Prisma model declares every column its table has, so a column the code touches and this list leaves out is unknown rather than merely unlisted.
18
18
  - `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.
19
- - Relation fields also appear, 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 that calls itself exhaustive would report working code as reading an undeclared field. When the model stores the foreign key, the columns listed in `@relation(fields: [...])` come along as `relationKey`. Prisma allows that argument on one side of a relation only, so the other side and every implicit many-to-many have no key: connecting a row there changes a join-table entry rather than a column of this model.
19
+ - Relation fields also appear, 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 that calls itself exhaustive would report working code as reading an undeclared field. When the model stores the foreign key, the columns listed in `@relation(fields: [...])` come along as `relationKey`. Prisma allows that argument on one side of a relation only, so the other side and every implicit many-to-many have no key.
20
+ - A many-to-many relation with no explicit join model gets a `joinContainer` entry instead, saying which table Prisma manages for it. See below.
20
21
  - A model with any relation also gets a derived `_count` entry, which is what the client exposes for relation counts.
21
22
  - `indexes`, from `@@index([...])` (not unique), `@@unique([...])`, and `@@id([...])` (both unique).
22
23
  - `physicalTable`, when `@@map("...")` gives the table a SQL name different from the model name. That is the bridge to code speaking SQL names directly, such as Drizzle's `pgTable("users")` or a raw query.
23
24
 
24
25
  These summaries have no inputs and no transitions. A table is a shape the code agrees with, not a function that returns something. Confidence is `declared` at `high`, since the schema says all of this outright.
25
26
 
27
+ ### Implicit many-to-many join tables
28
+
29
+ Two list fields pointing at each other with neither side declaring `@relation(fields: [...])` are 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 emits a summary for that table too, with the boundary Prisma would create: container `_Name` when the fields carry `@relation("Name")`, or `_FirstToSecond` with the two model names in alphabetical order otherwise, and 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 own container name, so a `connect`, `disconnect` or `set` through the field counts as a write there instead of going unrecorded.
30
+
31
+ An explicit join model, the kind written out by hand to carry extra columns, already has a boundary through the ordinary model path above and gets none of this.
32
+
26
33
  ## What it does not read
27
34
 
28
35
  - **Non-relational providers.** A MongoDB schema comes back empty; document storage semantics are separate work.
package/dist/index.cjs CHANGED
@@ -85,6 +85,21 @@ function prismaSchemaToSummaries(source, options = {}) {
85
85
  const sourceFile = options.source ?? "schema.prisma";
86
86
  const scope = options.scope ?? "default";
87
87
  const summaries = [];
88
+ const models = list.filter(
89
+ (node) => node.type === "model"
90
+ );
91
+ const relations = implicitManyToManyRelations(models, modelNames);
92
+ const joinContainerByField = /* @__PURE__ */ new Map();
93
+ for (const relation of relations) {
94
+ joinContainerByField.set(
95
+ `${relation.leftModel}.${relation.leftField}`,
96
+ relation.joinTable
97
+ );
98
+ joinContainerByField.set(
99
+ `${relation.rightModel}.${relation.rightField}`,
100
+ relation.joinTable
101
+ );
102
+ }
88
103
  for (const node of list) {
89
104
  const n = node;
90
105
  if (n.type !== "model" && n.type !== "view") {
@@ -98,12 +113,132 @@ function prismaSchemaToSummaries(source, options = {}) {
98
113
  enumNames,
99
114
  storageSystem,
100
115
  scope,
101
- sourceFile
116
+ sourceFile,
117
+ joinContainerByField
102
118
  })
103
119
  );
104
120
  }
121
+ for (const relation of relations) {
122
+ summaries.push(
123
+ buildJoinTableSummary({ relation, storageSystem, scope, sourceFile })
124
+ );
125
+ }
105
126
  return summaries;
106
127
  }
128
+ function implicitManyToManyRelations(models, modelNames) {
129
+ const byName = new Map(models.map((model) => [model.name, model]));
130
+ const found = [];
131
+ const seen = /* @__PURE__ */ new Set();
132
+ for (const model of models) {
133
+ for (const property of model.properties) {
134
+ if (property.type !== "field") {
135
+ continue;
136
+ }
137
+ const field = property;
138
+ if (field.array !== true || !modelNames.has(field.fieldType) || relationKeyOf(field) !== null) {
139
+ continue;
140
+ }
141
+ const target = byName.get(field.fieldType);
142
+ if (target === void 0) {
143
+ continue;
144
+ }
145
+ const relationName = relationNameOf(field);
146
+ const counterpart = counterpartField(
147
+ target,
148
+ model.name,
149
+ field.name,
150
+ relationName
151
+ );
152
+ if (counterpart === null) {
153
+ continue;
154
+ }
155
+ const signature = pairSignature(
156
+ { model: model.name, field: field.name },
157
+ { model: field.fieldType, field: counterpart.name }
158
+ );
159
+ if (seen.has(signature)) {
160
+ continue;
161
+ }
162
+ seen.add(signature);
163
+ found.push({
164
+ leftModel: model.name,
165
+ leftField: field.name,
166
+ rightModel: field.fieldType,
167
+ rightField: counterpart.name,
168
+ relationName,
169
+ joinTable: joinTableName(model.name, field.fieldType, relationName)
170
+ });
171
+ }
172
+ }
173
+ return found;
174
+ }
175
+ function counterpartField(target, backTo, ownFieldName, relationName) {
176
+ for (const property of target.properties) {
177
+ if (property.type !== "field") {
178
+ continue;
179
+ }
180
+ const field = property;
181
+ if (target.name === backTo && field.name === ownFieldName) {
182
+ continue;
183
+ }
184
+ if (field.array === true && field.fieldType === backTo && relationKeyOf(field) === null && relationNameOf(field) === relationName) {
185
+ return field;
186
+ }
187
+ }
188
+ return null;
189
+ }
190
+ function pairSignature(a, b) {
191
+ const label = (side) => `${side.model}.${side.field}`;
192
+ return [label(a), label(b)].sort().join("|");
193
+ }
194
+ function joinTableName(leftModel, rightModel, relationName) {
195
+ if (relationName !== null) {
196
+ return `_${relationName}`;
197
+ }
198
+ const [first, second] = [leftModel, rightModel].sort();
199
+ return `_${first}To${second}`;
200
+ }
201
+ function buildJoinTableSummary(opts) {
202
+ const [modelA, modelB] = [
203
+ opts.relation.leftModel,
204
+ opts.relation.rightModel
205
+ ].sort();
206
+ return {
207
+ kind: "library",
208
+ location: {
209
+ file: opts.sourceFile,
210
+ range: { start: 1, end: 1 },
211
+ exportName: null
212
+ },
213
+ identity: {
214
+ name: opts.relation.joinTable,
215
+ exportPath: null,
216
+ boundaryBinding: (0, import_behavioral_ir.storageBinding)({
217
+ recognition: "prisma",
218
+ storageSystem: opts.storageSystem,
219
+ scope: opts.scope,
220
+ container: opts.relation.joinTable
221
+ })
222
+ },
223
+ inputs: [],
224
+ transitions: [],
225
+ gaps: [],
226
+ confidence: { source: "declared", level: "high" },
227
+ metadata: {
228
+ storageContract: {
229
+ fieldSet: "exhaustive",
230
+ fields: [
231
+ { name: "A", type: modelA, nullable: false, primary: true },
232
+ { name: "B", type: modelB, nullable: false, primary: true }
233
+ ],
234
+ indexes: [
235
+ { fields: ["A", "B"], unique: true },
236
+ { fields: ["B"], unique: false }
237
+ ]
238
+ }
239
+ }
240
+ };
241
+ }
107
242
  function prismaSchemaFileToSummaries(schemaPath, options = {}) {
108
243
  if (!import_node_fs.default.existsSync(schemaPath)) {
109
244
  throw new Error(`Prisma schema not found: ${schemaPath}`);
@@ -126,7 +261,8 @@ function buildModelSummary(opts) {
126
261
  if (column !== null) {
127
262
  columns.push(column);
128
263
  }
129
- const related = relationField(field, opts.modelNames);
264
+ const joinContainer = opts.joinContainerByField.get(`${opts.model.name}.${field.name}`) ?? null;
265
+ const related = relationField(field, opts.modelNames, joinContainer);
130
266
  if (related !== null) {
131
267
  hasRelation = true;
132
268
  columns.push(related);
@@ -180,7 +316,7 @@ function buildModelSummary(opts) {
180
316
  }
181
317
  };
182
318
  }
183
- function relationField(field, modelNames) {
319
+ function relationField(field, modelNames, joinContainer) {
184
320
  if (!modelNames.has(field.fieldType)) {
185
321
  return null;
186
322
  }
@@ -190,15 +326,21 @@ function relationField(field, modelNames) {
190
326
  type: field.array === true ? `${field.fieldType}[]` : field.fieldType,
191
327
  nullable: field.optional === true,
192
328
  derived: true,
193
- ...key === null ? {} : { relationKey: key }
329
+ ...key === null ? {} : { relationKey: key },
330
+ ...joinContainer === null ? {} : { joinContainer }
194
331
  };
195
332
  }
333
+ function relationAttributeOf(field) {
334
+ return (field.attributes ?? []).find((attr) => attr.name === "relation");
335
+ }
196
336
  function relationKeyOf(field) {
197
- const relation = (field.attributes ?? []).find(
198
- (attr) => attr.name === "relation"
199
- );
337
+ const relation = relationAttributeOf(field);
200
338
  return relation === void 0 ? null : readKeyedArrayArg(relation, "fields");
201
339
  }
340
+ function relationNameOf(field) {
341
+ const relation = relationAttributeOf(field);
342
+ return relation === void 0 ? null : readStringArg(relation);
343
+ }
202
344
  function fieldToColumn(field, modelNames, enumNames) {
203
345
  if (field.array === true) {
204
346
  return null;
package/dist/index.js CHANGED
@@ -50,6 +50,21 @@ function prismaSchemaToSummaries(source, options = {}) {
50
50
  const sourceFile = options.source ?? "schema.prisma";
51
51
  const scope = options.scope ?? "default";
52
52
  const summaries = [];
53
+ const models = list.filter(
54
+ (node) => node.type === "model"
55
+ );
56
+ const relations = implicitManyToManyRelations(models, modelNames);
57
+ const joinContainerByField = /* @__PURE__ */ new Map();
58
+ for (const relation of relations) {
59
+ joinContainerByField.set(
60
+ `${relation.leftModel}.${relation.leftField}`,
61
+ relation.joinTable
62
+ );
63
+ joinContainerByField.set(
64
+ `${relation.rightModel}.${relation.rightField}`,
65
+ relation.joinTable
66
+ );
67
+ }
53
68
  for (const node of list) {
54
69
  const n = node;
55
70
  if (n.type !== "model" && n.type !== "view") {
@@ -63,12 +78,132 @@ function prismaSchemaToSummaries(source, options = {}) {
63
78
  enumNames,
64
79
  storageSystem,
65
80
  scope,
66
- sourceFile
81
+ sourceFile,
82
+ joinContainerByField
67
83
  })
68
84
  );
69
85
  }
86
+ for (const relation of relations) {
87
+ summaries.push(
88
+ buildJoinTableSummary({ relation, storageSystem, scope, sourceFile })
89
+ );
90
+ }
70
91
  return summaries;
71
92
  }
93
+ function implicitManyToManyRelations(models, modelNames) {
94
+ const byName = new Map(models.map((model) => [model.name, model]));
95
+ const found = [];
96
+ const seen = /* @__PURE__ */ new Set();
97
+ for (const model of models) {
98
+ for (const property of model.properties) {
99
+ if (property.type !== "field") {
100
+ continue;
101
+ }
102
+ const field = property;
103
+ if (field.array !== true || !modelNames.has(field.fieldType) || relationKeyOf(field) !== null) {
104
+ continue;
105
+ }
106
+ const target = byName.get(field.fieldType);
107
+ if (target === void 0) {
108
+ continue;
109
+ }
110
+ const relationName = relationNameOf(field);
111
+ const counterpart = counterpartField(
112
+ target,
113
+ model.name,
114
+ field.name,
115
+ relationName
116
+ );
117
+ if (counterpart === null) {
118
+ continue;
119
+ }
120
+ const signature = pairSignature(
121
+ { model: model.name, field: field.name },
122
+ { model: field.fieldType, field: counterpart.name }
123
+ );
124
+ if (seen.has(signature)) {
125
+ continue;
126
+ }
127
+ seen.add(signature);
128
+ found.push({
129
+ leftModel: model.name,
130
+ leftField: field.name,
131
+ rightModel: field.fieldType,
132
+ rightField: counterpart.name,
133
+ relationName,
134
+ joinTable: joinTableName(model.name, field.fieldType, relationName)
135
+ });
136
+ }
137
+ }
138
+ return found;
139
+ }
140
+ function counterpartField(target, backTo, ownFieldName, relationName) {
141
+ for (const property of target.properties) {
142
+ if (property.type !== "field") {
143
+ continue;
144
+ }
145
+ const field = property;
146
+ if (target.name === backTo && field.name === ownFieldName) {
147
+ continue;
148
+ }
149
+ if (field.array === true && field.fieldType === backTo && relationKeyOf(field) === null && relationNameOf(field) === relationName) {
150
+ return field;
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+ function pairSignature(a, b) {
156
+ const label = (side) => `${side.model}.${side.field}`;
157
+ return [label(a), label(b)].sort().join("|");
158
+ }
159
+ function joinTableName(leftModel, rightModel, relationName) {
160
+ if (relationName !== null) {
161
+ return `_${relationName}`;
162
+ }
163
+ const [first, second] = [leftModel, rightModel].sort();
164
+ return `_${first}To${second}`;
165
+ }
166
+ function buildJoinTableSummary(opts) {
167
+ const [modelA, modelB] = [
168
+ opts.relation.leftModel,
169
+ opts.relation.rightModel
170
+ ].sort();
171
+ return {
172
+ kind: "library",
173
+ location: {
174
+ file: opts.sourceFile,
175
+ range: { start: 1, end: 1 },
176
+ exportName: null
177
+ },
178
+ identity: {
179
+ name: opts.relation.joinTable,
180
+ exportPath: null,
181
+ boundaryBinding: storageBinding({
182
+ recognition: "prisma",
183
+ storageSystem: opts.storageSystem,
184
+ scope: opts.scope,
185
+ container: opts.relation.joinTable
186
+ })
187
+ },
188
+ inputs: [],
189
+ transitions: [],
190
+ gaps: [],
191
+ confidence: { source: "declared", level: "high" },
192
+ metadata: {
193
+ storageContract: {
194
+ fieldSet: "exhaustive",
195
+ fields: [
196
+ { name: "A", type: modelA, nullable: false, primary: true },
197
+ { name: "B", type: modelB, nullable: false, primary: true }
198
+ ],
199
+ indexes: [
200
+ { fields: ["A", "B"], unique: true },
201
+ { fields: ["B"], unique: false }
202
+ ]
203
+ }
204
+ }
205
+ };
206
+ }
72
207
  function prismaSchemaFileToSummaries(schemaPath, options = {}) {
73
208
  if (!fs.existsSync(schemaPath)) {
74
209
  throw new Error(`Prisma schema not found: ${schemaPath}`);
@@ -91,7 +226,8 @@ function buildModelSummary(opts) {
91
226
  if (column !== null) {
92
227
  columns.push(column);
93
228
  }
94
- const related = relationField(field, opts.modelNames);
229
+ const joinContainer = opts.joinContainerByField.get(`${opts.model.name}.${field.name}`) ?? null;
230
+ const related = relationField(field, opts.modelNames, joinContainer);
95
231
  if (related !== null) {
96
232
  hasRelation = true;
97
233
  columns.push(related);
@@ -145,7 +281,7 @@ function buildModelSummary(opts) {
145
281
  }
146
282
  };
147
283
  }
148
- function relationField(field, modelNames) {
284
+ function relationField(field, modelNames, joinContainer) {
149
285
  if (!modelNames.has(field.fieldType)) {
150
286
  return null;
151
287
  }
@@ -155,15 +291,21 @@ function relationField(field, modelNames) {
155
291
  type: field.array === true ? `${field.fieldType}[]` : field.fieldType,
156
292
  nullable: field.optional === true,
157
293
  derived: true,
158
- ...key === null ? {} : { relationKey: key }
294
+ ...key === null ? {} : { relationKey: key },
295
+ ...joinContainer === null ? {} : { joinContainer }
159
296
  };
160
297
  }
298
+ function relationAttributeOf(field) {
299
+ return (field.attributes ?? []).find((attr) => attr.name === "relation");
300
+ }
161
301
  function relationKeyOf(field) {
162
- const relation = (field.attributes ?? []).find(
163
- (attr) => attr.name === "relation"
164
- );
302
+ const relation = relationAttributeOf(field);
165
303
  return relation === void 0 ? null : readKeyedArrayArg(relation, "fields");
166
304
  }
305
+ function relationNameOf(field) {
306
+ const relation = relationAttributeOf(field);
307
+ return relation === void 0 ? null : readStringArg(relation);
308
+ }
167
309
  function fieldToColumn(field, modelNames, enumNames) {
168
310
  if (field.array === true) {
169
311
  return null;
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "@suss/contract-prisma",
3
- "version": "0.22.3",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
- "description": "Generate suss behavioral summaries from Prisma schema declarations.",
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": [
7
7
  "suss",
8
- "behavioral-summary",
8
+ "static-analysis",
9
9
  "prisma",
10
10
  "orm",
11
- "storage",
12
- "contract"
11
+ "database",
12
+ "api-contract",
13
+ "api-drift"
13
14
  ],
14
15
  "license": "Apache-2.0",
15
16
  "author": "Nimbus Cloud AI LLC",
@@ -47,7 +48,7 @@
47
48
  },
48
49
  "dependencies": {
49
50
  "@mrleebo/prisma-ast": "^0.15.0",
50
- "@suss/behavioral-ir": "0.22.3"
51
+ "@suss/behavioral-ir": "0.23.0"
51
52
  },
52
53
  "devDependencies": {
53
54
  "tsup": "*",