@opensaas/stack-core 0.23.0 → 0.25.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 (77) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +256 -0
  3. package/dist/access/access-filter.d.ts +39 -0
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +121 -0
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/field-access.d.ts +1 -0
  8. package/dist/access/field-access.d.ts.map +1 -1
  9. package/dist/access/field-access.js +79 -4
  10. package/dist/access/field-access.js.map +1 -1
  11. package/dist/access/field-access.test.js +213 -0
  12. package/dist/access/field-access.test.js.map +1 -1
  13. package/dist/access/index.d.ts +1 -1
  14. package/dist/access/index.d.ts.map +1 -1
  15. package/dist/access/index.js +1 -1
  16. package/dist/access/index.js.map +1 -1
  17. package/dist/access/types.d.ts +39 -0
  18. package/dist/access/types.d.ts.map +1 -1
  19. package/dist/config/index.d.ts +1 -1
  20. package/dist/config/index.d.ts.map +1 -1
  21. package/dist/config/types.d.ts +378 -0
  22. package/dist/config/types.d.ts.map +1 -1
  23. package/dist/context/index.d.ts +19 -1
  24. package/dist/context/index.d.ts.map +1 -1
  25. package/dist/context/index.js +153 -26
  26. package/dist/context/index.js.map +1 -1
  27. package/dist/context/nested-operations.d.ts +59 -3
  28. package/dist/context/nested-operations.d.ts.map +1 -1
  29. package/dist/context/nested-operations.js +552 -129
  30. package/dist/context/nested-operations.js.map +1 -1
  31. package/dist/context/transaction-boundary.d.ts +91 -0
  32. package/dist/context/transaction-boundary.d.ts.map +1 -0
  33. package/dist/context/transaction-boundary.js +329 -0
  34. package/dist/context/transaction-boundary.js.map +1 -0
  35. package/dist/context/write-pipeline.d.ts +15 -1
  36. package/dist/context/write-pipeline.d.ts.map +1 -1
  37. package/dist/context/write-pipeline.js +173 -10
  38. package/dist/context/write-pipeline.js.map +1 -1
  39. package/dist/fields/calendar-day.test.d.ts +2 -0
  40. package/dist/fields/calendar-day.test.d.ts.map +1 -0
  41. package/dist/fields/calendar-day.test.js +120 -0
  42. package/dist/fields/calendar-day.test.js.map +1 -0
  43. package/dist/fields/index.d.ts +18 -2
  44. package/dist/fields/index.d.ts.map +1 -1
  45. package/dist/fields/index.js +93 -17
  46. package/dist/fields/index.js.map +1 -1
  47. package/dist/hooks/index.d.ts +116 -0
  48. package/dist/hooks/index.d.ts.map +1 -1
  49. package/dist/hooks/index.js +154 -0
  50. package/dist/hooks/index.js.map +1 -1
  51. package/dist/validation/schema.test.js +222 -1
  52. package/dist/validation/schema.test.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/access/access-filter.ts +156 -0
  55. package/src/access/field-access.test.ts +255 -0
  56. package/src/access/field-access.ts +91 -5
  57. package/src/access/index.ts +1 -1
  58. package/src/access/types.ts +45 -0
  59. package/src/config/index.ts +2 -0
  60. package/src/config/types.ts +426 -0
  61. package/src/context/index.ts +207 -37
  62. package/src/context/nested-operations.ts +969 -143
  63. package/src/context/transaction-boundary.ts +440 -0
  64. package/src/context/write-pipeline.ts +234 -13
  65. package/src/fields/calendar-day.test.ts +140 -0
  66. package/src/fields/index.ts +96 -16
  67. package/src/hooks/index.ts +265 -0
  68. package/src/validation/schema.test.ts +266 -1
  69. package/tests/access.test.ts +24 -16
  70. package/tests/config.test.ts +30 -0
  71. package/tests/context.test.ts +481 -0
  72. package/tests/field-types.test.ts +17 -3
  73. package/tests/nested-access-and-hooks.test.ts +1130 -54
  74. package/tests/nested-operation-registry.test.ts +28 -3
  75. package/tests/nested-write-hooks.test.ts +864 -0
  76. package/tests/transaction-boundary-hooks.test.ts +465 -0
  77. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,440 @@
1
+ import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
2
+ import type { AccessContext, PrismaClientLike } from '../access/types.js'
3
+ import { getRelatedListConfig } from '../access/index.js'
4
+ import {
5
+ executeBeforeTransaction,
6
+ executeAfterTransaction,
7
+ executeFieldBeforeTransactionHooks,
8
+ executeFieldAfterTransactionHooks,
9
+ type TransactionOutcome,
10
+ } from '../hooks/index.js'
11
+ import type { WriteOperation } from './write-pipeline.js'
12
+
13
+ /**
14
+ * Transaction-boundary hooks (#590 / ADR-0010).
15
+ *
16
+ * `beforeTransaction`/`afterTransaction` run OUTSIDE the write's `$transaction`
17
+ * — `beforeTransaction` before it opens, `afterTransaction` after it settles —
18
+ * for non-transactional side effects (e.g. external API calls) that must not
19
+ * hold a DB transaction open and cannot be rolled back. The pair forms a
20
+ * compensation bracket around the atomic write described by ADR-0010.
21
+ *
22
+ * This module:
23
+ * 1. Enumerates the lists involved in a write up front, BY WALKING THE INPUT
24
+ * TREE only (no DB reads), so the bracket can run per involved list before
25
+ * the transaction opens (mirroring how in-transaction before/afterOperation
26
+ * fire per record, but at list granularity).
27
+ * 2. Runs all `beforeTransaction` hooks, tracking exactly which involved lists'
28
+ * `beforeTransaction` ran, then — after the caller settles the transaction —
29
+ * runs `afterTransaction` ONLY for those lists (the symmetric-bracket
30
+ * "always-run" rule), surfacing any hook errors afterward.
31
+ */
32
+
33
+ /**
34
+ * One list involved in a write, with the data the transaction-boundary hooks
35
+ * receive. Enumerated purely from the input tree (no DB reads).
36
+ *
37
+ * The persisted/pre-write rows (`item`/`originalItem`) are surfaced to
38
+ * `afterTransaction` ONLY for the TOP-LEVEL record (`isTopLevel`). For nested
39
+ * lists the per-record persisted row is not reliably recoverable outside the
40
+ * transaction, so they are passed as `undefined` rather than mis-handing the
41
+ * top-level row as if it were the nested row. `originalItem` here is therefore
42
+ * populated only for the top-level update/delete target (the pipeline resolves
43
+ * it before the transaction opens).
44
+ */
45
+ export interface InvolvedList {
46
+ listKey: string
47
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
48
+ listConfig: ListConfig<any>
49
+ operation: WriteOperation
50
+ /** Whether this is the top-level write target (the only list with a reliable persisted row). */
51
+ isTopLevel: boolean
52
+ /** The input payload for this involvement (create/update); `undefined` for delete. */
53
+ inputData: Record<string, unknown> | undefined
54
+ /** The existing row for the TOP-LEVEL update/delete target; `undefined` otherwise. */
55
+ originalItem: Record<string, unknown> | undefined
56
+ }
57
+
58
+ /** Max nesting depth walked when enumerating involved lists (matches nested-operations). */
59
+ const MAX_DEPTH = 5
60
+
61
+ /** Nested-op kinds whose payloads imply an involved list + operation. */
62
+ const NESTED_OP_OPERATIONS: ReadonlyArray<{ kind: string; operation: WriteOperation }> = [
63
+ { kind: 'create', operation: 'create' },
64
+ { kind: 'update', operation: 'update' },
65
+ { kind: 'delete', operation: 'delete' },
66
+ // connectOrCreate's create branch may create; treat as a possible create involvement.
67
+ { kind: 'connectOrCreate', operation: 'create' },
68
+ ]
69
+
70
+ function isRelationshipField(fieldConfig: FieldConfig | undefined): boolean {
71
+ return fieldConfig?.type === 'relationship'
72
+ }
73
+
74
+ function asRecordArray(value: unknown): Array<Record<string, unknown>> {
75
+ if (value == null) return []
76
+ if (Array.isArray(value)) return value.filter((v) => v && typeof v === 'object')
77
+ if (typeof value === 'object') return [value as Record<string, unknown>]
78
+ return []
79
+ }
80
+
81
+ /**
82
+ * Extract the create/update payload from a nested-op entry so nested
83
+ * `beforeTransaction` receives meaningful `inputData`.
84
+ *
85
+ * - `create`: the entry itself is the create data.
86
+ * - `update`: the entry's `data`.
87
+ * - `connectOrCreate`: the entry's `create`.
88
+ * - `delete`: no input payload.
89
+ */
90
+ function nestedInputData(
91
+ kind: string,
92
+ entry: Record<string, unknown>,
93
+ ): Record<string, unknown> | undefined {
94
+ if (kind === 'update') {
95
+ const data = entry.data
96
+ return data && typeof data === 'object' ? (data as Record<string, unknown>) : undefined
97
+ }
98
+ if (kind === 'connectOrCreate') {
99
+ const create = entry.create
100
+ return create && typeof create === 'object' ? (create as Record<string, unknown>) : undefined
101
+ }
102
+ if (kind === 'delete') return undefined
103
+ // create
104
+ return entry
105
+ }
106
+
107
+ /**
108
+ * Recursively walk a write payload's relationship fields, appending one
109
+ * {@link InvolvedList} per nested create/update/delete involvement. De-dups by
110
+ * (listKey, operation) so a list with many nested records of the same operation
111
+ * fires its transaction-boundary bracket once (these hooks are a per-LIST
112
+ * compensation bracket, not per-record).
113
+ */
114
+ function walkNested(
115
+ data: Record<string, unknown> | undefined,
116
+ fieldConfigs: Record<string, FieldConfig>,
117
+ config: OpenSaasConfig,
118
+ out: InvolvedList[],
119
+ seen: Set<string>,
120
+ depth: number,
121
+ ): void {
122
+ if (!data || depth >= MAX_DEPTH) return
123
+
124
+ for (const [fieldName, value] of Object.entries(data)) {
125
+ const fieldConfig = fieldConfigs[fieldName]
126
+ if (!isRelationshipField(fieldConfig) || value == null || typeof value !== 'object') continue
127
+
128
+ const relationshipField = fieldConfig as { type: 'relationship'; ref: string }
129
+ const related = getRelatedListConfig(relationshipField.ref, config)
130
+ if (!related) continue
131
+ const { listName: relatedListName, listConfig: relatedListConfig } = related
132
+
133
+ const valueRecord = value as Record<string, unknown>
134
+ for (const { kind, operation } of NESTED_OP_OPERATIONS) {
135
+ const opValue = valueRecord[kind]
136
+ if (opValue === undefined) continue
137
+
138
+ const entries = asRecordArray(opValue)
139
+ // Record the involvement once per (list, operation).
140
+ const dedupeKey = `${relatedListName}:${operation}`
141
+ if (!seen.has(dedupeKey)) {
142
+ seen.add(dedupeKey)
143
+ out.push({
144
+ listKey: relatedListName,
145
+ listConfig: relatedListConfig,
146
+ operation,
147
+ isTopLevel: false,
148
+ inputData: entries.length > 0 ? nestedInputData(kind, entries[0]) : undefined,
149
+ originalItem: undefined,
150
+ })
151
+ }
152
+
153
+ // Recurse into each nested entry's own relationship payload.
154
+ for (const entry of entries) {
155
+ const childData = nestedInputData(kind, entry)
156
+ walkNested(childData, relatedListConfig.fields, config, out, seen, depth + 1)
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Enumerate the lists involved in a write — the top-level list plus every
164
+ * nested create/update/delete target reachable from the input tree — WITHOUT
165
+ * any DB reads. The top-level list is always first.
166
+ */
167
+ export function enumerateInvolvedLists(args: {
168
+ listName: string
169
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
170
+ listConfig: ListConfig<any>
171
+ operation: WriteOperation
172
+ inputData: Record<string, unknown> | undefined
173
+ /** Top-level existing row (update/delete), resolved before the transaction by the caller. */
174
+ topLevelOriginalItem: Record<string, unknown> | undefined
175
+ config: OpenSaasConfig
176
+ }): InvolvedList[] {
177
+ const { listName, listConfig, operation, inputData, topLevelOriginalItem, config } = args
178
+
179
+ const out: InvolvedList[] = [
180
+ {
181
+ listKey: listName,
182
+ listConfig,
183
+ operation,
184
+ isTopLevel: true,
185
+ inputData,
186
+ originalItem: topLevelOriginalItem,
187
+ },
188
+ ]
189
+ const seen = new Set<string>([`${listName}:${operation}`])
190
+
191
+ // Delete has no nested payload to walk (inputData is undefined).
192
+ walkNested(inputData, listConfig.fields, config, out, seen, 0)
193
+
194
+ return out
195
+ }
196
+
197
+ /**
198
+ * Run the list- and field-level `beforeTransaction` hooks for one involved list.
199
+ * A throw propagates to the caller (which aborts the write).
200
+ */
201
+ async function runBeforeTransactionForList<TPrisma extends PrismaClientLike>(
202
+ involved: InvolvedList,
203
+ context: AccessContext<TPrisma>,
204
+ ): Promise<void> {
205
+ const { listKey, listConfig, operation, inputData, originalItem } = involved
206
+
207
+ if (operation === 'create') {
208
+ await executeBeforeTransaction(listConfig.hooks, {
209
+ listKey,
210
+ operation: 'create',
211
+ inputData: inputData ?? {},
212
+ context,
213
+ })
214
+ } else if (operation === 'update') {
215
+ await executeBeforeTransaction(listConfig.hooks, {
216
+ listKey,
217
+ operation: 'update',
218
+ inputData: inputData ?? {},
219
+ item: originalItem,
220
+ context,
221
+ })
222
+ } else {
223
+ await executeBeforeTransaction(listConfig.hooks, {
224
+ listKey,
225
+ operation: 'delete',
226
+ item: originalItem,
227
+ context,
228
+ })
229
+ }
230
+
231
+ await executeFieldBeforeTransactionHooks(
232
+ inputData,
233
+ listConfig.fields,
234
+ operation,
235
+ context,
236
+ listKey,
237
+ originalItem,
238
+ )
239
+ }
240
+
241
+ /**
242
+ * Run the list- and field-level `afterTransaction` hooks for one involved list
243
+ * with the settled {@link TransactionOutcome}. Collects (does not throw) any
244
+ * errors so the caller can keep running the remaining lists' compensators.
245
+ */
246
+ async function runAfterTransactionForList<TPrisma extends PrismaClientLike>(
247
+ involved: InvolvedList,
248
+ outcome: TransactionOutcome,
249
+ context: AccessContext<TPrisma>,
250
+ errors: unknown[],
251
+ ): Promise<void> {
252
+ const { listKey, listConfig, operation, isTopLevel, inputData, originalItem } = involved
253
+
254
+ // On commit, the persisted row (`outcome.item`) is the TOP-LEVEL row. We only
255
+ // surface `item`/`originalItem` for the top-level list — handing the top-level
256
+ // row to a nested list's hook (whose type is the nested list's own item) would
257
+ // be unsound, since the hook would silently read the wrong record. For nested
258
+ // lists we pass `undefined`; per-record nested compensation must use the
259
+ // in-transaction `afterOperation`, which already receives the correct nested row.
260
+ try {
261
+ if (outcome.status === 'committed') {
262
+ // The persisted row is surfaced only for the top-level list (see above).
263
+ const committedItem = isTopLevel ? outcome.item : undefined
264
+ if (operation === 'create') {
265
+ await executeAfterTransaction(listConfig.hooks, {
266
+ listKey,
267
+ operation: 'create',
268
+ status: 'committed',
269
+ inputData: inputData ?? {},
270
+ item: committedItem,
271
+ context,
272
+ })
273
+ } else if (operation === 'update') {
274
+ await executeAfterTransaction(listConfig.hooks, {
275
+ listKey,
276
+ operation: 'update',
277
+ status: 'committed',
278
+ inputData: inputData ?? {},
279
+ originalItem: isTopLevel ? originalItem : undefined,
280
+ item: committedItem,
281
+ context,
282
+ })
283
+ } else {
284
+ await executeAfterTransaction(listConfig.hooks, {
285
+ listKey,
286
+ operation: 'delete',
287
+ status: 'committed',
288
+ originalItem: isTopLevel ? originalItem : undefined,
289
+ context,
290
+ })
291
+ }
292
+ } else {
293
+ // rolled-back: no persisted item.
294
+ if (operation === 'create') {
295
+ await executeAfterTransaction(listConfig.hooks, {
296
+ listKey,
297
+ operation: 'create',
298
+ status: 'rolled-back',
299
+ inputData: inputData ?? {},
300
+ error: outcome.error,
301
+ context,
302
+ })
303
+ } else if (operation === 'update') {
304
+ await executeAfterTransaction(listConfig.hooks, {
305
+ listKey,
306
+ operation: 'update',
307
+ status: 'rolled-back',
308
+ inputData: inputData ?? {},
309
+ originalItem,
310
+ error: outcome.error,
311
+ context,
312
+ })
313
+ } else {
314
+ await executeAfterTransaction(listConfig.hooks, {
315
+ listKey,
316
+ operation: 'delete',
317
+ status: 'rolled-back',
318
+ originalItem,
319
+ error: outcome.error,
320
+ context,
321
+ })
322
+ }
323
+ }
324
+ } catch (err) {
325
+ // A throwing afterTransaction must NOT stop the remaining compensators.
326
+ errors.push(err)
327
+ }
328
+
329
+ try {
330
+ await executeFieldAfterTransactionHooks(
331
+ outcome,
332
+ inputData,
333
+ listConfig.fields,
334
+ operation,
335
+ context,
336
+ listKey,
337
+ isTopLevel,
338
+ originalItem,
339
+ )
340
+ } catch (err) {
341
+ errors.push(err)
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Aggregated error surfaced when one or more `afterTransaction` hooks throw.
347
+ * The DB state is already final; all compensators still ran.
348
+ */
349
+ export class AfterTransactionError extends Error {
350
+ public errors: unknown[]
351
+ constructor(errors: unknown[]) {
352
+ super(
353
+ `afterTransaction hook(s) failed: ${errors
354
+ .map((e) => (e instanceof Error ? e.message : String(e)))
355
+ .join('; ')}`,
356
+ )
357
+ this.name = 'AfterTransactionError'
358
+ this.errors = errors
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Bracket a write's transaction with the transaction-boundary hooks (#590).
364
+ *
365
+ * Sequence:
366
+ * 1. Run every involved list's `beforeTransaction` in order, tracking which
367
+ * ran. A throw aborts: the transaction is NEVER opened; `afterTransaction`
368
+ * fires (status `rolled-back`, with the throw as `error`) ONLY for the lists
369
+ * whose `beforeTransaction` already ran (symmetric bracket), and the throw
370
+ * is then re-surfaced.
371
+ * 2. Otherwise open the transaction via `runTransaction` (the existing #569
372
+ * machinery). On settle (commit or rollback) run `afterTransaction` for
373
+ * EVERY involved list (all of their `beforeTransaction` ran) with the
374
+ * outcome.
375
+ * 3. If any `afterTransaction` throws, the rest still run; the collected
376
+ * errors are surfaced afterward as an {@link AfterTransactionError}.
377
+ *
378
+ * Sudo does not affect these hooks — they always run; sudo only bypasses access.
379
+ */
380
+ export async function runWithTransactionBoundary<TPrisma extends PrismaClientLike>(args: {
381
+ involvedLists: InvolvedList[]
382
+ context: AccessContext<TPrisma>
383
+ runTransaction: () => Promise<Record<string, unknown> | null>
384
+ }): Promise<Record<string, unknown> | null> {
385
+ const { involvedLists, context, runTransaction } = args
386
+
387
+ // Lists whose beforeTransaction ran (in order), for the symmetric bracket. A
388
+ // list is marked as "ran" the moment its beforeTransaction BEGINS, so even a
389
+ // list whose beforeTransaction throws gets its afterTransaction (it may have
390
+ // taken a partial external action that needs compensating).
391
+ const ran: InvolvedList[] = []
392
+
393
+ let beforeError: unknown
394
+ for (const involved of involvedLists) {
395
+ ran.push(involved)
396
+ try {
397
+ await runBeforeTransactionForList(involved, context)
398
+ } catch (err) {
399
+ beforeError = err
400
+ break
401
+ }
402
+ }
403
+
404
+ // beforeTransaction threw → abort: never open the transaction, compensate the
405
+ // lists whose beforeTransaction ran, then surface the original error.
406
+ if (beforeError !== undefined) {
407
+ const outcome: TransactionOutcome = { status: 'rolled-back', error: beforeError }
408
+ const afterErrors: unknown[] = []
409
+ for (const involved of ran) {
410
+ await runAfterTransactionForList(involved, outcome, context, afterErrors)
411
+ }
412
+ throw beforeError
413
+ }
414
+
415
+ // Open the transaction and capture the settle outcome.
416
+ let outcome: TransactionOutcome
417
+ let result: Record<string, unknown> | null = null
418
+ let txError: unknown
419
+ try {
420
+ result = await runTransaction()
421
+ outcome = { status: 'committed', item: result ?? {} }
422
+ } catch (err) {
423
+ txError = err
424
+ outcome = { status: 'rolled-back', error: err }
425
+ }
426
+
427
+ // afterTransaction always runs for every list whose beforeTransaction ran
428
+ // (here: all involved lists). All compensators run even if one throws.
429
+ const afterErrors: unknown[] = []
430
+ for (const involved of ran) {
431
+ await runAfterTransactionForList(involved, outcome, context, afterErrors)
432
+ }
433
+
434
+ // Surface errors: the transaction's own error takes precedence (the write
435
+ // failed); otherwise any afterTransaction errors.
436
+ if (txError !== undefined) throw txError
437
+ if (afterErrors.length > 0) throw new AfterTransactionError(afterErrors)
438
+
439
+ return result
440
+ }