@opensaas/stack-core 0.26.0 → 0.27.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 +48 -12
- package/dist/access/access-filter.d.ts +17 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +39 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-visibility.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/multi-column-read-write.test.js.map +1 -1
- package/dist/config/label.d.ts +22 -0
- package/dist/config/label.d.ts.map +1 -0
- package/dist/config/label.js +48 -0
- package/dist/config/label.js.map +1 -0
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +19 -5
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +7 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +30 -1
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.js +133 -7
- package/dist/fields/calendar-day.test.js.map +1 -1
- package/dist/fields/format-prisma-default.js.map +1 -1
- package/dist/fields/index.d.ts +7 -3
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +37 -8
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/relationship-options.d.ts +26 -0
- package/dist/query/relationship-options.d.ts.map +1 -0
- package/dist/query/relationship-options.js +43 -0
- package/dist/query/relationship-options.js.map +1 -0
- package/dist/query/relationship-options.test.d.ts +2 -0
- package/dist/query/relationship-options.test.d.ts.map +1 -0
- package/dist/query/relationship-options.test.js +121 -0
- package/dist/query/relationship-options.test.js.map +1 -0
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js.map +1 -1
- package/dist/validation/schema.js.map +1 -1
- package/package.json +6 -5
- package/src/access/access-filter.ts +54 -0
- package/src/access/index.ts +5 -1
- package/src/config/label.ts +58 -0
- package/src/config/types.ts +20 -13
- package/src/context/index.ts +45 -5
- package/src/context/write-pipeline.ts +1 -2
- package/src/fields/calendar-day.test.ts +158 -7
- package/src/fields/index.ts +39 -11
- package/src/hooks/index.ts +1 -2
- package/src/index.ts +11 -0
- package/src/query/relationship-options.test.ts +157 -0
- package/src/query/relationship-options.ts +70 -0
- package/tests/context.test.ts +220 -0
- package/tests/label.test.ts +135 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"password.d.ts","sourceRoot":"","sources":["../../src/utils/password.ts"],"names":[],"mappings":"AASA;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,CAChC,aAAa,EAAE,MAAM,EACrB,UAAU,GAAE,MAA4B,GACvC,OAAO,CAAC,MAAM,CAAC,CAMjB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CACnC,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,GACrB,OAAO,CAAC,OAAO,CAAC,CAgBlB;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAMvD;AAED;;;;;;;;;;GAUG;AACH,qBAAa,cAAc;IACb,OAAO,CAAC,QAAQ,CAAC,IAAI;
|
|
1
|
+
{"version":3,"file":"password.d.ts","sourceRoot":"","sources":["../../src/utils/password.ts"],"names":[],"mappings":"AASA;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,CAChC,aAAa,EAAE,MAAM,EACrB,UAAU,GAAE,MAA4B,GACvC,OAAO,CAAC,MAAM,CAAC,CAMjB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CACnC,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,GACrB,OAAO,CAAC,OAAO,CAAC,CAgBlB;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAMvD;AAED;;;;;;;;;;GAUG;AACH,qBAAa,cAAc;IACb,OAAO,CAAC,QAAQ,CAAC,IAAI;IAAjC,YAA6B,IAAI,EAAE,MAAM,EAIxC;IAED;;;;;OAKG;IACG,OAAO,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAErD;IAED;;;OAGG;IACH,QAAQ,IAAI,MAAM,CAEjB;IAED;;;OAGG;IACH,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKzC;IAED;;;OAGG;IACH,MAAM,IAAI,MAAM,CAEf;IAED;;;OAGG;IACH,OAAO,IAAI,MAAM,CAEhB;CACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"password.js","sourceRoot":"","sources":["../../src/utils/password.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,UAAU,CAAA;AAE7B;;;;GAIG;AACH,MAAM,mBAAmB,GAAG,EAAE,CAAA;AAE9B;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,aAAqB,EACrB,
|
|
1
|
+
{"version":3,"file":"password.js","sourceRoot":"","sources":["../../src/utils/password.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,UAAU,CAAA;AAE7B;;;;GAIG;AACH,MAAM,mBAAmB,GAAG,EAAE,CAAA;AAE9B;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,aAAqB,EACrB,UAAU,GAAW,mBAAmB;IAExC,IAAI,OAAO,aAAa,KAAK,QAAQ,IAAI,aAAa,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACpE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;IAED,OAAO,MAAM,CAAC,IAAI,CAAC,aAAa,EAAE,UAAU,CAAC,CAAA;AAC/C,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,aAAqB,EACrB,cAAsB;IAEtB,IAAI,OAAO,aAAa,KAAK,QAAQ,IAAI,OAAO,cAAc,KAAK,QAAQ,EAAE,CAAC;QAC5E,OAAO,KAAK,CAAA;IACd,CAAC;IAED,IAAI,aAAa,CAAC,MAAM,KAAK,CAAC,IAAI,cAAc,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC9D,OAAO,KAAK,CAAA;IACd,CAAC;IAED,IAAI,CAAC;QACH,OAAO,MAAM,MAAM,CAAC,OAAO,CAAC,aAAa,EAAE,cAAc,CAAC,CAAA;IAC5D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,4CAA4C;QAC5C,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAA;QACnD,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAa;IAC5C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAA;IAE3C,uEAAuE;IACvE,uCAAuC;IACvC,OAAO,0BAA0B,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;AAC/C,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,OAAO,cAAc;IACI,IAAI;IAAjC,YAA6B,IAAY;oBAAZ,IAAI;QAC/B,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;YACtC,MAAM,IAAI,KAAK,CAAC,iDAAiD,CAAC,CAAA;QACpE,CAAC;IACH,CAAC;IAED;;;;;OAKG;IACH,KAAK,CAAC,OAAO,CAAC,aAAqB;QACjC,OAAO,eAAe,CAAC,aAAa,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;IAClD,CAAC;IAED;;;OAGG;IACH,QAAQ;QACN,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED;;;OAGG;IACH,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,IAAY;QAC/B,IAAI,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YAC5C,OAAO,IAAI,CAAC,IAAI,CAAA;QAClB,CAAC;QACD,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED;;;OAGG;IACH,MAAM;QACJ,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED;;;OAGG;IACH,OAAO;QACL,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;CACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/validation/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAGvB;;GAEG;AACH,MAAM,UAAU,iBAAiB,CAC/B,YAAyC,EACzC,
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/validation/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAGvB;;GAEG;AACH,MAAM,UAAU,iBAAiB,CAC/B,YAAyC,EACzC,SAAS,GAAwB,QAAQ;IAEzC,MAAM,KAAK,GAAiC,EAAE,CAAA;IAE9C,KAAK,MAAM,CAAC,SAAS,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,CAAC;QACpE,wDAAwD;QACxD,+DAA+D;QAC/D,IACE,CAAC,IAAI,EAAE,WAAW,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;YACpD,WAAW,CAAC,IAAI,KAAK,cAAc;YACnC,WAAW,CAAC,OAAO,EACnB,CAAC;YACD,SAAQ;QACV,CAAC;QAED,mCAAmC;QACnC,IAAI,WAAW,CAAC,YAAY,EAAE,CAAC;YAC7B,KAAK,CAAC,SAAS,CAAC,GAAG,WAAW,CAAC,YAAY,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;QACnE,CAAC;aAAM,CAAC;YACN,4DAA4D;YAC5D,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAA;QACvC,CAAC;IACH,CAAC;IAED,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACxB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe,CAC7B,IAA6B,EAC7B,YAAyC,EACzC,SAAS,GAAwB,QAAQ;IAEzC,MAAM,MAAM,GAAG,iBAAiB,CAAC,YAAY,EAAE,SAAS,CAAC,CAAA;IAEzD,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA;IAErC,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;QACnB,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAC1B,CAAC;IAED,sDAAsD;IACtD,MAAM,MAAM,GAA2B,EAAE,CAAA;IACzC,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;QACxC,MAAM,SAAS,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACtC,MAAM,CAAC,SAAS,CAAC,GAAG,KAAK,CAAC,OAAO,CAAA;IACnC,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,CAAA;AACnC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opensaas/stack-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Core stack for OpenSaas - schema definition, access control, and runtime utilities",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -57,10 +57,11 @@
|
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@prisma/client": "^7.8.0",
|
|
60
|
-
"@types/node": "^
|
|
61
|
-
"@
|
|
62
|
-
"
|
|
63
|
-
"
|
|
60
|
+
"@types/node": "^26.1.1",
|
|
61
|
+
"@typescript/native": "npm:typescript@^7.0.2",
|
|
62
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
63
|
+
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
64
|
+
"vitest": "^4.1.10"
|
|
64
65
|
},
|
|
65
66
|
"scripts": {
|
|
66
67
|
"build": "tsc",
|
|
@@ -251,3 +251,57 @@ export function mergeIncludeWithAccessControl(
|
|
|
251
251
|
|
|
252
252
|
return merged
|
|
253
253
|
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Remove keys that correspond to `virtual` fields from a Prisma `include`
|
|
257
|
+
* object, recursing into nested relationship includes using the related
|
|
258
|
+
* list's field configs.
|
|
259
|
+
*
|
|
260
|
+
* Virtual fields are computed in JavaScript (via `resolveOutput` in
|
|
261
|
+
* `field-visibility.ts`) and have no database column. Naming one in `include`
|
|
262
|
+
* type-checks — the generated `Include` type lists every field the config
|
|
263
|
+
* declares — but Prisma throws `Unknown field '<name>' for include statement`
|
|
264
|
+
* at runtime. This is applied as the final step on whatever `include` a read
|
|
265
|
+
* op ends up with (fragment-built, access-controlled merge, or sudo
|
|
266
|
+
* passthrough) so a virtual key never reaches the Prisma client, regardless
|
|
267
|
+
* of which path produced it. The virtual value is still computed
|
|
268
|
+
* unconditionally by `filterReadableFields`, so stripping the include key has
|
|
269
|
+
* no effect on whether the value appears in the result (#628).
|
|
270
|
+
*/
|
|
271
|
+
export function stripVirtualFieldsFromInclude(
|
|
272
|
+
include: Record<string, unknown> | undefined,
|
|
273
|
+
fieldConfigs: Record<string, FieldConfig>,
|
|
274
|
+
config: OpenSaasConfig,
|
|
275
|
+
): Record<string, unknown> | undefined {
|
|
276
|
+
if (!include) return include
|
|
277
|
+
|
|
278
|
+
const result: Record<string, unknown> = {}
|
|
279
|
+
|
|
280
|
+
for (const [key, value] of Object.entries(include)) {
|
|
281
|
+
const fieldConfig = fieldConfigs[key]
|
|
282
|
+
|
|
283
|
+
// Virtual fields have no database column — drop them from the include.
|
|
284
|
+
if (fieldConfig?.virtual) continue
|
|
285
|
+
|
|
286
|
+
const isDeclaredRelationship =
|
|
287
|
+
fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && !!fieldConfig.ref
|
|
288
|
+
const entry = asEntryObject(value)
|
|
289
|
+
|
|
290
|
+
if (isDeclaredRelationship && entry?.include) {
|
|
291
|
+
const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
|
|
292
|
+
if (relatedConfig) {
|
|
293
|
+
const nestedInclude = stripVirtualFieldsFromInclude(
|
|
294
|
+
entry.include,
|
|
295
|
+
relatedConfig.listConfig.fields,
|
|
296
|
+
config,
|
|
297
|
+
)
|
|
298
|
+
result[key] = { ...(value as Record<string, unknown>), include: nestedInclude }
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
result[key] = value
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return result
|
|
307
|
+
}
|
package/src/access/index.ts
CHANGED
|
@@ -22,6 +22,10 @@ export {
|
|
|
22
22
|
// Canonical field-level access evaluation (shared by read and write paths).
|
|
23
23
|
export { checkFieldAccess, filterWritableFields } from './field-access.js'
|
|
24
24
|
// Phase 1 — Access Filter (pre-query row/relation scoping).
|
|
25
|
-
export {
|
|
25
|
+
export {
|
|
26
|
+
buildIncludeWithAccessControl,
|
|
27
|
+
mergeIncludeWithAccessControl,
|
|
28
|
+
stripVirtualFieldsFromInclude,
|
|
29
|
+
} from './access-filter.js'
|
|
26
30
|
// Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
|
|
27
31
|
export { filterReadableFields } from './field-visibility.js'
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ListConfig } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the field name that represents a list's rows as a single label —
|
|
5
|
+
* the projection half of the label seam. `getItemLabel` reads exactly the
|
|
6
|
+
* field this returns, so the field chosen for projection can never drift
|
|
7
|
+
* from the field used for rendering.
|
|
8
|
+
*
|
|
9
|
+
* Fallback order: configured `ui.labelField` → `name` → `title` → `id`
|
|
10
|
+
* (first field that exists on the list).
|
|
11
|
+
*
|
|
12
|
+
* @throws if `ui.labelField` is set but does not reference a declared,
|
|
13
|
+
* non-relationship field on the list.
|
|
14
|
+
*/
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
16
|
+
export function getLabelFieldName(listConfig: ListConfig<any>): string {
|
|
17
|
+
const configured = listConfig.ui?.labelField
|
|
18
|
+
|
|
19
|
+
if (configured !== undefined) {
|
|
20
|
+
const field = listConfig.fields[configured]
|
|
21
|
+
if (!field) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`ui.labelField "${configured}" does not reference a field declared on this list.`,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
if (field.type === 'relationship') {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`ui.labelField "${configured}" must reference a scalar field, not a relationship.`,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
return configured
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if ('name' in listConfig.fields) return 'name'
|
|
35
|
+
if ('title' in listConfig.fields) return 'title'
|
|
36
|
+
return 'id'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the display label for a single row — the render half of the label
|
|
41
|
+
* seam. Reads the field `getLabelFieldName` resolves, falling back to `id`
|
|
42
|
+
* when that field is missing from the row (e.g. stripped by field-level
|
|
43
|
+
* access).
|
|
44
|
+
*/
|
|
45
|
+
export function getItemLabel(
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
47
|
+
listConfig: ListConfig<any>,
|
|
48
|
+
item: Record<string, unknown>,
|
|
49
|
+
): string {
|
|
50
|
+
const fieldName = getLabelFieldName(listConfig)
|
|
51
|
+
const value = Object.prototype.hasOwnProperty.call(item, fieldName) ? item[fieldName] : undefined
|
|
52
|
+
|
|
53
|
+
if (value === undefined || value === null) {
|
|
54
|
+
return String(item.id)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return String(value)
|
|
58
|
+
}
|
package/src/config/types.ts
CHANGED
|
@@ -5,14 +5,7 @@ import type { z } from 'zod'
|
|
|
5
5
|
* Field configuration types
|
|
6
6
|
*/
|
|
7
7
|
export type FieldType =
|
|
8
|
-
| '
|
|
9
|
-
| 'integer'
|
|
10
|
-
| 'checkbox'
|
|
11
|
-
| 'timestamp'
|
|
12
|
-
| 'password'
|
|
13
|
-
| 'select'
|
|
14
|
-
| 'relationship'
|
|
15
|
-
| string // Allow custom field types from third-party packages
|
|
8
|
+
'text' | 'integer' | 'checkbox' | 'timestamp' | 'password' | 'select' | 'relationship' | string // Allow custom field types from third-party packages
|
|
16
9
|
|
|
17
10
|
/**
|
|
18
11
|
* Field-level hook argument types (exported for user annotations)
|
|
@@ -1711,11 +1704,11 @@ export type ListConfig<TTypeInfo extends TypeInfo> = {
|
|
|
1711
1704
|
/**
|
|
1712
1705
|
* List-level UI configuration for the admin interface.
|
|
1713
1706
|
*
|
|
1714
|
-
* Mirrors Keystone's `ui` block on a list.
|
|
1715
|
-
*
|
|
1716
|
-
* Keystone concerns (`label`, `
|
|
1717
|
-
* deferred as they cover
|
|
1718
|
-
*
|
|
1707
|
+
* Mirrors Keystone's `ui` block on a list. List-view defaults (column
|
|
1708
|
+
* selection/order and default sort) and the label field are supported
|
|
1709
|
+
* today; other Keystone concerns (`label`, `description`) are intentionally
|
|
1710
|
+
* deferred as they cover navigation text rather than list-view or
|
|
1711
|
+
* row-labelling defaults.
|
|
1719
1712
|
*/
|
|
1720
1713
|
export type ListUIConfig = {
|
|
1721
1714
|
/**
|
|
@@ -1723,6 +1716,20 @@ export type ListUIConfig = {
|
|
|
1723
1716
|
* Keystone's `ui.listView`.
|
|
1724
1717
|
*/
|
|
1725
1718
|
listView?: ListViewUIConfig
|
|
1719
|
+
/**
|
|
1720
|
+
* The field used to represent a row as a single label — in relationship
|
|
1721
|
+
* cells, dropdown options, and page headings. Must reference a declared,
|
|
1722
|
+
* non-relationship field on this list.
|
|
1723
|
+
*
|
|
1724
|
+
* When omitted, resolves via `getLabelFieldName`'s fallback order: `name`
|
|
1725
|
+
* → `title` → `id` (first field that exists on the list).
|
|
1726
|
+
*
|
|
1727
|
+
* @example
|
|
1728
|
+
* ```typescript
|
|
1729
|
+
* ui: { labelField: 'email' }
|
|
1730
|
+
* ```
|
|
1731
|
+
*/
|
|
1732
|
+
labelField?: string
|
|
1726
1733
|
}
|
|
1727
1734
|
|
|
1728
1735
|
/**
|
package/src/context/index.ts
CHANGED
|
@@ -6,11 +6,13 @@ import {
|
|
|
6
6
|
filterReadableFields,
|
|
7
7
|
buildIncludeWithAccessControl,
|
|
8
8
|
mergeIncludeWithAccessControl,
|
|
9
|
+
stripVirtualFieldsFromInclude,
|
|
9
10
|
} from '../access/index.js'
|
|
10
11
|
import { ValidationError, DatabaseError } from '../hooks/index.js'
|
|
11
12
|
import { getDbKey } from '../lib/case-utils.js'
|
|
12
13
|
import type { PrismaClientLike } from '../access/types.js'
|
|
13
14
|
import { buildInclude, pickFields, isFragment } from '../query/index.js'
|
|
15
|
+
import { getRelationshipOptions } from '../query/relationship-options.js'
|
|
14
16
|
import {
|
|
15
17
|
runWritePipeline,
|
|
16
18
|
createWriteStrategy,
|
|
@@ -22,6 +24,14 @@ export type ServerActionProps =
|
|
|
22
24
|
| { listKey: string; action: 'create'; data: Record<string, unknown> }
|
|
23
25
|
| { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
|
|
24
26
|
| { listKey: string; action: 'delete'; id: string }
|
|
27
|
+
| {
|
|
28
|
+
listKey: string
|
|
29
|
+
action: 'relationshipOptions'
|
|
30
|
+
field: string
|
|
31
|
+
search?: string
|
|
32
|
+
take?: number
|
|
33
|
+
selectedIds?: string[]
|
|
34
|
+
}
|
|
25
35
|
|
|
26
36
|
/**
|
|
27
37
|
* Tracks which (listName, operation) pairs have already warned about an ignored
|
|
@@ -239,11 +249,7 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
|
239
249
|
* PostgreSQL — required for the concurrency-sensitive capacity-gate pattern).
|
|
240
250
|
*/
|
|
241
251
|
export type TransactionIsolationLevel =
|
|
242
|
-
| '
|
|
243
|
-
| 'ReadCommitted'
|
|
244
|
-
| 'RepeatableRead'
|
|
245
|
-
| 'Serializable'
|
|
246
|
-
| 'Snapshot'
|
|
252
|
+
'ReadUncommitted' | 'ReadCommitted' | 'RepeatableRead' | 'Serializable' | 'Snapshot'
|
|
247
253
|
|
|
248
254
|
/**
|
|
249
255
|
* Options for {@link StackContext.transaction}, forwarded verbatim to the
|
|
@@ -421,6 +427,26 @@ export function getContext<
|
|
|
421
427
|
}
|
|
422
428
|
|
|
423
429
|
try {
|
|
430
|
+
if (props.action === 'relationshipOptions') {
|
|
431
|
+
const fieldConfig = listConfig.fields[props.field] as
|
|
432
|
+
{ type?: string; ref?: string } | undefined
|
|
433
|
+
if (!fieldConfig || fieldConfig.type !== 'relationship' || !fieldConfig.ref) {
|
|
434
|
+
return {
|
|
435
|
+
success: false,
|
|
436
|
+
error: `Field "${props.field}" on list "${props.listKey}" is not a relationship field`,
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const relatedListKey = fieldConfig.ref.split('.')[0]
|
|
441
|
+
const options = await getRelationshipOptions(context, config, relatedListKey, {
|
|
442
|
+
search: props.search,
|
|
443
|
+
take: props.take,
|
|
444
|
+
selectedIds: props.selectedIds,
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
return { success: true, data: options }
|
|
448
|
+
}
|
|
449
|
+
|
|
424
450
|
let result: unknown = null
|
|
425
451
|
|
|
426
452
|
if (props.action === 'create') {
|
|
@@ -688,6 +714,13 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
688
714
|
: accessControlledInclude
|
|
689
715
|
}
|
|
690
716
|
|
|
717
|
+
// Virtual fields have no database column. Whichever path produced
|
|
718
|
+
// `include` (fragment, access-controlled merge, or sudo passthrough), a
|
|
719
|
+
// virtual key must never reach Prisma — it would throw "Unknown field"
|
|
720
|
+
// (#628). The virtual value is still computed unconditionally below by
|
|
721
|
+
// `filterReadableFields`, independent of what was requested here.
|
|
722
|
+
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
723
|
+
|
|
691
724
|
// Execute query with optimized includes
|
|
692
725
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
693
726
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -811,6 +844,13 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
811
844
|
: accessControlledInclude
|
|
812
845
|
}
|
|
813
846
|
|
|
847
|
+
// Virtual fields have no database column. Whichever path produced
|
|
848
|
+
// `include` (fragment, access-controlled merge, or sudo passthrough), a
|
|
849
|
+
// virtual key must never reach Prisma — it would throw "Unknown field"
|
|
850
|
+
// (#628). The virtual value is still computed unconditionally below by
|
|
851
|
+
// `filterReadableFields`, independent of what was requested here.
|
|
852
|
+
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
853
|
+
|
|
814
854
|
// Execute query with optimized includes
|
|
815
855
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
816
856
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -53,8 +53,7 @@ export type WriteOperation = 'create' | 'update' | 'delete'
|
|
|
53
53
|
* any input phases, before-hooks, or the DB call.
|
|
54
54
|
*/
|
|
55
55
|
export type TargetResolution =
|
|
56
|
-
|
|
57
|
-
| { status: 'denied' }
|
|
56
|
+
{ status: 'ok'; originalItem: Record<string, unknown> | undefined } | { status: 'denied' }
|
|
58
57
|
|
|
59
58
|
/**
|
|
60
59
|
* Minimal dynamic Prisma model surface used by the write pipeline. Model names
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { describe, it, expect, expectTypeOf } from 'vitest'
|
|
1
|
+
import { describe, it, expect, expectTypeOf, vi } from 'vitest'
|
|
2
2
|
import { calendarDay } from './index.js'
|
|
3
3
|
import { generateZodSchema, validateWithZod } from '../validation/schema.js'
|
|
4
|
+
import { getContext } from '../context/index.js'
|
|
4
5
|
import type { FieldConfig } from '../config/types.js'
|
|
6
|
+
import type { OpenSaasConfig } from '../config/types.js'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* calendarDay is a YYYY-MM-DD string end-to-end (Keystone's CalendarDay
|
|
@@ -54,7 +56,7 @@ describe('calendarDay field (YYYY-MM-DD string end-to-end)', () => {
|
|
|
54
56
|
})
|
|
55
57
|
})
|
|
56
58
|
|
|
57
|
-
describe('write validation (string-
|
|
59
|
+
describe('write validation (YYYY-MM-DD string, or a Date post-resolveInput)', () => {
|
|
58
60
|
const fields: Record<string, FieldConfig> = {
|
|
59
61
|
startsOn: calendarDay({ validation: { isRequired: true } }),
|
|
60
62
|
}
|
|
@@ -73,15 +75,18 @@ describe('calendarDay field (YYYY-MM-DD string end-to-end)', () => {
|
|
|
73
75
|
}
|
|
74
76
|
})
|
|
75
77
|
|
|
76
|
-
it('
|
|
77
|
-
//
|
|
78
|
-
//
|
|
78
|
+
it('accepts a Date instance (the shape resolveInput produces from a valid string)', () => {
|
|
79
|
+
// The write pipeline runs field `resolveInput` BEFORE this schema, and
|
|
80
|
+
// calendarDay's resolveInput turns a valid YYYY-MM-DD string into a UTC
|
|
81
|
+
// Date (see #621) so Prisma's `@db.Date` write validator accepts it. So
|
|
82
|
+
// by the time this schema runs, a successful write reaches it as a
|
|
83
|
+
// Date, not the original string — the schema must accept both shapes.
|
|
79
84
|
const result = validateWithZod(
|
|
80
|
-
{ startsOn: new Date('2025-01-
|
|
85
|
+
{ startsOn: new Date('2025-01-15T00:00:00.000Z') } as unknown as Record<string, unknown>,
|
|
81
86
|
fields,
|
|
82
87
|
'create',
|
|
83
88
|
)
|
|
84
|
-
expect(result.success).toBe(
|
|
89
|
+
expect(result.success).toBe(true)
|
|
85
90
|
})
|
|
86
91
|
|
|
87
92
|
it('zod schema for the field validates the YYYY-MM-DD shape', () => {
|
|
@@ -91,6 +96,62 @@ describe('calendarDay field (YYYY-MM-DD string end-to-end)', () => {
|
|
|
91
96
|
})
|
|
92
97
|
})
|
|
93
98
|
|
|
99
|
+
describe('write transform (resolveInput coerces YYYY-MM-DD string to a UTC Date, #621)', () => {
|
|
100
|
+
// The write pipeline calls fieldConfig.hooks.resolveInput({ resolvedData,
|
|
101
|
+
// fieldKey, ... }) BEFORE zod validation runs. We exercise that hook
|
|
102
|
+
// directly with the value shapes a caller (or an upstream list-level
|
|
103
|
+
// resolveInput) can produce.
|
|
104
|
+
function writeValue(value: unknown): unknown {
|
|
105
|
+
const field = calendarDay()
|
|
106
|
+
const hook = field.hooks?.resolveInput
|
|
107
|
+
if (!hook) throw new Error('calendarDay must define a resolveInput hook')
|
|
108
|
+
return (
|
|
109
|
+
hook as unknown as (args: {
|
|
110
|
+
resolvedData: Record<string, unknown>
|
|
111
|
+
fieldKey: string
|
|
112
|
+
}) => unknown
|
|
113
|
+
)({ resolvedData: { startsOn: value }, fieldKey: 'startsOn' })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
it('converts a YYYY-MM-DD string to a UTC-midnight Date', () => {
|
|
117
|
+
const result = writeValue('2025-01-15') as Date
|
|
118
|
+
expect(result).toBeInstanceOf(Date)
|
|
119
|
+
expect(result.toISOString()).toBe('2025-01-15T00:00:00.000Z')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('passes an already-Date value through unchanged', () => {
|
|
123
|
+
const date = new Date('2025-06-01T00:00:00.000Z')
|
|
124
|
+
expect(writeValue(date)).toBe(date)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('passes null/undefined through unchanged (so isRequired can still reject a missing value)', () => {
|
|
128
|
+
expect(writeValue(null)).toBeNull()
|
|
129
|
+
expect(writeValue(undefined)).toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('leaves a malformed string untouched so zod still rejects it with a clear message', () => {
|
|
133
|
+
expect(writeValue('15/01/2025')).toBe('15/01/2025')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('reads resolvedData[fieldKey], not inputData — survives a list-level resolveInput default', () => {
|
|
137
|
+
// A list-level resolveInput can inject a default for an omitted key
|
|
138
|
+
// into resolvedData before field resolveInput runs. Reading
|
|
139
|
+
// resolvedData (not the original inputData) here means that injected
|
|
140
|
+
// default is what gets coerced, instead of being read as `undefined`
|
|
141
|
+
// and overwriting the injected default with null.
|
|
142
|
+
const field = calendarDay()
|
|
143
|
+
const hook = field.hooks?.resolveInput as unknown as (args: {
|
|
144
|
+
resolvedData: Record<string, unknown>
|
|
145
|
+
fieldKey: string
|
|
146
|
+
}) => unknown
|
|
147
|
+
const result = hook({
|
|
148
|
+
resolvedData: { startsOn: '2025-03-20' }, // injected by a list-level hook
|
|
149
|
+
fieldKey: 'startsOn',
|
|
150
|
+
}) as Date
|
|
151
|
+
expect(result.toISOString()).toBe('2025-03-20T00:00:00.000Z')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
94
155
|
describe('read transform (resolveOutput returns a YYYY-MM-DD string)', () => {
|
|
95
156
|
// The read pipeline calls fieldConfig.hooks.resolveOutput({ value, ... }).
|
|
96
157
|
// We exercise that hook directly with the value shapes Prisma can return.
|
|
@@ -137,4 +198,94 @@ describe('calendarDay field (YYYY-MM-DD string end-to-end)', () => {
|
|
|
137
198
|
expect(hook({ value: '2025-01-15' })).toBe('custom:2025-01-15')
|
|
138
199
|
})
|
|
139
200
|
})
|
|
201
|
+
|
|
202
|
+
describe('end-to-end via context.db.*.create/update (#621 repro)', () => {
|
|
203
|
+
// Prisma 7's client validator rejects a bare YYYY-MM-DD string for a
|
|
204
|
+
// `@db.Date` column. Assert the value actually forwarded to the Prisma
|
|
205
|
+
// client is a Date, not the string the caller passed in.
|
|
206
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock Prisma client
|
|
207
|
+
let mockPrisma: any
|
|
208
|
+
|
|
209
|
+
function buildConfig(): OpenSaasConfig {
|
|
210
|
+
mockPrisma = {
|
|
211
|
+
event: {
|
|
212
|
+
findFirst: vi.fn(),
|
|
213
|
+
findUnique: vi.fn(),
|
|
214
|
+
findMany: vi.fn(),
|
|
215
|
+
create: vi.fn(),
|
|
216
|
+
update: vi.fn(),
|
|
217
|
+
delete: vi.fn(),
|
|
218
|
+
count: vi.fn(),
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
db: {
|
|
223
|
+
provider: 'postgresql',
|
|
224
|
+
prismaClientConstructor: (PrismaClient) => new PrismaClient(),
|
|
225
|
+
},
|
|
226
|
+
lists: {
|
|
227
|
+
Event: {
|
|
228
|
+
fields: {
|
|
229
|
+
startsOn: calendarDay({ validation: { isRequired: true } }),
|
|
230
|
+
},
|
|
231
|
+
access: {
|
|
232
|
+
operation: {
|
|
233
|
+
query: () => true,
|
|
234
|
+
create: () => true,
|
|
235
|
+
update: () => true,
|
|
236
|
+
delete: () => true,
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
it('create: a YYYY-MM-DD string reaches Prisma as a UTC-midnight Date', async () => {
|
|
245
|
+
const config = buildConfig()
|
|
246
|
+
mockPrisma.event.create.mockResolvedValue({
|
|
247
|
+
id: '1',
|
|
248
|
+
startsOn: new Date('2025-01-15T00:00:00.000Z'),
|
|
249
|
+
})
|
|
250
|
+
const context = await getContext(config, mockPrisma, null)
|
|
251
|
+
|
|
252
|
+
await context.db.event.create({ data: { startsOn: '2025-01-15' } })
|
|
253
|
+
|
|
254
|
+
expect(mockPrisma.event.create).toHaveBeenCalledTimes(1)
|
|
255
|
+
const callArgs = mockPrisma.event.create.mock.calls[0][0]
|
|
256
|
+
expect(callArgs.data.startsOn).toBeInstanceOf(Date)
|
|
257
|
+
expect((callArgs.data.startsOn as Date).toISOString()).toBe('2025-01-15T00:00:00.000Z')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('update: a YYYY-MM-DD string reaches Prisma as a UTC-midnight Date', async () => {
|
|
261
|
+
const config = buildConfig()
|
|
262
|
+
const existing = { id: '1', startsOn: new Date('2025-01-15T00:00:00.000Z') }
|
|
263
|
+
mockPrisma.event.findUnique.mockResolvedValue(existing)
|
|
264
|
+
mockPrisma.event.update.mockResolvedValue({
|
|
265
|
+
...existing,
|
|
266
|
+
startsOn: new Date('2025-02-20T00:00:00.000Z'),
|
|
267
|
+
})
|
|
268
|
+
const context = await getContext(config, mockPrisma, null)
|
|
269
|
+
|
|
270
|
+
await context.db.event.update({ where: { id: '1' }, data: { startsOn: '2025-02-20' } })
|
|
271
|
+
|
|
272
|
+
expect(mockPrisma.event.update).toHaveBeenCalledTimes(1)
|
|
273
|
+
const callArgs = mockPrisma.event.update.mock.calls[0][0]
|
|
274
|
+
expect(callArgs.data.startsOn).toBeInstanceOf(Date)
|
|
275
|
+
expect((callArgs.data.startsOn as Date).toISOString()).toBe('2025-02-20T00:00:00.000Z')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('the read result is still normalised back to a YYYY-MM-DD string', async () => {
|
|
279
|
+
const config = buildConfig()
|
|
280
|
+
mockPrisma.event.create.mockResolvedValue({
|
|
281
|
+
id: '1',
|
|
282
|
+
startsOn: new Date('2025-01-15T00:00:00.000Z'),
|
|
283
|
+
})
|
|
284
|
+
const context = await getContext(config, mockPrisma, null)
|
|
285
|
+
|
|
286
|
+
const result = await context.db.event.create({ data: { startsOn: '2025-01-15' } })
|
|
287
|
+
|
|
288
|
+
expect(result?.startsOn).toBe('2025-01-15')
|
|
289
|
+
})
|
|
290
|
+
})
|
|
140
291
|
})
|
package/src/fields/index.ts
CHANGED
|
@@ -523,9 +523,13 @@ export function timestamp<
|
|
|
523
523
|
* - Stores date values only (no time component)
|
|
524
524
|
* - PostgreSQL/MySQL: Uses native DATE type via @db.Date
|
|
525
525
|
* - SQLite: Uses String representation
|
|
526
|
-
* - **Writes:**
|
|
527
|
-
* `
|
|
528
|
-
*
|
|
526
|
+
* - **Writes:** pass a `YYYY-MM-DD` string (the declared type). A
|
|
527
|
+
* `resolveInput` hook converts a valid string to a UTC-midnight `Date`
|
|
528
|
+
* before validation, since Prisma 7's client validator rejects a bare date
|
|
529
|
+
* string for a `@db.Date` column (#621); a `Date` is also accepted
|
|
530
|
+
* directly. A malformed string is rejected at runtime by validation (a
|
|
531
|
+
* `ValidationError`). Genuine compile-time rejection of a `Date` at the
|
|
532
|
+
* `context.db` call site is tracked in #599.
|
|
529
533
|
* - **Reads:** always return a `YYYY-MM-DD` string. Even though the underlying
|
|
530
534
|
* `@db.Date` column hands Prisma a `Date`, a `resolveOutput` transform
|
|
531
535
|
* normalises it back to a `YYYY-MM-DD` string so the runtime value matches
|
|
@@ -573,6 +577,17 @@ export function calendarDay<
|
|
|
573
577
|
return {
|
|
574
578
|
type: 'calendarDay',
|
|
575
579
|
...options,
|
|
580
|
+
// Writes: the write pipeline runs field resolveInput BEFORE zod
|
|
581
|
+
// validation (Hook Pipeline: field resolveInput → built-in field rules),
|
|
582
|
+
// so this is the only point a YYYY-MM-DD string can be turned into
|
|
583
|
+
// something Prisma's `@db.Date` write validator accepts — Prisma 7
|
|
584
|
+
// rejects a bare date string there (#621). Convert a valid string to a
|
|
585
|
+
// UTC-midnight Date; leave anything else (a Date already, null/undefined,
|
|
586
|
+
// or a malformed string) untouched so the zod schema below still rejects
|
|
587
|
+
// malformed input with a clear message. Reads resolvedData[fieldKey]
|
|
588
|
+
// (not raw inputData) so a list-level resolveInput that injects a default
|
|
589
|
+
// for an omitted key is still coerced instead of being overwritten.
|
|
590
|
+
//
|
|
576
591
|
// Reads: the underlying @db.Date column hands Prisma a Date (or a TEXT
|
|
577
592
|
// string under the SQLite fallback). Normalise to a YYYY-MM-DD string so the
|
|
578
593
|
// runtime value matches the declared `string` type. UTC components are used
|
|
@@ -580,6 +595,15 @@ export function calendarDay<
|
|
|
580
595
|
// Cast hooks to any since field builders are generic and can't know the
|
|
581
596
|
// specific TFieldKey (same pattern as password()).
|
|
582
597
|
hooks: {
|
|
598
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
|
|
599
|
+
resolveInput: ({ resolvedData, fieldKey }: { resolvedData: any; fieldKey: string }) => {
|
|
600
|
+
const value = resolvedData?.[fieldKey]
|
|
601
|
+
if (value == null || value instanceof Date) return value
|
|
602
|
+
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
603
|
+
return new Date(`${value}T00:00:00.000Z`)
|
|
604
|
+
}
|
|
605
|
+
return value
|
|
606
|
+
},
|
|
583
607
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
|
|
584
608
|
resolveOutput: ({ value }: { value: any }) => formatCalendarDay(value),
|
|
585
609
|
// Merge with user-provided hooks if any
|
|
@@ -590,15 +614,19 @@ export function calendarDay<
|
|
|
590
614
|
const validation = options?.validation
|
|
591
615
|
const isRequired = validation?.isRequired
|
|
592
616
|
|
|
593
|
-
// Accept ISO8601 date strings (YYYY-MM-DD)
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
617
|
+
// Accept ISO8601 date strings (YYYY-MM-DD) in the shape a caller passes,
|
|
618
|
+
// or a `Date` — the shape resolveInput above turns a valid string into
|
|
619
|
+
// before this schema runs. Malformed strings fall through resolveInput
|
|
620
|
+
// untouched and still fail the regex here with a clear message.
|
|
621
|
+
const stringSchema = z
|
|
622
|
+
.string({
|
|
623
|
+
message: `${formatFieldName(fieldName)} must be a valid date in ISO8601 format (YYYY-MM-DD)`,
|
|
624
|
+
})
|
|
625
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/, {
|
|
626
|
+
message: `${formatFieldName(fieldName)} must be in YYYY-MM-DD format`,
|
|
627
|
+
})
|
|
597
628
|
|
|
598
|
-
|
|
599
|
-
const dateSchema = baseSchema.regex(/^\d{4}-\d{2}-\d{2}$/, {
|
|
600
|
-
message: `${formatFieldName(fieldName)} must be in YYYY-MM-DD format`,
|
|
601
|
-
})
|
|
629
|
+
const dateSchema = z.union([stringSchema, z.date()])
|
|
602
630
|
|
|
603
631
|
if (isRequired && operation === 'create') {
|
|
604
632
|
return dateSchema
|
package/src/hooks/index.ts
CHANGED
|
@@ -386,8 +386,7 @@ export async function executeFieldBeforeTransactionHooks(
|
|
|
386
386
|
* so hooks can compensate.
|
|
387
387
|
*/
|
|
388
388
|
export type TransactionOutcome =
|
|
389
|
-
|
|
390
|
-
| { status: 'rolled-back'; error: unknown }
|
|
389
|
+
{ status: 'committed'; item: Record<string, unknown> } | { status: 'rolled-back'; error: unknown }
|
|
391
390
|
|
|
392
391
|
/**
|
|
393
392
|
* Execute field-level afterTransaction hooks (#590 / ADR-0010).
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,11 @@ export type {
|
|
|
43
43
|
// Naming utilities (documented public helpers; used for URLs and db keys)
|
|
44
44
|
export { getDbKey, getUrlKey, getListKeyFromUrl } from './lib/case-utils.js'
|
|
45
45
|
|
|
46
|
+
// Label seam — resolves the field that represents a row as a single label
|
|
47
|
+
// (projection) and reads it off a row (render). Used by the admin UI for
|
|
48
|
+
// relationship cells, dropdown options, and page headings.
|
|
49
|
+
export { getLabelFieldName, getItemLabel } from './config/label.js'
|
|
50
|
+
|
|
46
51
|
// Validation error surfaced by write operations
|
|
47
52
|
export { ValidationError } from './hooks/index.js'
|
|
48
53
|
|
|
@@ -61,3 +66,9 @@ export type { FieldConfigValidationError } from './validation/field-config.js'
|
|
|
61
66
|
// root surface — those live on '@opensaas/stack-core/internal'.
|
|
62
67
|
export { defineFragment, runQuery, runQueryOne } from './query/index.js'
|
|
63
68
|
export type { ResultOf, RelationSelector, QueryArgs } from './query/index.js'
|
|
69
|
+
|
|
70
|
+
// Relationship-options read primitive — bounded, projected fetch for
|
|
71
|
+
// relationship editors. Backs the `relationshipOptions` context.serverAction
|
|
72
|
+
// op; also callable directly wherever a full context is already in hand.
|
|
73
|
+
export { getRelationshipOptions } from './query/relationship-options.js'
|
|
74
|
+
export type { RelationshipOption, RelationshipOptionsArgs } from './query/relationship-options.js'
|