@opensaas/stack-core 0.1.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 (95) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/README.md +447 -0
  3. package/dist/access/engine.d.ts +73 -0
  4. package/dist/access/engine.d.ts.map +1 -0
  5. package/dist/access/engine.js +244 -0
  6. package/dist/access/engine.js.map +1 -0
  7. package/dist/access/field-transforms.d.ts +47 -0
  8. package/dist/access/field-transforms.d.ts.map +1 -0
  9. package/dist/access/field-transforms.js +2 -0
  10. package/dist/access/field-transforms.js.map +1 -0
  11. package/dist/access/index.d.ts +3 -0
  12. package/dist/access/index.d.ts.map +1 -0
  13. package/dist/access/index.js +2 -0
  14. package/dist/access/index.js.map +1 -0
  15. package/dist/access/types.d.ts +83 -0
  16. package/dist/access/types.d.ts.map +1 -0
  17. package/dist/access/types.js +2 -0
  18. package/dist/access/types.js.map +1 -0
  19. package/dist/config/index.d.ts +39 -0
  20. package/dist/config/index.d.ts.map +1 -0
  21. package/dist/config/index.js +38 -0
  22. package/dist/config/index.js.map +1 -0
  23. package/dist/config/types.d.ts +413 -0
  24. package/dist/config/types.d.ts.map +1 -0
  25. package/dist/config/types.js +2 -0
  26. package/dist/config/types.js.map +1 -0
  27. package/dist/context/index.d.ts +31 -0
  28. package/dist/context/index.d.ts.map +1 -0
  29. package/dist/context/index.js +524 -0
  30. package/dist/context/index.js.map +1 -0
  31. package/dist/context/nested-operations.d.ts +10 -0
  32. package/dist/context/nested-operations.d.ts.map +1 -0
  33. package/dist/context/nested-operations.js +261 -0
  34. package/dist/context/nested-operations.js.map +1 -0
  35. package/dist/fields/index.d.ts +78 -0
  36. package/dist/fields/index.d.ts.map +1 -0
  37. package/dist/fields/index.js +381 -0
  38. package/dist/fields/index.js.map +1 -0
  39. package/dist/hooks/index.d.ts +58 -0
  40. package/dist/hooks/index.d.ts.map +1 -0
  41. package/dist/hooks/index.js +79 -0
  42. package/dist/hooks/index.js.map +1 -0
  43. package/dist/index.d.ts +11 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +12 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/lib/case-utils.d.ts +49 -0
  48. package/dist/lib/case-utils.d.ts.map +1 -0
  49. package/dist/lib/case-utils.js +68 -0
  50. package/dist/lib/case-utils.js.map +1 -0
  51. package/dist/lib/case-utils.test.d.ts +2 -0
  52. package/dist/lib/case-utils.test.d.ts.map +1 -0
  53. package/dist/lib/case-utils.test.js +101 -0
  54. package/dist/lib/case-utils.test.js.map +1 -0
  55. package/dist/utils/password.d.ts +81 -0
  56. package/dist/utils/password.d.ts.map +1 -0
  57. package/dist/utils/password.js +132 -0
  58. package/dist/utils/password.js.map +1 -0
  59. package/dist/validation/schema.d.ts +17 -0
  60. package/dist/validation/schema.d.ts.map +1 -0
  61. package/dist/validation/schema.js +42 -0
  62. package/dist/validation/schema.js.map +1 -0
  63. package/dist/validation/schema.test.d.ts +2 -0
  64. package/dist/validation/schema.test.d.ts.map +1 -0
  65. package/dist/validation/schema.test.js +143 -0
  66. package/dist/validation/schema.test.js.map +1 -0
  67. package/docs/type-distribution-fix.md +136 -0
  68. package/package.json +48 -0
  69. package/src/access/engine.ts +360 -0
  70. package/src/access/field-transforms.ts +99 -0
  71. package/src/access/index.ts +20 -0
  72. package/src/access/types.ts +103 -0
  73. package/src/config/index.ts +71 -0
  74. package/src/config/types.ts +478 -0
  75. package/src/context/index.ts +814 -0
  76. package/src/context/nested-operations.ts +412 -0
  77. package/src/fields/index.ts +438 -0
  78. package/src/hooks/index.ts +132 -0
  79. package/src/index.ts +62 -0
  80. package/src/lib/case-utils.test.ts +127 -0
  81. package/src/lib/case-utils.ts +74 -0
  82. package/src/utils/password.ts +147 -0
  83. package/src/validation/schema.test.ts +171 -0
  84. package/src/validation/schema.ts +59 -0
  85. package/tests/access-relationships.test.ts +613 -0
  86. package/tests/access.test.ts +499 -0
  87. package/tests/config.test.ts +195 -0
  88. package/tests/context.test.ts +248 -0
  89. package/tests/hooks.test.ts +417 -0
  90. package/tests/password-type-distribution.test.ts +155 -0
  91. package/tests/password-types.test.ts +147 -0
  92. package/tests/password.test.ts +249 -0
  93. package/tsconfig.json +12 -0
  94. package/tsconfig.tsbuildinfo +1 -0
  95. package/vitest.config.ts +27 -0
@@ -0,0 +1,412 @@
1
+ import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
2
+ import type { AccessContext } from '../access/types.js'
3
+ import { checkAccess, filterWritableFields, getRelatedListConfig } from '../access/index.js'
4
+ import {
5
+ executeResolveInput,
6
+ executeValidateInput,
7
+ validateFieldRules,
8
+ ValidationError,
9
+ } from '../hooks/index.js'
10
+ import { getDbKey } from '../lib/case-utils.js'
11
+
12
+ /**
13
+ * Check if a field config is a relationship field
14
+ */
15
+ function isRelationshipField(fieldConfig: FieldConfig | undefined): boolean {
16
+ return fieldConfig?.type === 'relationship'
17
+ }
18
+
19
+ /**
20
+ * Process nested create operations
21
+ * Applies hooks and access control to each item being created
22
+ */
23
+ async function processNestedCreate(
24
+ items: Record<string, unknown> | Array<Record<string, unknown>>,
25
+ relatedListConfig: ListConfig,
26
+ context: AccessContext,
27
+ config: OpenSaasConfig,
28
+ ): Promise<Record<string, unknown> | Array<Record<string, unknown>>> {
29
+ const itemsArray = Array.isArray(items) ? items : [items]
30
+
31
+ const processedItems = await Promise.all(
32
+ itemsArray.map(async (item) => {
33
+ // 1. Check create access
34
+ const createAccess = relatedListConfig.access?.operation?.create
35
+ const accessResult = await checkAccess(createAccess, {
36
+ session: context.session,
37
+ context,
38
+ })
39
+
40
+ if (accessResult === false) {
41
+ throw new Error('Access denied: Cannot create related item')
42
+ }
43
+
44
+ // 2. Execute resolveInput hook
45
+ let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
46
+ operation: 'create',
47
+ resolvedData: item,
48
+ context,
49
+ })
50
+
51
+ // 3. Execute validateInput hook
52
+ await executeValidateInput(relatedListConfig.hooks, {
53
+ operation: 'create',
54
+ resolvedData,
55
+ context,
56
+ })
57
+
58
+ // 4. Field validation
59
+ const validation = validateFieldRules(resolvedData, relatedListConfig.fields, 'create')
60
+ if (validation.errors.length > 0) {
61
+ throw new ValidationError(validation.errors, validation.fieldErrors)
62
+ }
63
+
64
+ // 5. Filter writable fields
65
+ const filtered = await filterWritableFields(
66
+ resolvedData,
67
+ relatedListConfig.fields,
68
+ 'create',
69
+ {
70
+ session: context.session,
71
+ context,
72
+ },
73
+ )
74
+
75
+ // 6. Recursively process nested operations in this item
76
+ return await processNestedOperations(
77
+ filtered,
78
+ relatedListConfig.fields,
79
+ config,
80
+ context,
81
+ 'create',
82
+ )
83
+ }),
84
+ )
85
+
86
+ return Array.isArray(items) ? processedItems : processedItems[0]
87
+ }
88
+
89
+ /**
90
+ * Process nested connect operations
91
+ * Verifies update access to the items being connected
92
+ */
93
+ async function processNestedConnect(
94
+ connections: Record<string, unknown> | Array<Record<string, unknown>>,
95
+ relatedListName: string,
96
+ relatedListConfig: ListConfig,
97
+ context: AccessContext,
98
+ prisma: unknown,
99
+ ): Promise<Record<string, unknown> | Array<Record<string, unknown>>> {
100
+ const connectionsArray = Array.isArray(connections) ? connections : [connections]
101
+
102
+ // Check update access for each item being connected
103
+ for (const connection of connectionsArray) {
104
+ // Access Prisma model dynamically - required because model names are generated at runtime
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
+ const model = (prisma as any)[getDbKey(relatedListName)]
107
+
108
+ // Fetch the item to check access
109
+ const item = await model.findUnique({
110
+ where: connection,
111
+ })
112
+
113
+ if (!item) {
114
+ throw new Error(`Cannot connect: Item not found`)
115
+ }
116
+
117
+ // Check update access (connecting modifies the relationship)
118
+ const updateAccess = relatedListConfig.access?.operation?.update
119
+ const accessResult = await checkAccess(updateAccess, {
120
+ session: context.session,
121
+ item,
122
+ context,
123
+ })
124
+
125
+ if (accessResult === false) {
126
+ throw new Error('Access denied: Cannot connect to this item')
127
+ }
128
+
129
+ // If access returns a filter, check if item matches
130
+ if (typeof accessResult === 'object') {
131
+ // Simple field matching
132
+ for (const [key, value] of Object.entries(accessResult)) {
133
+ if (typeof value === 'object' && value !== null && 'equals' in value) {
134
+ if (item[key] !== (value as Record<string, unknown>).equals) {
135
+ throw new Error('Access denied: Cannot connect to this item')
136
+ }
137
+ } else if (item[key] !== value) {
138
+ throw new Error('Access denied: Cannot connect to this item')
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ return connections
145
+ }
146
+
147
+ /**
148
+ * Process nested update operations
149
+ * Applies hooks and access control to updates
150
+ */
151
+ async function processNestedUpdate(
152
+ updates: Record<string, unknown> | Array<Record<string, unknown>>,
153
+ relatedListName: string,
154
+ relatedListConfig: ListConfig,
155
+ context: AccessContext,
156
+ config: OpenSaasConfig,
157
+ prisma: unknown,
158
+ ): Promise<Record<string, unknown> | Array<Record<string, unknown>>> {
159
+ const updatesArray = Array.isArray(updates) ? updates : [updates]
160
+
161
+ const processedUpdates = await Promise.all(
162
+ updatesArray.map(async (update) => {
163
+ // Access Prisma model dynamically - required because model names are generated at runtime
164
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
165
+ const model = (prisma as any)[getDbKey(relatedListName)]
166
+
167
+ // Fetch the existing item
168
+ const item = await model.findUnique({
169
+ where: (update as Record<string, unknown>).where,
170
+ })
171
+
172
+ if (!item) {
173
+ throw new Error('Cannot update: Item not found')
174
+ }
175
+
176
+ // Check update access
177
+ const updateAccess = relatedListConfig.access?.operation?.update
178
+ const accessResult = await checkAccess(updateAccess, {
179
+ session: context.session,
180
+ item,
181
+ context,
182
+ })
183
+
184
+ if (accessResult === false) {
185
+ throw new Error('Access denied: Cannot update related item')
186
+ }
187
+
188
+ // Execute resolveInput hook
189
+ const updateData = (update as Record<string, unknown>).data as Record<string, unknown>
190
+ let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
191
+ operation: 'update',
192
+ resolvedData: updateData,
193
+ item,
194
+ context,
195
+ })
196
+
197
+ // Execute validateInput hook
198
+ await executeValidateInput(relatedListConfig.hooks, {
199
+ operation: 'update',
200
+ resolvedData,
201
+ item,
202
+ context,
203
+ })
204
+
205
+ // Field validation
206
+ const validation = validateFieldRules(resolvedData, relatedListConfig.fields, 'update')
207
+ if (validation.errors.length > 0) {
208
+ throw new ValidationError(validation.errors, validation.fieldErrors)
209
+ }
210
+
211
+ // Filter writable fields
212
+ const filtered = await filterWritableFields(
213
+ resolvedData,
214
+ relatedListConfig.fields,
215
+ 'update',
216
+ {
217
+ session: context.session,
218
+ item,
219
+ context,
220
+ },
221
+ )
222
+
223
+ // Recursively process nested operations
224
+ const processedData = await processNestedOperations(
225
+ filtered,
226
+ relatedListConfig.fields,
227
+ config,
228
+ context,
229
+ 'update',
230
+ )
231
+
232
+ return {
233
+ where: (update as Record<string, unknown>).where,
234
+ data: processedData,
235
+ }
236
+ }),
237
+ )
238
+
239
+ return Array.isArray(updates) ? processedUpdates : processedUpdates[0]
240
+ }
241
+
242
+ /**
243
+ * Process nested connectOrCreate operations
244
+ */
245
+ async function processNestedConnectOrCreate(
246
+ operations: Record<string, unknown> | Array<Record<string, unknown>>,
247
+ relatedListName: string,
248
+ relatedListConfig: ListConfig,
249
+ context: AccessContext,
250
+ config: OpenSaasConfig,
251
+ prisma: unknown,
252
+ ): Promise<Record<string, unknown> | Array<Record<string, unknown>>> {
253
+ const operationsArray = Array.isArray(operations) ? operations : [operations]
254
+
255
+ const processedOps = await Promise.all(
256
+ operationsArray.map(async (op) => {
257
+ // Process the create portion through create hooks
258
+ const opRecord = op as Record<string, unknown>
259
+ const processedCreate = await processNestedCreate(
260
+ opRecord.create as Record<string, unknown> | Array<Record<string, unknown>>,
261
+ relatedListConfig,
262
+ context,
263
+ config,
264
+ )
265
+
266
+ // Check access for the connect portion (try to find existing item)
267
+ try {
268
+ // Access Prisma model dynamically - required because model names are generated at runtime
269
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
270
+ const model = (prisma as any)[getDbKey(relatedListName)]
271
+ const existingItem = await model.findUnique({
272
+ where: opRecord.where,
273
+ })
274
+
275
+ if (existingItem) {
276
+ // Check update access for connection
277
+ const updateAccess = relatedListConfig.access?.operation?.update
278
+ const accessResult = await checkAccess(updateAccess, {
279
+ session: context.session,
280
+ item: existingItem,
281
+ context,
282
+ })
283
+
284
+ if (accessResult === false) {
285
+ throw new Error('Access denied: Cannot connect to existing item')
286
+ }
287
+ }
288
+ } catch {
289
+ // Item doesn't exist, will use create (already processed)
290
+ }
291
+
292
+ return {
293
+ where: (op as Record<string, unknown>).where,
294
+ create: processedCreate,
295
+ }
296
+ }),
297
+ )
298
+
299
+ return Array.isArray(operations) ? processedOps : processedOps[0]
300
+ }
301
+
302
+ /**
303
+ * Process all nested operations in a data payload
304
+ * Recursively handles relationship fields with nested writes
305
+ */
306
+ export async function processNestedOperations(
307
+ data: Record<string, unknown>,
308
+ fieldConfigs: Record<string, FieldConfig>,
309
+ config: OpenSaasConfig,
310
+ context: AccessContext & { prisma: unknown },
311
+ operation: 'create' | 'update',
312
+ depth: number = 0,
313
+ ): Promise<Record<string, unknown>> {
314
+ const MAX_DEPTH = 5
315
+
316
+ if (depth >= MAX_DEPTH) {
317
+ return data
318
+ }
319
+
320
+ const processed: Record<string, unknown> = {}
321
+
322
+ for (const [fieldName, value] of Object.entries(data)) {
323
+ const fieldConfig = fieldConfigs[fieldName]
324
+
325
+ // If not a relationship field or no value, pass through
326
+ if (!isRelationshipField(fieldConfig) || value === null || value === undefined) {
327
+ processed[fieldName] = value
328
+ continue
329
+ }
330
+
331
+ // Get related list config
332
+ const relationshipField = fieldConfig as { type: 'relationship'; ref: string }
333
+ const relatedConfig = getRelatedListConfig(relationshipField.ref, config)
334
+ if (!relatedConfig) {
335
+ processed[fieldName] = value
336
+ continue
337
+ }
338
+
339
+ const { listName: relatedListName, listConfig: relatedListConfig } = relatedConfig
340
+
341
+ // Process different nested operation types
342
+ const nestedOp: Record<string, unknown> = {}
343
+ const valueRecord = value as Record<string, unknown>
344
+
345
+ if (valueRecord.create !== undefined) {
346
+ nestedOp.create = await processNestedCreate(
347
+ valueRecord.create as Record<string, unknown> | Array<Record<string, unknown>>,
348
+ relatedListConfig,
349
+ context,
350
+ config,
351
+ )
352
+ }
353
+
354
+ if (valueRecord.connect !== undefined) {
355
+ nestedOp.connect = await processNestedConnect(
356
+ valueRecord.connect as Record<string, unknown> | Array<Record<string, unknown>>,
357
+ relatedListName,
358
+ relatedListConfig,
359
+ context,
360
+ context.prisma,
361
+ )
362
+ }
363
+
364
+ if (valueRecord.connectOrCreate !== undefined) {
365
+ nestedOp.connectOrCreate = await processNestedConnectOrCreate(
366
+ valueRecord.connectOrCreate as Record<string, unknown> | Array<Record<string, unknown>>,
367
+ relatedListName,
368
+ relatedListConfig,
369
+ context,
370
+ config,
371
+ context.prisma,
372
+ )
373
+ }
374
+
375
+ if (valueRecord.update !== undefined) {
376
+ nestedOp.update = await processNestedUpdate(
377
+ valueRecord.update as Record<string, unknown> | Array<Record<string, unknown>>,
378
+ relatedListName,
379
+ relatedListConfig,
380
+ context,
381
+ config,
382
+ context.prisma,
383
+ )
384
+ }
385
+
386
+ // For other operations, pass through (disconnect, delete, set, etc.)
387
+ // These will be subject to Prisma's own constraints
388
+ if (valueRecord.disconnect !== undefined) {
389
+ nestedOp.disconnect = valueRecord.disconnect
390
+ }
391
+
392
+ if (valueRecord.delete !== undefined) {
393
+ nestedOp.delete = valueRecord.delete
394
+ }
395
+
396
+ if (valueRecord.deleteMany !== undefined) {
397
+ nestedOp.deleteMany = valueRecord.deleteMany
398
+ }
399
+
400
+ if (valueRecord.set !== undefined) {
401
+ nestedOp.set = valueRecord.set
402
+ }
403
+
404
+ if (valueRecord.updateMany !== undefined) {
405
+ nestedOp.updateMany = valueRecord.updateMany
406
+ }
407
+
408
+ processed[fieldName] = nestedOp
409
+ }
410
+
411
+ return processed
412
+ }