@opensaas/stack-core 0.20.1 → 0.22.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 (136) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +334 -0
  3. package/CLAUDE.md +29 -11
  4. package/dist/access/access-filter.d.ts +29 -0
  5. package/dist/access/access-filter.d.ts.map +1 -0
  6. package/dist/access/access-filter.js +68 -0
  7. package/dist/access/access-filter.js.map +1 -0
  8. package/dist/access/engine.d.ts +15 -48
  9. package/dist/access/engine.d.ts.map +1 -1
  10. package/dist/access/engine.js +14 -280
  11. package/dist/access/engine.js.map +1 -1
  12. package/dist/access/field-access.d.ts +44 -0
  13. package/dist/access/field-access.d.ts.map +1 -0
  14. package/dist/access/field-access.js +123 -0
  15. package/dist/access/field-access.js.map +1 -0
  16. package/dist/access/field-access.test.d.ts +2 -0
  17. package/dist/access/field-access.test.d.ts.map +1 -0
  18. package/dist/access/{engine.test.js → field-access.test.js} +2 -2
  19. package/dist/access/field-access.test.js.map +1 -0
  20. package/dist/access/field-visibility.d.ts +13 -0
  21. package/dist/access/field-visibility.d.ts.map +1 -0
  22. package/dist/access/field-visibility.js +178 -0
  23. package/dist/access/field-visibility.js.map +1 -0
  24. package/dist/access/index.d.ts +4 -1
  25. package/dist/access/index.d.ts.map +1 -1
  26. package/dist/access/index.js +8 -1
  27. package/dist/access/index.js.map +1 -1
  28. package/dist/access/multi-column-read-write.test.d.ts +2 -0
  29. package/dist/access/multi-column-read-write.test.d.ts.map +1 -0
  30. package/dist/access/multi-column-read-write.test.js +149 -0
  31. package/dist/access/multi-column-read-write.test.js.map +1 -0
  32. package/dist/config/index.d.ts +1 -1
  33. package/dist/config/index.d.ts.map +1 -1
  34. package/dist/config/types.d.ts +334 -5
  35. package/dist/config/types.d.ts.map +1 -1
  36. package/dist/context/hook-pipeline.d.ts +49 -0
  37. package/dist/context/hook-pipeline.d.ts.map +1 -0
  38. package/dist/context/hook-pipeline.js +75 -0
  39. package/dist/context/hook-pipeline.js.map +1 -0
  40. package/dist/context/index.d.ts.map +1 -1
  41. package/dist/context/index.js +30 -462
  42. package/dist/context/index.js.map +1 -1
  43. package/dist/context/nested-operations.d.ts.map +1 -1
  44. package/dist/context/nested-operations.js +72 -68
  45. package/dist/context/nested-operations.js.map +1 -1
  46. package/dist/context/write-pipeline.d.ts +158 -0
  47. package/dist/context/write-pipeline.d.ts.map +1 -0
  48. package/dist/context/write-pipeline.js +306 -0
  49. package/dist/context/write-pipeline.js.map +1 -0
  50. package/dist/extend.d.ts +3 -0
  51. package/dist/extend.d.ts.map +1 -0
  52. package/dist/extend.js +10 -0
  53. package/dist/extend.js.map +1 -0
  54. package/dist/fields/format-prisma-default.d.ts +35 -0
  55. package/dist/fields/format-prisma-default.d.ts.map +1 -0
  56. package/dist/fields/format-prisma-default.js +52 -0
  57. package/dist/fields/format-prisma-default.js.map +1 -0
  58. package/dist/fields/format-prisma-default.test.d.ts +2 -0
  59. package/dist/fields/format-prisma-default.test.d.ts.map +1 -0
  60. package/dist/fields/format-prisma-default.test.js +54 -0
  61. package/dist/fields/format-prisma-default.test.js.map +1 -0
  62. package/dist/fields/index.d.ts +1 -0
  63. package/dist/fields/index.d.ts.map +1 -1
  64. package/dist/fields/index.js +267 -18
  65. package/dist/fields/index.js.map +1 -1
  66. package/dist/fields/select.test.js +85 -0
  67. package/dist/fields/select.test.js.map +1 -1
  68. package/dist/fields/text-keystone-compat.test.d.ts +2 -0
  69. package/dist/fields/text-keystone-compat.test.d.ts.map +1 -0
  70. package/dist/fields/text-keystone-compat.test.js +93 -0
  71. package/dist/fields/text-keystone-compat.test.js.map +1 -0
  72. package/dist/hooks/index.d.ts +20 -0
  73. package/dist/hooks/index.d.ts.map +1 -1
  74. package/dist/hooks/index.js +246 -0
  75. package/dist/hooks/index.js.map +1 -1
  76. package/dist/index.d.ts +6 -8
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +25 -9
  79. package/dist/index.js.map +1 -1
  80. package/dist/index.test.d.ts +2 -0
  81. package/dist/index.test.d.ts.map +1 -0
  82. package/dist/index.test.js +33 -0
  83. package/dist/index.test.js.map +1 -0
  84. package/dist/internal.d.ts +8 -0
  85. package/dist/internal.d.ts.map +1 -0
  86. package/dist/internal.js +16 -0
  87. package/dist/internal.js.map +1 -0
  88. package/dist/mcp/handler.js +0 -1
  89. package/dist/mcp/handler.js.map +1 -1
  90. package/dist/validation/field-config.d.ts +55 -0
  91. package/dist/validation/field-config.d.ts.map +1 -0
  92. package/dist/validation/field-config.js +100 -0
  93. package/dist/validation/field-config.js.map +1 -0
  94. package/dist/validation/field-config.test.d.ts +2 -0
  95. package/dist/validation/field-config.test.d.ts.map +1 -0
  96. package/dist/validation/field-config.test.js +159 -0
  97. package/dist/validation/field-config.test.js.map +1 -0
  98. package/package.json +11 -3
  99. package/src/access/access-filter.ts +97 -0
  100. package/src/access/engine.ts +13 -396
  101. package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
  102. package/src/access/field-access.ts +159 -0
  103. package/src/access/field-visibility.ts +269 -0
  104. package/src/access/index.ts +7 -4
  105. package/src/access/multi-column-read-write.test.ts +255 -0
  106. package/src/config/index.ts +3 -0
  107. package/src/config/types.ts +342 -4
  108. package/src/context/hook-pipeline.ts +160 -0
  109. package/src/context/index.ts +29 -667
  110. package/src/context/nested-operations.ts +142 -111
  111. package/src/context/write-pipeline.ts +543 -0
  112. package/src/extend.ts +19 -0
  113. package/src/fields/format-prisma-default.test.ts +64 -0
  114. package/src/fields/format-prisma-default.ts +67 -0
  115. package/src/fields/index.ts +375 -20
  116. package/src/fields/select.test.ts +99 -0
  117. package/src/fields/text-keystone-compat.test.ts +126 -0
  118. package/src/hooks/index.ts +270 -0
  119. package/src/index.test.ts +50 -0
  120. package/src/index.ts +35 -82
  121. package/src/internal.ts +49 -0
  122. package/src/mcp/handler.ts +0 -2
  123. package/src/validation/field-config.test.ts +199 -0
  124. package/src/validation/field-config.ts +145 -0
  125. package/tests/access-relationships.test.ts +4 -4
  126. package/tests/access.test.ts +1 -1
  127. package/tests/field-hooks.test.ts +410 -0
  128. package/tests/field-types.test.ts +1 -1
  129. package/tests/hook-pipeline.test.ts +233 -0
  130. package/tests/nested-operation-registry.test.ts +206 -0
  131. package/tests/write-pipeline.test.ts +588 -0
  132. package/tsconfig.tsbuildinfo +1 -1
  133. package/vitest.config.ts +43 -1
  134. package/dist/access/engine.test.d.ts +0 -2
  135. package/dist/access/engine.test.d.ts.map +0 -1
  136. package/dist/access/engine.test.js.map +0 -1
@@ -4,54 +4,12 @@ import { checkAccess, filterWritableFields, getRelatedListConfig } from '../acce
4
4
  import {
5
5
  executeResolveInput,
6
6
  executeValidate,
7
+ executeFieldResolveInputHooks,
7
8
  validateFieldRules,
8
9
  ValidationError,
9
10
  } from '../hooks/index.js'
10
11
  import { getDbKey } from '../lib/case-utils.js'
11
12
 
12
- /**
13
- * Execute field-level resolveInput hooks
14
- * Allows fields to transform their input values before database write
15
- */
16
- async function executeFieldResolveInputHooks(
17
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
- inputData: Record<string, any>,
19
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
- resolvedData: Record<string, any>,
21
- fields: Record<string, FieldConfig>,
22
- operation: 'create' | 'update',
23
- context: AccessContext,
24
- listKey: string,
25
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
- item?: any,
27
- ): Promise<Record<string, unknown>> {
28
- let result = { ...resolvedData }
29
-
30
- for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
31
- // Skip if field not in data
32
- if (!(fieldKey in result)) continue
33
-
34
- // Skip if no hooks defined
35
- if (!fieldConfig.hooks?.resolveInput) continue
36
-
37
- // Execute field hook
38
- const transformedValue = await fieldConfig.hooks.resolveInput({
39
- listKey,
40
- fieldKey,
41
- operation,
42
- inputData,
43
- item,
44
- resolvedData: { ...result }, // Pass a copy to avoid mutation affecting recorded args
45
- context,
46
- } as Parameters<typeof fieldConfig.hooks.resolveInput>[0])
47
-
48
- // Create new object with updated field to avoid mutating the passed reference
49
- result = { ...result, [fieldKey]: transformedValue }
50
- }
51
-
52
- return result
53
- }
54
-
55
13
  /**
56
14
  * Check if a field config is a relationship field
57
15
  */
@@ -396,6 +354,139 @@ async function processNestedConnectOrCreate(
396
354
  return Array.isArray(operations) ? processedOps : processedOps[0]
397
355
  }
398
356
 
357
+ /**
358
+ * Arguments passed to every nested-operation handler.
359
+ *
360
+ * A handler receives the raw value supplied for a single nested-op kind
361
+ * (e.g. the contents of `value.create`) alongside everything it needs to apply
362
+ * hooks, access control, and recursion.
363
+ */
364
+ interface NestedOpHandlerArgs {
365
+ /** Raw payload supplied for this nested-op kind (e.g. the value of `value.create`). */
366
+ value: unknown
367
+ /** The list name of the related model (e.g. `'User'`). */
368
+ relatedListName: string
369
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
370
+ relatedListConfig: ListConfig<any>
371
+ context: AccessContext
372
+ config: OpenSaasConfig
373
+ /** Prisma client used for dynamic model access during access checks. */
374
+ prisma: unknown
375
+ }
376
+
377
+ /**
378
+ * A nested-operation handler describes how a single nested-op kind
379
+ * (`create`, `connect`, …) is processed before it reaches Prisma.
380
+ *
381
+ * Adding support for a new nested-op kind means registering a new entry in
382
+ * {@link nestedOpRegistry}, not editing the dispatch loop.
383
+ */
384
+ interface NestedOpHandler {
385
+ /** Produce the processed payload for this nested-op kind. */
386
+ execute(args: NestedOpHandlerArgs): Promise<unknown>
387
+ }
388
+
389
+ /**
390
+ * Registry of nested-operation handlers keyed by nested-op kind.
391
+ *
392
+ * The dispatch loop in {@link processNestedOperations} looks handlers up here
393
+ * instead of branching on each kind. Kinds that require hooks/access control
394
+ * (`create`, `connect`, `connectOrCreate`, `update`) provide an `execute` that
395
+ * applies them; pass-through kinds (`disconnect`, `delete`, `deleteMany`,
396
+ * `set`, `updateMany`) return their value unchanged so Prisma's own
397
+ * constraints apply.
398
+ */
399
+ const nestedOpRegistry: Record<string, NestedOpHandler> = {
400
+ create: {
401
+ execute: ({ value, relatedListConfig, context, config }) =>
402
+ processNestedCreate(
403
+ value as Record<string, unknown> | Array<Record<string, unknown>>,
404
+ relatedListConfig,
405
+ context,
406
+ config,
407
+ ),
408
+ },
409
+ connect: {
410
+ execute: ({ value, relatedListName, relatedListConfig, context, prisma }) =>
411
+ processNestedConnect(
412
+ value as Record<string, unknown> | Array<Record<string, unknown>>,
413
+ relatedListName,
414
+ relatedListConfig,
415
+ context,
416
+ prisma,
417
+ ),
418
+ },
419
+ connectOrCreate: {
420
+ execute: ({ value, relatedListName, relatedListConfig, context, config, prisma }) =>
421
+ processNestedConnectOrCreate(
422
+ value as Record<string, unknown> | Array<Record<string, unknown>>,
423
+ relatedListName,
424
+ relatedListConfig,
425
+ context,
426
+ config,
427
+ prisma,
428
+ ),
429
+ },
430
+ update: {
431
+ execute: ({ value, relatedListName, relatedListConfig, context, config, prisma }) =>
432
+ processNestedUpdate(
433
+ value as Record<string, unknown> | Array<Record<string, unknown>>,
434
+ relatedListName,
435
+ relatedListConfig,
436
+ context,
437
+ config,
438
+ prisma,
439
+ ),
440
+ },
441
+ // Pass-through kinds: no hooks/access control, left to Prisma's own constraints.
442
+ disconnect: { execute: ({ value }) => Promise.resolve(value) },
443
+ delete: { execute: ({ value }) => Promise.resolve(value) },
444
+ deleteMany: { execute: ({ value }) => Promise.resolve(value) },
445
+ set: { execute: ({ value }) => Promise.resolve(value) },
446
+ updateMany: { execute: ({ value }) => Promise.resolve(value) },
447
+ }
448
+
449
+ /**
450
+ * Order in which nested-op kinds are processed for a single relationship field.
451
+ *
452
+ * Mirrors the historical in-place dispatch order so behaviour is preserved.
453
+ */
454
+ const nestedOpOrder = [
455
+ 'create',
456
+ 'connect',
457
+ 'connectOrCreate',
458
+ 'update',
459
+ 'disconnect',
460
+ 'delete',
461
+ 'deleteMany',
462
+ 'set',
463
+ 'updateMany',
464
+ ] as const
465
+
466
+ /**
467
+ * Process the nested relationship operations supplied for a single
468
+ * relationship field's value, dispatching each present nested-op kind through
469
+ * the {@link nestedOpRegistry}.
470
+ */
471
+ async function processFieldNestedOps(
472
+ valueRecord: Record<string, unknown>,
473
+ args: Omit<NestedOpHandlerArgs, 'value'>,
474
+ ): Promise<Record<string, unknown>> {
475
+ const nestedOp: Record<string, unknown> = {}
476
+
477
+ for (const kind of nestedOpOrder) {
478
+ const value = valueRecord[kind]
479
+ if (value === undefined) {
480
+ continue
481
+ }
482
+
483
+ const handler = nestedOpRegistry[kind]
484
+ nestedOp[kind] = await handler.execute({ ...args, value })
485
+ }
486
+
487
+ return nestedOp
488
+ }
489
+
399
490
  /**
400
491
  * Process all nested operations in a data payload
401
492
  * Recursively handles relationship fields with nested writes
@@ -435,74 +526,14 @@ export async function processNestedOperations(
435
526
 
436
527
  const { listName: relatedListName, listConfig: relatedListConfig } = relatedConfig
437
528
 
438
- // Process different nested operation types
439
- const nestedOp: Record<string, unknown> = {}
440
- const valueRecord = value as Record<string, unknown>
441
-
442
- if (valueRecord.create !== undefined) {
443
- nestedOp.create = await processNestedCreate(
444
- valueRecord.create as Record<string, unknown> | Array<Record<string, unknown>>,
445
- relatedListConfig,
446
- context,
447
- config,
448
- )
449
- }
450
-
451
- if (valueRecord.connect !== undefined) {
452
- nestedOp.connect = await processNestedConnect(
453
- valueRecord.connect as Record<string, unknown> | Array<Record<string, unknown>>,
454
- relatedListName,
455
- relatedListConfig,
456
- context,
457
- context.prisma,
458
- )
459
- }
460
-
461
- if (valueRecord.connectOrCreate !== undefined) {
462
- nestedOp.connectOrCreate = await processNestedConnectOrCreate(
463
- valueRecord.connectOrCreate as Record<string, unknown> | Array<Record<string, unknown>>,
464
- relatedListName,
465
- relatedListConfig,
466
- context,
467
- config,
468
- context.prisma,
469
- )
470
- }
471
-
472
- if (valueRecord.update !== undefined) {
473
- nestedOp.update = await processNestedUpdate(
474
- valueRecord.update as Record<string, unknown> | Array<Record<string, unknown>>,
475
- relatedListName,
476
- relatedListConfig,
477
- context,
478
- config,
479
- context.prisma,
480
- )
481
- }
482
-
483
- // For other operations, pass through (disconnect, delete, set, etc.)
484
- // These will be subject to Prisma's own constraints
485
- if (valueRecord.disconnect !== undefined) {
486
- nestedOp.disconnect = valueRecord.disconnect
487
- }
488
-
489
- if (valueRecord.delete !== undefined) {
490
- nestedOp.delete = valueRecord.delete
491
- }
492
-
493
- if (valueRecord.deleteMany !== undefined) {
494
- nestedOp.deleteMany = valueRecord.deleteMany
495
- }
496
-
497
- if (valueRecord.set !== undefined) {
498
- nestedOp.set = valueRecord.set
499
- }
500
-
501
- if (valueRecord.updateMany !== undefined) {
502
- nestedOp.updateMany = valueRecord.updateMany
503
- }
504
-
505
- processed[fieldName] = nestedOp
529
+ // Dispatch each present nested-op kind through the handler registry.
530
+ processed[fieldName] = await processFieldNestedOps(value as Record<string, unknown>, {
531
+ relatedListName,
532
+ relatedListConfig,
533
+ context,
534
+ config,
535
+ prisma: context.prisma,
536
+ })
506
537
  }
507
538
 
508
539
  return processed