@zeno-lib/schema 0.0.1 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeno-lib/schema",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -15,20 +15,20 @@
15
15
  "url": "https://github.com/zeno-lib/zeno.git",
16
16
  "directory": "packages/schema"
17
17
  },
18
- "scripts": {
19
- "test": "vitest --run",
20
- "test:watch": "vitest watch",
21
- "types:check": "tsc --noEmit"
22
- },
23
18
  "peerDependencies": {
24
19
  "drizzle-orm": "1.0.0-rc.3",
25
20
  "zod": ">=4"
26
21
  },
27
22
  "devDependencies": {
28
- "@zeno-lib/typescript": "workspace:^",
29
23
  "drizzle-orm": "1.0.0-rc.3",
30
- "typescript": "6.0.3",
31
- "vitest": "4.1.0",
32
- "zod": "^4.4.3"
24
+ "typescript": "7.0.2",
25
+ "vitest": "4.1.11",
26
+ "zod": "^4.4.3",
27
+ "@zeno-lib/typescript": "^1.1.0"
28
+ },
29
+ "scripts": {
30
+ "test": "vitest --run",
31
+ "test:watch": "vitest watch",
32
+ "types:check": "tsc --noEmit"
33
33
  }
34
- }
34
+ }
@@ -1,4 +1,5 @@
1
- import { integer, pgTable, text } from "drizzle-orm/pg-core"
1
+ import { sql } from "drizzle-orm"
2
+ import { integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"
2
3
  import { expectTypeOf, test } from "vitest"
3
4
  import { z } from "zod"
4
5
 
@@ -52,3 +53,30 @@ test("refined schemas preserve refinement-specific output types", () => {
52
53
  title: "fixed"
53
54
  }>()
54
55
  })
56
+
57
+ test("defaulted audit columns leave the insert and update types", () => {
58
+ const notes = pgTable("notes", {
59
+ body: text("body").notNull(),
60
+ createdAt: timestamp("created_at").notNull().defaultNow(),
61
+ createdBy: uuid("created_by").notNull().default(sql`auth.uid()`),
62
+ updatedAt: timestamp("updated_at").notNull(),
63
+ })
64
+ const schemas = defineTableSchema(notes)
65
+
66
+ expectTypeOf<z.infer<typeof schemas.insert>>().toEqualTypeOf<{
67
+ body: string
68
+ updatedAt: Date
69
+ }>()
70
+ expectTypeOf<z.infer<typeof schemas.update>>().toEqualTypeOf<{
71
+ body?: string | undefined
72
+ updatedAt?: Date | undefined
73
+ }>()
74
+ expectTypeOf<keyof z.infer<typeof schemas.select>>().toEqualTypeOf<
75
+ "body" | "createdAt" | "createdBy" | "updatedAt"
76
+ >()
77
+
78
+ defineTableSchema(notes, {
79
+ // @ts-expect-error: an omitted audit column has nothing to refine.
80
+ insert: { createdBy: (schema) => schema },
81
+ })
82
+ })
package/src/index.test.ts CHANGED
@@ -1,4 +1,12 @@
1
- import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"
1
+ import { sql } from "drizzle-orm"
2
+ import {
3
+ bigint,
4
+ integer,
5
+ pgTable,
6
+ text,
7
+ timestamp,
8
+ uuid,
9
+ } from "drizzle-orm/pg-core"
2
10
  import { describe, expect, it } from "vitest"
3
11
 
4
12
  import { defineTableSchema } from "./index"
@@ -116,4 +124,90 @@ describe("defineTableSchema", () => {
116
124
  }).success
117
125
  ).toBe(true)
118
126
  })
127
+
128
+ it("omits defaulted audit columns from insert and update, not select", () => {
129
+ const notes = pgTable("notes", {
130
+ body: text("body").notNull(),
131
+ createdAt: timestamp("created_at").notNull().defaultNow(),
132
+ createdBy: uuid("created_by").notNull().default(sql`auth.uid()`),
133
+ id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
134
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
135
+ updatedBy: uuid("updated_by").default(sql`auth.uid()`),
136
+ })
137
+ const schemas = defineTableSchema(notes)
138
+ const forged = {
139
+ body: "Hello",
140
+ createdAt: new Date("2000-01-01T00:00:00.000Z"),
141
+ createdBy: "00000000-0000-4000-8000-000000000000",
142
+ updatedAt: new Date("2000-01-01T00:00:00.000Z"),
143
+ updatedBy: "00000000-0000-4000-8000-000000000000",
144
+ }
145
+
146
+ expect(schemas.insert.parse(forged)).toEqual({ body: "Hello" })
147
+ expect(schemas.update.parse(forged)).toEqual({ body: "Hello" })
148
+ expect(Object.keys(schemas.select.shape)).toEqual(
149
+ expect.arrayContaining([
150
+ "createdAt",
151
+ "createdBy",
152
+ "updatedAt",
153
+ "updatedBy",
154
+ ])
155
+ )
156
+ })
157
+
158
+ it("keeps an audit-named column the database does not fill", () => {
159
+ const events = pgTable("events", {
160
+ createdAt: timestamp("created_at").notNull(),
161
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
162
+ })
163
+
164
+ expect(defineTableSchema(events).insert.safeParse({}).success).toBe(false)
165
+ })
166
+
167
+ it("rejects non-positive identity ids in every variant", () => {
168
+ const items = pgTable("items", {
169
+ id: bigint("id", { mode: "number" })
170
+ .primaryKey()
171
+ .generatedByDefaultAsIdentity(),
172
+ label: text("label").notNull(),
173
+ })
174
+ const schemas = defineTableSchema(items)
175
+
176
+ for (const id of [0, -1]) {
177
+ expect(schemas.insert.safeParse({ id, label: "x" }).success).toBe(false)
178
+ expect(schemas.update.safeParse({ id }).success).toBe(false)
179
+ expect(schemas.select.safeParse({ id, label: "x" }).success).toBe(false)
180
+ }
181
+
182
+ expect(schemas.insert.parse({ id: 1, label: "x" })).toEqual({
183
+ id: 1,
184
+ label: "x",
185
+ })
186
+ expect(schemas.insert.parse({ label: "x" })).toEqual({ label: "x" })
187
+ })
188
+
189
+ it("leaves identities configured to go below one unconstrained", () => {
190
+ const ledger = pgTable("ledger", {
191
+ id: integer("id")
192
+ .primaryKey()
193
+ .generatedByDefaultAsIdentity({ increment: -1, startWith: -1 }),
194
+ })
195
+
196
+ expect(defineTableSchema(ledger).select.parse({ id: -5 })).toEqual({
197
+ id: -5,
198
+ })
199
+ })
200
+
201
+ it("runs a function refinement on top of the positive identity check", () => {
202
+ const items = pgTable("items", {
203
+ id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
204
+ })
205
+ const schemas = defineTableSchema(items, {
206
+ insert: { id: (schema) => schema.max(10) },
207
+ })
208
+
209
+ expect(schemas.insert.safeParse({ id: 0 }).success).toBe(false)
210
+ expect(schemas.insert.safeParse({ id: 11 }).success).toBe(false)
211
+ expect(schemas.insert.safeParse({ id: 5 }).success).toBe(true)
212
+ })
119
213
  })
package/src/index.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  /* biome-ignore-all lint/performance/noBarrelFile: package root intentionally re-exports the public schema surface. */
2
- import type { InferInsertModel, InferSelectModel, Table } from "drizzle-orm"
2
+ import {
3
+ type Column,
4
+ getColumns,
5
+ type InferInsertModel,
6
+ type InferSelectModel,
7
+ type Table,
8
+ } from "drizzle-orm"
3
9
  import {
4
10
  type BuildRefine,
5
11
  type BuildSchema,
@@ -8,6 +14,7 @@ import {
8
14
  createSchemaFactory as createDrizzleSchemaFactory,
9
15
  type NoUnknownKeys,
10
16
  } from "drizzle-orm/zod"
17
+ import type { z } from "zod"
11
18
 
12
19
  export {
13
20
  createInsertSchema,
@@ -20,9 +27,30 @@ declare const noTableSchemaOptions: unique symbol
20
27
  type NoTableSchemaOptions = { [noTableSchemaOptions]?: never }
21
28
 
22
29
  type TableColumns<TTable extends Table> = TTable["_"]["columns"]
30
+
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
48
+ : never
49
+ }[keyof TableColumns<TTable>]
50
+
23
51
  type InsertColumns<TTable extends Table> = Pick<
24
52
  TableColumns<TTable>,
25
- keyof InferInsertModel<TTable>
53
+ Exclude<keyof InferInsertModel<TTable>, AuditKeys<TTable>>
26
54
  >
27
55
 
28
56
  type SelectRefine<
@@ -63,16 +91,27 @@ type RefineFromOptions<
63
91
  : undefined
64
92
  : undefined
65
93
 
94
+ type OmitKeys<TSchema, TKeys extends PropertyKey> =
95
+ TSchema extends z.ZodObject<infer TShape, infer TConfig>
96
+ ? z.ZodObject<
97
+ { [K in keyof TShape as K extends TKeys ? never : K]: TShape[K] },
98
+ TConfig
99
+ >
100
+ : never
101
+
66
102
  type DefineTableSchemaResult<
67
103
  TTable extends Table,
68
104
  TCoerce extends CoerceOptions,
69
105
  TOptions,
70
106
  > = {
71
- insert: BuildSchema<
72
- "insert",
73
- TableColumns<TTable>,
74
- RefineFromOptions<TOptions, "insert">,
75
- TCoerce
107
+ insert: OmitKeys<
108
+ BuildSchema<
109
+ "insert",
110
+ TableColumns<TTable>,
111
+ RefineFromOptions<TOptions, "insert">,
112
+ TCoerce
113
+ >,
114
+ AuditKeys<TTable>
76
115
  >
77
116
  select: BuildSchema<
78
117
  "select",
@@ -80,14 +119,86 @@ type DefineTableSchemaResult<
80
119
  RefineFromOptions<TOptions, "select">,
81
120
  TCoerce
82
121
  >
83
- update: BuildSchema<
84
- "update",
85
- TableColumns<TTable>,
86
- RefineFromOptions<TOptions, "update">,
87
- TCoerce
122
+ update: OmitKeys<
123
+ BuildSchema<
124
+ "update",
125
+ TableColumns<TTable>,
126
+ RefineFromOptions<TOptions, "update">,
127
+ TCoerce
128
+ >,
129
+ AuditKeys<TTable>
88
130
  >
89
131
  }
90
132
 
133
+ type Refinements = Record<string, unknown>
134
+
135
+ const auditKeys = (table: Table) => {
136
+ const columns: Record<string, Column> = getColumns(table)
137
+ const keys: string[] = []
138
+
139
+ for (const key of Object.keys(columns)) {
140
+ if (AUDIT_COLUMN_KEYS.has(key) && columns[key]?.hasDefault) {
141
+ keys.push(key)
142
+ }
143
+ }
144
+
145
+ return keys
146
+ }
147
+
148
+ // Postgres starts an ascending identity at its minimum value, which defaults
149
+ // to 1, so only a sequence configured below that can hand out 0 or less.
150
+ const isPositiveIdentity = (column: Column) => {
151
+ const identity = column.generatedIdentity
152
+
153
+ if (identity === undefined) {
154
+ return false
155
+ }
156
+
157
+ const {
158
+ increment = 1,
159
+ minValue = 1,
160
+ startWith = minValue,
161
+ } = identity.sequenceOptions ?? {}
162
+
163
+ return Number(increment) > 0 && Number(minValue) > 0 && Number(startWith) > 0
164
+ }
165
+
166
+ type PositiveSchema = { positive: () => unknown }
167
+
168
+ const toPositive = (schema: unknown) =>
169
+ typeof (schema as Partial<PositiveSchema>).positive === "function"
170
+ ? (schema as PositiveSchema).positive()
171
+ : schema
172
+
173
+ // A caller's function refinement runs on the positive schema, so it can narrow
174
+ // further. A caller's schema replaces the column schema outright, positive
175
+ // check included, the same as it replaces every other generated rule.
176
+ const withPositiveIdentities = (
177
+ table: Table,
178
+ refinements: Refinements = {}
179
+ ): Refinements => {
180
+ const columns: Record<string, Column> = getColumns(table)
181
+ const merged: Refinements = { ...refinements }
182
+
183
+ for (const key of Object.keys(columns)) {
184
+ const column = columns[key]
185
+
186
+ if (column === undefined || !isPositiveIdentity(column)) {
187
+ continue
188
+ }
189
+
190
+ const refinement = refinements[key]
191
+
192
+ if (refinement === undefined) {
193
+ merged[key] = toPositive
194
+ } else if (typeof refinement === "function") {
195
+ merged[key] = (schema: unknown) => refinement(toPositive(schema))
196
+ }
197
+ }
198
+
199
+ return merged
200
+ }
201
+
91
202
  function defineTableSchema<TTable extends Table>(
92
203
  table: TTable
93
204
  ): DefineTableSchemaResult<TTable, undefined, NoTableSchemaOptions>
@@ -114,21 +225,36 @@ function defineTableSchema<
114
225
  options?: TOptions
115
226
  ): DefineTableSchemaResult<TTable, TCoerce, TOptions> {
116
227
  const schemaFactory = createDrizzleSchemaFactory(options?.factory)
228
+ const audit = auditKeys(table)
229
+ // Zod rejects an omit key the shape lacks, which a generated audit column
230
+ // would be.
231
+ const omitAudit = (schema: z.ZodObject) => {
232
+ const mask: Record<string, true> = {}
117
233
 
118
- return {
119
- insert: schemaFactory.createInsertSchema(
120
- table,
121
- options?.insert
122
- ) as DefineTableSchemaResult<TTable, TCoerce, TOptions>["insert"],
123
- select: schemaFactory.createSelectSchema(
124
- table,
125
- options?.select
126
- ) as DefineTableSchemaResult<TTable, TCoerce, TOptions>["select"],
127
- update: schemaFactory.createUpdateSchema(
128
- table,
129
- options?.update
130
- ) as DefineTableSchemaResult<TTable, TCoerce, TOptions>["update"],
234
+ for (const key of audit) {
235
+ if (key in schema.shape) {
236
+ mask[key] = true
237
+ }
238
+ }
239
+
240
+ return schema.omit(mask)
131
241
  }
242
+ // The drizzle factory's refinement parameters are typed per table, which a
243
+ // generic `TTable` cannot satisfy, so the merged maps go in untyped.
244
+ const factory = schemaFactory as unknown as Record<
245
+ "createInsertSchema" | "createSelectSchema" | "createUpdateSchema",
246
+ (table: Table, refine: Refinements) => z.ZodObject
247
+ >
248
+ const build = (
249
+ variant: "insert" | "select" | "update",
250
+ create: (typeof factory)["createInsertSchema"]
251
+ ) => create(table, withPositiveIdentities(table, options?.[variant]))
252
+
253
+ return {
254
+ insert: omitAudit(build("insert", factory.createInsertSchema)),
255
+ select: build("select", factory.createSelectSchema),
256
+ update: omitAudit(build("update", factory.createUpdateSchema)),
257
+ } as unknown as DefineTableSchemaResult<TTable, TCoerce, TOptions>
132
258
  }
133
259
 
134
260
  export type { DefineTableSchemaOptions, DefineTableSchemaResult }