Skip to content

feat: Isomorphic mutators - #6

Draft
Chriztiaan wants to merge 3 commits into
feat/mutatorfrom
feat/mutator-drizzle
Draft

feat: Isomorphic mutators#6
Chriztiaan wants to merge 3 commits into
feat/mutatorfrom
feat/mutator-drizzle

Conversation

@Chriztiaan

@Chriztiaan Chriztiaan commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Define each mutator once and run it on both the client and the server

Builds on the original Mutator PoC #2

Why

The original Mutator PoC introduced mutators: a named write operation with a Zod arg schema, invoked on the client and replayed on the server over POST /api/mutators/invoke. It works, but every mutator has to be written twice:

  • The Zod arg schema is defined in shared/mutators/index.ts and used in both the client and the server.
  • The mutator's write logic is defined for both the client and the server (We simplify this demo by only considering PG on the backend).
  • Nothing checks that the two sides agree. The name sets, the arg schemas and the actual write behaviour are kept in sync manually.

This PR removes the duplication. A mutator is defined once, in shared/, and both sides run the same function.

How

The mutator body is written against Drizzle's query builder instead of raw SQL, with the transaction handle and the table definitions passed in:

export const todoCreate: Mutator<typeof todoCreateArgs> = {
  args: todoCreateArgs,
  run: async (args, { tx, schema, userId }) => {
    await tx.insert(schema.todos).values({
      id: args.id,
      listId: args.list_id,
      createdBy: userId,
      description: args.description,
      completed: false,
      createdAt: new Date().toISOString()
    });
  }
};

See shared/mutators/lists.ts for more examples.

Drizzle handles the placeholder syntax, boolean representation and timestamp formatting per dialect.

What doesn't change

The wire protocol and the upload path are untouched: mutator_calls is still an insert-only table, DemoConnector.uploadData still turns those rows into envelopes, /api/mutators/invoke keeps the same request and response shape.

Trade-offs:

  • Drizzle has no table object that spans dialects, so pgTable and sqliteTable are still declared separately and kept aligned by hand. The mutator logic is written once, but you still maintain two schemas.
  • The shared client's types are pinned to the Postgres dialect as canonical (types for AppTx, AppSchema come from the pgTable definitions). So
  • Only the common subset of the query builder is portable. RETURNING, onConflict, raw sql templates and dialect-specific column defaults break isomorphism. In practice that means values like created_at and completed_at are now set explicitly in the mutator rather than relying on Postgres default now().

Limitations

This PoC only covered Postgres as a source database. Drizzle also supports MySQL and MSSQL so those would also be supported in this approach in theory, however Drizzle does not support MongoDB and having isomorphic mutators with the SQLite+MongoDB combination would need a different approach.

AI Disclosure

This PR was created with the help of Claude Code. Help constitutes assistance in research, planning, and rough outline of implementation. Beyond having a hand in the implementation, I have also manually tested this work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant