@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/dist/index.d.mts +121 -14
- package/dist/index.mjs +137 -28
- package/package.json +2 -2
- package/src/index.test-d.ts +26 -34
- package/src/index.test.ts +144 -71
- package/src/index.ts +505 -91
- package/src/test-table.ts +11 -0
package/src/index.test.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm"
|
|
2
2
|
import {
|
|
3
3
|
bigint,
|
|
4
|
+
date,
|
|
4
5
|
integer,
|
|
6
|
+
pgEnum,
|
|
7
|
+
pgSchema,
|
|
5
8
|
pgTable,
|
|
6
9
|
text,
|
|
7
10
|
timestamp,
|
|
@@ -9,15 +12,18 @@ import {
|
|
|
9
12
|
} from "drizzle-orm/pg-core"
|
|
10
13
|
import { describe, expect, it } from "vitest"
|
|
11
14
|
|
|
12
|
-
import { defineTableSchema } from "./index"
|
|
15
|
+
import { defineTableSchema, writableColumns } from "./index"
|
|
16
|
+
import { zenoTable } from "./test-table"
|
|
13
17
|
|
|
14
|
-
const posts =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
const posts = zenoTable(
|
|
19
|
+
pgTable("posts", {
|
|
20
|
+
createdAt: timestamp("created_at", { mode: "date" }).notNull().defaultNow(),
|
|
21
|
+
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
|
22
|
+
slug: text("slug").notNull(),
|
|
23
|
+
summary: text("summary"),
|
|
24
|
+
title: text("title").notNull(),
|
|
25
|
+
})
|
|
26
|
+
)
|
|
21
27
|
|
|
22
28
|
describe("defineTableSchema", () => {
|
|
23
29
|
it("returns select, insert, and update Zod schemas for a Drizzle table", () => {
|
|
@@ -57,6 +63,13 @@ describe("defineTableSchema", () => {
|
|
|
57
63
|
})
|
|
58
64
|
})
|
|
59
65
|
|
|
66
|
+
it("refuses a plain drizzle table, whose column markers would be ignored", () => {
|
|
67
|
+
const raw = pgTable("raw", { label: text("label") })
|
|
68
|
+
|
|
69
|
+
// @ts-expect-error: only a table from `@zeno-lib/db/schema` records rules.
|
|
70
|
+
expect(() => defineTableSchema(raw)).toThrow(TypeError)
|
|
71
|
+
})
|
|
72
|
+
|
|
60
73
|
it("omits generated columns from insert and update schemas", () => {
|
|
61
74
|
const schemas = defineTableSchema(posts)
|
|
62
75
|
|
|
@@ -125,73 +138,54 @@ describe("defineTableSchema", () => {
|
|
|
125
138
|
).toBe(true)
|
|
126
139
|
})
|
|
127
140
|
|
|
128
|
-
it("
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const schemas = defineTableSchema(notes)
|
|
138
|
-
const forged = {
|
|
139
|
-
body: "Hello",
|
|
141
|
+
it("keeps a column named like an audit column, which only its marker leaves out", () => {
|
|
142
|
+
const events = zenoTable(
|
|
143
|
+
pgTable("events", {
|
|
144
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
145
|
+
createdBy: uuid("created_by").default(sql`auth.uid()`),
|
|
146
|
+
label: text("label").notNull(),
|
|
147
|
+
})
|
|
148
|
+
)
|
|
149
|
+
const imported = {
|
|
140
150
|
createdAt: new Date("2000-01-01T00:00:00.000Z"),
|
|
141
151
|
createdBy: "00000000-0000-4000-8000-000000000000",
|
|
142
|
-
|
|
143
|
-
updatedBy: "00000000-0000-4000-8000-000000000000",
|
|
152
|
+
label: "x",
|
|
144
153
|
}
|
|
145
154
|
|
|
146
|
-
expect(
|
|
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
|
-
)
|
|
155
|
+
expect(defineTableSchema(events).insert.parse(imported)).toEqual(imported)
|
|
156
156
|
})
|
|
157
157
|
|
|
158
|
-
it("keeps
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
158
|
+
it("keeps identity ids out of insert and update, and positive in select", () => {
|
|
159
|
+
const items = zenoTable(
|
|
160
|
+
pgTable("items", {
|
|
161
|
+
id: bigint("id", { mode: "number" })
|
|
162
|
+
.primaryKey()
|
|
163
|
+
.generatedByDefaultAsIdentity(),
|
|
164
|
+
label: text("label").notNull(),
|
|
165
|
+
})
|
|
166
|
+
)
|
|
167
|
+
const schemas = defineTableSchema(items)
|
|
166
168
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
label: text("label").notNull(),
|
|
169
|
+
expect(schemas.insert.parse({ id: 5000, label: "x" })).toEqual({
|
|
170
|
+
label: "x",
|
|
171
|
+
})
|
|
172
|
+
expect(schemas.update.parse({ id: 5000, label: "x" })).toEqual({
|
|
173
|
+
label: "x",
|
|
173
174
|
})
|
|
174
|
-
const schemas = defineTableSchema(items)
|
|
175
175
|
|
|
176
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
177
|
expect(schemas.select.safeParse({ id, label: "x" }).success).toBe(false)
|
|
180
178
|
}
|
|
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
179
|
})
|
|
188
180
|
|
|
189
181
|
it("leaves identities configured to go below one unconstrained", () => {
|
|
190
|
-
const ledger =
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
182
|
+
const ledger = zenoTable(
|
|
183
|
+
pgTable("ledger", {
|
|
184
|
+
id: integer("id")
|
|
185
|
+
.primaryKey()
|
|
186
|
+
.generatedByDefaultAsIdentity({ increment: -1, startWith: -1 }),
|
|
187
|
+
})
|
|
188
|
+
)
|
|
195
189
|
|
|
196
190
|
expect(defineTableSchema(ledger).select.parse({ id: -5 })).toEqual({
|
|
197
191
|
id: -5,
|
|
@@ -199,23 +193,27 @@ describe("defineTableSchema", () => {
|
|
|
199
193
|
})
|
|
200
194
|
|
|
201
195
|
it("runs a function refinement on top of the positive identity check", () => {
|
|
202
|
-
const items =
|
|
203
|
-
|
|
204
|
-
|
|
196
|
+
const items = zenoTable(
|
|
197
|
+
pgTable("items", {
|
|
198
|
+
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
|
|
199
|
+
})
|
|
200
|
+
)
|
|
205
201
|
const schemas = defineTableSchema(items, {
|
|
206
|
-
|
|
202
|
+
select: { id: (schema) => schema.max(10) },
|
|
207
203
|
})
|
|
208
204
|
|
|
209
|
-
expect(schemas.
|
|
210
|
-
expect(schemas.
|
|
211
|
-
expect(schemas.
|
|
205
|
+
expect(schemas.select.safeParse({ id: 0 }).success).toBe(false)
|
|
206
|
+
expect(schemas.select.safeParse({ id: 11 }).success).toBe(false)
|
|
207
|
+
expect(schemas.select.safeParse({ id: 5 }).success).toBe(true)
|
|
212
208
|
})
|
|
213
209
|
|
|
214
210
|
it("keeps a function-refined nullable insert column optional", () => {
|
|
215
|
-
const drafts =
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
211
|
+
const drafts = zenoTable(
|
|
212
|
+
pgTable("drafts", {
|
|
213
|
+
note: text("note"),
|
|
214
|
+
title: text("title").notNull(),
|
|
215
|
+
})
|
|
216
|
+
)
|
|
219
217
|
const schemas = defineTableSchema(drafts, {
|
|
220
218
|
insert: { note: (schema) => schema.min(1) },
|
|
221
219
|
})
|
|
@@ -230,3 +228,78 @@ describe("defineTableSchema", () => {
|
|
|
230
228
|
)
|
|
231
229
|
})
|
|
232
230
|
})
|
|
231
|
+
|
|
232
|
+
describe("string-mode dates and timestamps", () => {
|
|
233
|
+
const events = zenoTable(
|
|
234
|
+
pgTable("events", {
|
|
235
|
+
at: timestamp("at", { mode: "string", withTimezone: true }),
|
|
236
|
+
day: date("day", { mode: "string" }),
|
|
237
|
+
days: date("days", { mode: "string" }).array(),
|
|
238
|
+
localAt: timestamp("local_at", { mode: "string" }),
|
|
239
|
+
})
|
|
240
|
+
)
|
|
241
|
+
const { insert, update } = defineTableSchema(events)
|
|
242
|
+
const accepts = (value: Record<string, unknown>) =>
|
|
243
|
+
insert.safeParse(value).success && update.safeParse(value).success
|
|
244
|
+
|
|
245
|
+
it("takes a calendar date", () => {
|
|
246
|
+
expect(accepts({ day: "2026-09-17" })).toBe(true)
|
|
247
|
+
expect(accepts({ day: null })).toBe(true)
|
|
248
|
+
expect(accepts({ day: "2026-02-30" })).toBe(false)
|
|
249
|
+
expect(accepts({ day: "17.09.2026" })).toBe(false)
|
|
250
|
+
expect(accepts({ day: "infinity" })).toBe(true)
|
|
251
|
+
expect(accepts({ days: ["2026-09-17", "2026-09-18"] })).toBe(true)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it("takes ISO 8601 and the text Postgres returns, with the offset a time zone needs", () => {
|
|
255
|
+
for (const at of [
|
|
256
|
+
"2026-09-17T07:53:56.357127+00:00",
|
|
257
|
+
"2026-09-17 07:53:56.357127+00",
|
|
258
|
+
"2026-09-17T07:53Z",
|
|
259
|
+
]) {
|
|
260
|
+
expect(accepts({ at }), at).toBe(true)
|
|
261
|
+
}
|
|
262
|
+
expect(accepts({ at: "2026-09-17T07:53:56" })).toBe(false)
|
|
263
|
+
expect(accepts({ at: "yesterday" })).toBe(false)
|
|
264
|
+
expect(accepts({ at: "-infinity" })).toBe(true)
|
|
265
|
+
expect(accepts({ localAt: "2026-09-17 07:53:56.357127" })).toBe(true)
|
|
266
|
+
expect(accepts({ localAt: "yesterday" })).toBe(false)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it("refuses a time Postgres would reject or shift", () => {
|
|
270
|
+
expect(accepts({ at: "2026-02-30 07:00+00" })).toBe(false)
|
|
271
|
+
expect(accepts({ at: "2026-09-17T24:00Z" })).toBe(false)
|
|
272
|
+
expect(accepts({ at: "2026-09-17T07:00+16:00" })).toBe(false)
|
|
273
|
+
// Postgres drops an offset on a column without a time zone.
|
|
274
|
+
expect(accepts({ localAt: "2026-09-16T22:00:00.000Z" })).toBe(false)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it("lets an object refinement report next to a bad timestamp", () => {
|
|
278
|
+
const result = insert
|
|
279
|
+
.refine(() => false, { message: "cross-field" })
|
|
280
|
+
.safeParse({ at: "yesterday" })
|
|
281
|
+
|
|
282
|
+
expect(result.error?.issues.map((issue) => issue.message)).toContain(
|
|
283
|
+
"cross-field"
|
|
284
|
+
)
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe("writableColumns", () => {
|
|
289
|
+
it("lists each Zeno table's writable columns, and skips everything else", () => {
|
|
290
|
+
const status = pgEnum("status", ["open", "closed"])
|
|
291
|
+
const billing = pgSchema("billing")
|
|
292
|
+
const invoices = zenoTable(
|
|
293
|
+
billing.table("invoices", {
|
|
294
|
+
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
|
|
295
|
+
total: integer("total").notNull(),
|
|
296
|
+
})
|
|
297
|
+
)
|
|
298
|
+
const raw = pgTable("raw", { label: text("label") })
|
|
299
|
+
|
|
300
|
+
expect(writableColumns({ invoices, posts, raw, status })).toEqual({
|
|
301
|
+
"billing.invoices": ["total"],
|
|
302
|
+
posts: ["createdAt", "slug", "summary", "title"],
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
})
|