@opensaas/stack-core 0.12.1 → 0.14.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 (46) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +291 -0
  3. package/README.md +6 -3
  4. package/dist/access/engine.d.ts +2 -0
  5. package/dist/access/engine.d.ts.map +1 -1
  6. package/dist/access/engine.js +8 -6
  7. package/dist/access/engine.js.map +1 -1
  8. package/dist/access/engine.test.js +4 -0
  9. package/dist/access/engine.test.js.map +1 -1
  10. package/dist/access/types.d.ts +31 -4
  11. package/dist/access/types.d.ts.map +1 -1
  12. package/dist/config/index.d.ts +12 -10
  13. package/dist/config/index.d.ts.map +1 -1
  14. package/dist/config/index.js +37 -1
  15. package/dist/config/index.js.map +1 -1
  16. package/dist/config/types.d.ts +341 -82
  17. package/dist/config/types.d.ts.map +1 -1
  18. package/dist/context/index.d.ts.map +1 -1
  19. package/dist/context/index.js +330 -60
  20. package/dist/context/index.js.map +1 -1
  21. package/dist/context/nested-operations.d.ts.map +1 -1
  22. package/dist/context/nested-operations.js +38 -25
  23. package/dist/context/nested-operations.js.map +1 -1
  24. package/dist/hooks/index.d.ts +45 -7
  25. package/dist/hooks/index.d.ts.map +1 -1
  26. package/dist/hooks/index.js +10 -4
  27. package/dist/hooks/index.js.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/access/engine.test.ts +4 -0
  33. package/src/access/engine.ts +10 -7
  34. package/src/access/types.ts +45 -4
  35. package/src/config/index.ts +65 -9
  36. package/src/config/types.ts +402 -91
  37. package/src/context/index.ts +421 -82
  38. package/src/context/nested-operations.ts +40 -25
  39. package/src/hooks/index.ts +66 -14
  40. package/src/index.ts +11 -0
  41. package/tests/access.test.ts +28 -28
  42. package/tests/config.test.ts +20 -3
  43. package/tests/nested-access-and-hooks.test.ts +8 -3
  44. package/tests/singleton.test.ts +329 -0
  45. package/tests/sudo.test.ts +2 -13
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -1,5 +1,5 @@
1
1
  import { checkAccess, mergeFilters, filterReadableFields, filterWritableFields, buildIncludeWithAccessControl, } from '../access/index.js';
2
- import { executeResolveInput, executeValidateInput, executeBeforeOperation, executeAfterOperation, validateFieldRules, ValidationError, DatabaseError, } from '../hooks/index.js';
2
+ import { executeResolveInput, executeValidate, executeBeforeOperation, executeAfterOperation, validateFieldRules, ValidationError, DatabaseError, } from '../hooks/index.js';
3
3
  import { processNestedOperations } from './nested-operations.js';
4
4
  import { getDbKey } from '../lib/case-utils.js';
5
5
  /**
@@ -8,13 +8,15 @@ import { getDbKey } from '../lib/case-utils.js';
8
8
  */
9
9
  async function executeFieldResolveInputHooks(
10
10
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
- data, fields, operation, context, listKey,
11
+ inputData,
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ resolvedData, fields, operation, context, listKey,
12
14
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
15
  item) {
14
- const result = { ...data };
15
- for (const [fieldName, fieldConfig] of Object.entries(fields)) {
16
+ let result = { ...resolvedData };
17
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
16
18
  // Skip if field not in data
17
- if (!(fieldName in result))
19
+ if (!(fieldKey in result))
18
20
  continue;
19
21
  // Skip if no hooks defined
20
22
  if (!fieldConfig.hooks?.resolveInput)
@@ -23,42 +25,134 @@ item) {
23
25
  // Type assertion is safe here because hooks are typed correctly in field definitions
24
26
  // and we're working with runtime values that match those types
25
27
  const transformedValue = await fieldConfig.hooks.resolveInput({
26
- inputValue: result[fieldName],
27
- operation,
28
- fieldName,
29
28
  listKey,
29
+ fieldKey,
30
+ operation,
31
+ inputData,
30
32
  item,
33
+ resolvedData: { ...result }, // Pass a copy to avoid mutation affecting recorded args
31
34
  context,
32
35
  });
33
- result[fieldName] = transformedValue;
36
+ // Create new object with updated field to avoid mutating the passed reference
37
+ result = { ...result, [fieldKey]: transformedValue };
34
38
  }
35
39
  return result;
36
40
  }
41
+ /**
42
+ * Execute field-level validate hooks
43
+ * Allows fields to perform custom validation after resolveInput but before database write
44
+ */
45
+ async function executeFieldValidateHooks(
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
+ inputData,
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ resolvedData, fields, operation, context, listKey,
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ item) {
52
+ const errors = [];
53
+ const fieldErrors = {};
54
+ const addValidationError = (fieldKey) => (msg) => {
55
+ errors.push(msg);
56
+ fieldErrors[fieldKey] = msg;
57
+ };
58
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
59
+ // Support both 'validate' (new) and 'validateInput' (deprecated) for backwards compatibility
60
+ const validateHook = fieldConfig.hooks?.validate ?? fieldConfig.hooks?.validateInput;
61
+ if (!validateHook)
62
+ continue;
63
+ // Execute field hook
64
+ // Type assertion is safe here because hooks are typed correctly in field definitions
65
+ if (operation === 'delete') {
66
+ await validateHook({
67
+ listKey,
68
+ fieldKey,
69
+ operation: 'delete',
70
+ item,
71
+ context,
72
+ addValidationError: addValidationError(fieldKey),
73
+ });
74
+ }
75
+ else if (operation === 'create') {
76
+ await validateHook({
77
+ listKey,
78
+ fieldKey,
79
+ operation: 'create',
80
+ inputData,
81
+ item: undefined,
82
+ resolvedData,
83
+ context,
84
+ addValidationError: addValidationError(fieldKey),
85
+ });
86
+ }
87
+ else {
88
+ // operation === 'update'
89
+ await validateHook({
90
+ listKey,
91
+ fieldKey,
92
+ operation: 'update',
93
+ inputData,
94
+ item,
95
+ resolvedData,
96
+ context,
97
+ addValidationError: addValidationError(fieldKey),
98
+ });
99
+ }
100
+ }
101
+ if (errors.length > 0) {
102
+ throw new ValidationError(errors, fieldErrors);
103
+ }
104
+ }
37
105
  /**
38
106
  * Execute field-level beforeOperation hooks (side effects only)
39
107
  * Allows fields to perform side effects before database write
40
108
  */
41
109
  async function executeFieldBeforeOperationHooks(
42
110
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
- data, fields, operation, context, listKey,
111
+ inputData,
112
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
113
+ resolvedData, fields, operation, context, listKey,
44
114
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
115
  item) {
46
- for (const [fieldName, fieldConfig] of Object.entries(fields)) {
47
- // Skip if field not in data (for create/update) or if no hooks defined
116
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
117
+ // Skip if no hooks defined
48
118
  if (!fieldConfig.hooks?.beforeOperation)
49
119
  continue;
50
- if (operation !== 'delete' && !(fieldName in data))
120
+ // Skip if field not in data (for create/update)
121
+ if (operation !== 'delete' && !(fieldKey in resolvedData))
51
122
  continue;
52
123
  // Execute field hook (side effects only, no return value used)
53
124
  // Type assertion is safe here because hooks are typed correctly in field definitions
54
- await fieldConfig.hooks.beforeOperation({
55
- resolvedValue: data[fieldName],
56
- operation,
57
- fieldName,
58
- listKey,
59
- item,
60
- context,
61
- });
125
+ if (operation === 'delete') {
126
+ await fieldConfig.hooks.beforeOperation({
127
+ listKey,
128
+ fieldKey,
129
+ operation: 'delete',
130
+ item,
131
+ context,
132
+ });
133
+ }
134
+ else if (operation === 'create') {
135
+ await fieldConfig.hooks.beforeOperation({
136
+ listKey,
137
+ fieldKey,
138
+ operation: 'create',
139
+ inputData,
140
+ resolvedData,
141
+ context,
142
+ });
143
+ }
144
+ else {
145
+ // operation === 'update'
146
+ await fieldConfig.hooks.beforeOperation({
147
+ listKey,
148
+ fieldKey,
149
+ operation: 'update',
150
+ inputData,
151
+ item,
152
+ resolvedData,
153
+ context,
154
+ });
155
+ }
62
156
  }
63
157
  }
64
158
  /**
@@ -67,26 +161,88 @@ item) {
67
161
  */
68
162
  async function executeFieldAfterOperationHooks(
69
163
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
- item, data, fields, operation, context, listKey,
164
+ item, inputData, resolvedData, fields, operation, context, listKey,
71
165
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
166
  originalItem) {
73
- for (const [fieldName, fieldConfig] of Object.entries(fields)) {
167
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
74
168
  // Skip if no hooks defined
75
169
  if (!fieldConfig.hooks?.afterOperation)
76
170
  continue;
77
- // Get the value from item (for all operations)
78
- const value = item?.[fieldName];
79
171
  // Execute field hook (side effects only, no return value used)
80
- await fieldConfig.hooks.afterOperation({
81
- value,
82
- operation,
83
- fieldName,
84
- listKey,
85
- item,
86
- originalItem,
87
- context,
88
- });
172
+ if (operation === 'delete') {
173
+ await fieldConfig.hooks.afterOperation({
174
+ listKey,
175
+ fieldKey,
176
+ operation: 'delete',
177
+ originalItem,
178
+ context,
179
+ });
180
+ }
181
+ else if (operation === 'create') {
182
+ await fieldConfig.hooks.afterOperation({
183
+ listKey,
184
+ fieldKey,
185
+ operation: 'create',
186
+ inputData,
187
+ item,
188
+ resolvedData,
189
+ context,
190
+ });
191
+ }
192
+ else {
193
+ // operation === 'update'
194
+ await fieldConfig.hooks.afterOperation({
195
+ listKey,
196
+ fieldKey,
197
+ operation: 'update',
198
+ inputData,
199
+ originalItem,
200
+ item,
201
+ resolvedData,
202
+ context,
203
+ });
204
+ }
205
+ }
206
+ }
207
+ /**
208
+ * Check if a list is configured as a singleton
209
+ */
210
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
211
+ function isSingletonList(listConfig) {
212
+ return !!listConfig.isSingleton;
213
+ }
214
+ /**
215
+ * Check if auto-create is enabled for a singleton list
216
+ * Defaults to true if not explicitly set to false
217
+ */
218
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
219
+ function shouldAutoCreate(listConfig) {
220
+ if (!listConfig.isSingleton)
221
+ return false;
222
+ if (typeof listConfig.isSingleton === 'boolean')
223
+ return true;
224
+ return listConfig.isSingleton.autoCreate !== false;
225
+ }
226
+ /**
227
+ * Extract default values from field configs
228
+ * Used to auto-create singleton records with sensible defaults
229
+ */
230
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
231
+ function getDefaultData(listConfig) {
232
+ const data = {};
233
+ for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
234
+ // Skip virtual fields - they're not stored in database
235
+ if (fieldConfig.virtual)
236
+ continue;
237
+ // Skip system fields (id, createdAt, updatedAt)
238
+ if (fieldKey === 'id' || fieldKey === 'createdAt' || fieldKey === 'updatedAt')
239
+ continue;
240
+ // Add default value if present
241
+ if ('defaultValue' in fieldConfig && fieldConfig.defaultValue !== undefined) {
242
+ data[fieldKey] = fieldConfig.defaultValue;
243
+ }
89
244
  }
245
+ return data;
90
246
  }
91
247
  /**
92
248
  * Parse Prisma error and convert to user-friendly DatabaseError
@@ -171,14 +327,21 @@ export function getContext(config, prisma, session, storage, _isSudo = false) {
171
327
  // Create access-controlled operations for each list
172
328
  for (const [listName, listConfig] of Object.entries(config.lists)) {
173
329
  const dbKey = getDbKey(listName);
174
- db[dbKey] = {
330
+ // Create base operations
331
+ const createOp = createCreate(listName, listConfig, prisma, context, config);
332
+ const operations = {
175
333
  findUnique: createFindUnique(listName, listConfig, prisma, context, config),
176
334
  findMany: createFindMany(listName, listConfig, prisma, context, config),
177
- create: createCreate(listName, listConfig, prisma, context, config),
335
+ create: createOp,
178
336
  update: createUpdate(listName, listConfig, prisma, context, config),
179
337
  delete: createDelete(listName, listConfig, prisma, context),
180
338
  count: createCount(listName, listConfig, prisma, context),
181
339
  };
340
+ // Add get() method for singleton lists
341
+ if (isSingletonList(listConfig)) {
342
+ operations.get = createGet(listName, listConfig, prisma, context, config, createOp);
343
+ }
344
+ db[dbKey] = operations;
182
345
  }
183
346
  // Execute plugin runtime functions and populate context.plugins
184
347
  // Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
@@ -332,8 +495,6 @@ listConfig, prisma, context, config) {
332
495
  session: context.session,
333
496
  context: { ...context, _isSudo: context._isSudo },
334
497
  }, config, 0, listName);
335
- // Execute field afterOperation hooks (side effects only)
336
- await executeFieldAfterOperationHooks(filtered, undefined, listConfig.fields, 'query', context, listName, undefined);
337
498
  return filtered;
338
499
  };
339
500
  }
@@ -344,6 +505,10 @@ function createFindMany(listName,
344
505
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
345
506
  listConfig, prisma, context, config) {
346
507
  return async (args) => {
508
+ // Check singleton constraint (throw error instead of silently returning empty)
509
+ if (isSingletonList(listConfig)) {
510
+ throw new ValidationError([`Cannot use findMany: ${listName} is a singleton list. Use get() instead.`], {});
511
+ }
347
512
  // Check query access (skip if sudo mode)
348
513
  let where = args?.where;
349
514
  if (!context._isSudo) {
@@ -385,8 +550,6 @@ listConfig, prisma, context, config) {
385
550
  session: context.session,
386
551
  context: { ...context, _isSudo: context._isSudo },
387
552
  }, config, 0, listName)));
388
- // Execute field afterOperation hooks for each item (side effects only)
389
- await Promise.all(filtered.map((item) => executeFieldAfterOperationHooks(item, undefined, listConfig.fields, 'query', context, listName, undefined)));
390
553
  return filtered;
391
554
  };
392
555
  }
@@ -397,6 +560,16 @@ function createCreate(listName,
397
560
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
398
561
  listConfig, prisma, context, config) {
399
562
  return async (args) => {
563
+ // 0. Check singleton constraint (enforce even in sudo mode)
564
+ if (isSingletonList(listConfig)) {
565
+ // Access Prisma model dynamically - required because model names are generated at runtime
566
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
567
+ const model = prisma[getDbKey(listName)];
568
+ const existingCount = await model.count();
569
+ if (existingCount > 0) {
570
+ throw new ValidationError([`Cannot create: ${listName} is a singleton list with an existing record`], {});
571
+ }
572
+ }
400
573
  // 1. Check create access (skip if sudo mode)
401
574
  if (!context._isSudo) {
402
575
  const createAccess = listConfig.access?.operation?.create;
@@ -410,20 +583,26 @@ listConfig, prisma, context, config) {
410
583
  }
411
584
  // 2. Execute list-level resolveInput hook
412
585
  let resolvedData = await executeResolveInput(listConfig.hooks, {
586
+ listKey: listName,
413
587
  operation: 'create',
588
+ inputData: args.data,
414
589
  resolvedData: args.data,
415
590
  item: undefined,
416
591
  context,
417
592
  });
418
593
  // 2.5. Execute field-level resolveInput hooks (e.g., hash passwords)
419
- resolvedData = await executeFieldResolveInputHooks(resolvedData, listConfig.fields, 'create', context, listName);
420
- // 3. Execute validateInput hook
421
- await executeValidateInput(listConfig.hooks, {
594
+ resolvedData = await executeFieldResolveInputHooks(args.data, resolvedData, listConfig.fields, 'create', context, listName);
595
+ // 3. Execute list-level validate hook
596
+ await executeValidate(listConfig.hooks, {
597
+ listKey: listName,
422
598
  operation: 'create',
599
+ inputData: args.data,
423
600
  resolvedData,
424
601
  item: undefined,
425
602
  context,
426
603
  });
604
+ // 3.5. Execute field-level validate hooks
605
+ await executeFieldValidateHooks(args.data, resolvedData, listConfig.fields, 'create', context, listName);
427
606
  // 4. Field validation (isRequired, length, etc.)
428
607
  const validation = validateFieldRules(resolvedData, listConfig.fields, 'create');
429
608
  if (validation.errors.length > 0) {
@@ -433,14 +612,18 @@ listConfig, prisma, context, config) {
433
612
  const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'create', {
434
613
  session: context.session,
435
614
  context: { ...context, _isSudo: context._isSudo },
615
+ inputData: args.data,
436
616
  });
437
617
  // 5.5. Process nested relationship operations
438
618
  const data = await processNestedOperations(filteredData, listConfig.fields, config, { ...context, prisma }, 'create');
439
619
  // 6. Execute field-level beforeOperation hooks (side effects only)
440
- await executeFieldBeforeOperationHooks(data, listConfig.fields, 'create', context, listName);
620
+ await executeFieldBeforeOperationHooks(args.data, resolvedData, listConfig.fields, 'create', context, listName);
441
621
  // 7. Execute list-level beforeOperation hook
442
622
  await executeBeforeOperation(listConfig.hooks, {
623
+ listKey: listName,
443
624
  operation: 'create',
625
+ inputData: args.data,
626
+ resolvedData,
444
627
  context,
445
628
  });
446
629
  // 8. Execute database create
@@ -452,13 +635,15 @@ listConfig, prisma, context, config) {
452
635
  });
453
636
  // 9. Execute list-level afterOperation hook
454
637
  await executeAfterOperation(listConfig.hooks, {
638
+ listKey: listName,
455
639
  operation: 'create',
640
+ inputData: args.data,
456
641
  item,
457
- originalItem: undefined,
642
+ resolvedData,
458
643
  context,
459
644
  });
460
645
  // 10. Execute field-level afterOperation hooks (side effects only)
461
- await executeFieldAfterOperationHooks(item, data, listConfig.fields, 'create', context, listName, undefined);
646
+ await executeFieldAfterOperationHooks(item, args.data, resolvedData, listConfig.fields, 'create', context, listName, undefined);
462
647
  // 11. Filter readable fields and apply resolveOutput hooks (including nested relationships)
463
648
  // Pass sudo flag through context to skip field-level access checks
464
649
  const filtered = await filterReadableFields(item, listConfig.fields, {
@@ -508,20 +693,26 @@ listConfig, prisma, context, config) {
508
693
  }
509
694
  // 3. Execute list-level resolveInput hook
510
695
  let resolvedData = await executeResolveInput(listConfig.hooks, {
696
+ listKey: listName,
511
697
  operation: 'update',
698
+ inputData: args.data,
512
699
  resolvedData: args.data,
513
700
  item,
514
701
  context,
515
702
  });
516
703
  // 3.5. Execute field-level resolveInput hooks (e.g., hash passwords)
517
- resolvedData = await executeFieldResolveInputHooks(resolvedData, listConfig.fields, 'update', context, listName, item);
518
- // 4. Execute validateInput hook
519
- await executeValidateInput(listConfig.hooks, {
704
+ resolvedData = await executeFieldResolveInputHooks(args.data, resolvedData, listConfig.fields, 'update', context, listName, item);
705
+ // 4. Execute list-level validate hook
706
+ await executeValidate(listConfig.hooks, {
707
+ listKey: listName,
520
708
  operation: 'update',
709
+ inputData: args.data,
521
710
  resolvedData,
522
711
  item,
523
712
  context,
524
713
  });
714
+ // 4.5. Execute field-level validate hooks
715
+ await executeFieldValidateHooks(args.data, resolvedData, listConfig.fields, 'update', context, listName, item);
525
716
  // 5. Field validation (isRequired, length, etc.)
526
717
  const validation = validateFieldRules(resolvedData, listConfig.fields, 'update');
527
718
  if (validation.errors.length > 0) {
@@ -532,15 +723,19 @@ listConfig, prisma, context, config) {
532
723
  session: context.session,
533
724
  item,
534
725
  context: { ...context, _isSudo: context._isSudo },
726
+ inputData: args.data,
535
727
  });
536
728
  // 6.5. Process nested relationship operations
537
729
  const data = await processNestedOperations(filteredData, listConfig.fields, config, { ...context, prisma }, 'update');
538
730
  // 7. Execute field-level beforeOperation hooks (side effects only)
539
- await executeFieldBeforeOperationHooks(data, listConfig.fields, 'update', context, listName, item);
731
+ await executeFieldBeforeOperationHooks(args.data, resolvedData, listConfig.fields, 'update', context, listName, item);
540
732
  // 8. Execute list-level beforeOperation hook
541
733
  await executeBeforeOperation(listConfig.hooks, {
734
+ listKey: listName,
542
735
  operation: 'update',
736
+ inputData: args.data,
543
737
  item,
738
+ resolvedData,
544
739
  context,
545
740
  });
546
741
  // 9. Execute database update
@@ -550,13 +745,16 @@ listConfig, prisma, context, config) {
550
745
  });
551
746
  // 10. Execute list-level afterOperation hook
552
747
  await executeAfterOperation(listConfig.hooks, {
748
+ listKey: listName,
553
749
  operation: 'update',
554
- item: updated,
750
+ inputData: args.data,
555
751
  originalItem: item, // item is the original item before the update
752
+ item: updated,
753
+ resolvedData,
556
754
  context,
557
755
  });
558
756
  // 11. Execute field-level afterOperation hooks (side effects only)
559
- await executeFieldAfterOperationHooks(updated, data, listConfig.fields, 'update', context, listName, item);
757
+ await executeFieldAfterOperationHooks(updated, args.data, resolvedData, listConfig.fields, 'update', context, listName, item);
560
758
  // 12. Filter readable fields and apply resolveOutput hooks (including nested relationships)
561
759
  // Pass sudo flag through context to skip field-level access checks
562
760
  const filtered = await filterReadableFields(updated, listConfig.fields, {
@@ -573,6 +771,10 @@ function createDelete(listName,
573
771
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
574
772
  listConfig, prisma, context) {
575
773
  return async (args) => {
774
+ // 0. Check singleton constraint (enforce even in sudo mode)
775
+ if (isSingletonList(listConfig)) {
776
+ throw new ValidationError([`Cannot delete: ${listName} is a singleton list`], {});
777
+ }
576
778
  // 1. Fetch the item to pass to access control and hooks
577
779
  // Access Prisma model dynamically - required because model names are generated at runtime
578
780
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -604,27 +806,37 @@ listConfig, prisma, context) {
604
806
  }
605
807
  }
606
808
  }
607
- // 3. Execute field-level beforeOperation hooks (side effects only)
608
- await executeFieldBeforeOperationHooks({}, listConfig.fields, 'delete', context, listName, item);
609
- // 4. Execute list-level beforeOperation hook
809
+ // 3. Execute list-level validate hook
810
+ await executeValidate(listConfig.hooks, {
811
+ listKey: listName,
812
+ operation: 'delete',
813
+ item,
814
+ context,
815
+ });
816
+ // 3.5. Execute field-level validate hooks
817
+ await executeFieldValidateHooks(undefined, undefined, listConfig.fields, 'delete', context, listName, item);
818
+ // 4. Execute field-level beforeOperation hooks (side effects only)
819
+ await executeFieldBeforeOperationHooks({}, {}, listConfig.fields, 'delete', context, listName, item);
820
+ // 5. Execute list-level beforeOperation hook
610
821
  await executeBeforeOperation(listConfig.hooks, {
822
+ listKey: listName,
611
823
  operation: 'delete',
612
824
  item,
613
825
  context,
614
826
  });
615
- // 5. Execute database delete
827
+ // 6. Execute database delete
616
828
  const deleted = await model.delete({
617
829
  where: args.where,
618
830
  });
619
- // 6. Execute list-level afterOperation hook
831
+ // 7. Execute list-level afterOperation hook
620
832
  await executeAfterOperation(listConfig.hooks, {
833
+ listKey: listName,
621
834
  operation: 'delete',
622
- item: deleted,
623
835
  originalItem: item, // item is the original item before deletion
624
836
  context,
625
837
  });
626
- // 7. Execute field-level afterOperation hooks (side effects only)
627
- await executeFieldAfterOperationHooks(deleted, undefined, listConfig.fields, 'delete', context, listName, item);
838
+ // 8. Execute field-level afterOperation hooks (side effects only)
839
+ await executeFieldAfterOperationHooks(deleted, undefined, undefined, listConfig.fields, 'delete', context, listName, item);
628
840
  return deleted;
629
841
  };
630
842
  }
@@ -663,4 +875,62 @@ listConfig, prisma, context) {
663
875
  return count;
664
876
  };
665
877
  }
878
+ /**
879
+ * Create get operation for singleton lists
880
+ * Returns the single record, or auto-creates it if enabled
881
+ */
882
+ function createGet(listName,
883
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
884
+ listConfig, prisma, context, config,
885
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
886
+ createFn) {
887
+ return async () => {
888
+ // First try to find the existing record
889
+ // Access Prisma model dynamically - required because model names are generated at runtime
890
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
891
+ const model = prisma[getDbKey(listName)];
892
+ // Check query access (skip if sudo mode)
893
+ let where = {};
894
+ if (!context._isSudo) {
895
+ const queryAccess = listConfig.access?.operation?.query;
896
+ const accessResult = await checkAccess(queryAccess, {
897
+ session: context.session,
898
+ context,
899
+ });
900
+ if (accessResult === false) {
901
+ return null;
902
+ }
903
+ // Merge access filter (for singleton, we don't have a specific where clause)
904
+ if (accessResult && typeof accessResult === 'object') {
905
+ where = accessResult;
906
+ }
907
+ }
908
+ // Build include with access control filters
909
+ const accessControlledInclude = await buildIncludeWithAccessControl(listConfig.fields, {
910
+ session: context.session,
911
+ context,
912
+ }, config);
913
+ // Try to find the record
914
+ const item = await model.findFirst({
915
+ where,
916
+ include: accessControlledInclude,
917
+ });
918
+ // If record exists, return it
919
+ if (item) {
920
+ // Filter readable fields and apply resolveOutput hooks
921
+ const filtered = await filterReadableFields(item, listConfig.fields, {
922
+ session: context.session,
923
+ context: { ...context, _isSudo: context._isSudo },
924
+ }, config, 0, listName);
925
+ return filtered;
926
+ }
927
+ // If no record and auto-create is enabled, create it
928
+ if (shouldAutoCreate(listConfig)) {
929
+ const defaultData = getDefaultData(listConfig);
930
+ return await createFn({ data: defaultData });
931
+ }
932
+ // No record and auto-create is disabled
933
+ return null;
934
+ };
935
+ }
666
936
  //# sourceMappingURL=index.js.map