@opensaas/stack-core 0.24.0 → 0.26.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +263 -0
- package/CLAUDE.md +50 -0
- package/dist/access/access-filter.d.ts +39 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +121 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-access.d.ts +1 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +79 -4
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +213 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/types.d.ts +39 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/types.d.ts +318 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/apply-defaults.d.ts +36 -0
- package/dist/context/apply-defaults.d.ts.map +1 -0
- package/dist/context/apply-defaults.js +70 -0
- package/dist/context/apply-defaults.js.map +1 -0
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +10 -0
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +79 -18
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +194 -39
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +59 -3
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +558 -129
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +91 -0
- package/dist/context/transaction-boundary.d.ts.map +1 -0
- package/dist/context/transaction-boundary.js +329 -0
- package/dist/context/transaction-boundary.js.map +1 -0
- package/dist/context/write-pipeline.d.ts +15 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +173 -10
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.d.ts +2 -0
- package/dist/fields/calendar-day.test.d.ts.map +1 -0
- package/dist/fields/calendar-day.test.js +120 -0
- package/dist/fields/calendar-day.test.js.map +1 -0
- package/dist/fields/index.d.ts +18 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +93 -17
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +116 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +154 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/validation/schema.test.js +222 -1
- package/dist/validation/schema.test.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.ts +156 -0
- package/src/access/field-access.test.ts +255 -0
- package/src/access/field-access.ts +91 -5
- package/src/access/index.ts +1 -1
- package/src/access/types.ts +45 -0
- package/src/config/types.ts +364 -0
- package/src/context/apply-defaults.ts +79 -0
- package/src/context/hook-pipeline.ts +11 -0
- package/src/context/index.ts +340 -68
- package/src/context/nested-operations.ts +976 -143
- package/src/context/transaction-boundary.ts +440 -0
- package/src/context/write-pipeline.ts +234 -13
- package/src/fields/calendar-day.test.ts +140 -0
- package/src/fields/index.ts +96 -16
- package/src/hooks/index.ts +265 -0
- package/src/index.ts +5 -0
- package/src/validation/schema.test.ts +266 -1
- package/tests/access.test.ts +24 -16
- package/tests/apply-defaults.test.ts +119 -0
- package/tests/context.test.ts +481 -0
- package/tests/default-value-create.test.ts +299 -0
- package/tests/field-types.test.ts +17 -3
- package/tests/interactive-transaction.test.ts +444 -0
- package/tests/nested-access-and-hooks.test.ts +1130 -54
- package/tests/nested-operation-registry.test.ts +28 -3
- package/tests/nested-write-hooks.test.ts +864 -0
- package/tests/sudo.test.ts +7 -3
- package/tests/transaction-boundary-hooks.test.ts +465 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/config/types.ts
CHANGED
|
@@ -150,6 +150,125 @@ export type FieldAfterOperationHookArgs<
|
|
|
150
150
|
context: import('../access/types.js').AccessContext
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Arguments for field-level beforeTransaction hook (#590 / ADR-0010).
|
|
155
|
+
*
|
|
156
|
+
* Transaction-boundary hooks run OUTSIDE the write's database transaction.
|
|
157
|
+
* `beforeTransaction` runs before the transaction opens, so it has the input
|
|
158
|
+
* data but no persisted `item` yet (and, for create, no `item` to read). For
|
|
159
|
+
* update/delete the existing `item` is best-effort: present for the top-level
|
|
160
|
+
* target (which the pipeline resolves before opening the transaction) and
|
|
161
|
+
* `undefined` for nested targets (not resolved at the boundary to avoid
|
|
162
|
+
* pre-transaction reads). Use it for non-transactional side effects (e.g.
|
|
163
|
+
* external API calls) whose compensation pairs with `afterTransaction`.
|
|
164
|
+
*/
|
|
165
|
+
export type FieldBeforeTransactionHookArgs<
|
|
166
|
+
TTypeInfo extends TypeInfo,
|
|
167
|
+
TFieldKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
|
|
168
|
+
> =
|
|
169
|
+
| {
|
|
170
|
+
listKey: string
|
|
171
|
+
fieldKey: TFieldKey
|
|
172
|
+
operation: 'create'
|
|
173
|
+
inputData: TTypeInfo['inputs']['create']
|
|
174
|
+
context: import('../access/types.js').AccessContext
|
|
175
|
+
}
|
|
176
|
+
| {
|
|
177
|
+
listKey: string
|
|
178
|
+
fieldKey: TFieldKey
|
|
179
|
+
operation: 'update'
|
|
180
|
+
inputData: TTypeInfo['inputs']['update']
|
|
181
|
+
item: TTypeInfo['item'] | undefined
|
|
182
|
+
context: import('../access/types.js').AccessContext
|
|
183
|
+
}
|
|
184
|
+
| {
|
|
185
|
+
listKey: string
|
|
186
|
+
fieldKey: TFieldKey
|
|
187
|
+
operation: 'delete'
|
|
188
|
+
item: TTypeInfo['item'] | undefined
|
|
189
|
+
context: import('../access/types.js').AccessContext
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Arguments for field-level afterTransaction hook (#590 / ADR-0010).
|
|
194
|
+
*
|
|
195
|
+
* Runs AFTER the transaction settles and ALWAYS runs when its paired
|
|
196
|
+
* `beforeTransaction` ran (symmetric bracket). The `status` discriminant tells
|
|
197
|
+
* the hook whether the write committed or rolled back:
|
|
198
|
+
* - `committed`: the persisted `item`/`originalItem` are populated ONLY for the
|
|
199
|
+
* TOP-LEVEL record of the write. For NESTED lists they are `undefined` — the
|
|
200
|
+
* per-record persisted row is not reliably recoverable outside the
|
|
201
|
+
* transaction, and these hooks fire at per-(list, operation) granularity, not
|
|
202
|
+
* per record. For per-record nested compensation use the in-transaction
|
|
203
|
+
* `afterOperation` (which receives the correct nested `item`).
|
|
204
|
+
* - `rolled-back`: NO persisted `item`; the hook gets `inputData` and the
|
|
205
|
+
* `error` that caused the rollback so it can compensate for whatever
|
|
206
|
+
* `beforeTransaction` did externally.
|
|
207
|
+
*/
|
|
208
|
+
export type FieldAfterTransactionHookArgs<
|
|
209
|
+
TTypeInfo extends TypeInfo,
|
|
210
|
+
TFieldKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
|
|
211
|
+
> =
|
|
212
|
+
| {
|
|
213
|
+
listKey: string
|
|
214
|
+
fieldKey: TFieldKey
|
|
215
|
+
operation: 'create'
|
|
216
|
+
status: 'committed'
|
|
217
|
+
inputData: TTypeInfo['inputs']['create']
|
|
218
|
+
/** Persisted row — populated for the top-level list only; `undefined` for nested lists. */
|
|
219
|
+
item: TTypeInfo['item'] | undefined
|
|
220
|
+
context: import('../access/types.js').AccessContext
|
|
221
|
+
}
|
|
222
|
+
| {
|
|
223
|
+
listKey: string
|
|
224
|
+
fieldKey: TFieldKey
|
|
225
|
+
operation: 'create'
|
|
226
|
+
status: 'rolled-back'
|
|
227
|
+
inputData: TTypeInfo['inputs']['create']
|
|
228
|
+
error: unknown
|
|
229
|
+
context: import('../access/types.js').AccessContext
|
|
230
|
+
}
|
|
231
|
+
| {
|
|
232
|
+
listKey: string
|
|
233
|
+
fieldKey: TFieldKey
|
|
234
|
+
operation: 'update'
|
|
235
|
+
status: 'committed'
|
|
236
|
+
inputData: TTypeInfo['inputs']['update']
|
|
237
|
+
/** Pre-write row — populated for the top-level list only; `undefined` for nested lists. */
|
|
238
|
+
originalItem: TTypeInfo['item'] | undefined
|
|
239
|
+
/** Persisted row — populated for the top-level list only; `undefined` for nested lists. */
|
|
240
|
+
item: TTypeInfo['item'] | undefined
|
|
241
|
+
context: import('../access/types.js').AccessContext
|
|
242
|
+
}
|
|
243
|
+
| {
|
|
244
|
+
listKey: string
|
|
245
|
+
fieldKey: TFieldKey
|
|
246
|
+
operation: 'update'
|
|
247
|
+
status: 'rolled-back'
|
|
248
|
+
inputData: TTypeInfo['inputs']['update']
|
|
249
|
+
originalItem: TTypeInfo['item'] | undefined
|
|
250
|
+
error: unknown
|
|
251
|
+
context: import('../access/types.js').AccessContext
|
|
252
|
+
}
|
|
253
|
+
| {
|
|
254
|
+
listKey: string
|
|
255
|
+
fieldKey: TFieldKey
|
|
256
|
+
operation: 'delete'
|
|
257
|
+
status: 'committed'
|
|
258
|
+
/** Pre-write row — populated for the top-level list only; `undefined` for nested lists. */
|
|
259
|
+
originalItem: TTypeInfo['item'] | undefined
|
|
260
|
+
context: import('../access/types.js').AccessContext
|
|
261
|
+
}
|
|
262
|
+
| {
|
|
263
|
+
listKey: string
|
|
264
|
+
fieldKey: TFieldKey
|
|
265
|
+
operation: 'delete'
|
|
266
|
+
status: 'rolled-back'
|
|
267
|
+
originalItem: TTypeInfo['item'] | undefined
|
|
268
|
+
error: unknown
|
|
269
|
+
context: import('../access/types.js').AccessContext
|
|
270
|
+
}
|
|
271
|
+
|
|
153
272
|
/**
|
|
154
273
|
* Arguments for field-level resolveOutput hook
|
|
155
274
|
* Used to transform field values after database read
|
|
@@ -278,6 +397,53 @@ export type FieldHooks<
|
|
|
278
397
|
*/
|
|
279
398
|
afterOperation?: (args: FieldAfterOperationHookArgs<TTypeInfo, TFieldKey>) => Promise<void> | void
|
|
280
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Perform side effects BEFORE the write's database transaction opens
|
|
402
|
+
* (#590 / ADR-0010 transaction-boundary hook).
|
|
403
|
+
*
|
|
404
|
+
* Unlike `beforeOperation` (which runs INSIDE the transaction and rolls back
|
|
405
|
+
* with it), this runs OUTSIDE the transaction — use it for non-transactional
|
|
406
|
+
* side effects such as external API calls that must not hold a DB transaction
|
|
407
|
+
* open and cannot be rolled back. If this throws, the write is aborted (the
|
|
408
|
+
* transaction never opens) and the paired `afterTransaction` fires with
|
|
409
|
+
* `status: 'rolled-back'`.
|
|
410
|
+
*
|
|
411
|
+
* @example
|
|
412
|
+
* ```typescript
|
|
413
|
+
* beforeTransaction: async ({ operation, inputData }) => {
|
|
414
|
+
* await externalApi.reserve(inputData.externalId)
|
|
415
|
+
* }
|
|
416
|
+
* ```
|
|
417
|
+
*/
|
|
418
|
+
beforeTransaction?: (
|
|
419
|
+
args: FieldBeforeTransactionHookArgs<TTypeInfo, TFieldKey>,
|
|
420
|
+
) => Promise<void> | void
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Perform side effects AFTER the write's database transaction settles
|
|
424
|
+
* (#590 / ADR-0010 transaction-boundary hook).
|
|
425
|
+
*
|
|
426
|
+
* ALWAYS runs when the paired `beforeTransaction` ran (symmetric bracket),
|
|
427
|
+
* receiving `status: 'committed' | 'rolled-back'`. On `committed` it gets the
|
|
428
|
+
* persisted `item` ONLY for the top-level record (`undefined` for nested
|
|
429
|
+
* lists — use the in-transaction `afterOperation` for per-record nested
|
|
430
|
+
* compensation); on `rolled-back` it gets the `error` that caused the
|
|
431
|
+
* rollback and NO `item`, so it can compensate for whatever `beforeTransaction`
|
|
432
|
+
* did externally.
|
|
433
|
+
*
|
|
434
|
+
* @example
|
|
435
|
+
* ```typescript
|
|
436
|
+
* afterTransaction: async (args) => {
|
|
437
|
+
* if (args.status === 'rolled-back') {
|
|
438
|
+
* await externalApi.release(args.inputData.externalId)
|
|
439
|
+
* }
|
|
440
|
+
* }
|
|
441
|
+
* ```
|
|
442
|
+
*/
|
|
443
|
+
afterTransaction?: (
|
|
444
|
+
args: FieldAfterTransactionHookArgs<TTypeInfo, TFieldKey>,
|
|
445
|
+
) => Promise<void> | void
|
|
446
|
+
|
|
281
447
|
/**
|
|
282
448
|
* Transform field value after database read
|
|
283
449
|
* Called when returning results from query operations
|
|
@@ -1269,6 +1435,117 @@ export type AfterOperationHookArgs<
|
|
|
1269
1435
|
context: import('../access/types.js').AccessContext
|
|
1270
1436
|
}
|
|
1271
1437
|
|
|
1438
|
+
/**
|
|
1439
|
+
* Hook arguments for the list-level beforeTransaction hook (#590 / ADR-0010).
|
|
1440
|
+
*
|
|
1441
|
+
* Runs BEFORE the write's transaction opens (outside it). Has input data but no
|
|
1442
|
+
* persisted `item`. For update/delete the existing `item` is best-effort:
|
|
1443
|
+
* present for the top-level target (resolved before the transaction opens) and
|
|
1444
|
+
* `undefined` for nested targets. For non-transactional side effects whose
|
|
1445
|
+
* compensation pairs with `afterTransaction`.
|
|
1446
|
+
*/
|
|
1447
|
+
export type BeforeTransactionHookArgs<
|
|
1448
|
+
TOutput = Record<string, unknown>,
|
|
1449
|
+
TCreateInput = Record<string, unknown>,
|
|
1450
|
+
TUpdateInput = Record<string, unknown>,
|
|
1451
|
+
> =
|
|
1452
|
+
| {
|
|
1453
|
+
listKey: string
|
|
1454
|
+
operation: 'create'
|
|
1455
|
+
inputData: TCreateInput
|
|
1456
|
+
context: import('../access/types.js').AccessContext
|
|
1457
|
+
}
|
|
1458
|
+
| {
|
|
1459
|
+
listKey: string
|
|
1460
|
+
operation: 'update'
|
|
1461
|
+
inputData: TUpdateInput
|
|
1462
|
+
item: TOutput | undefined
|
|
1463
|
+
context: import('../access/types.js').AccessContext
|
|
1464
|
+
}
|
|
1465
|
+
| {
|
|
1466
|
+
listKey: string
|
|
1467
|
+
operation: 'delete'
|
|
1468
|
+
item: TOutput | undefined
|
|
1469
|
+
context: import('../access/types.js').AccessContext
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* Hook arguments for the list-level afterTransaction hook (#590 / ADR-0010).
|
|
1474
|
+
*
|
|
1475
|
+
* Runs AFTER the write's transaction settles and ALWAYS runs when the paired
|
|
1476
|
+
* `beforeTransaction` ran (symmetric bracket). The `status` discriminant tells
|
|
1477
|
+
* the hook whether the write committed or rolled back:
|
|
1478
|
+
* - `committed`: the persisted `item`/`originalItem` are populated ONLY for the
|
|
1479
|
+
* TOP-LEVEL record of the write. For NESTED lists they are `undefined` — the
|
|
1480
|
+
* per-record persisted row is not reliably recoverable outside the
|
|
1481
|
+
* transaction (recovering it would duplicate #569's in-transaction id-diff
|
|
1482
|
+
* machinery), and these hooks fire at per-(list, operation) granularity, not
|
|
1483
|
+
* per record. For per-record nested compensation use the in-transaction
|
|
1484
|
+
* `afterOperation` (which receives the correct nested `item`);
|
|
1485
|
+
* transaction-boundary hooks are for external-call compensation keyed off
|
|
1486
|
+
* `status`/`inputData`.
|
|
1487
|
+
* - `rolled-back`: NO persisted `item`; the hook gets `inputData` and the
|
|
1488
|
+
* `error` that caused the rollback so it can compensate.
|
|
1489
|
+
*/
|
|
1490
|
+
export type AfterTransactionHookArgs<
|
|
1491
|
+
TOutput = Record<string, unknown>,
|
|
1492
|
+
TCreateInput = Record<string, unknown>,
|
|
1493
|
+
TUpdateInput = Record<string, unknown>,
|
|
1494
|
+
> =
|
|
1495
|
+
| {
|
|
1496
|
+
listKey: string
|
|
1497
|
+
operation: 'create'
|
|
1498
|
+
status: 'committed'
|
|
1499
|
+
inputData: TCreateInput
|
|
1500
|
+
/** Persisted row — populated for the top-level list only; `undefined` for nested lists. */
|
|
1501
|
+
item: TOutput | undefined
|
|
1502
|
+
context: import('../access/types.js').AccessContext
|
|
1503
|
+
}
|
|
1504
|
+
| {
|
|
1505
|
+
listKey: string
|
|
1506
|
+
operation: 'create'
|
|
1507
|
+
status: 'rolled-back'
|
|
1508
|
+
inputData: TCreateInput
|
|
1509
|
+
error: unknown
|
|
1510
|
+
context: import('../access/types.js').AccessContext
|
|
1511
|
+
}
|
|
1512
|
+
| {
|
|
1513
|
+
listKey: string
|
|
1514
|
+
operation: 'update'
|
|
1515
|
+
status: 'committed'
|
|
1516
|
+
inputData: TUpdateInput
|
|
1517
|
+
/** Pre-write row — populated for the top-level list only; `undefined` for nested lists. */
|
|
1518
|
+
originalItem: TOutput | undefined
|
|
1519
|
+
/** Persisted row — populated for the top-level list only; `undefined` for nested lists. */
|
|
1520
|
+
item: TOutput | undefined
|
|
1521
|
+
context: import('../access/types.js').AccessContext
|
|
1522
|
+
}
|
|
1523
|
+
| {
|
|
1524
|
+
listKey: string
|
|
1525
|
+
operation: 'update'
|
|
1526
|
+
status: 'rolled-back'
|
|
1527
|
+
inputData: TUpdateInput
|
|
1528
|
+
originalItem: TOutput | undefined
|
|
1529
|
+
error: unknown
|
|
1530
|
+
context: import('../access/types.js').AccessContext
|
|
1531
|
+
}
|
|
1532
|
+
| {
|
|
1533
|
+
listKey: string
|
|
1534
|
+
operation: 'delete'
|
|
1535
|
+
status: 'committed'
|
|
1536
|
+
/** Pre-write row — populated for the top-level list only; `undefined` for nested lists. */
|
|
1537
|
+
originalItem: TOutput | undefined
|
|
1538
|
+
context: import('../access/types.js').AccessContext
|
|
1539
|
+
}
|
|
1540
|
+
| {
|
|
1541
|
+
listKey: string
|
|
1542
|
+
operation: 'delete'
|
|
1543
|
+
status: 'rolled-back'
|
|
1544
|
+
originalItem: TOutput | undefined
|
|
1545
|
+
error: unknown
|
|
1546
|
+
context: import('../access/types.js').AccessContext
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1272
1549
|
export type Hooks<
|
|
1273
1550
|
TOutput = Record<string, unknown>,
|
|
1274
1551
|
TCreateInput = Record<string, unknown>,
|
|
@@ -1284,6 +1561,26 @@ export type Hooks<
|
|
|
1284
1561
|
afterOperation?: (
|
|
1285
1562
|
args: AfterOperationHookArgs<TOutput, TCreateInput, TUpdateInput>,
|
|
1286
1563
|
) => Promise<void>
|
|
1564
|
+
/**
|
|
1565
|
+
* Side effect BEFORE the write's transaction opens (#590 / ADR-0010).
|
|
1566
|
+
* Runs OUTSIDE the transaction — for non-transactional work (external API
|
|
1567
|
+
* calls). Throwing aborts the write; the paired `afterTransaction` then fires
|
|
1568
|
+
* with `status: 'rolled-back'`. See {@link BeforeTransactionHookArgs}.
|
|
1569
|
+
*/
|
|
1570
|
+
beforeTransaction?: (
|
|
1571
|
+
args: BeforeTransactionHookArgs<TOutput, TCreateInput, TUpdateInput>,
|
|
1572
|
+
) => Promise<void> | void
|
|
1573
|
+
/**
|
|
1574
|
+
* Side effect AFTER the write's transaction settles (#590 / ADR-0010).
|
|
1575
|
+
* ALWAYS runs when `beforeTransaction` ran; receives `committed | rolled-back`
|
|
1576
|
+
* + `error`. The persisted `item`/`originalItem` are present only on commit
|
|
1577
|
+
* AND only for the top-level record (`undefined` for nested lists). The
|
|
1578
|
+
* compensation half of the transaction-boundary bracket. See
|
|
1579
|
+
* {@link AfterTransactionHookArgs}.
|
|
1580
|
+
*/
|
|
1581
|
+
afterTransaction?: (
|
|
1582
|
+
args: AfterTransactionHookArgs<TOutput, TCreateInput, TUpdateInput>,
|
|
1583
|
+
) => Promise<void> | void
|
|
1287
1584
|
/**
|
|
1288
1585
|
* @deprecated Use 'validate' instead. This alias is provided for backwards compatibility.
|
|
1289
1586
|
*/
|
|
@@ -1673,6 +1970,40 @@ export type DatabaseConfig = {
|
|
|
1673
1970
|
* ```
|
|
1674
1971
|
*/
|
|
1675
1972
|
extendPrismaSchema?: (schema: string) => string
|
|
1973
|
+
/**
|
|
1974
|
+
* Override the Prisma `generator client { ... }` options the CLI emits for the
|
|
1975
|
+
* `.opensaas` prisma-client subtree.
|
|
1976
|
+
*
|
|
1977
|
+
* By default the generator emits `importFileExtension = "ts"` and
|
|
1978
|
+
* `moduleFormat = "esm"` so the whole generated bundle is statically
|
|
1979
|
+
* resolvable and matches the explicit `.ts` import-extension style the rest of
|
|
1980
|
+
* the `.opensaas` bundle uses (see ADR-0008). Supply this option only when you
|
|
1981
|
+
* need a different module/extension story (e.g. emitting `.js` extensions for a
|
|
1982
|
+
* Node-only consumer). Any value you provide wins; omitted keys fall back to
|
|
1983
|
+
* the `ts`/`esm` defaults.
|
|
1984
|
+
*
|
|
1985
|
+
* @example Emit `.js` extensions and CommonJS for a plain-Node consumer
|
|
1986
|
+
* ```typescript
|
|
1987
|
+
* db: {
|
|
1988
|
+
* provider: 'postgresql',
|
|
1989
|
+
* prismaGeneratorOptions: {
|
|
1990
|
+
* importFileExtension: 'js',
|
|
1991
|
+
* moduleFormat: 'commonjs',
|
|
1992
|
+
* },
|
|
1993
|
+
* // ... rest of config
|
|
1994
|
+
* }
|
|
1995
|
+
* ```
|
|
1996
|
+
*/
|
|
1997
|
+
prismaGeneratorOptions?: {
|
|
1998
|
+
/**
|
|
1999
|
+
* Value for the generator's `importFileExtension` option. Defaults to `'ts'`.
|
|
2000
|
+
*/
|
|
2001
|
+
importFileExtension?: 'ts' | 'js'
|
|
2002
|
+
/**
|
|
2003
|
+
* Value for the generator's `moduleFormat` option. Defaults to `'esm'`.
|
|
2004
|
+
*/
|
|
2005
|
+
moduleFormat?: 'esm' | 'commonjs'
|
|
2006
|
+
}
|
|
1676
2007
|
}
|
|
1677
2008
|
|
|
1678
2009
|
/**
|
|
@@ -2158,6 +2489,39 @@ export interface OutputConfig {
|
|
|
2158
2489
|
* @default ".opensaas"
|
|
2159
2490
|
*/
|
|
2160
2491
|
opensaasDir?: string
|
|
2492
|
+
/**
|
|
2493
|
+
* Opt in to an additional **Node build** of the Generated bundle.
|
|
2494
|
+
*
|
|
2495
|
+
* By default (omitted) the generator emits only the bundler-loadable `.ts`
|
|
2496
|
+
* form (ADR-0008): TypeScript with explicit `.ts` import extensions, traced
|
|
2497
|
+
* and transpiled by the host's bundler. That form cannot execute under plain
|
|
2498
|
+
* Node, so a live module that must run in BOTH a bundled and a bundler-less
|
|
2499
|
+
* runtime (e.g. better-auth's Prisma adapter, imported by the Next server AND
|
|
2500
|
+
* by a Playwright e2e helper or a build-time script) has no Node-loadable
|
|
2501
|
+
* entry to point at.
|
|
2502
|
+
*
|
|
2503
|
+
* Setting `buildTarget: 'node'` additionally compiles the bundle to a
|
|
2504
|
+
* plain-Node-loadable ESM form under `<opensaasDir>/dist/` (`.js` + `.d.ts`,
|
|
2505
|
+
* with a `{"type":"module"}` marker). The compiled entry is
|
|
2506
|
+
* `<opensaasDir>/dist/context.js`; a portable module imports it directly so
|
|
2507
|
+
* the bundler traces it AND plain Node executes it (one specifier, both
|
|
2508
|
+
* runtimes — see ADR-0011). The default `.ts` form is unchanged and still
|
|
2509
|
+
* emitted; the Node build is purely additive.
|
|
2510
|
+
*
|
|
2511
|
+
* `'node'` is the only target today. The field is a string-literal union so
|
|
2512
|
+
* future compiled targets can be added without a breaking change.
|
|
2513
|
+
*
|
|
2514
|
+
* @example
|
|
2515
|
+
* ```typescript
|
|
2516
|
+
* export default config({
|
|
2517
|
+
* output: { buildTarget: 'node' },
|
|
2518
|
+
* // ...
|
|
2519
|
+
* })
|
|
2520
|
+
* // Then import the compiled entry from a plain-Node consumer:
|
|
2521
|
+
* // const { rawOpensaasContext } = await import('./.opensaas/dist/context.js')
|
|
2522
|
+
* ```
|
|
2523
|
+
*/
|
|
2524
|
+
buildTarget?: 'node'
|
|
2161
2525
|
}
|
|
2162
2526
|
|
|
2163
2527
|
export interface OpenSaasConfig {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { FieldConfig } from '../config/types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Apply field `defaultValue`s to omitted inputs on CREATE — the runtime half of
|
|
5
|
+
* the resolve-then-validate ordering (Keystone 6 parity, issue #615).
|
|
6
|
+
*
|
|
7
|
+
* A field's `defaultValue` is otherwise only realised as a Prisma `@default(...)`
|
|
8
|
+
* applied by the database at write time, which is AFTER the write pipeline's
|
|
9
|
+
* validation phase has already run. That ordering means a required-with-default
|
|
10
|
+
* field (e.g. `select({ validation: { isRequired: true }, defaultValue: 'X' })`)
|
|
11
|
+
* fails `isRequired` validation on an omitted input even though a default exists.
|
|
12
|
+
*
|
|
13
|
+
* This helper closes that gap: in the resolve phase (after `resolveInput` hooks,
|
|
14
|
+
* before validation) it fills `resolvedData[field]` with the field's
|
|
15
|
+
* `defaultValue` ONLY when the field was OMITTED (value is `undefined`). It is a
|
|
16
|
+
* SINGLE shared mechanism used by both the top-level create path (Hook Pipeline)
|
|
17
|
+
* and the nested-relation create path.
|
|
18
|
+
*
|
|
19
|
+
* Guard rails (each acceptance-criteria-driven):
|
|
20
|
+
* - CREATE only. Update never injects defaults for omitted fields (the caller
|
|
21
|
+
* only invokes this for `operation === 'create'`).
|
|
22
|
+
* - Explicitly-provided values are preserved. A key present in `resolvedData`
|
|
23
|
+
* — INCLUDING an explicit `null` — is left untouched; only `undefined`
|
|
24
|
+
* (omitted) keys are filled.
|
|
25
|
+
* - Virtual, system (`id`/`createdAt`/`updatedAt`) and relationship fields are
|
|
26
|
+
* skipped — they have no scalar `defaultValue` to inject and relationships
|
|
27
|
+
* carry connect/create payloads rather than literal defaults.
|
|
28
|
+
* - The timestamp `{ kind: 'now' }` sentinel is NOT injected: it is not a
|
|
29
|
+
* literal value but a request for the DB-level `@default(now())`, which still
|
|
30
|
+
* applies at write time. Injecting the sentinel object would corrupt the
|
|
31
|
+
* payload. (A concrete `Date` default is a real literal and IS injected.)
|
|
32
|
+
*
|
|
33
|
+
* The function mutates and returns `resolvedData` (consistent with the other
|
|
34
|
+
* resolve-phase helpers that thread `resolvedData` through the pipeline).
|
|
35
|
+
*/
|
|
36
|
+
export function applyCreateDefaults(
|
|
37
|
+
resolvedData: Record<string, unknown>,
|
|
38
|
+
fieldConfigs: Record<string, FieldConfig>,
|
|
39
|
+
): Record<string, unknown> {
|
|
40
|
+
for (const [fieldKey, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
41
|
+
// Skip virtual fields — not stored in the database.
|
|
42
|
+
if (fieldConfig.virtual) continue
|
|
43
|
+
|
|
44
|
+
// Skip system fields — always managed by the framework/DB.
|
|
45
|
+
if (fieldKey === 'id' || fieldKey === 'createdAt' || fieldKey === 'updatedAt') continue
|
|
46
|
+
|
|
47
|
+
// Skip relationships — they carry connect/create payloads, not literal defaults.
|
|
48
|
+
if (fieldConfig.type === 'relationship') continue
|
|
49
|
+
|
|
50
|
+
// No declared default → nothing to inject.
|
|
51
|
+
if (!('defaultValue' in fieldConfig) || fieldConfig.defaultValue === undefined) continue
|
|
52
|
+
|
|
53
|
+
// Only fill OMITTED keys. An explicitly-provided value (including explicit
|
|
54
|
+
// `null`) is preserved and must not be overwritten by the default.
|
|
55
|
+
if (resolvedData[fieldKey] !== undefined) continue
|
|
56
|
+
|
|
57
|
+
const defaultValue = fieldConfig.defaultValue
|
|
58
|
+
|
|
59
|
+
// The timestamp `{ kind: 'now' }` sentinel is a DB-level `@default(now())`
|
|
60
|
+
// request, not a literal — leave it for Prisma to apply at write time.
|
|
61
|
+
if (isNowSentinel(defaultValue)) continue
|
|
62
|
+
|
|
63
|
+
resolvedData[fieldKey] = defaultValue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return resolvedData
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Detect the timestamp `{ kind: 'now' }` default sentinel.
|
|
71
|
+
*/
|
|
72
|
+
function isNowSentinel(value: unknown): boolean {
|
|
73
|
+
return (
|
|
74
|
+
typeof value === 'object' &&
|
|
75
|
+
value !== null &&
|
|
76
|
+
'kind' in value &&
|
|
77
|
+
(value as { kind: unknown }).kind === 'now'
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
validateFieldRules,
|
|
9
9
|
ValidationError,
|
|
10
10
|
} from '../hooks/index.js'
|
|
11
|
+
import { applyCreateDefaults } from './apply-defaults.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Hook Pipeline — the single module that runs the transform+validate span of a
|
|
@@ -109,6 +110,16 @@ async function runHookPipeline(args: HookPipelineArgs): Promise<HookPipelineResu
|
|
|
109
110
|
item,
|
|
110
111
|
)
|
|
111
112
|
|
|
113
|
+
// ── Phase 1.75: apply field defaults to omitted inputs (CREATE only) ───────
|
|
114
|
+
// Resolve-then-validate (Keystone parity, #615): a field declaring a
|
|
115
|
+
// `defaultValue` is filled into `resolvedData` here — AFTER resolveInput hooks
|
|
116
|
+
// and BEFORE validation — but only when the field was OMITTED, so a
|
|
117
|
+
// required-with-default field passes `isRequired` instead of failing it.
|
|
118
|
+
// Update is untouched (no default injection on update).
|
|
119
|
+
if (operation === 'create') {
|
|
120
|
+
resolvedData = applyCreateDefaults(resolvedData, listConfig.fields)
|
|
121
|
+
}
|
|
122
|
+
|
|
112
123
|
// ── Phase 2: list-level validate ──────────────────────────────────────────
|
|
113
124
|
await executeValidate(
|
|
114
125
|
listConfig.hooks,
|