@zeno-lib/schema 0.2.2 → 0.4.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/dist/index.d.mts +120 -14
- package/dist/index.mjs +137 -28
- package/package.json +15 -15
- package/src/index.test-d.ts +28 -38
- package/src/index.test.ts +144 -71
- package/src/index.ts +504 -91
- package/src/test-table.ts +11 -0
package/src/index.ts
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
import {
|
|
3
3
|
type Column,
|
|
4
4
|
getColumns,
|
|
5
|
+
getTableName,
|
|
6
|
+
getTableUniqueName,
|
|
5
7
|
type InferInsertModel,
|
|
6
8
|
type InferSelectModel,
|
|
7
|
-
|
|
9
|
+
is,
|
|
10
|
+
Table,
|
|
8
11
|
} from "drizzle-orm"
|
|
9
12
|
import {
|
|
10
13
|
type BuildRefine,
|
|
@@ -14,7 +17,7 @@ import {
|
|
|
14
17
|
createSchemaFactory as createDrizzleSchemaFactory,
|
|
15
18
|
type NoUnknownKeys,
|
|
16
19
|
} from "drizzle-orm/zod"
|
|
17
|
-
import
|
|
20
|
+
import { z } from "zod"
|
|
18
21
|
|
|
19
22
|
export {
|
|
20
23
|
createInsertSchema,
|
|
@@ -28,58 +31,114 @@ type NoTableSchemaOptions = { [noTableSchemaOptions]?: never }
|
|
|
28
31
|
|
|
29
32
|
type TableColumns<TTable extends Table> = TTable["_"]["columns"]
|
|
30
33
|
|
|
31
|
-
// The
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
34
|
+
// The key a table built by `table()` from `@zeno-lib/db/schema` carries its
|
|
35
|
+
// column rules under. A string rather than a symbol, so the two packages agree
|
|
36
|
+
// on it without either importing the other; `@zeno-lib/db` mirrors it and
|
|
37
|
+
// `packages/db/src/column-rules.test.ts` fails if they drift.
|
|
38
|
+
const COLUMN_RULES_KEY = "~zeno"
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What a column marker from `@zeno-lib/db/schema` records. `writer` says who
|
|
42
|
+
* writes the column when a client doesn't: `"server"` code (`serverOwned`), or
|
|
43
|
+
* only the `"database"`, through a default and a trigger (the audit columns
|
|
44
|
+
* `timestamps()` and `authorship()` emit). `refine` runs on it in insert and
|
|
45
|
+
* update, and `hint` goes to the form field that edits it.
|
|
46
|
+
*/
|
|
47
|
+
type ColumnRule = {
|
|
48
|
+
readonly writer?: "database" | "server"
|
|
49
|
+
readonly refine?: unknown
|
|
50
|
+
readonly hint?: FieldHint
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Writer = NonNullable<ColumnRule["writer"]>
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A table built by `table()`, `unsecureTable()` or `schema(...).table` from
|
|
57
|
+
* `@zeno-lib/db/schema`, which records its column rules. A plain drizzle
|
|
58
|
+
* table has none, so its markers would vanish without a trace.
|
|
59
|
+
*/
|
|
60
|
+
type ZenoTable = Table & {
|
|
61
|
+
readonly [COLUMN_RULES_KEY]: { readonly columns: object }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type TableColumnRules<TTable extends ZenoTable> =
|
|
65
|
+
TTable[typeof COLUMN_RULES_KEY]["columns"]
|
|
66
|
+
|
|
67
|
+
type WrittenByKeys<TTable extends ZenoTable, TWriter extends Writer> = {
|
|
68
|
+
[K in keyof TableColumnRules<TTable>]: TableColumnRules<TTable>[K] extends {
|
|
69
|
+
readonly writer: TWriter
|
|
70
|
+
}
|
|
71
|
+
? K
|
|
72
|
+
: never
|
|
73
|
+
}[keyof TableColumnRules<TTable>]
|
|
74
|
+
|
|
75
|
+
type ColumnRefinements<TTable extends ZenoTable> = {
|
|
76
|
+
[K in keyof TableColumnRules<TTable> as TableColumnRules<TTable>[K] extends {
|
|
77
|
+
readonly refine: unknown
|
|
78
|
+
}
|
|
79
|
+
? K
|
|
80
|
+
: never]: TableColumnRules<TTable>[K] extends {
|
|
81
|
+
readonly refine: infer TRefine
|
|
82
|
+
}
|
|
83
|
+
? TRefine
|
|
84
|
+
: never
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Identity columns, `by default` as well as `always`: the sequence hands them
|
|
88
|
+
// out, so a client never writes one. Drizzle drops only `always` itself.
|
|
89
|
+
type IdentityKeys<TTable extends Table> = {
|
|
90
|
+
[K in keyof TableColumns<TTable>]: TableColumns<TTable>[K]["_"]["identity"] extends
|
|
91
|
+
| "always"
|
|
92
|
+
| "byDefault"
|
|
93
|
+
? K
|
|
48
94
|
: never
|
|
49
95
|
}[keyof TableColumns<TTable>]
|
|
50
96
|
|
|
51
|
-
|
|
97
|
+
// Whose columns a variant leaves out: a client writes neither the database's
|
|
98
|
+
// nor the server's, and server code writes the server's.
|
|
99
|
+
type ClientExcludes = Writer
|
|
100
|
+
type ServerExcludes = "database"
|
|
101
|
+
|
|
102
|
+
// The keys insert and update never accept: identities, and the columns
|
|
103
|
+
// `TExcludes` writes.
|
|
104
|
+
type UnwritableKeys<
|
|
105
|
+
TTable extends ZenoTable,
|
|
106
|
+
TExcludes extends Writer = ClientExcludes,
|
|
107
|
+
> = IdentityKeys<TTable> | WrittenByKeys<TTable, TExcludes>
|
|
108
|
+
|
|
109
|
+
type InsertColumns<
|
|
110
|
+
TTable extends ZenoTable,
|
|
111
|
+
TExcludes extends Writer = ClientExcludes,
|
|
112
|
+
> = Pick<
|
|
52
113
|
TableColumns<TTable>,
|
|
53
|
-
Exclude<keyof InferInsertModel<TTable>,
|
|
114
|
+
Exclude<keyof InferInsertModel<TTable>, UnwritableKeys<TTable, TExcludes>>
|
|
54
115
|
>
|
|
55
116
|
|
|
56
117
|
type SelectRefine<
|
|
57
118
|
TTable extends Table,
|
|
58
119
|
TCoerce extends CoerceOptions,
|
|
59
120
|
> = BuildRefine<TableColumns<TTable>, TCoerce>
|
|
60
|
-
type
|
|
61
|
-
TTable extends
|
|
62
|
-
TCoerce extends CoerceOptions,
|
|
63
|
-
> = BuildRefine<InsertColumns<TTable>, TCoerce>
|
|
64
|
-
type UpdateRefine<
|
|
65
|
-
TTable extends Table,
|
|
121
|
+
type WriteRefineOptions<
|
|
122
|
+
TTable extends ZenoTable,
|
|
66
123
|
TCoerce extends CoerceOptions,
|
|
67
|
-
|
|
124
|
+
TExcludes extends Writer,
|
|
125
|
+
> = BuildRefine<InsertColumns<TTable, TExcludes>, TCoerce>
|
|
68
126
|
|
|
69
127
|
type DefineTableSchemaOptions<
|
|
70
|
-
TTable extends
|
|
128
|
+
TTable extends ZenoTable,
|
|
71
129
|
TCoerce extends CoerceOptions = undefined,
|
|
130
|
+
TExcludes extends Writer = ClientExcludes,
|
|
72
131
|
> = {
|
|
73
132
|
factory?: CreateSchemaFactoryOptions<TCoerce>
|
|
74
133
|
insert?: NoUnknownKeys<
|
|
75
|
-
|
|
134
|
+
WriteRefineOptions<TTable, TCoerce, TExcludes>,
|
|
76
135
|
InferInsertModel<TTable>
|
|
77
136
|
>
|
|
78
137
|
select?: NoUnknownKeys<
|
|
79
138
|
SelectRefine<TTable, TCoerce>,
|
|
80
139
|
InferSelectModel<TTable>
|
|
81
140
|
>
|
|
82
|
-
update?:
|
|
141
|
+
update?: WriteRefineOptions<TTable, TCoerce, TExcludes>
|
|
83
142
|
}
|
|
84
143
|
|
|
85
144
|
type RefineFromOptions<
|
|
@@ -91,6 +150,15 @@ type RefineFromOptions<
|
|
|
91
150
|
: undefined
|
|
92
151
|
: undefined
|
|
93
152
|
|
|
153
|
+
// A caller's refinement for a key replaces the column rule's in the type: a
|
|
154
|
+
// function receives the column rule's schema at runtime and returns its own.
|
|
155
|
+
type WriteRefine<TTable extends ZenoTable, TOptions, TKey extends string> =
|
|
156
|
+
RefineFromOptions<TOptions, TKey> extends infer TCaller
|
|
157
|
+
? TCaller extends Record<string, unknown>
|
|
158
|
+
? Omit<ColumnRefinements<TTable>, keyof TCaller> & TCaller
|
|
159
|
+
: ColumnRefinements<TTable>
|
|
160
|
+
: never
|
|
161
|
+
|
|
94
162
|
type OmitKeys<TSchema, TKeys extends PropertyKey> =
|
|
95
163
|
TSchema extends z.ZodObject<infer TShape, infer TConfig>
|
|
96
164
|
? z.ZodObject<
|
|
@@ -133,22 +201,25 @@ type OptionalRefinedInsert<TSchema, TTable extends Table, TRefine> =
|
|
|
133
201
|
: never
|
|
134
202
|
|
|
135
203
|
type DefineTableSchemaResult<
|
|
136
|
-
TTable extends
|
|
204
|
+
TTable extends ZenoTable,
|
|
137
205
|
TCoerce extends CoerceOptions,
|
|
138
206
|
TOptions,
|
|
207
|
+
TExcludes extends Writer = ClientExcludes,
|
|
139
208
|
> = {
|
|
209
|
+
/** The table the schemas come from, for `ownedRows`. */
|
|
210
|
+
table: TTable
|
|
140
211
|
insert: OmitKeys<
|
|
141
212
|
OptionalRefinedInsert<
|
|
142
213
|
BuildSchema<
|
|
143
214
|
"insert",
|
|
144
215
|
TableColumns<TTable>,
|
|
145
|
-
|
|
216
|
+
WriteRefine<TTable, TOptions, "insert">,
|
|
146
217
|
TCoerce
|
|
147
218
|
>,
|
|
148
219
|
TTable,
|
|
149
|
-
|
|
220
|
+
WriteRefine<TTable, TOptions, "insert">
|
|
150
221
|
>,
|
|
151
|
-
|
|
222
|
+
UnwritableKeys<TTable, TExcludes>
|
|
152
223
|
>
|
|
153
224
|
select: BuildSchema<
|
|
154
225
|
"select",
|
|
@@ -160,21 +231,31 @@ type DefineTableSchemaResult<
|
|
|
160
231
|
BuildSchema<
|
|
161
232
|
"update",
|
|
162
233
|
TableColumns<TTable>,
|
|
163
|
-
|
|
234
|
+
WriteRefine<TTable, TOptions, "update">,
|
|
164
235
|
TCoerce
|
|
165
236
|
>,
|
|
166
|
-
|
|
237
|
+
UnwritableKeys<TTable, TExcludes>
|
|
167
238
|
>
|
|
168
239
|
}
|
|
169
240
|
|
|
170
241
|
type Refinements = Record<string, unknown>
|
|
171
242
|
|
|
172
|
-
const
|
|
243
|
+
const columnRules = (table: ZenoTable) =>
|
|
244
|
+
table[COLUMN_RULES_KEY].columns as Record<string, ColumnRule>
|
|
245
|
+
|
|
246
|
+
const CLIENT_EXCLUDES: ReadonlySet<Writer> = new Set(["database", "server"])
|
|
247
|
+
const SERVER_EXCLUDES: ReadonlySet<Writer> = new Set(["database"])
|
|
248
|
+
|
|
249
|
+
const unwritableKeys = (table: ZenoTable, excludes: ReadonlySet<Writer>) => {
|
|
173
250
|
const columns: Record<string, Column> = getColumns(table)
|
|
251
|
+
const rules = columnRules(table)
|
|
174
252
|
const keys: string[] = []
|
|
175
253
|
|
|
176
254
|
for (const key of Object.keys(columns)) {
|
|
177
|
-
|
|
255
|
+
const isIdentity = columns[key]?.generatedIdentity !== undefined
|
|
256
|
+
const writer = rules[key]?.writer
|
|
257
|
+
|
|
258
|
+
if (isIdentity || (writer !== undefined && excludes.has(writer))) {
|
|
178
259
|
keys.push(key)
|
|
179
260
|
}
|
|
180
261
|
}
|
|
@@ -182,6 +263,75 @@ const auditKeys = (table: Table) => {
|
|
|
182
263
|
return keys
|
|
183
264
|
}
|
|
184
265
|
|
|
266
|
+
type SchemaRefinement = (schema: unknown) => unknown
|
|
267
|
+
|
|
268
|
+
// `generated` under the caller's `refinements`: a caller's function runs on
|
|
269
|
+
// the generated schema, so it can narrow further; a caller's schema replaces
|
|
270
|
+
// it outright, as it replaces any rule drizzle generates.
|
|
271
|
+
const composeRefinements = (
|
|
272
|
+
refinements: Refinements | undefined,
|
|
273
|
+
generated: Record<string, SchemaRefinement>
|
|
274
|
+
): Refinements => {
|
|
275
|
+
const merged: Refinements = { ...refinements }
|
|
276
|
+
|
|
277
|
+
for (const key of Object.keys(generated)) {
|
|
278
|
+
const inner = generated[key] as SchemaRefinement
|
|
279
|
+
const refinement = refinements?.[key]
|
|
280
|
+
|
|
281
|
+
if (refinement === undefined) {
|
|
282
|
+
merged[key] = inner
|
|
283
|
+
} else if (typeof refinement === "function") {
|
|
284
|
+
merged[key] = (schema: unknown) =>
|
|
285
|
+
(refinement as SchemaRefinement)(inner(schema))
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return merged
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** What a column's `withRule` hints the form field that edits it. */
|
|
293
|
+
type FieldHint = { readonly field: string }
|
|
294
|
+
|
|
295
|
+
// The hints, on the schema each rule returns. A registry of their own rather
|
|
296
|
+
// than `.meta()`, which `z.toJSONSchema` would copy into every JSON Schema.
|
|
297
|
+
// `@zeno-lib/forms` reads it under the same `globalThis` symbol without
|
|
298
|
+
// importing this package, and a second copy of either finds the same one.
|
|
299
|
+
const FIELD_HINTS_KEY = Symbol.for("@zeno-lib/field-hints")
|
|
300
|
+
|
|
301
|
+
const fieldHints = (() => {
|
|
302
|
+
const holder = globalThis as {
|
|
303
|
+
[FIELD_HINTS_KEY]?: z.core.$ZodRegistry<FieldHint>
|
|
304
|
+
}
|
|
305
|
+
holder[FIELD_HINTS_KEY] ??= z.registry<FieldHint>()
|
|
306
|
+
return holder[FIELD_HINTS_KEY]
|
|
307
|
+
})()
|
|
308
|
+
|
|
309
|
+
const withHint =
|
|
310
|
+
(rule: SchemaRefinement, hint: FieldHint): SchemaRefinement =>
|
|
311
|
+
(schema) => {
|
|
312
|
+
const ruled = rule(schema)
|
|
313
|
+
fieldHints.add(ruled as z.ZodType, hint)
|
|
314
|
+
return ruled
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const withRuleRefinements = (table: ZenoTable, refinements?: Refinements) => {
|
|
318
|
+
const rules = columnRules(table)
|
|
319
|
+
const generated: Record<string, SchemaRefinement> = {}
|
|
320
|
+
|
|
321
|
+
for (const key of Object.keys(rules)) {
|
|
322
|
+
const { hint, refine } = rules[key] ?? {}
|
|
323
|
+
|
|
324
|
+
if (typeof refine === "function") {
|
|
325
|
+
generated[key] =
|
|
326
|
+
hint === undefined
|
|
327
|
+
? (refine as SchemaRefinement)
|
|
328
|
+
: withHint(refine as SchemaRefinement, hint)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return composeRefinements(refinements, generated)
|
|
333
|
+
}
|
|
334
|
+
|
|
185
335
|
// Postgres starts an ascending identity at its minimum value, which defaults
|
|
186
336
|
// to 1, so only a sequence configured below that can hand out 0 or less.
|
|
187
337
|
const isPositiveIdentity = (column: Column) => {
|
|
@@ -207,40 +357,171 @@ const toPositive = (schema: unknown) =>
|
|
|
207
357
|
? (schema as PositiveSchema).positive()
|
|
208
358
|
: schema
|
|
209
359
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
360
|
+
const positiveCheck = (column: Column) =>
|
|
361
|
+
isPositiveIdentity(column) ? toPositive : undefined
|
|
362
|
+
|
|
363
|
+
// Drizzle's string mode reads Postgres's text form back
|
|
364
|
+
// (`2026-09-17 07:53:56.357127+00`), which ISO 8601 doesn't cover, so a
|
|
365
|
+
// timestamp takes a `T` or a space, and an offset of hours with optional
|
|
366
|
+
// minutes, up to the ±15:59 Postgres takes.
|
|
367
|
+
// The groups are the date and the offset.
|
|
368
|
+
const TIMESTAMP =
|
|
369
|
+
/^(\d{4}-\d{2}-\d{2})[T ](?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?([+-](?:0\d|1[0-5])(?::?[0-5]\d)?|Z)?$/
|
|
370
|
+
|
|
371
|
+
// Postgres's special values, which the string mode reads back as they are.
|
|
372
|
+
const INFINITIES: ReadonlySet<string> = new Set(["-infinity", "infinity"])
|
|
373
|
+
|
|
374
|
+
const ISO_DATE = z.iso.date()
|
|
375
|
+
|
|
376
|
+
const isDate = (value: string) => ISO_DATE.safeParse(value).success
|
|
377
|
+
|
|
378
|
+
// One with a time zone needs the offset, which would otherwise come from the
|
|
379
|
+
// session's time zone. One without refuses it, because Postgres drops it
|
|
380
|
+
// without a word and stores the time as written.
|
|
381
|
+
const isTimestamp = (withTimezone: boolean) => (value: string) => {
|
|
382
|
+
const [, day, offset] = TIMESTAMP.exec(value) ?? []
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
day !== undefined && isDate(day) && (offset !== undefined) === withTimezone
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const withFormat =
|
|
390
|
+
(format: "date" | "datetime", isValid: (value: string) => boolean) =>
|
|
391
|
+
(schema: unknown) =>
|
|
392
|
+
(schema as z.ZodString).check((context) => {
|
|
393
|
+
if (!(INFINITIES.has(context.value) || isValid(context.value))) {
|
|
394
|
+
// Zod's own code and format, so its locale words the message, and
|
|
395
|
+
// `continue` as its own format checks set, so object refinements
|
|
396
|
+
// still run.
|
|
397
|
+
context.issues.push({
|
|
398
|
+
code: "invalid_format",
|
|
399
|
+
continue: true,
|
|
400
|
+
format,
|
|
401
|
+
input: context.value,
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
// A string-mode `date` or `timestamp` column holds text Postgres must parse,
|
|
407
|
+
// and Drizzle's bridge only asks for a string. An array column's schema is
|
|
408
|
+
// the array, so its elements go unchecked.
|
|
409
|
+
const formatCheck = (column: Column): SchemaRefinement | undefined => {
|
|
410
|
+
const { dimensions = 0, withTimezone = false } = column as Column & {
|
|
411
|
+
dimensions?: number
|
|
412
|
+
withTimezone?: boolean
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (dimensions > 0) {
|
|
416
|
+
return undefined
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
switch (column.dataType) {
|
|
420
|
+
case "string date":
|
|
421
|
+
return withFormat("date", isDate)
|
|
422
|
+
case "string timestamp":
|
|
423
|
+
return withFormat("datetime", isTimestamp(withTimezone))
|
|
424
|
+
default:
|
|
425
|
+
return undefined
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Each column's check, under the caller's refinement for it.
|
|
430
|
+
const withChecks = (
|
|
214
431
|
table: Table,
|
|
215
|
-
|
|
216
|
-
|
|
432
|
+
checkFor: (column: Column) => SchemaRefinement | undefined,
|
|
433
|
+
refinements: Refinements | undefined
|
|
434
|
+
) => {
|
|
217
435
|
const columns: Record<string, Column> = getColumns(table)
|
|
218
|
-
const
|
|
436
|
+
const generated: Record<string, SchemaRefinement> = {}
|
|
219
437
|
|
|
220
438
|
for (const key of Object.keys(columns)) {
|
|
221
439
|
const column = columns[key]
|
|
440
|
+
const check = column === undefined ? undefined : checkFor(column)
|
|
222
441
|
|
|
223
|
-
if (
|
|
224
|
-
|
|
442
|
+
if (check !== undefined) {
|
|
443
|
+
generated[key] = check
|
|
225
444
|
}
|
|
445
|
+
}
|
|
226
446
|
|
|
227
|
-
|
|
447
|
+
return composeRefinements(refinements, generated)
|
|
448
|
+
}
|
|
228
449
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
450
|
+
// Zod rejects an omit key the shape lacks, which a generated or server-owned
|
|
451
|
+
// column would be.
|
|
452
|
+
const omitPresent = <TSchema extends z.ZodObject>(
|
|
453
|
+
schema: TSchema,
|
|
454
|
+
keys: readonly string[]
|
|
455
|
+
) => {
|
|
456
|
+
const mask: Record<string, true> = {}
|
|
457
|
+
|
|
458
|
+
for (const key of keys) {
|
|
459
|
+
if (key in schema.shape) {
|
|
460
|
+
mask[key] = true
|
|
233
461
|
}
|
|
234
462
|
}
|
|
235
463
|
|
|
236
|
-
return
|
|
464
|
+
return schema.omit(mask)
|
|
237
465
|
}
|
|
238
466
|
|
|
239
|
-
|
|
467
|
+
const buildTableSchema = (
|
|
468
|
+
name: string,
|
|
469
|
+
table: ZenoTable,
|
|
470
|
+
options: DefineTableSchemaOptions<ZenoTable, CoerceOptions> | undefined,
|
|
471
|
+
excludes: ReadonlySet<Writer>
|
|
472
|
+
) => {
|
|
473
|
+
// The type already refuses a plain drizzle table. This catches a cast, or a
|
|
474
|
+
// caller without TypeScript, before its markers are silently ignored.
|
|
475
|
+
if (!(COLUMN_RULES_KEY in table)) {
|
|
476
|
+
throw new TypeError(
|
|
477
|
+
`${name} reads the column rules of a table from table(), unsecureTable() or schema(...).table in @zeno-lib/db/schema, and "${getTableName(table)}" has none.`
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const schemaFactory = createDrizzleSchemaFactory(options?.factory)
|
|
482
|
+
const unwritable = unwritableKeys(table, excludes)
|
|
483
|
+
const omitUnwritable = (schema: z.ZodObject) =>
|
|
484
|
+
omitPresent(schema, unwritable)
|
|
485
|
+
// The drizzle factory's refinement parameters are typed per table, which a
|
|
486
|
+
// generic `TTable` cannot satisfy, so the merged maps go in untyped.
|
|
487
|
+
const factory = schemaFactory as unknown as Record<
|
|
488
|
+
"createInsertSchema" | "createSelectSchema" | "createUpdateSchema",
|
|
489
|
+
(table: Table, refine: Refinements) => z.ZodObject
|
|
490
|
+
>
|
|
491
|
+
// Column rules say what a client may write, so they reach insert and update.
|
|
492
|
+
// `select` describes the stored row, which a rule the database doesn't
|
|
493
|
+
// enforce may not hold for.
|
|
494
|
+
const write = (
|
|
495
|
+
variant: "insert" | "update",
|
|
496
|
+
create: (typeof factory)["createInsertSchema"]
|
|
497
|
+
) =>
|
|
498
|
+
omitUnwritable(
|
|
499
|
+
create(
|
|
500
|
+
table,
|
|
501
|
+
withChecks(
|
|
502
|
+
table,
|
|
503
|
+
formatCheck,
|
|
504
|
+
withRuleRefinements(table, options?.[variant] as Refinements)
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
insert: write("insert", factory.createInsertSchema),
|
|
511
|
+
select: factory.createSelectSchema(
|
|
512
|
+
table,
|
|
513
|
+
withChecks(table, positiveCheck, options?.select as Refinements)
|
|
514
|
+
),
|
|
515
|
+
table,
|
|
516
|
+
update: write("update", factory.createUpdateSchema),
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function defineTableSchema<TTable extends ZenoTable>(
|
|
240
521
|
table: TTable
|
|
241
522
|
): DefineTableSchemaResult<TTable, undefined, NoTableSchemaOptions>
|
|
242
523
|
function defineTableSchema<
|
|
243
|
-
TTable extends
|
|
524
|
+
TTable extends ZenoTable,
|
|
244
525
|
TCoerce extends CoerceOptions = undefined,
|
|
245
526
|
TOptions extends DefineTableSchemaOptions<
|
|
246
527
|
TTable,
|
|
@@ -250,49 +531,181 @@ function defineTableSchema<
|
|
|
250
531
|
table: TTable,
|
|
251
532
|
options: TOptions
|
|
252
533
|
): DefineTableSchemaResult<TTable, TCoerce, TOptions>
|
|
253
|
-
function defineTableSchema
|
|
254
|
-
|
|
534
|
+
function defineTableSchema(
|
|
535
|
+
table: ZenoTable,
|
|
536
|
+
options?: DefineTableSchemaOptions<ZenoTable, CoerceOptions>
|
|
537
|
+
) {
|
|
538
|
+
return buildTableSchema("defineTableSchema", table, options, CLIENT_EXCLUDES)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* `defineTableSchema` for code that runs only on the server, such as a sync
|
|
543
|
+
* job, which writes the columns `serverOwned()` keeps from clients. Identity
|
|
544
|
+
* and audit columns stay out, as the database writes them. Never parse what a
|
|
545
|
+
* client sends with it: a server action's input is a client's.
|
|
546
|
+
*/
|
|
547
|
+
function defineServerTableSchema<TTable extends ZenoTable>(
|
|
548
|
+
table: TTable
|
|
549
|
+
): DefineTableSchemaResult<
|
|
550
|
+
TTable,
|
|
551
|
+
undefined,
|
|
552
|
+
NoTableSchemaOptions,
|
|
553
|
+
ServerExcludes
|
|
554
|
+
>
|
|
555
|
+
function defineServerTableSchema<
|
|
556
|
+
TTable extends ZenoTable,
|
|
255
557
|
TCoerce extends CoerceOptions = undefined,
|
|
256
558
|
TOptions extends DefineTableSchemaOptions<
|
|
257
559
|
TTable,
|
|
258
|
-
TCoerce
|
|
259
|
-
|
|
560
|
+
TCoerce,
|
|
561
|
+
ServerExcludes
|
|
562
|
+
> = DefineTableSchemaOptions<TTable, TCoerce, ServerExcludes>,
|
|
260
563
|
>(
|
|
261
564
|
table: TTable,
|
|
262
|
-
options
|
|
263
|
-
): DefineTableSchemaResult<TTable, TCoerce, TOptions>
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
565
|
+
options: TOptions
|
|
566
|
+
): DefineTableSchemaResult<TTable, TCoerce, TOptions, ServerExcludes>
|
|
567
|
+
function defineServerTableSchema(
|
|
568
|
+
table: ZenoTable,
|
|
569
|
+
options?: DefineTableSchemaOptions<ZenoTable, CoerceOptions, ServerExcludes>
|
|
570
|
+
) {
|
|
571
|
+
return buildTableSchema(
|
|
572
|
+
"defineServerTableSchema",
|
|
573
|
+
table,
|
|
574
|
+
options as DefineTableSchemaOptions<ZenoTable, CoerceOptions>,
|
|
575
|
+
SERVER_EXCLUDES
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
type ColumnKey<TTable extends Table> = Extract<
|
|
580
|
+
keyof TableColumns<TTable>,
|
|
581
|
+
string
|
|
582
|
+
>
|
|
583
|
+
|
|
584
|
+
// The part of a `defineTableSchema` result `ownedRows` reads.
|
|
585
|
+
type OwnableSchema = {
|
|
586
|
+
insert: z.ZodObject
|
|
587
|
+
select: { shape: { id: z.ZodType } }
|
|
588
|
+
table: ZenoTable
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
type OwnedRowSchema<TSchema extends OwnableSchema, TParentKey extends string> =
|
|
592
|
+
TSchema["insert"] extends z.ZodObject<infer TShape, infer TConfig>
|
|
593
|
+
? z.ZodObject<
|
|
594
|
+
{
|
|
595
|
+
[K in keyof TShape as K extends TParentKey ? never : K]: TShape[K]
|
|
596
|
+
} & { id: z.ZodOptional<TSchema["select"]["shape"]["id"]> },
|
|
597
|
+
TConfig
|
|
598
|
+
>
|
|
599
|
+
: never
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* A table's rows that belong to one parent row and are edited as a list, such
|
|
603
|
+
* as an order's lines. `syncRows` from `@zeno-lib/db/query` takes it in place
|
|
604
|
+
* of the table, and a form validates each row with `row`.
|
|
605
|
+
*/
|
|
606
|
+
type OwnedRows<
|
|
607
|
+
TTable extends ZenoTable,
|
|
608
|
+
TParentKey extends string,
|
|
609
|
+
TRow extends z.ZodObject,
|
|
610
|
+
> = {
|
|
611
|
+
/** The columns that point at the parent row, which `row` leaves out. */
|
|
612
|
+
readonly parent: readonly TParentKey[]
|
|
613
|
+
/**
|
|
614
|
+
* One row as a client sends it: the `insert` columns but the parent's, and
|
|
615
|
+
* the `id` of a stored row, absent on a new one.
|
|
616
|
+
*/
|
|
617
|
+
readonly row: TRow
|
|
618
|
+
readonly table: TTable
|
|
619
|
+
}
|
|
276
620
|
|
|
277
|
-
|
|
621
|
+
/**
|
|
622
|
+
* The rows of `schema`'s table that belong to a parent through the `parent`
|
|
623
|
+
* columns, for a form that edits them as a list and the `syncRows` that saves
|
|
624
|
+
* it:
|
|
625
|
+
*
|
|
626
|
+
* ```ts
|
|
627
|
+
* const orderLines = ownedRows(defineTableSchema(lines), { parent: "orderId" })
|
|
628
|
+
*
|
|
629
|
+
* const orderSchema = defineTableSchema(orders).insert.extend({
|
|
630
|
+
* lines: z.array(orderLines.row),
|
|
631
|
+
* })
|
|
632
|
+
*
|
|
633
|
+
* await syncRows(tx, orderLines, { parent: { orderId }, rows: input.lines })
|
|
634
|
+
* ```
|
|
635
|
+
*
|
|
636
|
+
* The table needs a sequential `id`. A link table matched by a natural key
|
|
637
|
+
* goes to `syncRows` with its `key` option instead.
|
|
638
|
+
*/
|
|
639
|
+
const ownedRows = <
|
|
640
|
+
TSchema extends OwnableSchema,
|
|
641
|
+
TParentKey extends ColumnKey<TSchema["table"]>,
|
|
642
|
+
>(
|
|
643
|
+
schema: TSchema,
|
|
644
|
+
{ parent }: { parent: TParentKey | readonly TParentKey[] }
|
|
645
|
+
): OwnedRows<
|
|
646
|
+
TSchema["table"],
|
|
647
|
+
TParentKey,
|
|
648
|
+
OwnedRowSchema<TSchema, TParentKey>
|
|
649
|
+
> => {
|
|
650
|
+
const parentKeys: readonly TParentKey[] =
|
|
651
|
+
typeof parent === "string" ? [parent] : [...parent]
|
|
652
|
+
const id = schema.select.shape.id as z.ZodType | undefined
|
|
653
|
+
|
|
654
|
+
if (id === undefined) {
|
|
655
|
+
throw new TypeError(
|
|
656
|
+
`ownedRows tells rows apart by their id, and "${getTableName(schema.table)}" has none.`
|
|
657
|
+
)
|
|
278
658
|
}
|
|
279
|
-
// The drizzle factory's refinement parameters are typed per table, which a
|
|
280
|
-
// generic `TTable` cannot satisfy, so the merged maps go in untyped.
|
|
281
|
-
const factory = schemaFactory as unknown as Record<
|
|
282
|
-
"createInsertSchema" | "createSelectSchema" | "createUpdateSchema",
|
|
283
|
-
(table: Table, refine: Refinements) => z.ZodObject
|
|
284
|
-
>
|
|
285
|
-
const build = (
|
|
286
|
-
variant: "insert" | "select" | "update",
|
|
287
|
-
create: (typeof factory)["createInsertSchema"]
|
|
288
|
-
) => create(table, withPositiveIdentities(table, options?.[variant]))
|
|
289
659
|
|
|
290
660
|
return {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
} as unknown as
|
|
661
|
+
parent: parentKeys,
|
|
662
|
+
row: omitPresent(schema.insert, parentKeys).extend({ id: id.optional() }),
|
|
663
|
+
table: schema.table,
|
|
664
|
+
} as unknown as OwnedRows<
|
|
665
|
+
TSchema["table"],
|
|
666
|
+
TParentKey,
|
|
667
|
+
OwnedRowSchema<TSchema, TParentKey>
|
|
668
|
+
>
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const isZenoTable = (value: unknown): value is ZenoTable =>
|
|
672
|
+
is(value, Table) && COLUMN_RULES_KEY in value
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* The columns a client may write, for every Zeno table among `exports` (a
|
|
676
|
+
* schema barrel, say), keyed by name (`schema.table` outside `public`): what `defineTableSchema`'s
|
|
677
|
+
* `insert` accepts, and `update` accepts the same. Columns are writable unless
|
|
678
|
+
* marked, so snapshot this in one test, and a newly writable column shows up
|
|
679
|
+
* as a diff in review.
|
|
680
|
+
*/
|
|
681
|
+
const writableColumns = (exports: Record<string, unknown>) => {
|
|
682
|
+
const tables = Object.keys(exports)
|
|
683
|
+
.map((key) => exports[key])
|
|
684
|
+
.filter(isZenoTable)
|
|
685
|
+
.map((table) => [getTableUniqueName(table), table] as const)
|
|
686
|
+
// A plain sort, so the order doesn't hang on the machine's locale.
|
|
687
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
688
|
+
const columns: Record<string, string[]> = {}
|
|
689
|
+
|
|
690
|
+
for (const [name, table] of tables) {
|
|
691
|
+
columns[name] = Object.keys(
|
|
692
|
+
buildTableSchema("writableColumns", table, undefined, CLIENT_EXCLUDES)
|
|
693
|
+
.insert.shape
|
|
694
|
+
).sort()
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return columns
|
|
295
698
|
}
|
|
296
699
|
|
|
297
|
-
export type {
|
|
298
|
-
|
|
700
|
+
export type {
|
|
701
|
+
DefineTableSchemaOptions,
|
|
702
|
+
DefineTableSchemaResult,
|
|
703
|
+
OwnedRows,
|
|
704
|
+
ZenoTable,
|
|
705
|
+
}
|
|
706
|
+
export {
|
|
707
|
+
defineServerTableSchema,
|
|
708
|
+
defineTableSchema,
|
|
709
|
+
ownedRows,
|
|
710
|
+
writableColumns,
|
|
711
|
+
}
|