Skip to content

Commit 86dfe93

Browse files
committed
Upgrade to Zod 4
In truth, this is largely a ground-up reimplementation of our typed model layer that takes advantage of a few years of experience working with the original implementation. Upgrading to Zod 4 is *somewhat* orthogonal. My primary goal with this upgrade was to take advantage of Zod 4's native support for JSON Schemas. There already exists a library (zod-to-mongo-schema) which can utilize Zod's native JSON Schema support to generate MongoDB-compatible JSON Schemas. Given that our needs here are fairly undifferentiated, it doesn't feel like there's significant benefit to maintaining this code ourselves. This allows us to clean up the old code to generate JSON Schemas. As part of this swap, we need to deal with some differences in how zod-to-mongo-schemas represents schemas: * Zod's `z.int()` can technically capture any (53-bit) integer value representable in floating point, so zod-to-mongo-schema represents it as a "long"; we previously used "int". The MongoDB Javascript driver by default will serialize integer values to the BSON "int" type, so this causes conflicts. Switch to `z.int32()` instead to get the desired "int" type in the generated JSON Schema. As always, the most complex element of our typed model layer is handling fields which should be automatically populated. Our previous approach (documented in #1394) was powerful and flexible, but abusing Zod's input vs. output schemas and lying to the type system with transforms broke down with Zod's native JSON Schemas, which refuses to serialize transforms. Instead of trying to modernize that approach, this commit uses two systems to track automated fields. Within the type system, we use Zod's branded types to mark fields which should be auto-populated (effectively as a boolean marker), which we can then use to filter auto-populated fields, making them optional at insertion-time. At runtime, we use schema metadata, which captures when a field should be populated (insert, update, or both) and what value should be used (which has been limited to the specific types of values we actually use). (In bringing in branded types, we do return to our old friend of input/output schemas — auto-populated types are only branded on the input side, which is only used for computing the insert type, since we don't actually want nominal typing for our auto-populated fields.) This has the effect of dropping support for arbitrary transformation functions. In practice, we were only using that function to apply the `answerify` transform to answer fields, but we had previously concluded that answers were already uppercased at input time. In exchange for dropping transforms, we no longer need to analyze the entire update operation to make sure transformations are applied properly, and can instead just rely on MongoDB to enforce that the result matches our schema. This in turn lets us remove a significant amount of code around updates (including the mechanisms around `relaxSchema` and `parseMongoModifierAsync`). In addition to dropping support for transform functions, I also dropped support for the `bypassSchema` option for inserts, updates, and upserts. This wasn't a great abstraction, since it attempted to otherwise preserve the behavior of Meteor Collections' native methods, but had to do some extra work to do that. Instead, users can just reach for rawCollection. This only comes up in migration code anyway, which generally requires a fair amount of abstraction bypass regardless. And finally, I pulled the code into imports/lib/typedModel, rather than leaving it strewn about with actual model declarations.
1 parent 0691e58 commit 86dfe93

91 files changed

Lines changed: 1299 additions & 2903 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/assets.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ files:
77
- imports/server/lookupUrl.ts
88
- imports/server/models/Blobs.ts
99
- imports/server/publications/blobMappingsAll.ts
10-
updated: 2025-12-22
10+
updated: 2026-02-15
1111
---
1212

1313
# Custom Asset Pipeline

docs/google-drive.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ files:
3535
- imports/server/setup.ts
3636
- private/google-script/cookie-test.html
3737
- private/google-script/main.js
38-
updated: 2026-02-13T07:31:00Z
38+
updated: 2026-02-15
3939
---
4040

4141
# Google Drive Integration

imports/lib/models/APIKeys.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { z } from "zod";
2-
import { foreignKey } from "./customTypes";
3-
import type { ModelType } from "./Model";
4-
import SoftDeletedModel from "./SoftDeletedModel";
5-
import withCommon from "./withCommon";
2+
import { foreignKey } from "../typedModel/customTypes";
3+
import type { ModelType } from "../typedModel/Model";
4+
import SoftDeletedModel from "../typedModel/SoftDeletedModel";
5+
import withCommon from "../typedModel/withCommon";
66

77
const APIKey = withCommon(
88
z.object({

imports/lib/models/Announcements.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { z } from "zod";
2-
import { foreignKey, nonEmptyString } from "./customTypes";
3-
import type { ModelType } from "./Model";
4-
import SoftDeletedModel from "./SoftDeletedModel";
5-
import withCommon from "./withCommon";
2+
import { foreignKey, nonEmptyString } from "../typedModel/customTypes";
3+
import type { ModelType } from "../typedModel/Model";
4+
import SoftDeletedModel from "../typedModel/SoftDeletedModel";
5+
import withCommon from "../typedModel/withCommon";
66

77
// A broadcast message from a hunt operator to be displayed
88
// to all participants in the specified hunt.

imports/lib/models/BlobMappings.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { z } from "zod";
2-
import { nonEmptyString } from "./customTypes";
3-
import type { ModelType } from "./Model";
4-
import Model from "./Model";
2+
import { nonEmptyString } from "../typedModel/customTypes";
3+
import type { ModelType } from "../typedModel/Model";
4+
import Model from "../typedModel/Model";
55

66
// Note that the _id is the asset name
77
const BlobMapping = z.object({

imports/lib/models/BookmarkNotifications.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { z } from "zod";
22
import type { Solvedness } from "../solvedness";
3-
import { answer, foreignKey } from "./customTypes";
4-
import type { ModelType } from "./Model";
5-
import SoftDeletedModel from "./SoftDeletedModel";
6-
import withCommon from "./withCommon";
3+
import { answer, foreignKey } from "../typedModel/customTypes";
4+
import type { ModelType } from "../typedModel/Model";
5+
import SoftDeletedModel from "../typedModel/SoftDeletedModel";
6+
import withCommon from "../typedModel/withCommon";
77

88
const BookmarkNotificationSolvedness: z.ZodType<Solvedness> = z.enum([
99
"noAnswers",

imports/lib/models/Bookmarks.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { z } from "zod";
2-
import { foreignKey } from "./customTypes";
3-
import type { ModelType } from "./Model";
4-
import SoftDeletedModel from "./SoftDeletedModel";
5-
import withCommon from "./withCommon";
2+
import { foreignKey } from "../typedModel/customTypes";
3+
import type { ModelType } from "../typedModel/Model";
4+
import SoftDeletedModel from "../typedModel/SoftDeletedModel";
5+
import withCommon from "../typedModel/withCommon";
66

77
// Broadcast announcements that have not yet been viewed by a given
88
// user

imports/lib/models/ChatMessages.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { z } from "zod";
2-
import { allowedEmptyString, foreignKey } from "./customTypes";
3-
import type { ModelType } from "./Model";
4-
import SoftDeletedModel from "./SoftDeletedModel";
5-
import withCommon from "./withCommon";
2+
import { allowedEmptyString, foreignKey } from "../typedModel/customTypes";
3+
import type { ModelType } from "../typedModel/Model";
4+
import { URL } from "../typedModel/regexes";
5+
import SoftDeletedModel from "../typedModel/SoftDeletedModel";
6+
import withCommon from "../typedModel/withCommon";
67

78
const UserMentionBlock = z.object({
89
type: z.literal("mention"),
@@ -18,7 +19,7 @@ export type ChatMessageRoleMentionNodeType = z.infer<typeof RoleMentionBlock>;
1819

1920
const ImageBlock = z.object({
2021
type: z.literal("image"),
21-
url: z.string().url(),
22+
url: z.string().regex(URL),
2223
});
2324
export type ChatMessageImageNodeType = z.infer<typeof ImageBlock>;
2425

imports/lib/models/ChatNotifications.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { z } from "zod";
2+
import { foreignKey } from "../typedModel/customTypes";
3+
import type { ModelType } from "../typedModel/Model";
4+
import SoftDeletedModel from "../typedModel/SoftDeletedModel";
5+
import withCommon from "../typedModel/withCommon";
26
import { ChatMessageContent } from "./ChatMessages";
3-
import { foreignKey } from "./customTypes";
4-
import type { ModelType } from "./Model";
5-
import SoftDeletedModel from "./SoftDeletedModel";
6-
import withCommon from "./withCommon";
77

88
// A notification triggered by a chat message sent by a user.
99
const ChatNotification = withCommon(

imports/lib/models/DiscordAccount.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { z } from "zod";
2-
import { nonEmptyString } from "./customTypes";
2+
import { nonEmptyString } from "../typedModel/customTypes";
33

44
const DiscordAccount = z.object({
55
id: nonEmptyString,

0 commit comments

Comments
 (0)