@opensaas/stack-core 0.24.0 → 0.25.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 +223 -0
- package/dist/access/access-filter.d.ts +39 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +121 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-access.d.ts +1 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +79 -4
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +213 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/types.d.ts +39 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/types.d.ts +318 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +153 -26
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +59 -3
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +552 -129
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +91 -0
- package/dist/context/transaction-boundary.d.ts.map +1 -0
- package/dist/context/transaction-boundary.js +329 -0
- package/dist/context/transaction-boundary.js.map +1 -0
- package/dist/context/write-pipeline.d.ts +15 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +173 -10
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.d.ts +2 -0
- package/dist/fields/calendar-day.test.d.ts.map +1 -0
- package/dist/fields/calendar-day.test.js +120 -0
- package/dist/fields/calendar-day.test.js.map +1 -0
- package/dist/fields/index.d.ts +18 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +93 -17
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +116 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +154 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/validation/schema.test.js +222 -1
- package/dist/validation/schema.test.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.ts +156 -0
- package/src/access/field-access.test.ts +255 -0
- package/src/access/field-access.ts +91 -5
- package/src/access/index.ts +1 -1
- package/src/access/types.ts +45 -0
- package/src/config/types.ts +364 -0
- package/src/context/index.ts +207 -37
- package/src/context/nested-operations.ts +969 -143
- package/src/context/transaction-boundary.ts +440 -0
- package/src/context/write-pipeline.ts +234 -13
- package/src/fields/calendar-day.test.ts +140 -0
- package/src/fields/index.ts +96 -16
- package/src/hooks/index.ts +265 -0
- package/src/validation/schema.test.ts +266 -1
- package/tests/access.test.ts +24 -16
- package/tests/context.test.ts +481 -0
- package/tests/field-types.test.ts +17 -3
- package/tests/nested-access-and-hooks.test.ts +1130 -54
- package/tests/nested-operation-registry.test.ts +28 -3
- package/tests/nested-write-hooks.test.ts +864 -0
- package/tests/transaction-boundary-hooks.test.ts +465 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -16,8 +16,15 @@ import {
|
|
|
16
16
|
ValidationError,
|
|
17
17
|
} from '../hooks/index.js'
|
|
18
18
|
import { hookPipeline } from './hook-pipeline.js'
|
|
19
|
-
import { processNestedOperations } from './nested-operations.js'
|
|
19
|
+
import { processNestedOperations, runAfterTasks } from './nested-operations.js'
|
|
20
|
+
import type { AfterTask } from './nested-operations.js'
|
|
21
|
+
import { enumerateInvolvedLists, runWithTransactionBoundary } from './transaction-boundary.js'
|
|
20
22
|
import { getDbKey } from '../lib/case-utils.js'
|
|
23
|
+
// NOTE: `index.ts` imports from this module too — this is an intentional cyclic
|
|
24
|
+
// dependency. It is safe because `buildDbDelegate` is only INVOKED at write
|
|
25
|
+
// time (never during module evaluation), so by the time it runs the export is
|
|
26
|
+
// fully initialised.
|
|
27
|
+
import { buildDbDelegate } from './index.js'
|
|
21
28
|
|
|
22
29
|
/**
|
|
23
30
|
* Write Pipeline — the single module that runs the canonical, secured write
|
|
@@ -57,10 +64,14 @@ export interface PrismaModel {
|
|
|
57
64
|
findUnique: (args: { where: Record<string, unknown> }) => Promise<Record<string, unknown> | null>
|
|
58
65
|
findFirst: (args: { where: Record<string, unknown> }) => Promise<Record<string, unknown> | null>
|
|
59
66
|
count: () => Promise<number>
|
|
60
|
-
create: (args: {
|
|
67
|
+
create: (args: {
|
|
68
|
+
data: Record<string, unknown>
|
|
69
|
+
include?: Record<string, unknown>
|
|
70
|
+
}) => Promise<Record<string, unknown>>
|
|
61
71
|
update: (args: {
|
|
62
72
|
where: Record<string, unknown>
|
|
63
73
|
data: Record<string, unknown>
|
|
74
|
+
include?: Record<string, unknown>
|
|
64
75
|
}) => Promise<Record<string, unknown>>
|
|
65
76
|
delete: (args: { where: Record<string, unknown> }) => Promise<Record<string, unknown>>
|
|
66
77
|
}
|
|
@@ -95,8 +106,14 @@ export interface WriteStrategy {
|
|
|
95
106
|
/**
|
|
96
107
|
* Axis 3: execute the database write and return the persisted/deleted row.
|
|
97
108
|
* `data` is the fully-resolved write payload (empty object for delete).
|
|
109
|
+
* `include` (create/update) asks the DB to return nested relations so nested
|
|
110
|
+
* `afterOperation` can recover its persisted `item`; delete ignores it.
|
|
98
111
|
*/
|
|
99
|
-
persist(
|
|
112
|
+
persist(
|
|
113
|
+
model: PrismaModel,
|
|
114
|
+
data: Record<string, unknown>,
|
|
115
|
+
include?: Record<string, unknown>,
|
|
116
|
+
): Promise<Record<string, unknown>>
|
|
100
117
|
}
|
|
101
118
|
|
|
102
119
|
/**
|
|
@@ -112,6 +129,43 @@ function getModel<TPrisma extends PrismaClientLike>(
|
|
|
112
129
|
return (prisma as any)[getDbKey(listName)] as PrismaModel
|
|
113
130
|
}
|
|
114
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Minimal shape of a Prisma interactive-transaction-capable client.
|
|
134
|
+
*
|
|
135
|
+
* The transaction client `tx` is dynamically typed exactly like the model
|
|
136
|
+
* surface above (model names are generated at runtime), so the cast is kept
|
|
137
|
+
* localized and commented per the house rules.
|
|
138
|
+
*/
|
|
139
|
+
interface TransactionCapable {
|
|
140
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- $transaction callback receives a dynamically-typed tx client
|
|
141
|
+
$transaction?: (fn: (tx: any) => Promise<unknown>) => Promise<unknown>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Run `fn` inside ONE interactive transaction (ADR-0010: every write is
|
|
146
|
+
* transactional, so the hook contract does not depend on whether a write
|
|
147
|
+
* happened to be nested). The transaction client `tx` is passed to `fn` and
|
|
148
|
+
* used as the persistence target for the parent + all nested writes, so they
|
|
149
|
+
* are atomic and a throwing hook rolls the whole write back.
|
|
150
|
+
*
|
|
151
|
+
* If the client does not expose `$transaction` (e.g. a test mock), `fn` runs
|
|
152
|
+
* directly against the client — the hook ordering and arguments are identical;
|
|
153
|
+
* only the rollback guarantee is provided by the real transaction.
|
|
154
|
+
*/
|
|
155
|
+
async function runInTransaction<TPrisma extends PrismaClientLike>(
|
|
156
|
+
prisma: TPrisma,
|
|
157
|
+
fn: (tx: TPrisma) => Promise<Record<string, unknown> | null>,
|
|
158
|
+
): Promise<Record<string, unknown> | null> {
|
|
159
|
+
const client = prisma as unknown as TransactionCapable
|
|
160
|
+
if (typeof client.$transaction === 'function') {
|
|
161
|
+
return (await client.$transaction(async (tx) => fn(tx as TPrisma))) as Record<
|
|
162
|
+
string,
|
|
163
|
+
unknown
|
|
164
|
+
> | null
|
|
165
|
+
}
|
|
166
|
+
return fn(prisma)
|
|
167
|
+
}
|
|
168
|
+
|
|
115
169
|
/**
|
|
116
170
|
* Check if a list is configured as a singleton.
|
|
117
171
|
*/
|
|
@@ -134,6 +188,16 @@ export interface WritePipelineArgs<TPrisma extends PrismaClientLike> {
|
|
|
134
188
|
inputData: Record<string, unknown> | undefined
|
|
135
189
|
/** The per-operation strategy supplying the three variation axes. */
|
|
136
190
|
strategy: WriteStrategy
|
|
191
|
+
/**
|
|
192
|
+
* The target resolution computed ONCE before the transaction opened (#590).
|
|
193
|
+
*
|
|
194
|
+
* The transaction-boundary bracket resolves the top-level target + access
|
|
195
|
+
* before opening the transaction (both to gate silent-failures without firing
|
|
196
|
+
* boundary hooks, and to supply `originalItem` to `beforeTransaction`). To
|
|
197
|
+
* avoid a second resolution inside the transaction, the in-transaction body
|
|
198
|
+
* reuses this result instead of calling `strategy.resolveTarget` again.
|
|
199
|
+
*/
|
|
200
|
+
preResolvedTarget?: TargetResolution
|
|
137
201
|
}
|
|
138
202
|
|
|
139
203
|
/**
|
|
@@ -165,14 +229,120 @@ export interface WritePipelineArgs<TPrisma extends PrismaClientLike> {
|
|
|
165
229
|
export async function runWritePipeline<TPrisma extends PrismaClientLike>(
|
|
166
230
|
args: WritePipelineArgs<TPrisma>,
|
|
167
231
|
): Promise<Record<string, unknown> | null> {
|
|
168
|
-
const { listName, listConfig,
|
|
232
|
+
const { prisma, listName, listConfig, context, config, inputData, strategy } = args
|
|
233
|
+
|
|
234
|
+
// ── Pre-transaction access gate (#590) ──────────────────────────────────────
|
|
235
|
+
// Resolve the TOP-LEVEL target + operation-level access OUTSIDE the
|
|
236
|
+
// transaction first, using the NON-transactional client. A
|
|
237
|
+
// denied/missing/filter-non-match target short-circuits to `null` (silent
|
|
238
|
+
// failure) WITHOUT firing any transaction-boundary hooks — a denied write
|
|
239
|
+
// opens no transaction and takes no external action, so it must not run
|
|
240
|
+
// beforeTransaction/afterTransaction. This resolution also yields the
|
|
241
|
+
// top-level `originalItem` the boundary hooks receive for update/delete.
|
|
242
|
+
// The result is passed into the transaction as `preResolvedTarget` and REUSED
|
|
243
|
+
// there (the in-transaction resolveTarget does NOT re-run), so the target is
|
|
244
|
+
// read exactly once — #569's resolveTarget call-count semantics are preserved.
|
|
245
|
+
const gate = await strategy.resolveTarget(getModel(prisma, listName))
|
|
246
|
+
if (gate.status === 'denied') {
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Enumerate involved lists from the input tree (no DB reads) ──────────────
|
|
251
|
+
const involvedLists = enumerateInvolvedLists({
|
|
252
|
+
listName,
|
|
253
|
+
listConfig,
|
|
254
|
+
operation: strategy.operation,
|
|
255
|
+
inputData,
|
|
256
|
+
topLevelOriginalItem: gate.originalItem,
|
|
257
|
+
config,
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// ── Bracket the transaction with beforeTransaction/afterTransaction (#590) ──
|
|
261
|
+
// beforeTransaction runs before the transaction opens; afterTransaction runs
|
|
262
|
+
// after it settles (commit or rollback), per the symmetric-bracket rule.
|
|
263
|
+
return runWithTransactionBoundary({
|
|
264
|
+
involvedLists,
|
|
265
|
+
context,
|
|
266
|
+
runTransaction: () =>
|
|
267
|
+
// ADR-0010: every write runs inside ONE interactive transaction. The
|
|
268
|
+
// parent and ALL nested writes share this transaction's client `tx` as
|
|
269
|
+
// their persistence target, so they are atomic and a throwing
|
|
270
|
+
// `beforeOperation`/`afterOperation` (or validation) rolls the whole write
|
|
271
|
+
// back. `runWriteInTransaction` resolves the target row, runs the full
|
|
272
|
+
// hook pipeline, persists, and runs nested + own `afterOperation` — all
|
|
273
|
+
// against `tx`.
|
|
274
|
+
runInTransaction(prisma, (tx) =>
|
|
275
|
+
runWriteInTransaction({
|
|
276
|
+
...args,
|
|
277
|
+
prisma: tx,
|
|
278
|
+
// Reuse the pre-transaction target resolution (computed above) so the
|
|
279
|
+
// target is read exactly once (#569 call-count semantics preserved).
|
|
280
|
+
preResolvedTarget: gate,
|
|
281
|
+
// ADR-0010 atomicity: hooks that write via `context.db` must hit the
|
|
282
|
+
// SAME transaction, or those writes would commit independently and
|
|
283
|
+
// survive a rollback. Rebind the context's `db` (and `prisma`) to the
|
|
284
|
+
// transaction client `tx` so before/afterOperation `context.db` writes
|
|
285
|
+
// participate in — and roll back with — this write's transaction.
|
|
286
|
+
context: bindContextToTransaction(args, tx),
|
|
287
|
+
}),
|
|
288
|
+
),
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Build an {@link AccessContext} whose `db`/`prisma` target the transaction
|
|
294
|
+
* client `tx`, so any `context.db` write a hook performs runs inside this
|
|
295
|
+
* write's transaction and rolls back with it (ADR-0010).
|
|
296
|
+
*
|
|
297
|
+
* The access-controlled `db` delegates capture their Prisma client at
|
|
298
|
+
* construction, so the request-time `context.db` is bound to the ORIGINAL
|
|
299
|
+
* client. We rebuild the delegates against `tx` via {@link buildDbDelegate},
|
|
300
|
+
* reusing the request context's `session`, `storage`, `plugins`, `_isSudo`, and
|
|
301
|
+
* the shared `_resolveOutputCounter` reference (so resolveOutput depth tracking
|
|
302
|
+
* is preserved). Plugin runtimes are NOT re-executed; the existing
|
|
303
|
+
* `plugins` object is reused as-is.
|
|
304
|
+
*/
|
|
305
|
+
function bindContextToTransaction<TPrisma extends PrismaClientLike>(
|
|
306
|
+
args: WritePipelineArgs<TPrisma>,
|
|
307
|
+
tx: TPrisma,
|
|
308
|
+
): AccessContext<TPrisma> {
|
|
309
|
+
const { context, config } = args
|
|
310
|
+
const txContext: AccessContext<TPrisma> = {
|
|
311
|
+
session: context.session,
|
|
312
|
+
prisma: tx,
|
|
313
|
+
db: context.db,
|
|
314
|
+
storage: context.storage,
|
|
315
|
+
plugins: context.plugins,
|
|
316
|
+
_isSudo: context._isSudo,
|
|
317
|
+
_resolveOutputCounter: context._resolveOutputCounter,
|
|
318
|
+
}
|
|
319
|
+
// Rebuild the db delegate against `tx`, pointing back at `txContext` so hooks
|
|
320
|
+
// reached through it also see the transactional context.
|
|
321
|
+
txContext.db = buildDbDelegate(config, tx, txContext)
|
|
322
|
+
return txContext
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The body of one secured write, executed against the transaction client `tx`
|
|
327
|
+
* (passed in as `args.prisma`). Returns `null` for the silent-failure cases and
|
|
328
|
+
* the Field-Visibility-filtered row otherwise. Any throw here propagates out of
|
|
329
|
+
* `runInTransaction` and rolls the transaction back.
|
|
330
|
+
*/
|
|
331
|
+
async function runWriteInTransaction<TPrisma extends PrismaClientLike>(
|
|
332
|
+
args: WritePipelineArgs<TPrisma>,
|
|
333
|
+
): Promise<Record<string, unknown> | null> {
|
|
334
|
+
const { listName, listConfig, prisma: tx, context, config, inputData, strategy } = args
|
|
169
335
|
const { operation } = strategy
|
|
170
|
-
const model = getModel(
|
|
336
|
+
const model = getModel(tx, listName)
|
|
171
337
|
|
|
172
338
|
// ── Phase 1: resolve target + operation-level access ──────────────────────
|
|
173
339
|
// Short-circuits to `null` (silent failure) for missing target, denied
|
|
174
340
|
// access, or filter non-match — before any hook side effects or the DB call.
|
|
175
|
-
|
|
341
|
+
// The transaction-boundary bracket (#590) already resolved this once before
|
|
342
|
+
// opening the transaction; reuse that result rather than reading the target
|
|
343
|
+
// twice. (When invoked without the bracket — e.g. a direct unit test — fall
|
|
344
|
+
// back to resolving here against the tx model.)
|
|
345
|
+
const resolution = args.preResolvedTarget ?? (await strategy.resolveTarget(model))
|
|
176
346
|
if (resolution.status === 'denied') {
|
|
177
347
|
return null
|
|
178
348
|
}
|
|
@@ -214,12 +384,24 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
|
|
|
214
384
|
})
|
|
215
385
|
|
|
216
386
|
// ── Phase 5.5: process nested relationship operations ───────────────────────
|
|
217
|
-
|
|
387
|
+
// This runs each nested record's resolveInput/validate/field-rules AND its
|
|
388
|
+
// `beforeOperation` (inside this transaction), returning the transformed
|
|
389
|
+
// payload plus deferred `afterOperation` tasks and the relation fields to
|
|
390
|
+
// `include` so those tasks can recover their persisted `item`. All nested DB
|
|
391
|
+
// reads/persistence go through `tx`.
|
|
392
|
+
const { data, afterTasks, includeFields } = await processNestedOperations(
|
|
218
393
|
filteredData,
|
|
219
394
|
listConfig.fields,
|
|
220
395
|
config,
|
|
221
|
-
{ ...context, prisma },
|
|
396
|
+
{ ...context, prisma: tx },
|
|
222
397
|
writeOp,
|
|
398
|
+
listName,
|
|
399
|
+
originalItem,
|
|
400
|
+
// Pass the enclosing write's `inputData` (the SAME value the Phase-5
|
|
401
|
+
// `filterWritableFields` call above uses) so the connect-site owning-field
|
|
402
|
+
// gate evaluates item-/inputData-dependent field rules identically to Phase 5
|
|
403
|
+
// and the two cannot diverge into a spurious connect denial (#588 finding).
|
|
404
|
+
input,
|
|
223
405
|
)
|
|
224
406
|
|
|
225
407
|
// ── Phase 6: field-level beforeOperation (side effects only) ────────────────
|
|
@@ -255,7 +437,10 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
|
|
|
255
437
|
)
|
|
256
438
|
|
|
257
439
|
// ── Phase 8: DB write ───────────────────────────────────────────────────────
|
|
258
|
-
|
|
440
|
+
// Ask the DB to return the nested relations that have deferred
|
|
441
|
+
// `afterOperation` tasks so they can recover their persisted `item`.
|
|
442
|
+
const include = buildIncludeFromFields(includeFields)
|
|
443
|
+
const item = await strategy.persist(model, data, include)
|
|
259
444
|
|
|
260
445
|
// ── Phase 9: list-level afterOperation ──────────────────────────────────────
|
|
261
446
|
await executeAfterOperation(
|
|
@@ -293,6 +478,12 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
|
|
|
293
478
|
originalItem, // undefined for create, original row for update
|
|
294
479
|
)
|
|
295
480
|
|
|
481
|
+
// ── Phase 10.5: nested afterOperation (deferred, in this transaction) ────────
|
|
482
|
+
// Each nested create/update/delete's `afterOperation` fires now, with the
|
|
483
|
+
// persisted nested row recovered from the parent's included relations. A throw
|
|
484
|
+
// here rolls the whole transaction back (parent write included).
|
|
485
|
+
await runNestedAfterTasks(afterTasks, item)
|
|
486
|
+
|
|
296
487
|
// ── Phase 11: Field Visibility (filter readable fields + resolveOutput) ─────
|
|
297
488
|
return filterReadableFields(
|
|
298
489
|
item,
|
|
@@ -307,6 +498,36 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
|
|
|
307
498
|
)
|
|
308
499
|
}
|
|
309
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Build a Prisma `include` from the set of relation field names that have
|
|
503
|
+
* deferred nested `afterOperation` tasks, so the parent write returns those
|
|
504
|
+
* relations and the tasks can recover their persisted `item`.
|
|
505
|
+
*/
|
|
506
|
+
function buildIncludeFromFields(includeFields: Set<string>): Record<string, unknown> | undefined {
|
|
507
|
+
if (includeFields.size === 0) return undefined
|
|
508
|
+
const include: Record<string, unknown> = {}
|
|
509
|
+
for (const field of includeFields) {
|
|
510
|
+
include[field] = true
|
|
511
|
+
}
|
|
512
|
+
return include
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Run the deferred nested `afterOperation` tasks against the persisted parent
|
|
517
|
+
* row. The persisted parent is always a record here; the `?? {}` is only a
|
|
518
|
+
* type-narrowing guard. Each task recovers its OWN nested row from `item` by
|
|
519
|
+
* id-diff (create) or known id (update); if a created row cannot be recovered
|
|
520
|
+
* the task THROWS rather than firing `afterOperation` with a fabricated item
|
|
521
|
+
* (see `recoverCreatedRows`/the create task in `nested-operations.ts`).
|
|
522
|
+
*/
|
|
523
|
+
async function runNestedAfterTasks(
|
|
524
|
+
afterTasks: AfterTask[],
|
|
525
|
+
item: Record<string, unknown>,
|
|
526
|
+
): Promise<void> {
|
|
527
|
+
if (afterTasks.length === 0) return
|
|
528
|
+
await runAfterTasks(afterTasks, item ?? {})
|
|
529
|
+
}
|
|
530
|
+
|
|
310
531
|
/**
|
|
311
532
|
* The delete tail of the pipeline: skips the input-shaping phases and runs only
|
|
312
533
|
* validate/field-validate before the DB delete, then the after-hooks. Returns
|
|
@@ -434,10 +655,10 @@ export function createWriteStrategy(
|
|
|
434
655
|
|
|
435
656
|
return { status: 'ok', originalItem: undefined }
|
|
436
657
|
},
|
|
437
|
-
async persist(model, data) {
|
|
658
|
+
async persist(model, data, include) {
|
|
438
659
|
// Singleton lists use Int @id with value always 1 (matching Keystone 6).
|
|
439
660
|
const createData = singleton ? { id: 1, ...data } : data
|
|
440
|
-
return model.create({ data: createData })
|
|
661
|
+
return model.create(include ? { data: createData, include } : { data: createData })
|
|
441
662
|
},
|
|
442
663
|
}
|
|
443
664
|
}
|
|
@@ -504,8 +725,8 @@ export function updateWriteStrategy(
|
|
|
504
725
|
operation: 'update',
|
|
505
726
|
runInputPhases: true,
|
|
506
727
|
resolveTarget: resolveExistingTarget(listConfig, context, where, 'update'),
|
|
507
|
-
async persist(model, data) {
|
|
508
|
-
return model.update({ where, data })
|
|
728
|
+
async persist(model, data, include) {
|
|
729
|
+
return model.update(include ? { where, data, include } : { where, data })
|
|
509
730
|
},
|
|
510
731
|
}
|
|
511
732
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect, expectTypeOf } from 'vitest'
|
|
2
|
+
import { calendarDay } from './index.js'
|
|
3
|
+
import { generateZodSchema, validateWithZod } from '../validation/schema.js'
|
|
4
|
+
import type { FieldConfig } from '../config/types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* calendarDay is a YYYY-MM-DD string end-to-end (Keystone's CalendarDay
|
|
8
|
+
* scalar). Type, validation, and runtime read value must all agree on `string`.
|
|
9
|
+
* See issue #571.
|
|
10
|
+
*/
|
|
11
|
+
describe('calendarDay field (YYYY-MM-DD string end-to-end)', () => {
|
|
12
|
+
describe('getTypeScriptType', () => {
|
|
13
|
+
it('returns string (not Date), driving the entity + input types', () => {
|
|
14
|
+
const field = calendarDay()
|
|
15
|
+
expect(field.getTypeScriptType?.()).toEqual({ type: 'string', optional: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('is non-optional when required and not nullable', () => {
|
|
19
|
+
const field = calendarDay({ validation: { isRequired: true } })
|
|
20
|
+
expect(field.getTypeScriptType?.()).toEqual({ type: 'string', optional: false })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('type-level: the declared type is the literal "string"', () => {
|
|
24
|
+
const field = calendarDay()
|
|
25
|
+
const tsType = field.getTypeScriptType?.()
|
|
26
|
+
// The entity/read type and the standalone generated CreateInput/UpdateInput
|
|
27
|
+
// types are emitted from this literal, so asserting it is exactly 'string'
|
|
28
|
+
// pins those types to `string`. (At the context.db write path a Date is
|
|
29
|
+
// rejected at runtime by validation, not at compile time — tracked in #599.)
|
|
30
|
+
expectTypeOf(tsType).toEqualTypeOf<{ type: string; optional: boolean } | undefined>()
|
|
31
|
+
if (tsType) {
|
|
32
|
+
expectTypeOf(tsType.type).toEqualTypeOf<string>()
|
|
33
|
+
expect(tsType.type).toBe('string')
|
|
34
|
+
// @ts-expect-error - the runtime type is 'string', never 'Date'
|
|
35
|
+
const _notDate: 'Date' = tsType.type
|
|
36
|
+
void _notDate
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('getPrismaType (unchanged — stays DateTime @db.Date)', () => {
|
|
42
|
+
it('keeps DateTime storage with @db.Date on non-sqlite providers', () => {
|
|
43
|
+
const field = calendarDay({ validation: { isRequired: true } })
|
|
44
|
+
const prisma = field.getPrismaType?.('startsOn', 'postgresql')
|
|
45
|
+
expect(prisma?.type).toBe('DateTime')
|
|
46
|
+
expect(prisma?.modifiers).toContain('@db.Date')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('omits @db.Date on sqlite (TEXT fallback)', () => {
|
|
50
|
+
const field = calendarDay({ validation: { isRequired: true } })
|
|
51
|
+
const prisma = field.getPrismaType?.('startsOn', 'sqlite')
|
|
52
|
+
expect(prisma?.type).toBe('DateTime')
|
|
53
|
+
expect(prisma?.modifiers ?? '').not.toContain('@db.Date')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('write validation (string-only)', () => {
|
|
58
|
+
const fields: Record<string, FieldConfig> = {
|
|
59
|
+
startsOn: calendarDay({ validation: { isRequired: true } }),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it('accepts a valid YYYY-MM-DD string on create', () => {
|
|
63
|
+
const result = validateWithZod({ startsOn: '2025-01-15' }, fields, 'create')
|
|
64
|
+
expect(result.success).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('rejects a malformed string with a clear message', () => {
|
|
68
|
+
const result = validateWithZod({ startsOn: '15/01/2025' }, fields, 'create')
|
|
69
|
+
expect(result.success).toBe(false)
|
|
70
|
+
if (!result.success) {
|
|
71
|
+
expect(result.errors).toHaveProperty('startsOn')
|
|
72
|
+
expect(result.errors.startsOn).toMatch(/YYYY-MM-DD/)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('rejects a Date instance at runtime (not a string)', () => {
|
|
77
|
+
// A typed caller cannot reach here (input type is `string`), but the
|
|
78
|
+
// validator is string-only as a runtime backstop.
|
|
79
|
+
const result = validateWithZod(
|
|
80
|
+
{ startsOn: new Date('2025-01-15') } as unknown as Record<string, unknown>,
|
|
81
|
+
fields,
|
|
82
|
+
'create',
|
|
83
|
+
)
|
|
84
|
+
expect(result.success).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('zod schema for the field validates the YYYY-MM-DD shape', () => {
|
|
88
|
+
const schema = generateZodSchema(fields, 'create')
|
|
89
|
+
expect(schema.safeParse({ startsOn: '2025-12-31' }).success).toBe(true)
|
|
90
|
+
expect(schema.safeParse({ startsOn: 'nope' }).success).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('read transform (resolveOutput returns a YYYY-MM-DD string)', () => {
|
|
95
|
+
// The read pipeline calls fieldConfig.hooks.resolveOutput({ value, ... }).
|
|
96
|
+
// We exercise that hook directly with the value shapes Prisma can return.
|
|
97
|
+
function readValue(value: unknown): unknown {
|
|
98
|
+
const field = calendarDay()
|
|
99
|
+
const hook = field.hooks?.resolveOutput
|
|
100
|
+
if (!hook) throw new Error('calendarDay must define a resolveOutput hook')
|
|
101
|
+
// Cast to the runtime call shape used by field-visibility.ts.
|
|
102
|
+
return (hook as unknown as (args: { value: unknown }) => unknown)({ value })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it('formats a Date (Postgres/MySQL @db.Date) to YYYY-MM-DD', () => {
|
|
106
|
+
expect(readValue(new Date('2025-01-15T00:00:00.000Z'))).toBe('2025-01-15')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('is timezone-safe — a late-UTC Date does not drift a day', () => {
|
|
110
|
+
// 23:59:59Z is the same UTC calendar day; UTC-based formatting keeps it.
|
|
111
|
+
expect(readValue(new Date('2025-01-15T23:59:59.999Z'))).toBe('2025-01-15')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('passes through an already-formatted string (SQLite TEXT)', () => {
|
|
115
|
+
expect(readValue('2025-01-15')).toBe('2025-01-15')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('takes the date-only prefix of a full ISO string (SQLite TEXT)', () => {
|
|
119
|
+
expect(readValue('2025-01-15T00:00:00.000Z')).toBe('2025-01-15')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('passes null/undefined through unchanged', () => {
|
|
123
|
+
expect(readValue(null)).toBeNull()
|
|
124
|
+
expect(readValue(undefined)).toBeUndefined()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('user-provided hooks are preserved', () => {
|
|
129
|
+
it('merges a user resolveOutput over the default (last wins)', () => {
|
|
130
|
+
const field = calendarDay({
|
|
131
|
+
hooks: {
|
|
132
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test hook
|
|
133
|
+
resolveOutput: ({ value }: { value: any }) => `custom:${value}`,
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
const hook = field.hooks?.resolveOutput as unknown as (args: { value: unknown }) => unknown
|
|
137
|
+
expect(hook({ value: '2025-01-15' })).toBe('custom:2025-01-15')
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
})
|
package/src/fields/index.ts
CHANGED
|
@@ -84,7 +84,7 @@ export function text<
|
|
|
84
84
|
: withMin
|
|
85
85
|
|
|
86
86
|
if (isRequired && operation === 'update') {
|
|
87
|
-
return
|
|
87
|
+
return withMax.optional()
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
return !isRequired ? withMax.optional().nullable() : withMax
|
|
@@ -514,11 +514,23 @@ export function timestamp<
|
|
|
514
514
|
/**
|
|
515
515
|
* Calendar Day field - date only (no time) in ISO8601 format
|
|
516
516
|
*
|
|
517
|
+
* Mirrors Keystone's `CalendarDay` scalar: the wire format through
|
|
518
|
+
* `context.db.*` is a `YYYY-MM-DD` **string** in both directions (read and
|
|
519
|
+
* write). The field's TypeScript type — entity, `CreateInput`, and
|
|
520
|
+
* `UpdateInput` — is `string`, so passing a `Date` is a compile-time error.
|
|
521
|
+
*
|
|
517
522
|
* **Features:**
|
|
518
523
|
* - Stores date values only (no time component)
|
|
519
524
|
* - PostgreSQL/MySQL: Uses native DATE type via @db.Date
|
|
520
525
|
* - SQLite: Uses String representation
|
|
521
|
-
* -
|
|
526
|
+
* - **Writes:** accept only a `YYYY-MM-DD` string; a malformed string or a
|
|
527
|
+
* `Date` is rejected at runtime by validation (a `ValidationError`). Genuine
|
|
528
|
+
* compile-time rejection at the `context.db` call site is tracked in #599.
|
|
529
|
+
* - **Reads:** always return a `YYYY-MM-DD` string. Even though the underlying
|
|
530
|
+
* `@db.Date` column hands Prisma a `Date`, a `resolveOutput` transform
|
|
531
|
+
* normalises it back to a `YYYY-MM-DD` string so the runtime value matches
|
|
532
|
+
* the declared `string` type. UTC components are used to avoid timezone
|
|
533
|
+
* off-by-one errors.
|
|
522
534
|
* - Optional validation for required fields
|
|
523
535
|
* - Database column mapping and nullability control
|
|
524
536
|
* - Index support (boolean or 'unique')
|
|
@@ -539,13 +551,17 @@ export function timestamp<
|
|
|
539
551
|
* })
|
|
540
552
|
* }
|
|
541
553
|
*
|
|
542
|
-
* // Creating with date values
|
|
554
|
+
* // Creating with date values — pass YYYY-MM-DD strings (NOT Date objects)
|
|
543
555
|
* const event = await context.db.event.create({
|
|
544
556
|
* data: {
|
|
545
557
|
* startDate: '2025-01-15',
|
|
546
558
|
* endDate: '2025-01-20'
|
|
547
559
|
* }
|
|
548
560
|
* })
|
|
561
|
+
*
|
|
562
|
+
* // Reading — values come back as YYYY-MM-DD strings
|
|
563
|
+
* const e = await context.db.event.findUnique({ where: { id } })
|
|
564
|
+
* e?.startDate // => '2025-01-15' (a string, not a Date)
|
|
549
565
|
* ```
|
|
550
566
|
*
|
|
551
567
|
* @param options - Field configuration options
|
|
@@ -557,6 +573,19 @@ export function calendarDay<
|
|
|
557
573
|
return {
|
|
558
574
|
type: 'calendarDay',
|
|
559
575
|
...options,
|
|
576
|
+
// Reads: the underlying @db.Date column hands Prisma a Date (or a TEXT
|
|
577
|
+
// string under the SQLite fallback). Normalise to a YYYY-MM-DD string so the
|
|
578
|
+
// runtime value matches the declared `string` type. UTC components are used
|
|
579
|
+
// so the formatting never drifts a day in non-UTC timezones.
|
|
580
|
+
// Cast hooks to any since field builders are generic and can't know the
|
|
581
|
+
// specific TFieldKey (same pattern as password()).
|
|
582
|
+
hooks: {
|
|
583
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
|
|
584
|
+
resolveOutput: ({ value }: { value: any }) => formatCalendarDay(value),
|
|
585
|
+
// Merge with user-provided hooks if any
|
|
586
|
+
...options?.hooks,
|
|
587
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hook object needs type assertion for field builder
|
|
588
|
+
} as any,
|
|
560
589
|
getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
|
|
561
590
|
const validation = options?.validation
|
|
562
591
|
const isRequired = validation?.isRequired
|
|
@@ -574,8 +603,8 @@ export function calendarDay<
|
|
|
574
603
|
if (isRequired && operation === 'create') {
|
|
575
604
|
return dateSchema
|
|
576
605
|
} else if (isRequired && operation === 'update') {
|
|
577
|
-
// Required in update mode:
|
|
578
|
-
return
|
|
606
|
+
// Required in update mode: omitted keys pass; present values must be valid
|
|
607
|
+
return dateSchema.optional()
|
|
579
608
|
} else {
|
|
580
609
|
return dateSchema.optional().nullable()
|
|
581
610
|
}
|
|
@@ -628,14 +657,49 @@ export function calendarDay<
|
|
|
628
657
|
const isRequired = validation?.isRequired
|
|
629
658
|
const isNullable = db?.isNullable ?? !isRequired
|
|
630
659
|
|
|
660
|
+
// calendarDay is a YYYY-MM-DD string end-to-end (Keystone's CalendarDay
|
|
661
|
+
// scalar). Returning 'string' here makes the entity/read type and the
|
|
662
|
+
// standalone generated CreateInput/UpdateInput types `string`. At the
|
|
663
|
+
// context.db write path a Date is still rejected at runtime by validation
|
|
664
|
+
// (the generated db method `data` type derives from Prisma's `Date | string`
|
|
665
|
+
// input — making it a compile-time error is tracked in #599).
|
|
631
666
|
return {
|
|
632
|
-
type: '
|
|
667
|
+
type: 'string',
|
|
633
668
|
optional: isNullable,
|
|
634
669
|
}
|
|
635
670
|
},
|
|
636
671
|
}
|
|
637
672
|
}
|
|
638
673
|
|
|
674
|
+
/**
|
|
675
|
+
* Format a stored calendar-day value to a `YYYY-MM-DD` string.
|
|
676
|
+
*
|
|
677
|
+
* Handles the value being a `Date` (Postgres/MySQL `@db.Date`), an already
|
|
678
|
+
* formatted string (SQLite TEXT fallback), or null/undefined. Dates are
|
|
679
|
+
* formatted from their UTC components so the result never drifts a day in
|
|
680
|
+
* non-UTC timezones.
|
|
681
|
+
*/
|
|
682
|
+
function formatCalendarDay(value: unknown): string | null | undefined {
|
|
683
|
+
if (value === null || value === undefined) {
|
|
684
|
+
return value as null | undefined
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (value instanceof Date) {
|
|
688
|
+
// toISOString() is UTC; the YYYY-MM-DD prefix is timezone-safe.
|
|
689
|
+
return value.toISOString().slice(0, 10)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (typeof value === 'string') {
|
|
693
|
+
// SQLite stores DateTime as TEXT. The value may already be YYYY-MM-DD or a
|
|
694
|
+
// full ISO timestamp — take the date-only prefix either way.
|
|
695
|
+
return value.slice(0, 10)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Any other shape is unexpected for a @db.Date column; surface it untouched
|
|
699
|
+
// by returning undefined so callers see the field as absent rather than wrong.
|
|
700
|
+
return undefined
|
|
701
|
+
}
|
|
702
|
+
|
|
639
703
|
/**
|
|
640
704
|
* Password field (automatically hashed using bcrypt)
|
|
641
705
|
*
|
|
@@ -752,13 +816,13 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
|
|
|
752
816
|
message: `${formatFieldName(fieldName)} is required`,
|
|
753
817
|
})
|
|
754
818
|
} else if (isRequired && operation === 'update') {
|
|
755
|
-
// Required in update mode: if provided, reject empty strings
|
|
756
|
-
return z
|
|
757
|
-
|
|
819
|
+
// Required in update mode: omitted keys pass; if provided, reject empty strings
|
|
820
|
+
return z
|
|
821
|
+
.string()
|
|
822
|
+
.min(1, {
|
|
758
823
|
message: `${formatFieldName(fieldName)} is required`,
|
|
759
|
-
})
|
|
760
|
-
|
|
761
|
-
])
|
|
824
|
+
})
|
|
825
|
+
.optional()
|
|
762
826
|
} else {
|
|
763
827
|
// Not required: can be undefined or any string
|
|
764
828
|
return z
|
|
@@ -1312,11 +1376,27 @@ export function json<
|
|
|
1312
1376
|
const baseSchema = z.unknown()
|
|
1313
1377
|
|
|
1314
1378
|
if (isRequired && operation === 'create') {
|
|
1315
|
-
// Required in create mode: value must be provided
|
|
1316
|
-
|
|
1379
|
+
// Required in create mode: a value must be provided and it must be
|
|
1380
|
+
// non-null (issue #604 — a required json field means non-null). A bare
|
|
1381
|
+
// z.unknown() is treated as optional inside z.object(), so an omitted
|
|
1382
|
+
// key would silently pass; the refinement makes the key genuinely
|
|
1383
|
+
// required by rejecting undefined (which also covers an absent key) and
|
|
1384
|
+
// rejecting a present null, while still accepting any other present
|
|
1385
|
+
// JSON value (object, array, primitive, including falsy 0/""/false).
|
|
1386
|
+
return baseSchema.refine((value) => value !== undefined && value !== null, {
|
|
1387
|
+
message: `${formatFieldName(fieldName)} is required`,
|
|
1388
|
+
})
|
|
1317
1389
|
} else if (isRequired && operation === 'update') {
|
|
1318
|
-
// Required in update mode:
|
|
1319
|
-
|
|
1390
|
+
// Required in update mode: omitted keys still pass (issue #570 — partial
|
|
1391
|
+
// updates may leave the field untouched), but a present null is rejected
|
|
1392
|
+
// (issue #604 — required json means non-null). The `.refine()` runs
|
|
1393
|
+
// before `.optional()` short-circuits on undefined: absent/undefined
|
|
1394
|
+
// passes, a present null is rejected, and other present values pass.
|
|
1395
|
+
return baseSchema
|
|
1396
|
+
.refine((value) => value !== null, {
|
|
1397
|
+
message: `${formatFieldName(fieldName)} is required`,
|
|
1398
|
+
})
|
|
1399
|
+
.optional()
|
|
1320
1400
|
} else {
|
|
1321
1401
|
// Not required: can be undefined or null
|
|
1322
1402
|
return baseSchema.optional().nullable()
|