@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +334 -0
- package/CLAUDE.md +29 -11
- package/dist/access/access-filter.d.ts +29 -0
- package/dist/access/access-filter.d.ts.map +1 -0
- package/dist/access/access-filter.js +68 -0
- package/dist/access/access-filter.js.map +1 -0
- package/dist/access/engine.d.ts +15 -48
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +14 -280
- package/dist/access/engine.js.map +1 -1
- package/dist/access/field-access.d.ts +44 -0
- package/dist/access/field-access.d.ts.map +1 -0
- package/dist/access/field-access.js +123 -0
- package/dist/access/field-access.js.map +1 -0
- package/dist/access/field-access.test.d.ts +2 -0
- package/dist/access/field-access.test.d.ts.map +1 -0
- package/dist/access/{engine.test.js → field-access.test.js} +2 -2
- package/dist/access/field-access.test.js.map +1 -0
- package/dist/access/field-visibility.d.ts +13 -0
- package/dist/access/field-visibility.d.ts.map +1 -0
- package/dist/access/field-visibility.js +178 -0
- package/dist/access/field-visibility.js.map +1 -0
- package/dist/access/index.d.ts +4 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +8 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.d.ts +2 -0
- package/dist/access/multi-column-read-write.test.d.ts.map +1 -0
- package/dist/access/multi-column-read-write.test.js +149 -0
- package/dist/access/multi-column-read-write.test.js.map +1 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +334 -5
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +49 -0
- package/dist/context/hook-pipeline.d.ts.map +1 -0
- package/dist/context/hook-pipeline.js +75 -0
- package/dist/context/hook-pipeline.js.map +1 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +30 -462
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +72 -68
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +158 -0
- package/dist/context/write-pipeline.d.ts.map +1 -0
- package/dist/context/write-pipeline.js +306 -0
- package/dist/context/write-pipeline.js.map +1 -0
- package/dist/extend.d.ts +3 -0
- package/dist/extend.d.ts.map +1 -0
- package/dist/extend.js +10 -0
- package/dist/extend.js.map +1 -0
- package/dist/fields/format-prisma-default.d.ts +35 -0
- package/dist/fields/format-prisma-default.d.ts.map +1 -0
- package/dist/fields/format-prisma-default.js +52 -0
- package/dist/fields/format-prisma-default.js.map +1 -0
- package/dist/fields/format-prisma-default.test.d.ts +2 -0
- package/dist/fields/format-prisma-default.test.d.ts.map +1 -0
- package/dist/fields/format-prisma-default.test.js +54 -0
- package/dist/fields/format-prisma-default.test.js.map +1 -0
- package/dist/fields/index.d.ts +1 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +267 -18
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/select.test.js +85 -0
- package/dist/fields/select.test.js.map +1 -1
- package/dist/fields/text-keystone-compat.test.d.ts +2 -0
- package/dist/fields/text-keystone-compat.test.d.ts.map +1 -0
- package/dist/fields/text-keystone-compat.test.js +93 -0
- package/dist/fields/text-keystone-compat.test.js.map +1 -0
- package/dist/hooks/index.d.ts +20 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +246 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +6 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -9
- package/dist/index.js.map +1 -1
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +33 -0
- package/dist/index.test.js.map +1 -0
- package/dist/internal.d.ts +8 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +16 -0
- package/dist/internal.js.map +1 -0
- package/dist/mcp/handler.js +0 -1
- package/dist/mcp/handler.js.map +1 -1
- package/dist/validation/field-config.d.ts +55 -0
- package/dist/validation/field-config.d.ts.map +1 -0
- package/dist/validation/field-config.js +100 -0
- package/dist/validation/field-config.js.map +1 -0
- package/dist/validation/field-config.test.d.ts +2 -0
- package/dist/validation/field-config.test.d.ts.map +1 -0
- package/dist/validation/field-config.test.js +159 -0
- package/dist/validation/field-config.test.js.map +1 -0
- package/package.json +11 -3
- package/src/access/access-filter.ts +97 -0
- package/src/access/engine.ts +13 -396
- package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
- package/src/access/field-access.ts +159 -0
- package/src/access/field-visibility.ts +269 -0
- package/src/access/index.ts +7 -4
- package/src/access/multi-column-read-write.test.ts +255 -0
- package/src/config/index.ts +3 -0
- package/src/config/types.ts +342 -4
- package/src/context/hook-pipeline.ts +160 -0
- package/src/context/index.ts +29 -667
- package/src/context/nested-operations.ts +142 -111
- package/src/context/write-pipeline.ts +543 -0
- package/src/extend.ts +19 -0
- package/src/fields/format-prisma-default.test.ts +64 -0
- package/src/fields/format-prisma-default.ts +67 -0
- package/src/fields/index.ts +375 -20
- package/src/fields/select.test.ts +99 -0
- package/src/fields/text-keystone-compat.test.ts +126 -0
- package/src/hooks/index.ts +270 -0
- package/src/index.test.ts +50 -0
- package/src/index.ts +35 -82
- package/src/internal.ts +49 -0
- package/src/mcp/handler.ts +0 -2
- package/src/validation/field-config.test.ts +199 -0
- package/src/validation/field-config.ts +145 -0
- package/tests/access-relationships.test.ts +4 -4
- package/tests/access.test.ts +1 -1
- package/tests/field-hooks.test.ts +410 -0
- package/tests/field-types.test.ts +1 -1
- package/tests/hook-pipeline.test.ts +233 -0
- package/tests/nested-operation-registry.test.ts +206 -0
- package/tests/write-pipeline.test.ts +588 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +43 -1
- package/dist/access/engine.test.d.ts +0 -2
- package/dist/access/engine.test.d.ts.map +0 -1
- 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
|
+
}
|