@edium/halifax 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +72 -50
  3. package/README_AUTOCRUD.md +61 -19
  4. package/README_QUERYBUILDER.md +1 -1
  5. package/README_REPO_ADAPTERS.md +80 -11
  6. package/dist/adapters/http/ExpressAdapter.d.ts +34 -5
  7. package/dist/adapters/http/ExpressAdapter.js +20 -12
  8. package/dist/adapters/http/FastifyAdapter.d.ts +93 -0
  9. package/dist/adapters/http/FastifyAdapter.js +125 -0
  10. package/dist/adapters/http/HyperExpressAdapter.d.ts +82 -0
  11. package/dist/adapters/http/HyperExpressAdapter.js +128 -0
  12. package/dist/adapters/http/UltimateExpressAdapter.d.ts +84 -0
  13. package/dist/adapters/http/UltimateExpressAdapter.js +108 -0
  14. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +89 -40
  15. package/dist/adapters/orm/prisma/PrismaAdapter.js +233 -71
  16. package/dist/adapters/orm/prisma/astToPrisma.d.ts +26 -0
  17. package/dist/adapters/orm/prisma/astToPrisma.js +140 -0
  18. package/dist/adapters/orm/prisma/createPrismaResources.d.ts +1 -2
  19. package/dist/adapters/orm/prisma/createPrismaResources.js +10 -6
  20. package/dist/adapters/orm/prisma/helpers.d.ts +0 -1
  21. package/dist/adapters/orm/prisma/helpers.js +0 -1
  22. package/dist/adapters/orm/prisma/index.d.ts +1 -2
  23. package/dist/adapters/orm/prisma/index.js +0 -1
  24. package/dist/adapters/orm/prisma/types.d.ts +14 -9
  25. package/dist/adapters/orm/prisma/types.js +0 -1
  26. package/dist/auth/AuthStrategy.d.ts +0 -9
  27. package/dist/auth/AuthStrategy.js +0 -7
  28. package/dist/core/cache/CacheStore.d.ts +25 -0
  29. package/dist/core/cache/CacheStore.js +1 -0
  30. package/dist/core/cache/createCachingRepository.d.ts +39 -0
  31. package/dist/core/cache/createCachingRepository.js +116 -0
  32. package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +19 -0
  33. package/dist/core/cache/in-memory/InMemoryCacheStore.js +34 -0
  34. package/dist/core/cache/index.d.ts +5 -0
  35. package/dist/core/cache/index.js +5 -0
  36. package/dist/core/cache/redis/RedisCacheStore.d.ts +28 -0
  37. package/dist/core/cache/redis/RedisCacheStore.js +42 -0
  38. package/dist/core/cache/redis/RedisLikeClient.d.ts +12 -0
  39. package/dist/core/cache/redis/RedisLikeClient.js +1 -0
  40. package/dist/core/crudRouter.d.ts +65 -8
  41. package/dist/core/crudRouter.js +231 -95
  42. package/dist/core/queryString.d.ts +3 -3
  43. package/dist/core/queryString.js +16 -7
  44. package/dist/core/types.d.ts +141 -31
  45. package/dist/core/types.js +13 -1
  46. package/dist/core/validation.d.ts +12 -4
  47. package/dist/core/validation.js +33 -13
  48. package/dist/enums/SqlComparison.d.ts +13 -3
  49. package/dist/enums/SqlComparison.js +12 -2
  50. package/dist/enums/SqlOperator.d.ts +0 -1
  51. package/dist/enums/SqlOperator.js +0 -1
  52. package/dist/enums/SqlOrder.d.ts +0 -1
  53. package/dist/enums/SqlOrder.js +0 -1
  54. package/dist/errors/AuthenticationError.d.ts +0 -1
  55. package/dist/errors/AuthenticationError.js +0 -1
  56. package/dist/errors/AuthorizationError.d.ts +0 -1
  57. package/dist/errors/AuthorizationError.js +0 -1
  58. package/dist/errors/BadRequestError.d.ts +0 -1
  59. package/dist/errors/BadRequestError.js +0 -1
  60. package/dist/errors/HttpError.d.ts +0 -1
  61. package/dist/errors/HttpError.js +0 -1
  62. package/dist/errors/MethodNotAllowedError.d.ts +0 -1
  63. package/dist/errors/MethodNotAllowedError.js +0 -1
  64. package/dist/errors/NotAcceptableError.d.ts +0 -1
  65. package/dist/errors/NotAcceptableError.js +0 -1
  66. package/dist/errors/NotFoundError.d.ts +0 -1
  67. package/dist/errors/NotFoundError.js +0 -1
  68. package/dist/errors/NotImplementedError.d.ts +0 -1
  69. package/dist/errors/NotImplementedError.js +0 -1
  70. package/dist/errors/ServerError.d.ts +0 -1
  71. package/dist/errors/ServerError.js +0 -1
  72. package/dist/errors/UnprocessableEntityError.d.ts +0 -1
  73. package/dist/errors/UnprocessableEntityError.js +0 -1
  74. package/dist/errors/UnsupportedMediaTypeError.d.ts +0 -1
  75. package/dist/errors/UnsupportedMediaTypeError.js +0 -1
  76. package/dist/index.d.ts +1 -3
  77. package/dist/index.js +1 -3
  78. package/dist/interfaces/IQueryFilter.d.ts +1 -2
  79. package/dist/interfaces/IQueryFilter.js +0 -1
  80. package/dist/interfaces/IQueryOptions.d.ts +9 -9
  81. package/dist/interfaces/IQueryOptions.js +0 -1
  82. package/dist/interfaces/ISort.d.ts +0 -1
  83. package/dist/interfaces/ISort.js +0 -1
  84. package/package.json +10 -8
  85. package/dist/adapters/http/ExpressAdapter.d.ts.map +0 -1
  86. package/dist/adapters/http/ExpressAdapter.js.map +0 -1
  87. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts.map +0 -1
  88. package/dist/adapters/orm/prisma/PrismaAdapter.js.map +0 -1
  89. package/dist/adapters/orm/prisma/createPrismaResources.d.ts.map +0 -1
  90. package/dist/adapters/orm/prisma/createPrismaResources.js.map +0 -1
  91. package/dist/adapters/orm/prisma/helpers.d.ts.map +0 -1
  92. package/dist/adapters/orm/prisma/helpers.js.map +0 -1
  93. package/dist/adapters/orm/prisma/index.d.ts.map +0 -1
  94. package/dist/adapters/orm/prisma/index.js.map +0 -1
  95. package/dist/adapters/orm/prisma/types.d.ts.map +0 -1
  96. package/dist/adapters/orm/prisma/types.js.map +0 -1
  97. package/dist/auth/AuthStrategy.d.ts.map +0 -1
  98. package/dist/auth/AuthStrategy.js.map +0 -1
  99. package/dist/classes/QueryBuilder.d.ts +0 -33
  100. package/dist/classes/QueryBuilder.d.ts.map +0 -1
  101. package/dist/classes/QueryBuilder.js +0 -262
  102. package/dist/classes/QueryBuilder.js.map +0 -1
  103. package/dist/core/crudRouter.d.ts.map +0 -1
  104. package/dist/core/crudRouter.js.map +0 -1
  105. package/dist/core/queryString.d.ts.map +0 -1
  106. package/dist/core/queryString.js.map +0 -1
  107. package/dist/core/types.d.ts.map +0 -1
  108. package/dist/core/types.js.map +0 -1
  109. package/dist/core/validation.d.ts.map +0 -1
  110. package/dist/core/validation.js.map +0 -1
  111. package/dist/enums/SqlComparison.d.ts.map +0 -1
  112. package/dist/enums/SqlComparison.js.map +0 -1
  113. package/dist/enums/SqlOperator.d.ts.map +0 -1
  114. package/dist/enums/SqlOperator.js.map +0 -1
  115. package/dist/enums/SqlOrder.d.ts.map +0 -1
  116. package/dist/enums/SqlOrder.js.map +0 -1
  117. package/dist/errors/AuthenticationError.d.ts.map +0 -1
  118. package/dist/errors/AuthenticationError.js.map +0 -1
  119. package/dist/errors/AuthorizationError.d.ts.map +0 -1
  120. package/dist/errors/AuthorizationError.js.map +0 -1
  121. package/dist/errors/BadRequestError.d.ts.map +0 -1
  122. package/dist/errors/BadRequestError.js.map +0 -1
  123. package/dist/errors/HttpError.d.ts.map +0 -1
  124. package/dist/errors/HttpError.js.map +0 -1
  125. package/dist/errors/MethodNotAllowedError.d.ts.map +0 -1
  126. package/dist/errors/MethodNotAllowedError.js.map +0 -1
  127. package/dist/errors/NotAcceptableError.d.ts.map +0 -1
  128. package/dist/errors/NotAcceptableError.js.map +0 -1
  129. package/dist/errors/NotFoundError.d.ts.map +0 -1
  130. package/dist/errors/NotFoundError.js.map +0 -1
  131. package/dist/errors/NotImplementedError.d.ts.map +0 -1
  132. package/dist/errors/NotImplementedError.js.map +0 -1
  133. package/dist/errors/ServerError.d.ts.map +0 -1
  134. package/dist/errors/ServerError.js.map +0 -1
  135. package/dist/errors/UnprocessableEntityError.d.ts.map +0 -1
  136. package/dist/errors/UnprocessableEntityError.js.map +0 -1
  137. package/dist/errors/UnsupportedMediaTypeError.d.ts.map +0 -1
  138. package/dist/errors/UnsupportedMediaTypeError.js.map +0 -1
  139. package/dist/index.d.ts.map +0 -1
  140. package/dist/index.js.map +0 -1
  141. package/dist/interfaces/IParamQuery.d.ts +0 -8
  142. package/dist/interfaces/IParamQuery.d.ts.map +0 -1
  143. package/dist/interfaces/IParamQuery.js +0 -2
  144. package/dist/interfaces/IParamQuery.js.map +0 -1
  145. package/dist/interfaces/IQueryFilter.d.ts.map +0 -1
  146. package/dist/interfaces/IQueryFilter.js.map +0 -1
  147. package/dist/interfaces/IQueryOptions.d.ts.map +0 -1
  148. package/dist/interfaces/IQueryOptions.js.map +0 -1
  149. package/dist/interfaces/ISort.d.ts.map +0 -1
  150. package/dist/interfaces/ISort.js.map +0 -1
@@ -1,6 +1,7 @@
1
- import { QueryBuilder } from '../../../classes/QueryBuilder.js';
2
1
  import { NotImplementedError } from '../../../errors/NotImplementedError.js';
2
+ import { NotFoundError } from '../../../errors/NotFoundError.js';
3
3
  import { ServerError } from '../../../errors/ServerError.js';
4
+ import { astToPrismaWhere, astToPrismaOrderBy } from './astToPrisma.js';
4
5
  /** Returns true for Prisma's P2025 "record not found" error. */
5
6
  function isNotFoundError(error) {
6
7
  return (typeof error === 'object' &&
@@ -10,50 +11,129 @@ function isNotFoundError(error) {
10
11
  }
11
12
  import { toSelect, toInclude, toOrderBy } from './helpers.js';
12
13
  /**
13
- * PrismaAdapter is a generic repository implementation that uses Prisma delegates to perform database operations.
14
- * It supports basic CRUD operations and can be extended to support more complex queries.
15
- * The adapter can optionally use a Prisma client for executing raw SQL queries when needed.
16
- * It also extracts field and relation definitions from a provided Prisma model schema to enhance query capabilities.
14
+ * PrismaAdapter is a generic repository implementation that uses Prisma delegates to perform
15
+ * database operations. It handles CRUD plus the query-builder/bulk paths by compiling the
16
+ * query AST to portable Prisma Client calls (no raw SQL), so it works on every Prisma
17
+ * provider. It also extracts field and relation definitions from a provided model schema.
17
18
  */
18
19
  export class PrismaAdapter {
19
- /** Private properties to hold the Prisma delegate, client, and configuration options. */
20
+ /** Private properties to hold the Prisma delegate and configuration options. */
20
21
  delegate;
21
- /** Optional Prisma client for executing raw SQL queries, required for certain operations like updateMany and deleteMany. */
22
- client;
23
22
  /** The field name used for the primary key in the model. */
24
23
  idField;
25
- /** The name of the database table associated with the model. */
26
- tableName;
27
24
  /** A flag indicating whether to return created records. */
28
25
  returnCreated;
26
+ /** The original construction options, used to build request-scoped clones. */
27
+ options;
28
+ /** Tenant constraint bound to this instance, or `undefined` for unscoped access. */
29
+ scope;
29
30
  /** A set of capabilities that the repository supports. */
30
31
  capabilities;
31
- /** An array of field definitions for the model. */
32
+ /** An array of field definitions for the model (present when built with a `model`). */
32
33
  fields;
33
- /** An array of relation definitions for the model. */
34
+ /** An array of relation definitions for the model (present when built with a `model`). */
34
35
  relations;
35
36
  /**
36
37
  * Constructs a new instance of PrismaAdapter with the provided options.
37
- * @param options - An object containing the Prisma delegate, optional client, and configuration settings for the adapter.
38
+ * @param options - The Prisma delegate plus optional id field, return-created flag, and model schema.
38
39
  */
39
40
  constructor(options) {
41
+ this.options = options;
40
42
  this.delegate = options.delegate;
41
- this.client = options.client;
42
43
  this.idField = options.idField ?? 'id';
43
- this.tableName = options.tableName;
44
44
  this.returnCreated = options.returnCreated ?? false;
45
+ this.scope = options.scope;
45
46
  this.capabilities = {
46
- supportsNativeSql: !!options.client,
47
47
  supportsIncludes: true,
48
- supportsTransactions: false,
49
- supportsCreateManyReturn: this.returnCreated,
50
- supportsNoSqlQueryAst: false
48
+ supportsCreateManyReturn: this.returnCreated
51
49
  };
52
50
  if (options.model) {
53
51
  this.fields = PrismaAdapter.fieldsFromModel(options.model);
54
52
  this.relations = PrismaAdapter.relationsFromModel(options.model);
55
53
  }
56
54
  }
55
+ /**
56
+ * Returns a request-scoped clone of this adapter bound to `scope`. Every read is
57
+ * filtered by the scope, every write is stamped with it, and every bulk/SQL operation
58
+ * has the scope AND-ed into its WHERE clause. The original instance is never mutated.
59
+ * @param scope - The resolved tenant constraint for the current request.
60
+ * @returns A new {@link PrismaAdapter} that enforces `scope` on all operations.
61
+ */
62
+ withScope(scope) {
63
+ return new PrismaAdapter({ ...this.options, scope });
64
+ }
65
+ /**
66
+ * Merges the bound tenant constraint into a Prisma `where` object. The scope is spread
67
+ * last so it always wins over any caller-supplied value for the same key.
68
+ * @param where - The caller-derived where clause (may be undefined).
69
+ * @returns A where object with the tenant constraint applied, or `where` when unscoped.
70
+ */
71
+ scopedWhere(where) {
72
+ if (!this.scope)
73
+ return where;
74
+ return { ...(where ?? {}), [this.scope.field]: this.scope.value };
75
+ }
76
+ /**
77
+ * Removes the tenant field from a write payload so callers can never reassign a row
78
+ * to another tenant via create/update/upsert bodies. No-op when unscoped.
79
+ * @param data - The write payload to sanitise.
80
+ * @returns A copy of `data` without the tenant field, or `data` when unscoped.
81
+ */
82
+ stripTenant(data) {
83
+ if (!this.scope)
84
+ return data;
85
+ if (data === null || typeof data !== 'object')
86
+ return data;
87
+ const copy = { ...data };
88
+ delete copy[this.scope.field];
89
+ return copy;
90
+ }
91
+ /**
92
+ * Stamps the bound tenant value onto a create payload, overriding any caller-supplied
93
+ * value for the tenant field. No-op when unscoped.
94
+ * @param data - The create payload.
95
+ * @returns A copy of `data` with the tenant field forced to the scope value.
96
+ */
97
+ stampTenant(data) {
98
+ if (!this.scope)
99
+ return data;
100
+ return { ...data, [this.scope.field]: this.scope.value };
101
+ }
102
+ /**
103
+ * AND-s the bound tenant constraint into a query-builder WHERE clause. The caller's filters
104
+ * are nested as a child group beneath the tenant condition, so a caller-supplied `OR` can
105
+ * never break out of the tenant boundary once the AST is compiled to a Prisma `where`.
106
+ * @param where - The caller-supplied filter list (may be undefined/empty).
107
+ * @returns A new filter list with the tenant condition enforced, or `where` when unscoped.
108
+ */
109
+ scopedFilters(where) {
110
+ if (!this.scope)
111
+ return where;
112
+ const tenantNode = {
113
+ field: this.scope.field,
114
+ comparison: '=',
115
+ value1: this.scope.value
116
+ };
117
+ if (where?.length) {
118
+ tenantNode.operator = 'AND';
119
+ tenantNode.children = where;
120
+ }
121
+ return [tenantNode];
122
+ }
123
+ /**
124
+ * Resolves a query-builder AST for the tenant-scoped paths: applies the tenant constraint
125
+ * via {@link PrismaAdapter.scopedFilters}. The `where` key is only set when defined (to
126
+ * satisfy `exactOptionalPropertyTypes`).
127
+ * @param query - The incoming query AST.
128
+ * @returns A new AST with the tenant scope applied.
129
+ */
130
+ resolveScopedQuery(query) {
131
+ const resolved = { ...query };
132
+ const where = this.scopedFilters(query.where);
133
+ if (where)
134
+ resolved.where = where;
135
+ return resolved;
136
+ }
57
137
  /**
58
138
  * Extracts field definitions from a Prisma model schema.
59
139
  * @param model - The Prisma model schema.
@@ -89,11 +169,20 @@ export class PrismaAdapter {
89
169
  async getOne(id, options) {
90
170
  const select = toSelect(options?.fields);
91
171
  const include = toInclude(options?.include);
92
- const args = { where: { [this.idField]: id } };
172
+ const args = { where: this.scopedWhere({ [this.idField]: id }) };
93
173
  if (select)
94
174
  args.select = select;
95
175
  else if (include)
96
176
  args.include = include;
177
+ // When scoped, the WHERE carries a non-unique tenant predicate, so we must use
178
+ // findFirst (findUnique only accepts unique fields). This is what enforces that a
179
+ // row outside the caller's tenant reads back as "not found".
180
+ if (this.scope) {
181
+ if (this.delegate.findFirst) {
182
+ return (await this.delegate.findFirst(args));
183
+ }
184
+ throw new ServerError('Prisma delegate does not support findFirst (required for tenant scoping).');
185
+ }
97
186
  if (this.delegate.findUnique) {
98
187
  return (await this.delegate.findUnique(args));
99
188
  }
@@ -111,8 +200,9 @@ export class PrismaAdapter {
111
200
  async getMany(options = {}) {
112
201
  const select = toSelect(options.fields);
113
202
  const include = toInclude(options.include);
203
+ const where = this.scopedWhere(options.where);
114
204
  const args = {
115
- where: options.where,
205
+ where,
116
206
  orderBy: toOrderBy(options.orderBy),
117
207
  skip: options.offset,
118
208
  take: options.limit
@@ -122,7 +212,7 @@ export class PrismaAdapter {
122
212
  else if (include)
123
213
  args.include = include;
124
214
  const [count, results] = await Promise.all([
125
- this.delegate.count({ where: options.where }),
215
+ this.delegate.count({ where }),
126
216
  this.delegate.findMany(args)
127
217
  ]);
128
218
  return { count, results: results };
@@ -134,7 +224,7 @@ export class PrismaAdapter {
134
224
  * @throws ServerError if the Prisma delegate does not support the create method.
135
225
  */
136
226
  async createOne(data) {
137
- return (await this.delegate.create({ data }));
227
+ return (await this.delegate.create({ data: this.stampTenant(data) }));
138
228
  }
139
229
  /**
140
230
  * Creates multiple records in the database using the provided array of data objects.
@@ -147,7 +237,7 @@ export class PrismaAdapter {
147
237
  if (!this.delegate.createMany || this.returnCreated) {
148
238
  return await Promise.all(data.map((item) => this.createOne(item)));
149
239
  }
150
- await this.delegate.createMany({ data });
240
+ await this.delegate.createMany({ data: data.map((item) => this.stampTenant(item)) });
151
241
  return [];
152
242
  }
153
243
  /**
@@ -158,6 +248,20 @@ export class PrismaAdapter {
158
248
  * @throws ServerError if the Prisma delegate does not support the update method.
159
249
  */
160
250
  async updateOne(id, data) {
251
+ // When scoped, confirm the row belongs to the caller's tenant before touching it,
252
+ // and strip the tenant field from the payload so the row can't be moved tenants.
253
+ if (this.scope) {
254
+ if (!this.delegate.findFirst) {
255
+ throw new ServerError('Prisma delegate does not support findFirst (required for tenant scoping).');
256
+ }
257
+ const owned = await this.delegate.findFirst({
258
+ where: this.scopedWhere({ [this.idField]: id }),
259
+ select: { [this.idField]: true }
260
+ });
261
+ if (!owned)
262
+ return null;
263
+ data = this.stripTenant(data);
264
+ }
161
265
  try {
162
266
  return (await this.delegate.update({ where: { [this.idField]: id }, data }));
163
267
  }
@@ -168,29 +272,31 @@ export class PrismaAdapter {
168
272
  }
169
273
  }
170
274
  /**
171
- * Updates multiple records that match the provided query options with the given data.
172
- * This method requires a Prisma client for executing raw SQL queries, as Prisma does not
173
- * natively support bulk updates with return values. It first selects the IDs of the records
174
- * to be updated based on the query options, then executes an update query, and finally returns
175
- * the IDs of the updated records.
275
+ * Updates every record matching the query and returns the IDs of the affected rows.
176
276
  *
177
- * **Note:** The SELECT and UPDATE are issued as two separate queries without a transaction.
178
- * Under concurrent writes, rows inserted or deleted between the two queries may cause the
179
- * returned `updated` IDs to differ from the rows actually modified.
277
+ * The query AST is compiled to a portable Prisma `where` (no raw SQL), so this works on
278
+ * every Prisma provider. Because Prisma's `updateMany` only returns a count, the matching
279
+ * IDs are selected first and then the bulk update is applied.
180
280
  *
181
- * @param query - An object containing query options to filter the records that should be updated, such as where conditions, sorting, and pagination.
182
- * @param data - An object containing the data to update the matching records with.
183
- * @returns A promise that resolves to an object containing an array of the IDs of the updated records.
184
- * @throws NotImplementedError if the Prisma client or tableName is not provided, as they are required for executing raw SQL queries.
185
- * @throws ServerError if the Prisma client does not support the required methods for executing raw SQL queries.
281
+ * **Note:** the SELECT and UPDATE are two separate statements without a transaction; under
282
+ * concurrent writes the returned `updated` IDs may differ from the rows actually modified.
283
+ *
284
+ * @param query - Query AST describing which rows to update (filtered, tenant-scoped).
285
+ * @param data - Fields to apply to all matching rows (the tenant field is stripped).
286
+ * @returns The IDs of the updated rows.
287
+ * @throws NotImplementedError when the delegate does not support `updateMany`.
186
288
  */
187
289
  async updateMany(query, data) {
188
- if (!this.client?.$queryRawUnsafe || !this.tableName) {
189
- throw new NotImplementedError('Native SQL updateMany requires a Prisma client and tableName.');
290
+ if (!this.delegate.updateMany) {
291
+ throw new NotImplementedError('This repository does not support updateMany.');
190
292
  }
191
- const updateQuery = QueryBuilder.buildUpdateQuery({ ...query, tableName: query.tableName || this.tableName }, data, [this.idField], this.idField);
192
- const updated = await this.client.$queryRawUnsafe(updateQuery.statement, ...updateQuery.parameters);
193
- return { updated: updated.map((item) => item[this.idField]) };
293
+ const where = astToPrismaWhere(this.resolveScopedQuery(query).where);
294
+ const rows = (await this.delegate.findMany({
295
+ where,
296
+ select: { [this.idField]: true }
297
+ }));
298
+ await this.delegate.updateMany({ where, data: this.stripTenant(data) });
299
+ return { updated: rows.map((item) => item[this.idField]) };
194
300
  }
195
301
  /**
196
302
  * Upserts a single record identified by its ID with the provided data. If the record does not exist, it creates a new one.
@@ -205,6 +311,25 @@ export class PrismaAdapter {
205
311
  if (!this.delegate.upsert) {
206
312
  throw new NotImplementedError('Prisma delegate does not support upsert.');
207
313
  }
314
+ // When scoped, an upsert keyed on a unique id could otherwise overwrite a row owned
315
+ // by another tenant. Reject that case (hidden as "not found"), stamp the tenant on
316
+ // create, and forbid reassigning the tenant on update.
317
+ if (this.scope) {
318
+ if (!this.delegate.findFirst) {
319
+ throw new ServerError('Prisma delegate does not support findFirst (required for tenant scoping).');
320
+ }
321
+ const existing = (await this.delegate.findFirst({
322
+ where: { [this.idField]: id }
323
+ }));
324
+ if (existing && existing[this.scope.field] !== this.scope.value) {
325
+ throw new NotFoundError();
326
+ }
327
+ return (await this.delegate.upsert({
328
+ where: { [this.idField]: id },
329
+ create: this.stampTenant(data),
330
+ update: this.stripTenant(data)
331
+ }));
332
+ }
208
333
  return (await this.delegate.upsert({
209
334
  where: { [this.idField]: id },
210
335
  create: data,
@@ -220,6 +345,26 @@ export class PrismaAdapter {
220
345
  * @throws ServerError if the Prisma delegate does not support the required methods for deleting records.
221
346
  */
222
347
  async deleteOne(id) {
348
+ // When scoped, delete through deleteMany with the tenant predicate so the ownership
349
+ // check and the delete are a single atomic statement (no TOCTOU window). A row in
350
+ // another tenant simply matches nothing and reports as "not found".
351
+ if (this.scope) {
352
+ if (this.delegate.deleteMany) {
353
+ const result = await this.delegate.deleteMany({
354
+ where: this.scopedWhere({ [this.idField]: id })
355
+ });
356
+ return (result?.count ?? 0) > 0;
357
+ }
358
+ if (!this.delegate.findFirst) {
359
+ throw new ServerError('Prisma delegate does not support deleteMany or findFirst (required for tenant scoping).');
360
+ }
361
+ const owned = await this.delegate.findFirst({
362
+ where: this.scopedWhere({ [this.idField]: id }),
363
+ select: { [this.idField]: true }
364
+ });
365
+ if (!owned)
366
+ return false;
367
+ }
223
368
  try {
224
369
  await this.delegate.delete({ where: { [this.idField]: id } });
225
370
  return true;
@@ -238,40 +383,57 @@ export class PrismaAdapter {
238
383
  * Under concurrent writes, rows inserted or deleted between the two queries may cause the
239
384
  * returned `deleted` IDs to differ from the rows actually removed.
240
385
  *
241
- * @param query - An object containing query options to filter the records that should be deleted, such as where conditions, sorting, and pagination.
242
- * @returns A promise that resolves to an object containing an array of the IDs of the deleted records.
243
- * @throws NotImplementedError if the Prisma client or tableName is not provided, as they are required for executing raw SQL queries.
244
- * @throws ServerError if the Prisma client does not support the required methods for executing raw SQL queries.
386
+ * @param query - Query AST describing which rows to delete (filtered, tenant-scoped).
387
+ * @returns The IDs of the deleted rows.
388
+ * @throws NotImplementedError when the delegate does not support `deleteMany`.
245
389
  */
246
390
  async deleteMany(query) {
247
- if (!this.client?.$queryRawUnsafe || !this.tableName) {
248
- throw new NotImplementedError('Native SQL deleteMany requires a Prisma client and tableName.');
391
+ if (!this.delegate.deleteMany) {
392
+ throw new NotImplementedError('This repository does not support deleteMany.');
249
393
  }
250
- const resolvedQuery = { ...query, tableName: query.tableName || this.tableName };
251
- const deleteQuery = QueryBuilder.buildDeleteQuery(resolvedQuery, [this.idField]);
252
- const deleted = await this.client.$queryRawUnsafe(deleteQuery.statement, ...deleteQuery.parameters);
253
- return { deleted: deleted.map((item) => item[this.idField]) };
394
+ const where = astToPrismaWhere(this.resolveScopedQuery(query).where);
395
+ const rows = (await this.delegate.findMany({
396
+ where,
397
+ select: { [this.idField]: true }
398
+ }));
399
+ await this.delegate.deleteMany({ where });
400
+ return { deleted: rows.map((item) => item[this.idField]) };
254
401
  }
255
402
  /**
256
- * Executes a query built using the QueryBuilder, which allows for complex filtering, sorting, pagination, and field selection. This method requires a Prisma client for executing raw SQL queries, as it relies on the QueryBuilder to generate SQL statements. It first builds a count query to get the total number of matching records and then builds a select query to retrieve the actual records based on the provided query options.
257
- * @param query - An object containing query options such as filtering conditions, sorting, pagination, and field selection, which will be used to build the SQL queries.
258
- * @returns A promise that resolves to an object containing the total count of matching records and an array of the retrieved records.
259
- * @throws NotImplementedError if the Prisma client or tableName is not provided, as they are required for executing raw SQL queries.
260
- * @throws ServerError if the Prisma client does not support the required methods for executing raw SQL queries.
403
+ * Executes a query-builder AST as a portable Prisma query: the WHERE tree is compiled to a
404
+ * Prisma `where`, and field projection, ordering, pagination, and `distinct` are mapped to
405
+ * `findMany` arguments. No raw SQL is generated, so the same query runs identically on every
406
+ * Prisma provider (PostgreSQL, MySQL, SQLite, SQL Server, CockroachDB, MongoDB).
407
+ *
408
+ * Validation (field/comparison/depth checks → 4xx) happens in the router *before* this
409
+ * method, so malformed queries never reach Prisma.
410
+ *
411
+ * **Note:** the COUNT and SELECT are two separate statements without a transaction; under
412
+ * concurrent writes the returned `count` may differ from the number of rows in `results`.
413
+ *
414
+ * @param query - The validated query AST (filters, sort, pagination, projection, distinct).
415
+ * @returns A count-and-results envelope for the matching rows.
261
416
  */
262
- async executeQueryBuilder(query) {
263
- if (!this.client?.$queryRawUnsafe || !this.tableName) {
264
- throw new NotImplementedError('Native SQL query-builder requires a Prisma client and tableName.');
265
- }
266
- const resolvedQuery = { ...query, tableName: query.tableName || this.tableName };
267
- const countQuery = QueryBuilder.buildCountQuery(resolvedQuery);
268
- const selectQuery = QueryBuilder.buildSelectQuery(resolvedQuery);
269
- const countRows = await this.client.$queryRawUnsafe(countQuery.statement, ...countQuery.parameters);
270
- const results = await this.client.$queryRawUnsafe(selectQuery.statement, ...selectQuery.parameters);
271
- return {
272
- count: Number(countRows[0]?.count ?? 0),
273
- results
274
- };
417
+ async executeQuery(query) {
418
+ const resolved = this.resolveScopedQuery(query);
419
+ const where = astToPrismaWhere(resolved.where);
420
+ const args = { where };
421
+ const select = toSelect(resolved.fields);
422
+ if (select)
423
+ args.select = select;
424
+ const orderBy = astToPrismaOrderBy(resolved.orderBy);
425
+ if (orderBy)
426
+ args.orderBy = orderBy;
427
+ if (resolved.limit !== undefined)
428
+ args.take = resolved.limit;
429
+ if (resolved.offset !== undefined)
430
+ args.skip = resolved.offset;
431
+ if (resolved.distinct?.length)
432
+ args.distinct = resolved.distinct;
433
+ const [count, results] = await Promise.all([
434
+ this.delegate.count({ where }),
435
+ this.delegate.findMany(args)
436
+ ]);
437
+ return { count, results: results };
275
438
  }
276
439
  }
277
- //# sourceMappingURL=PrismaAdapter.js.map
@@ -0,0 +1,26 @@
1
+ import type { IQueryFilter } from '../../../interfaces/IQueryFilter.js';
2
+ import type { ISort } from '../../../interfaces/ISort.js';
3
+ /** A Prisma `where` fragment — an arbitrary nested object of field conditions and AND/OR/NOT groups. */
4
+ export type PrismaWhere = Record<string, unknown>;
5
+ /**
6
+ * Translates a validated query-builder WHERE tree ({@link IQueryFilter}[]) into a Prisma
7
+ * `where` object.
8
+ *
9
+ * Logical precedence matches SQL: each filter's `operator` joins it to the *next* sibling,
10
+ * `AND` binds tighter than `OR`, so the list is split into OR-separated groups of AND-runs.
11
+ * A single condition is returned bare; an all-AND list as `{ AND: [...] }`; mixed operators
12
+ * as `{ OR: [{ AND: [...] }, ...] }`.
13
+ *
14
+ * This runs **after** {@link validateAdvancedQuery}, so every field name and comparison has
15
+ * already been checked — the 4xx errors are raised there, never inside Prisma.
16
+ *
17
+ * @param where - The validated filter list (may be empty/undefined).
18
+ * @returns A Prisma `where` object (empty `{}` when there are no filters).
19
+ */
20
+ export declare function astToPrismaWhere(where?: IQueryFilter[]): PrismaWhere;
21
+ /**
22
+ * Converts the AST `orderBy` ({@link ISort}[]) into a Prisma `orderBy` array.
23
+ * @param orderBy - Sort expressions from the query AST.
24
+ * @returns A Prisma `orderBy` array, or `undefined` when there are no sorts.
25
+ */
26
+ export declare function astToPrismaOrderBy(orderBy?: ISort[]): Array<Record<string, 'asc' | 'desc'>> | undefined;
@@ -0,0 +1,140 @@
1
+ import { SqlComparison } from '../../../enums/SqlComparison.js';
2
+ import { SqlOperator } from '../../../enums/SqlOperator.js';
3
+ import { SqlOrder } from '../../../enums/SqlOrder.js';
4
+ /**
5
+ * Splits a `LIKE` pattern into a Prisma string operator based on its `%` wildcards.
6
+ * `%x%` → `contains`, `x%` → `startsWith`, `%x` → `endsWith`, and a wildcard-free value
7
+ * collapses to `equals` (matching SQL `LIKE 'x'` semantics).
8
+ * @param value - The raw LIKE pattern.
9
+ * @returns A Prisma string-filter object.
10
+ */
11
+ function likeToPrisma(value) {
12
+ const text = String(value ?? '');
13
+ const lead = text.startsWith('%');
14
+ const trail = text.endsWith('%');
15
+ const inner = text.replace(/^%/, '').replace(/%$/, '');
16
+ if (lead && trail)
17
+ return { contains: inner };
18
+ if (trail)
19
+ return { startsWith: inner };
20
+ if (lead)
21
+ return { endsWith: inner };
22
+ return { equals: text };
23
+ }
24
+ /**
25
+ * Converts a single {@link IQueryFilter} comparison into a Prisma `where` fragment for one field.
26
+ *
27
+ * Most comparisons map to `{ [field]: { <op>: value } }`; `NOT BETWEEN` and `NOT LIKE`
28
+ * expand to `OR` / `NOT` groups. This is the only place comparison semantics live, so the
29
+ * delegate query path and the (validated) AST stay in lockstep.
30
+ *
31
+ * @param filter - The filter whose `field`, `comparison`, `value1`, and `value2` are read.
32
+ * @returns A Prisma `where` fragment expressing the comparison.
33
+ */
34
+ function comparisonToPrisma(filter) {
35
+ const field = filter.field;
36
+ const v1 = filter.value1;
37
+ const v2 = filter.value2;
38
+ const comparison = (filter.comparison?.toUpperCase() ?? '=');
39
+ switch (comparison) {
40
+ case SqlComparison.Equal:
41
+ return { [field]: { equals: v1 } };
42
+ case SqlComparison.NotEqual:
43
+ return { [field]: { not: v1 } };
44
+ case SqlComparison.GreaterThan:
45
+ return { [field]: { gt: v1 } };
46
+ case SqlComparison.GreaterThanOrEqual:
47
+ return { [field]: { gte: v1 } };
48
+ case SqlComparison.LessThan:
49
+ return { [field]: { lt: v1 } };
50
+ case SqlComparison.LessThanOrEqual:
51
+ return { [field]: { lte: v1 } };
52
+ case SqlComparison.In:
53
+ return { [field]: { in: Array.isArray(v1) ? v1 : [v1] } };
54
+ case SqlComparison.NotIn:
55
+ return { [field]: { notIn: Array.isArray(v1) ? v1 : [v1] } };
56
+ case SqlComparison.Between:
57
+ return { [field]: { gte: v1, lte: v2 } };
58
+ case SqlComparison.NotBetween:
59
+ return { OR: [{ [field]: { lt: v1 } }, { [field]: { gt: v2 } }] };
60
+ case SqlComparison.IsNull:
61
+ return { [field]: null };
62
+ case SqlComparison.IsNotNull:
63
+ return { [field]: { not: null } };
64
+ case SqlComparison.Contains:
65
+ return { [field]: { contains: String(v1 ?? '') } };
66
+ case SqlComparison.StartsWith:
67
+ return { [field]: { startsWith: String(v1 ?? '') } };
68
+ case SqlComparison.EndsWith:
69
+ return { [field]: { endsWith: String(v1 ?? '') } };
70
+ case SqlComparison.Like:
71
+ return { [field]: likeToPrisma(v1) };
72
+ case SqlComparison.NotLike:
73
+ return { NOT: { [field]: likeToPrisma(v1) } };
74
+ default:
75
+ return { [field]: { equals: v1 } };
76
+ }
77
+ }
78
+ /**
79
+ * Translates one filter node into a Prisma fragment. When the node has nested `children`,
80
+ * its own condition is combined with the (parenthesised) children group using the node's
81
+ * `operator` — so `{ ...cond, operator: 'OR', children }` becomes `cond OR (children)` and
82
+ * `operator: 'AND'` (or omitted) becomes `cond AND (children)`, exactly as the original SQL
83
+ * builder rendered it.
84
+ * @param filter - The filter node.
85
+ * @returns The Prisma fragment for this node (and its children, if any).
86
+ */
87
+ function nodeToPrisma(filter) {
88
+ const self = comparisonToPrisma(filter);
89
+ if (filter.children?.length) {
90
+ const childWhere = astToPrismaWhere(filter.children);
91
+ return filter.operator?.toUpperCase() === SqlOperator.Or
92
+ ? { OR: [self, childWhere] }
93
+ : { AND: [self, childWhere] };
94
+ }
95
+ return self;
96
+ }
97
+ /**
98
+ * Translates a validated query-builder WHERE tree ({@link IQueryFilter}[]) into a Prisma
99
+ * `where` object.
100
+ *
101
+ * Logical precedence matches SQL: each filter's `operator` joins it to the *next* sibling,
102
+ * `AND` binds tighter than `OR`, so the list is split into OR-separated groups of AND-runs.
103
+ * A single condition is returned bare; an all-AND list as `{ AND: [...] }`; mixed operators
104
+ * as `{ OR: [{ AND: [...] }, ...] }`.
105
+ *
106
+ * This runs **after** {@link validateAdvancedQuery}, so every field name and comparison has
107
+ * already been checked — the 4xx errors are raised there, never inside Prisma.
108
+ *
109
+ * @param where - The validated filter list (may be empty/undefined).
110
+ * @returns A Prisma `where` object (empty `{}` when there are no filters).
111
+ */
112
+ export function astToPrismaWhere(where) {
113
+ if (!where?.length)
114
+ return {};
115
+ const groups = [[]];
116
+ where.forEach((filter, index) => {
117
+ groups[groups.length - 1].push(nodeToPrisma(filter));
118
+ // A node with children has consumed its operator to join self↔children, so it does not
119
+ // also split sibling groups; only a childless node's `OR` starts a new OR-group.
120
+ const joinsWithOr = !filter.children?.length && filter.operator?.toUpperCase() === SqlOperator.Or;
121
+ if (joinsWithOr && index < where.length - 1)
122
+ groups.push([]);
123
+ });
124
+ const andify = (group) => group.length === 1 ? group[0] : { AND: group };
125
+ if (groups.length === 1)
126
+ return andify(groups[0]);
127
+ return { OR: groups.map(andify) };
128
+ }
129
+ /**
130
+ * Converts the AST `orderBy` ({@link ISort}[]) into a Prisma `orderBy` array.
131
+ * @param orderBy - Sort expressions from the query AST.
132
+ * @returns A Prisma `orderBy` array, or `undefined` when there are no sorts.
133
+ */
134
+ export function astToPrismaOrderBy(orderBy) {
135
+ if (!orderBy?.length)
136
+ return undefined;
137
+ return orderBy.map((sort) => ({
138
+ [sort.field]: sort.order.toUpperCase() === SqlOrder.DESC ? 'desc' : 'asc'
139
+ }));
140
+ }
@@ -3,7 +3,7 @@ import type { CreatePrismaResourcesOptions } from './types.js';
3
3
  /**
4
4
  * Creates resource definitions for Prisma models based on the provided schema and options.
5
5
  * @param prismaClient An instance of the Prisma client to be used for database operations.
6
- * @param schema An array of model definitions, where each model includes its name, optional database name, and fields.
6
+ * @param schema An array of model definitions, where each model includes its name and fields.
7
7
  * @param options An optional configuration object that allows customization of the generated resources, including model-specific options, default limits, and permissions.
8
8
  * @returns An array of resource definitions that can be used to set up API endpoints or other data access layers based on the Prisma models.
9
9
  */
@@ -12,4 +12,3 @@ export declare function createPrismaResources(prismaClient: object, schema: Read
12
12
  dbName?: string | null;
13
13
  fields: ModelField[];
14
14
  }>, options?: CreatePrismaResourcesOptions): ResourceDefinition[];
15
- //# sourceMappingURL=createPrismaResources.d.ts.map