@zeno-lib/schema 0.3.0 → 0.4.1

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/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
- type Table,
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 type { z } from "zod"
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 keys `timestamps()` and `authorship()` from `@zeno-lib/db/schema` emit.
32
- // This package cannot import them, so it matches the names instead, and only
33
- // when the column has a default, since a column the database never fills is
34
- // one the caller has to write.
35
- const AUDIT_COLUMN_KEYS = new Set<string>([
36
- "createdAt",
37
- "createdBy",
38
- "updatedAt",
39
- "updatedBy",
40
- ])
41
- type AuditColumnKey = "createdAt" | "createdBy" | "updatedAt" | "updatedBy"
42
-
43
- type AuditKeys<TTable extends Table> = {
44
- [K in keyof TableColumns<TTable>]: K extends AuditColumnKey
45
- ? TableColumns<TTable>[K]["_"]["hasDefault"] extends true
46
- ? K
47
- : never
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 (`serverManaged`),
43
+ * or only the `"database"` (`databaseManaged`, which the audit columns of
44
+ * `timestamps()` and `authorship()` carry). `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
- type InsertColumns<TTable extends Table> = Pick<
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>, AuditKeys<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 InsertRefine<
61
- TTable extends Table,
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
- > = BuildRefine<InsertColumns<TTable>, TCoerce>
124
+ TExcludes extends Writer,
125
+ > = BuildRefine<InsertColumns<TTable, TExcludes>, TCoerce>
68
126
 
69
127
  type DefineTableSchemaOptions<
70
- TTable extends Table,
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
- InsertRefine<TTable, TCoerce>,
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?: UpdateRefine<TTable, TCoerce>
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 Table,
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
- RefineFromOptions<TOptions, "insert">,
216
+ WriteRefine<TTable, TOptions, "insert">,
146
217
  TCoerce
147
218
  >,
148
219
  TTable,
149
- RefineFromOptions<TOptions, "insert">
220
+ WriteRefine<TTable, TOptions, "insert">
150
221
  >,
151
- AuditKeys<TTable>
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
- RefineFromOptions<TOptions, "update">,
234
+ WriteRefine<TTable, TOptions, "update">,
164
235
  TCoerce
165
236
  >,
166
- AuditKeys<TTable>
237
+ UnwritableKeys<TTable, TExcludes>
167
238
  >
168
239
  }
169
240
 
170
241
  type Refinements = Record<string, unknown>
171
242
 
172
- const auditKeys = (table: Table) => {
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
- if (AUDIT_COLUMN_KEYS.has(key) && columns[key]?.hasDefault) {
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
- // A caller's function refinement runs on the positive schema, so it can narrow
211
- // further. A caller's schema replaces the column schema outright, positive
212
- // check included, the same as it replaces every other generated rule.
213
- const withPositiveIdentities = (
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
- refinements: Refinements = {}
216
- ): Refinements => {
432
+ checkFor: (column: Column) => SchemaRefinement | undefined,
433
+ refinements: Refinements | undefined
434
+ ) => {
217
435
  const columns: Record<string, Column> = getColumns(table)
218
- const merged: Refinements = { ...refinements }
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 (column === undefined || !isPositiveIdentity(column)) {
224
- continue
442
+ if (check !== undefined) {
443
+ generated[key] = check
225
444
  }
445
+ }
226
446
 
227
- const refinement = refinements[key]
447
+ return composeRefinements(refinements, generated)
448
+ }
228
449
 
229
- if (refinement === undefined) {
230
- merged[key] = toPositive
231
- } else if (typeof refinement === "function") {
232
- merged[key] = (schema: unknown) => refinement(toPositive(schema))
450
+ // Zod rejects an omit key the shape lacks, which a generated or managed column
451
+ // 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 merged
464
+ return schema.omit(mask)
237
465
  }
238
466
 
239
- function defineTableSchema<TTable extends Table>(
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 Table,
524
+ TTable extends ZenoTable,
244
525
  TCoerce extends CoerceOptions = undefined,
245
526
  TOptions extends DefineTableSchemaOptions<
246
527
  TTable,
@@ -250,49 +531,182 @@ function defineTableSchema<
250
531
  table: TTable,
251
532
  options: TOptions
252
533
  ): DefineTableSchemaResult<TTable, TCoerce, TOptions>
253
- function defineTableSchema<
254
- TTable extends Table,
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 `serverManaged()` keeps from clients.
544
+ * Identity, audit and `databaseManaged()` columns stay out, as the database
545
+ * writes them. Never parse what a
546
+ * client sends with it: a server action's input is a client's.
547
+ */
548
+ function defineServerTableSchema<TTable extends ZenoTable>(
549
+ table: TTable
550
+ ): DefineTableSchemaResult<
551
+ TTable,
552
+ undefined,
553
+ NoTableSchemaOptions,
554
+ ServerExcludes
555
+ >
556
+ function defineServerTableSchema<
557
+ TTable extends ZenoTable,
255
558
  TCoerce extends CoerceOptions = undefined,
256
559
  TOptions extends DefineTableSchemaOptions<
257
560
  TTable,
258
- TCoerce
259
- > = DefineTableSchemaOptions<TTable, TCoerce>,
561
+ TCoerce,
562
+ ServerExcludes
563
+ > = DefineTableSchemaOptions<TTable, TCoerce, ServerExcludes>,
260
564
  >(
261
565
  table: TTable,
262
- options?: TOptions
263
- ): DefineTableSchemaResult<TTable, TCoerce, TOptions> {
264
- const schemaFactory = createDrizzleSchemaFactory(options?.factory)
265
- const audit = auditKeys(table)
266
- // Zod rejects an omit key the shape lacks, which a generated audit column
267
- // would be.
268
- const omitAudit = (schema: z.ZodObject) => {
269
- const mask: Record<string, true> = {}
270
-
271
- for (const key of audit) {
272
- if (key in schema.shape) {
273
- mask[key] = true
274
- }
275
- }
566
+ options: TOptions
567
+ ): DefineTableSchemaResult<TTable, TCoerce, TOptions, ServerExcludes>
568
+ function defineServerTableSchema(
569
+ table: ZenoTable,
570
+ options?: DefineTableSchemaOptions<ZenoTable, CoerceOptions, ServerExcludes>
571
+ ) {
572
+ return buildTableSchema(
573
+ "defineServerTableSchema",
574
+ table,
575
+ options as DefineTableSchemaOptions<ZenoTable, CoerceOptions>,
576
+ SERVER_EXCLUDES
577
+ )
578
+ }
579
+
580
+ type ColumnKey<TTable extends Table> = Extract<
581
+ keyof TableColumns<TTable>,
582
+ string
583
+ >
584
+
585
+ // The part of a `defineTableSchema` result `ownedRows` reads.
586
+ type OwnableSchema = {
587
+ insert: z.ZodObject
588
+ select: { shape: { id: z.ZodType } }
589
+ table: ZenoTable
590
+ }
591
+
592
+ type OwnedRowSchema<TSchema extends OwnableSchema, TParentKey extends string> =
593
+ TSchema["insert"] extends z.ZodObject<infer TShape, infer TConfig>
594
+ ? z.ZodObject<
595
+ {
596
+ [K in keyof TShape as K extends TParentKey ? never : K]: TShape[K]
597
+ } & { id: z.ZodOptional<TSchema["select"]["shape"]["id"]> },
598
+ TConfig
599
+ >
600
+ : never
601
+
602
+ /**
603
+ * A table's rows that belong to one parent row and are edited as a list, such
604
+ * as an order's lines. `syncRows` from `@zeno-lib/db/query` takes it in place
605
+ * of the table, and a form validates each row with `row`.
606
+ */
607
+ type OwnedRows<
608
+ TTable extends ZenoTable,
609
+ TParentKey extends string,
610
+ TRow extends z.ZodObject,
611
+ > = {
612
+ /** The columns that point at the parent row, which `row` leaves out. */
613
+ readonly parent: readonly TParentKey[]
614
+ /**
615
+ * One row as a client sends it: the `insert` columns but the parent's, and
616
+ * the `id` of a stored row, absent on a new one.
617
+ */
618
+ readonly row: TRow
619
+ readonly table: TTable
620
+ }
276
621
 
277
- return schema.omit(mask)
622
+ /**
623
+ * The rows of `schema`'s table that belong to a parent through the `parent`
624
+ * columns, for a form that edits them as a list and the `syncRows` that saves
625
+ * it:
626
+ *
627
+ * ```ts
628
+ * const orderLines = ownedRows(defineTableSchema(lines), { parent: "orderId" })
629
+ *
630
+ * const orderSchema = defineTableSchema(orders).insert.extend({
631
+ * lines: z.array(orderLines.row),
632
+ * })
633
+ *
634
+ * await syncRows(tx, orderLines, { parent: { orderId }, rows: input.lines })
635
+ * ```
636
+ *
637
+ * The table needs a sequential `id`. A link table matched by a natural key
638
+ * goes to `syncRows` with its `key` option instead.
639
+ */
640
+ const ownedRows = <
641
+ TSchema extends OwnableSchema,
642
+ TParentKey extends ColumnKey<TSchema["table"]>,
643
+ >(
644
+ schema: TSchema,
645
+ { parent }: { parent: TParentKey | readonly TParentKey[] }
646
+ ): OwnedRows<
647
+ TSchema["table"],
648
+ TParentKey,
649
+ OwnedRowSchema<TSchema, TParentKey>
650
+ > => {
651
+ const parentKeys: readonly TParentKey[] =
652
+ typeof parent === "string" ? [parent] : [...parent]
653
+ const id = schema.select.shape.id as z.ZodType | undefined
654
+
655
+ if (id === undefined) {
656
+ throw new TypeError(
657
+ `ownedRows tells rows apart by their id, and "${getTableName(schema.table)}" has none.`
658
+ )
278
659
  }
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
660
 
290
661
  return {
291
- insert: omitAudit(build("insert", factory.createInsertSchema)),
292
- select: build("select", factory.createSelectSchema),
293
- update: omitAudit(build("update", factory.createUpdateSchema)),
294
- } as unknown as DefineTableSchemaResult<TTable, TCoerce, TOptions>
662
+ parent: parentKeys,
663
+ row: omitPresent(schema.insert, parentKeys).extend({ id: id.optional() }),
664
+ table: schema.table,
665
+ } as unknown as OwnedRows<
666
+ TSchema["table"],
667
+ TParentKey,
668
+ OwnedRowSchema<TSchema, TParentKey>
669
+ >
670
+ }
671
+
672
+ const isZenoTable = (value: unknown): value is ZenoTable =>
673
+ is(value, Table) && COLUMN_RULES_KEY in value
674
+
675
+ /**
676
+ * The columns a client may write, for every Zeno table among `exports` (a
677
+ * schema barrel, say), keyed by name (`schema.table` outside `public`): what `defineTableSchema`'s
678
+ * `insert` accepts, and `update` accepts the same. Columns are writable unless
679
+ * marked, so snapshot this in one test, and a newly writable column shows up
680
+ * as a diff in review.
681
+ */
682
+ const writableColumns = (exports: Record<string, unknown>) => {
683
+ const tables = Object.keys(exports)
684
+ .map((key) => exports[key])
685
+ .filter(isZenoTable)
686
+ .map((table) => [getTableUniqueName(table), table] as const)
687
+ // A plain sort, so the order doesn't hang on the machine's locale.
688
+ .sort(([a], [b]) => (a < b ? -1 : 1))
689
+ const columns: Record<string, string[]> = {}
690
+
691
+ for (const [name, table] of tables) {
692
+ columns[name] = Object.keys(
693
+ buildTableSchema("writableColumns", table, undefined, CLIENT_EXCLUDES)
694
+ .insert.shape
695
+ ).sort()
696
+ }
697
+
698
+ return columns
295
699
  }
296
700
 
297
- export type { DefineTableSchemaOptions, DefineTableSchemaResult }
298
- export { defineTableSchema }
701
+ export type {
702
+ DefineTableSchemaOptions,
703
+ DefineTableSchemaResult,
704
+ OwnedRows,
705
+ ZenoTable,
706
+ }
707
+ export {
708
+ defineServerTableSchema,
709
+ defineTableSchema,
710
+ ownedRows,
711
+ writableColumns,
712
+ }