@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
@@ -0,0 +1,543 @@
1
+ import type { OpenSaasConfig, ListConfig } from '../config/types.js'
2
+ import type { AccessContext, PrismaClientLike } from '../access/types.js'
3
+ import {
4
+ checkAccess,
5
+ mergeFilters,
6
+ filterReadableFields,
7
+ filterWritableFields,
8
+ } from '../access/index.js'
9
+ import {
10
+ executeValidate,
11
+ executeBeforeOperation,
12
+ executeAfterOperation,
13
+ executeFieldValidateHooks,
14
+ executeFieldBeforeOperationHooks,
15
+ executeFieldAfterOperationHooks,
16
+ ValidationError,
17
+ } from '../hooks/index.js'
18
+ import { hookPipeline } from './hook-pipeline.js'
19
+ import { processNestedOperations } from './nested-operations.js'
20
+ import { getDbKey } from '../lib/case-utils.js'
21
+
22
+ /**
23
+ * Write Pipeline — the single module that runs the canonical, secured write
24
+ * sequence for one create/update/delete. It owns the phase order in one place;
25
+ * the per-operation differences (target resolution + access, which input phases
26
+ * run, the DB verb and returned row) are supplied by a {@link WriteStrategy}.
27
+ *
28
+ * The phase order is the framework's single most important invariant. See the
29
+ * "Write Pipeline" glossary term in CONTEXT.md and the hooks ordering in
30
+ * CLAUDE.md. Reads (findUnique/findMany) and the two-phase read model
31
+ * (ADR-0001) are intentionally out of scope here.
32
+ */
33
+
34
+ /**
35
+ * The write operations the pipeline can run.
36
+ */
37
+ export type WriteOperation = 'create' | 'update' | 'delete'
38
+
39
+ /**
40
+ * Result of resolving a write target (axis 1).
41
+ *
42
+ * - `{ status: 'ok', originalItem }` — proceed. `originalItem` is the existing
43
+ * row for update/delete, or `undefined` for create.
44
+ * - `{ status: 'denied' }` — access denied, missing target, or filter
45
+ * non-match. The pipeline short-circuits to `null` (silent failure) BEFORE
46
+ * any input phases, before-hooks, or the DB call.
47
+ */
48
+ export type TargetResolution =
49
+ | { status: 'ok'; originalItem: Record<string, unknown> | undefined }
50
+ | { status: 'denied' }
51
+
52
+ /**
53
+ * Minimal dynamic Prisma model surface used by the write pipeline. Model names
54
+ * are generated at runtime, so the concrete client type is not known here.
55
+ */
56
+ export interface PrismaModel {
57
+ findUnique: (args: { where: Record<string, unknown> }) => Promise<Record<string, unknown> | null>
58
+ findFirst: (args: { where: Record<string, unknown> }) => Promise<Record<string, unknown> | null>
59
+ count: () => Promise<number>
60
+ create: (args: { data: Record<string, unknown> }) => Promise<Record<string, unknown>>
61
+ update: (args: {
62
+ where: Record<string, unknown>
63
+ data: Record<string, unknown>
64
+ }) => Promise<Record<string, unknown>>
65
+ delete: (args: { where: Record<string, unknown> }) => Promise<Record<string, unknown>>
66
+ }
67
+
68
+ /**
69
+ * Per-operation strategy. Supplies the three axes on which create/update/delete
70
+ * genuinely differ; the pipeline owns the shared phase order around them.
71
+ *
72
+ * 1. `resolveTarget` — fetch the target row (if any) + operation-level access.
73
+ * 2. `runInputPhases` — whether the resolveInput → validate-hooks → field
74
+ * rules → filter-writable → nested-ops span runs (create & update: yes;
75
+ * delete: no).
76
+ * 3. `persist` — the DB verb; returns the row passed through Field Visibility.
77
+ */
78
+ export interface WriteStrategy {
79
+ operation: WriteOperation
80
+
81
+ /**
82
+ * Axis 1: resolve the target row and check operation-level access. Receives
83
+ * the dynamically-resolved Prisma model so it can fetch rows and perform
84
+ * filter re-checks. Implementations must honour `context._isSudo`.
85
+ */
86
+ resolveTarget(model: PrismaModel): Promise<TargetResolution>
87
+
88
+ /**
89
+ * Axis 2: whether to run the input-shaping phases (resolveInput → validate
90
+ * hooks → built-in field rules → filter-writable → nested ops). Delete runs
91
+ * only its `validate`/field-validate hooks and skips the rest.
92
+ */
93
+ runInputPhases: boolean
94
+
95
+ /**
96
+ * Axis 3: execute the database write and return the persisted/deleted row.
97
+ * `data` is the fully-resolved write payload (empty object for delete).
98
+ */
99
+ persist(model: PrismaModel, data: Record<string, unknown>): Promise<Record<string, unknown>>
100
+ }
101
+
102
+ /**
103
+ * Resolve the dynamic Prisma model for a list. Model names are generated at
104
+ * runtime from list keys, which is the one place a cast is unavoidable — it is
105
+ * kept localized here (mirroring the existing pattern in `context/index.ts`).
106
+ */
107
+ function getModel<TPrisma extends PrismaClientLike>(
108
+ prisma: TPrisma,
109
+ listName: string,
110
+ ): PrismaModel {
111
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- model names are generated at runtime
112
+ return (prisma as any)[getDbKey(listName)] as PrismaModel
113
+ }
114
+
115
+ /**
116
+ * Check if a list is configured as a singleton.
117
+ */
118
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
119
+ function isSingletonList(listConfig: ListConfig<any>): boolean {
120
+ return !!listConfig.isSingleton
121
+ }
122
+
123
+ /**
124
+ * Arguments shared by every write pipeline run.
125
+ */
126
+ export interface WritePipelineArgs<TPrisma extends PrismaClientLike> {
127
+ listName: string
128
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
129
+ listConfig: ListConfig<any>
130
+ prisma: TPrisma
131
+ context: AccessContext<TPrisma>
132
+ config: OpenSaasConfig
133
+ /** The original input data for the write (create/update). `undefined` for delete. */
134
+ inputData: Record<string, unknown> | undefined
135
+ /** The per-operation strategy supplying the three variation axes. */
136
+ strategy: WriteStrategy
137
+ }
138
+
139
+ /**
140
+ * Run the canonical secured write sequence once.
141
+ *
142
+ * Phase order (owned here, in one place):
143
+ * resolve target + operation-level access
144
+ * → list/field `resolveInput`
145
+ * → list/field `validate`
146
+ * → built-in field rules (`validateFieldRules`)
147
+ * → filter writable fields
148
+ * → nested operations
149
+ * → list/field `beforeOperation`
150
+ * → DB
151
+ * → list/field `afterOperation`
152
+ * → `filterReadableFields` (Field Visibility)
153
+ *
154
+ * Contract preserved exactly:
155
+ * - missing target / access denied / filter non-match → `null` (silent),
156
+ * BEFORE the DB call and BEFORE `beforeOperation`.
157
+ * - validation failure → THROW `ValidationError` (never silent).
158
+ * - sudo mode skips access checks and writable-field filtering (the strategy
159
+ * and `filterWritableFields` both honour `context._isSudo`).
160
+ * - `afterOperation` receives `originalItem` for update/delete (undefined for
161
+ * create).
162
+ * - delete returns the deleted row as-is (no Field Visibility pass), matching
163
+ * current behaviour.
164
+ */
165
+ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
166
+ args: WritePipelineArgs<TPrisma>,
167
+ ): Promise<Record<string, unknown> | null> {
168
+ const { listName, listConfig, prisma, context, config, inputData, strategy } = args
169
+ const { operation } = strategy
170
+ const model = getModel(prisma, listName)
171
+
172
+ // ── Phase 1: resolve target + operation-level access ──────────────────────
173
+ // Short-circuits to `null` (silent failure) for missing target, denied
174
+ // access, or filter non-match — before any hook side effects or the DB call.
175
+ const resolution = await strategy.resolveTarget(model)
176
+ if (resolution.status === 'denied') {
177
+ return null
178
+ }
179
+ const originalItem = resolution.originalItem
180
+
181
+ // ── Delete path: skip input phases, run only validate/field-validate ────────
182
+ // (matches current delete behaviour exactly).
183
+ if (!strategy.runInputPhases) {
184
+ return runDeletePath({ listName, listConfig, context, originalItem, model, strategy })
185
+ }
186
+
187
+ // Only create/update reach here (delete short-circuited above). Narrow the
188
+ // operation so the field-hook helpers receive a 'create' | 'update' value.
189
+ const writeOp: 'create' | 'update' = operation === 'create' ? 'create' : 'update'
190
+
191
+ // `inputData` is always present for create/update (the operations that run
192
+ // input phases). Default to {} only as a defensive measure.
193
+ const input = inputData ?? {}
194
+
195
+ // ── Phases 2–4: transform + validate span (Hook Pipeline) ──────────────────
196
+ // The Hook Pipeline owns the list/field `resolveInput` → list/field `validate`
197
+ // → built-in field rules span and the `resolvedData` threading through it. It
198
+ // THROWS `ValidationError` on any validation failure (never silent).
199
+ const { resolvedData } = await hookPipeline.run({
200
+ operation: writeOp,
201
+ listName,
202
+ listConfig,
203
+ inputData: input,
204
+ item: originalItem,
205
+ context,
206
+ })
207
+
208
+ // ── Phase 5: filter writable fields (field-level access, skip if sudo) ──────
209
+ const filteredData = await filterWritableFields(resolvedData, listConfig.fields, writeOp, {
210
+ session: context.session,
211
+ item: originalItem,
212
+ context: { ...context, _isSudo: context._isSudo },
213
+ inputData: input,
214
+ })
215
+
216
+ // ── Phase 5.5: process nested relationship operations ───────────────────────
217
+ const data = await processNestedOperations(
218
+ filteredData,
219
+ listConfig.fields,
220
+ config,
221
+ { ...context, prisma },
222
+ writeOp,
223
+ )
224
+
225
+ // ── Phase 6: field-level beforeOperation (side effects only) ────────────────
226
+ await executeFieldBeforeOperationHooks(
227
+ input,
228
+ resolvedData,
229
+ listConfig.fields,
230
+ writeOp,
231
+ context,
232
+ listName,
233
+ originalItem,
234
+ )
235
+
236
+ // ── Phase 7: list-level beforeOperation ─────────────────────────────────────
237
+ await executeBeforeOperation(
238
+ listConfig.hooks,
239
+ operation === 'create'
240
+ ? {
241
+ listKey: listName,
242
+ operation: 'create',
243
+ inputData: input,
244
+ resolvedData,
245
+ context,
246
+ }
247
+ : {
248
+ listKey: listName,
249
+ operation: 'update',
250
+ inputData: input,
251
+ item: originalItem,
252
+ resolvedData,
253
+ context,
254
+ },
255
+ )
256
+
257
+ // ── Phase 8: DB write ───────────────────────────────────────────────────────
258
+ const item = await strategy.persist(model, data)
259
+
260
+ // ── Phase 9: list-level afterOperation ──────────────────────────────────────
261
+ await executeAfterOperation(
262
+ listConfig.hooks,
263
+ operation === 'create'
264
+ ? {
265
+ listKey: listName,
266
+ operation: 'create',
267
+ inputData: input,
268
+ item,
269
+ resolvedData,
270
+ context,
271
+ }
272
+ : {
273
+ listKey: listName,
274
+ operation: 'update',
275
+ inputData: input,
276
+ // originalItem is the row before the update
277
+ originalItem: originalItem as Record<string, unknown>,
278
+ item,
279
+ resolvedData,
280
+ context,
281
+ },
282
+ )
283
+
284
+ // ── Phase 10: field-level afterOperation (side effects only) ────────────────
285
+ await executeFieldAfterOperationHooks(
286
+ item,
287
+ input,
288
+ resolvedData,
289
+ listConfig.fields,
290
+ writeOp,
291
+ context,
292
+ listName,
293
+ originalItem, // undefined for create, original row for update
294
+ )
295
+
296
+ // ── Phase 11: Field Visibility (filter readable fields + resolveOutput) ─────
297
+ return filterReadableFields(
298
+ item,
299
+ listConfig.fields,
300
+ {
301
+ session: context.session,
302
+ context: { ...context, _isSudo: context._isSudo },
303
+ },
304
+ config,
305
+ 0,
306
+ listName,
307
+ )
308
+ }
309
+
310
+ /**
311
+ * The delete tail of the pipeline: skips the input-shaping phases and runs only
312
+ * validate/field-validate before the DB delete, then the after-hooks. Returns
313
+ * the deleted row as-is (no Field Visibility pass) — matching current delete
314
+ * behaviour exactly.
315
+ */
316
+ async function runDeletePath(args: {
317
+ listName: string
318
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
319
+ listConfig: ListConfig<any>
320
+ context: AccessContext
321
+ originalItem: Record<string, unknown> | undefined
322
+ model: PrismaModel
323
+ strategy: WriteStrategy
324
+ }): Promise<Record<string, unknown>> {
325
+ const { listName, listConfig, context, originalItem, model, strategy } = args
326
+ const item = originalItem as Record<string, unknown>
327
+
328
+ // ── Phase 3: list-level validate (delete) ──────────────────────────────────
329
+ await executeValidate(listConfig.hooks, {
330
+ listKey: listName,
331
+ operation: 'delete',
332
+ item,
333
+ context,
334
+ })
335
+
336
+ // ── Phase 3.5: field-level validate (delete) ────────────────────────────────
337
+ await executeFieldValidateHooks(
338
+ undefined,
339
+ undefined,
340
+ listConfig.fields,
341
+ 'delete',
342
+ context,
343
+ listName,
344
+ item,
345
+ )
346
+
347
+ // ── Phase 6: field-level beforeOperation (delete) ───────────────────────────
348
+ await executeFieldBeforeOperationHooks(
349
+ {},
350
+ {},
351
+ listConfig.fields,
352
+ 'delete',
353
+ context,
354
+ listName,
355
+ item,
356
+ )
357
+
358
+ // ── Phase 7: list-level beforeOperation (delete) ────────────────────────────
359
+ await executeBeforeOperation(listConfig.hooks, {
360
+ listKey: listName,
361
+ operation: 'delete',
362
+ item,
363
+ context,
364
+ })
365
+
366
+ // ── Phase 8: DB delete ──────────────────────────────────────────────────────
367
+ const deleted = await strategy.persist(model, {})
368
+
369
+ // ── Phase 9: list-level afterOperation (delete) ─────────────────────────────
370
+ await executeAfterOperation(listConfig.hooks, {
371
+ listKey: listName,
372
+ operation: 'delete',
373
+ originalItem: item,
374
+ context,
375
+ })
376
+
377
+ // ── Phase 10: field-level afterOperation (delete) ───────────────────────────
378
+ await executeFieldAfterOperationHooks(
379
+ deleted,
380
+ undefined,
381
+ undefined,
382
+ listConfig.fields,
383
+ 'delete',
384
+ context,
385
+ listName,
386
+ item, // original row before deletion
387
+ )
388
+
389
+ return deleted
390
+ }
391
+
392
+ // ── Per-operation strategies ──────────────────────────────────────────────────
393
+
394
+ /**
395
+ * Create strategy.
396
+ *
397
+ * Axis 1: checks `create` access with NO existing row. Enforces the
398
+ * singleton-create constraint even under sudo. On create, an access result of
399
+ * `true` OR a filter object both proceed — there is no filter re-check.
400
+ * Axis 2: runs all input phases.
401
+ * Axis 3: `model.create({ data })`, prepending `id: 1` for singleton lists.
402
+ */
403
+ export function createWriteStrategy(
404
+ listName: string,
405
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
406
+ listConfig: ListConfig<any>,
407
+ context: AccessContext,
408
+ ): WriteStrategy {
409
+ const singleton = isSingletonList(listConfig)
410
+ return {
411
+ operation: 'create',
412
+ runInputPhases: true,
413
+ async resolveTarget(model) {
414
+ // Singleton constraint is enforced even under sudo.
415
+ if (singleton) {
416
+ const existingCount = await model.count()
417
+ if (existingCount > 0) {
418
+ throw new ValidationError(
419
+ [`Cannot create: ${listName} is a singleton list with an existing record`],
420
+ {},
421
+ )
422
+ }
423
+ }
424
+
425
+ if (!context._isSudo) {
426
+ const accessResult = await checkAccess(listConfig.access?.operation?.create, {
427
+ session: context.session,
428
+ context,
429
+ })
430
+ if (accessResult === false) {
431
+ return { status: 'denied' }
432
+ }
433
+ }
434
+
435
+ return { status: 'ok', originalItem: undefined }
436
+ },
437
+ async persist(model, data) {
438
+ // Singleton lists use Int @id with value always 1 (matching Keystone 6).
439
+ const createData = singleton ? { id: 1, ...data } : data
440
+ return model.create({ data: createData })
441
+ },
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Build the shared target resolution for update/delete: fetch the row (missing
447
+ * → denied), check operation-level access (false → denied), and if access
448
+ * returns a filter, re-check via `findFirst(mergeFilters(where, filter))`
449
+ * (no match → denied). An access result of `true` proceeds with no re-check.
450
+ */
451
+ function resolveExistingTarget(
452
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
453
+ listConfig: ListConfig<any>,
454
+ context: AccessContext,
455
+ where: { id: string },
456
+ access: 'update' | 'delete',
457
+ ): (model: PrismaModel) => Promise<TargetResolution> {
458
+ return async (model) => {
459
+ const item = await model.findUnique({ where })
460
+ if (!item) {
461
+ return { status: 'denied' }
462
+ }
463
+
464
+ if (!context._isSudo) {
465
+ const accessResult = await checkAccess(listConfig.access?.operation?.[access], {
466
+ session: context.session,
467
+ item,
468
+ context,
469
+ })
470
+
471
+ if (accessResult === false) {
472
+ return { status: 'denied' }
473
+ }
474
+
475
+ // A filter result must additionally match the target row.
476
+ if (typeof accessResult === 'object') {
477
+ const matchesFilter = await model.findFirst({
478
+ where: mergeFilters(where, accessResult) ?? {},
479
+ })
480
+ if (!matchesFilter) {
481
+ return { status: 'denied' }
482
+ }
483
+ }
484
+ }
485
+
486
+ return { status: 'ok', originalItem: item }
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Update strategy.
492
+ *
493
+ * Axis 1: fetch row, check `update` access, re-check filter results.
494
+ * Axis 2: runs all input phases.
495
+ * Axis 3: `model.update({ where, data })`; afterOperation gets `originalItem`.
496
+ */
497
+ export function updateWriteStrategy(
498
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
499
+ listConfig: ListConfig<any>,
500
+ context: AccessContext,
501
+ where: { id: string },
502
+ ): WriteStrategy {
503
+ return {
504
+ operation: 'update',
505
+ runInputPhases: true,
506
+ resolveTarget: resolveExistingTarget(listConfig, context, where, 'update'),
507
+ async persist(model, data) {
508
+ return model.update({ where, data })
509
+ },
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Delete strategy.
515
+ *
516
+ * Axis 1: enforce singleton constraint (even under sudo), fetch row, check
517
+ * `delete` access, re-check filter results.
518
+ * Axis 2: SKIPS input phases (runs only validate/field-validate).
519
+ * Axis 3: `model.delete({ where })`; afterOperation gets `originalItem`.
520
+ */
521
+ export function deleteWriteStrategy(
522
+ listName: string,
523
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
524
+ listConfig: ListConfig<any>,
525
+ context: AccessContext,
526
+ where: { id: string },
527
+ ): WriteStrategy {
528
+ const resolveTarget = resolveExistingTarget(listConfig, context, where, 'delete')
529
+ return {
530
+ operation: 'delete',
531
+ runInputPhases: false,
532
+ async resolveTarget(model) {
533
+ // Singleton lists may not be deleted (enforced even under sudo).
534
+ if (isSingletonList(listConfig)) {
535
+ throw new ValidationError([`Cannot delete: ${listName} is a singleton list`], {})
536
+ }
537
+ return resolveTarget(model)
538
+ },
539
+ async persist(model) {
540
+ return model.delete({ where })
541
+ },
542
+ }
543
+ }
package/src/extend.ts ADDED
@@ -0,0 +1,19 @@
1
+ // ───────────────────────────────────────────────────────────────
2
+ // @opensaas/stack-core/extend
3
+ //
4
+ // Authoring contracts for extending the stack: implement these when
5
+ // you build a plugin or a third-party field package. Stable, public
6
+ // API — distinct from the everyday consumer surface on the root entry
7
+ // point and from the unstable plumbing on `/internal`.
8
+ // ───────────────────────────────────────────────────────────────
9
+
10
+ // Plugin authoring (see the Plugin System docs)
11
+ export type { Plugin, PluginContext, GeneratedFiles } from './config/index.js'
12
+
13
+ // Third-party field authoring (implement BaseFieldConfig; see custom-field docs)
14
+ export type {
15
+ BaseFieldConfig,
16
+ TypeInfo,
17
+ TypeDescriptor,
18
+ MultiColumnPrismaResult,
19
+ } from './config/index.js'
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { formatPrismaDefault, type PrismaDefaultFieldType } from './format-prisma-default.js'
3
+
4
+ describe('formatPrismaDefault', () => {
5
+ describe('table-driven serialisation', () => {
6
+ const cases: Array<{
7
+ name: string
8
+ value: unknown
9
+ fieldType: PrismaDefaultFieldType
10
+ expected: string | undefined
11
+ }> = [
12
+ // text
13
+ {
14
+ name: 'non-empty string',
15
+ value: 'PLEASE_UPDATE',
16
+ fieldType: 'text',
17
+ expected: '"PLEASE_UPDATE"',
18
+ },
19
+ { name: 'empty string', value: '', fieldType: 'text', expected: '""' },
20
+ {
21
+ name: 'string with embedded quotes',
22
+ value: 'say "hi"',
23
+ fieldType: 'text',
24
+ expected: '"say \\"hi\\""',
25
+ },
26
+ // integer
27
+ { name: 'integer', value: 3550, fieldType: 'integer', expected: '3550' },
28
+ { name: 'zero integer', value: 0, fieldType: 'integer', expected: '0' },
29
+ { name: 'negative integer', value: -7, fieldType: 'integer', expected: '-7' },
30
+ // json
31
+ { name: 'JSON array', value: [1, 2, 3, 4, 5], fieldType: 'json', expected: '"[1,2,3,4,5]"' },
32
+ {
33
+ name: 'JSON object',
34
+ value: { a: 1, b: 'two' },
35
+ fieldType: 'json',
36
+ expected: '"{\\"a\\":1,\\"b\\":\\"two\\"}"',
37
+ },
38
+ { name: 'empty array', value: [], fieldType: 'json', expected: '"[]"' },
39
+ { name: 'empty object', value: {}, fieldType: 'json', expected: '"{}"' },
40
+ // undefined → no default for every field type
41
+ { name: 'undefined text', value: undefined, fieldType: 'text', expected: undefined },
42
+ { name: 'undefined integer', value: undefined, fieldType: 'integer', expected: undefined },
43
+ { name: 'undefined json', value: undefined, fieldType: 'json', expected: undefined },
44
+ ]
45
+
46
+ it.each(cases)('serialises $name → $expected', ({ value, fieldType, expected }) => {
47
+ expect(formatPrismaDefault(value, fieldType)).toBe(expected)
48
+ })
49
+ })
50
+
51
+ it('produces canonical space-free JSON (no extra whitespace)', () => {
52
+ // Guards against pretty-printed JSON sneaking into the literal.
53
+ expect(formatPrismaDefault([1, 2, 3], 'json')).toBe('"[1,2,3]"')
54
+ expect(formatPrismaDefault({ nested: { x: [1] } }, 'json')).toBe(
55
+ '"{\\"nested\\":{\\"x\\":[1]}}"',
56
+ )
57
+ })
58
+
59
+ it('wraps a JSON string default in escaped quotes around the JSON text', () => {
60
+ // A string value under the json field type is itself valid JSON; it gets
61
+ // double-serialised: inner JSON.stringify("hi") = "\"hi\"", then wrapped.
62
+ expect(formatPrismaDefault('hi', 'json')).toBe('"\\"hi\\""')
63
+ })
64
+ })
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Field types that {@link formatPrismaDefault} knows how to serialise.
3
+ *
4
+ * Kept narrow on purpose: only the scalar fields whose `defaultValue` maps to a
5
+ * Prisma `@default(...)` literal via this shared helper. Other fields (e.g.
6
+ * `checkbox`, `decimal`, `timestamp`) format their own defaults inline because
7
+ * their literal forms diverge (`@default(now())`, bare booleans, etc.).
8
+ */
9
+ export type PrismaDefaultFieldType = 'text' | 'integer' | 'json'
10
+
11
+ /**
12
+ * Serialise a field's `defaultValue` into the inner literal of a Prisma
13
+ * `@default(...)` attribute.
14
+ *
15
+ * Pure: no I/O, no field-builder coupling. Returns just the literal (the caller
16
+ * wraps it in `@default(...)`), so it composes with whatever modifier string a
17
+ * field builder assembles. Returns `undefined` when there is nothing to emit, so
18
+ * a field with no `defaultValue` produces no `@default(...)` at all.
19
+ *
20
+ * Serialisation rules (Keystone 6 compatible):
21
+ * - `integer` → bare numeric literal, e.g. `3550` → `@default(3550)`.
22
+ * - `text` → double-quoted string literal, e.g. `PLEASE_UPDATE` → `@default("PLEASE_UPDATE")`.
23
+ * - `json` → Keystone's JSON-literal form: `JSON.stringify` the value with no
24
+ * extra whitespace, then wrap the result in escaped double quotes, e.g.
25
+ * `[1,2,3,4,5]` → `@default("[1,2,3,4,5]")` and `[]` → `@default("[]")`.
26
+ *
27
+ * Nullability (the `?` modifier) is the caller's concern and is handled
28
+ * independently of the default — this function never touches it.
29
+ *
30
+ * @param value - The configured `defaultValue` (the field builder's `defaultValue`).
31
+ * @param fieldType - The field's discriminator, selecting the serialisation rule.
32
+ * @returns The literal to place inside `@default(...)`, or `undefined` when
33
+ * `value` is `undefined` (no default to emit).
34
+ */
35
+ export function formatPrismaDefault(
36
+ value: unknown,
37
+ fieldType: PrismaDefaultFieldType,
38
+ ): string | undefined {
39
+ if (value === undefined) {
40
+ return undefined
41
+ }
42
+
43
+ switch (fieldType) {
44
+ case 'integer':
45
+ // Bare numeric literal — Prisma expects no quotes for Int defaults.
46
+ return String(value)
47
+
48
+ case 'text':
49
+ // Double-quoted string literal. The value is escaped via JSON.stringify so
50
+ // embedded quotes/backslashes are handled correctly.
51
+ return JSON.stringify(String(value))
52
+
53
+ case 'json': {
54
+ // Keystone's JSON-literal form: canonical, space-free JSON.stringify of the
55
+ // value, then wrap the whole serialised string in escaped double quotes so
56
+ // Prisma stores the JSON text as the column default. The outer
57
+ // JSON.stringify produces the escaped, double-quoted wrapper.
58
+ const serialised = JSON.stringify(value)
59
+ // JSON.stringify can return undefined for unserialisable values (e.g. a
60
+ // function). Treat that as "no default" rather than emitting `@default()`.
61
+ if (serialised === undefined) {
62
+ return undefined
63
+ }
64
+ return JSON.stringify(serialised)
65
+ }
66
+ }
67
+ }