@opensaas/stack-core 0.20.0 → 0.21.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 (105) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +74 -0
  3. package/CLAUDE.md +18 -2
  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 +155 -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/config/index.d.ts +1 -1
  29. package/dist/config/index.d.ts.map +1 -1
  30. package/dist/config/types.d.ts +45 -4
  31. package/dist/config/types.d.ts.map +1 -1
  32. package/dist/context/hook-pipeline.d.ts +49 -0
  33. package/dist/context/hook-pipeline.d.ts.map +1 -0
  34. package/dist/context/hook-pipeline.js +75 -0
  35. package/dist/context/hook-pipeline.js.map +1 -0
  36. package/dist/context/index.d.ts.map +1 -1
  37. package/dist/context/index.js +30 -462
  38. package/dist/context/index.js.map +1 -1
  39. package/dist/context/nested-operations.d.ts.map +1 -1
  40. package/dist/context/nested-operations.js +72 -68
  41. package/dist/context/nested-operations.js.map +1 -1
  42. package/dist/context/write-pipeline.d.ts +158 -0
  43. package/dist/context/write-pipeline.d.ts.map +1 -0
  44. package/dist/context/write-pipeline.js +306 -0
  45. package/dist/context/write-pipeline.js.map +1 -0
  46. package/dist/extend.d.ts +3 -0
  47. package/dist/extend.d.ts.map +1 -0
  48. package/dist/extend.js +10 -0
  49. package/dist/extend.js.map +1 -0
  50. package/dist/fields/index.d.ts +1 -0
  51. package/dist/fields/index.d.ts.map +1 -1
  52. package/dist/fields/index.js +213 -2
  53. package/dist/fields/index.js.map +1 -1
  54. package/dist/hooks/index.d.ts +20 -0
  55. package/dist/hooks/index.d.ts.map +1 -1
  56. package/dist/hooks/index.js +202 -0
  57. package/dist/hooks/index.js.map +1 -1
  58. package/dist/index.d.ts +5 -9
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +19 -10
  61. package/dist/index.js.map +1 -1
  62. package/dist/internal.d.ts +8 -0
  63. package/dist/internal.d.ts.map +1 -0
  64. package/dist/internal.js +16 -0
  65. package/dist/internal.js.map +1 -0
  66. package/dist/validation/field-config.d.ts +55 -0
  67. package/dist/validation/field-config.d.ts.map +1 -0
  68. package/dist/validation/field-config.js +100 -0
  69. package/dist/validation/field-config.js.map +1 -0
  70. package/dist/validation/field-config.test.d.ts +2 -0
  71. package/dist/validation/field-config.test.d.ts.map +1 -0
  72. package/dist/validation/field-config.test.js +159 -0
  73. package/dist/validation/field-config.test.js.map +1 -0
  74. package/package.json +12 -4
  75. package/src/access/access-filter.ts +97 -0
  76. package/src/access/engine.ts +13 -396
  77. package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
  78. package/src/access/field-access.ts +159 -0
  79. package/src/access/field-visibility.ts +247 -0
  80. package/src/access/index.ts +7 -4
  81. package/src/config/index.ts +1 -0
  82. package/src/config/types.ts +51 -4
  83. package/src/context/hook-pipeline.ts +160 -0
  84. package/src/context/index.ts +29 -667
  85. package/src/context/nested-operations.ts +142 -111
  86. package/src/context/write-pipeline.ts +543 -0
  87. package/src/extend.ts +14 -0
  88. package/src/fields/index.ts +310 -2
  89. package/src/hooks/index.ts +227 -0
  90. package/src/index.ts +27 -90
  91. package/src/internal.ts +49 -0
  92. package/src/validation/field-config.test.ts +199 -0
  93. package/src/validation/field-config.ts +145 -0
  94. package/tests/access-relationships.test.ts +4 -4
  95. package/tests/access.test.ts +1 -1
  96. package/tests/field-hooks.test.ts +410 -0
  97. package/tests/field-types.test.ts +1 -1
  98. package/tests/hook-pipeline.test.ts +233 -0
  99. package/tests/nested-operation-registry.test.ts +206 -0
  100. package/tests/write-pipeline.test.ts +588 -0
  101. package/tsconfig.tsbuildinfo +1 -1
  102. package/vitest.config.ts +43 -1
  103. package/dist/access/engine.test.d.ts +0 -2
  104. package/dist/access/engine.test.d.ts.map +0 -1
  105. 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,14 @@
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 { BaseFieldConfig, TypeInfo, TypeDescriptor } from './config/index.js'