Remote / foldkit-remote-drizzle

Mutations

A mutation declared as a Write (Mutation.write(name, Write.update(input, { id }))) needs no handler: writer(binding) lands it in the binding's table, and RemoteServer.write serves it.

ts
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { Schema } from 'effect'
import { Entity, Write } from 'foldkit-entity'
import { Mutation } from 'foldkit-remote'
import { RemoteServer } from 'foldkit-remote-server'
import { bind, source, writer } from 'foldkit-remote-drizzle'

const Project = Entity.define('Project', Schema.Struct({ id: Schema.String, name: Schema.String }))
const projects = sqliteTable('projects', { id: text('id').primaryKey(), name: text('name').notNull() })
const Db = bind({ Project }, { Project: { table: projects } })

const EditProject = Mutation.write(
  'EditProject',
  Write.update(Entity.input(Project, Schema.Struct({ id: Schema.String, name: Schema.String })), {
    id: 'id',
  }),
)
const Server = RemoteServer.make({
  entities: [source(Db.Project)],
  mutations: [RemoteServer.write(EditProject, writer(Db.Project))],
})

It is one update ... returning of the row by id, each field's column set to the value as the store encodes it, and only the keys the client named when it named some, so two authors editing different fields of a row both land. The written fields answer as the mutation's patch. A write that expects a revision is guarded by that column and moves it on by one in the same statement: of two writes made from one revision, the second finds no row and is refused as a conflict. A write that expects none, to a row that is not there, fails. A binding with a visible rule reads the answer back through it: the row as its writer may now see it, or, when the write hid it from them, a deletion.

An insert is one insert ... on conflict do nothing returning under the id the client chose: a retry finds the id taken and answers the row it made. A delete is one delete by id, guarded by the revision when it expects one; a row already gone is gone.

A retry is safe without a request log. A guarded write whose answer was lost is sent again with the same revision, and the guard misses. The writer then reads the row: one revision past the expected one and holding exactly these values means this write landed, and the row answers; anything else is a conflict. A guarded delete that finds no row succeeds. (Two clients writing identical values from one revision are not told apart, which leaves the row as both asked.)

A journal's edits

A table a foldkit-sync journal writes (editsJournal) takes its apply from the binding: applyEdits(Db.Product) writes one committed cell edit to the member's column, and the sequence it committed at to revision, in one statement that never moves a row back, so recovery may run it again. The column is the binding's, never one named by the change. Here the revision is the journal's sequence, so such a table should not also take writes that expect a counter.

Given { live: hub }, it tells that liveHub of each edit it applied (the member and the revision), so a Remote reader following the row with Data.live hears a settled edit as the journal applies it, with no refresh. An edit the row had already passed, run again by recovery, is not told. The hub is in-memory, so this reaches readers of the same process; across Workers requests, a live source reads the table instead.

Writing in a handler

Anything a declared write cannot say (several rows, a computed value, another table) is a RemoteServer.mutation handler using Drizzle directly. The adapter helps normalize rows you already chose to return, and runs each statement so a database error fails as a RemoteServerError saying only what failed, the driver's own (which carries the SQL and its parameters) logged, never sent:

ts
import { eq } from 'drizzle-orm'
import { Effect } from 'effect'
import { RemoteServer } from 'foldkit-remote-server'
import { returning, runStatement } from 'foldkit-remote-drizzle'

const project = returning(Project, [
  'id',
  'name',
  'owner',
])

const RenameProjectSource = RemoteServer.mutation(
  RenameProject,
  ({ input }) =>
    Effect.gen(function* () {
      const rows = yield* runStatement(
        () =>
          db
            .update(projects)
            .set({ name: input.name })
            .where(eq(projects.id, input.id))
            .returning(project.columns),
        'Database write failed',
      )

      return {
        output: { id: input.id },
        entities: project.patches(rows),
      }
    }),
)

returning(binding, fields) pairs the Drizzle column projection with the normalizer for those exact fields, so the mutation cannot accidentally select one shape and normalize another. Singular relation foreign keys are rewritten to Remote ref keys.

selectColumns and normalize are the lower-level halves when you need them separately.

Return the whole row you wrote. The client's store learns only what a mutation's entities patch, so a handler that updates a title and returns { output: {} } leaves every screen showing the old title. When a handler writes more than it wants to list, returning.row(binding, id) reads the row back, every column with each one relation as its ref:

ts
import { drizzleWrites, returning } from 'foldkit-remote-drizzle'

const PublishPostSource = RemoteServer.mutation(PublishPost, ({ input }) =>
  Effect.gen(function* () {
    const writes = yield* drizzleWrites
    yield* Effect.promise(() =>
      Promise.resolve(writes.update(posts).set({ title: input.title }).where(eq(posts.id, input.id))),
    )
    return { output: {}, entities: yield* returning.row(Post, input.id) }
  }),
)

drizzleWrites is the provided database's insert, update and delete, typed by each table's columns, so a handler writes without casting the DrizzleDatabase service, which names only the reads a Source makes.

Several rows: the driver's own transaction. A declared write is one statement, so it is atomic already. A handler that writes several rows wraps them in its driver's transaction; the adapter adds none, because the drivers disagree on what one is. A synchronous SQLite driver (node-sqlite, better-sqlite3) takes a synchronous callback: given an async one, it commits before the awaits run, and a later failure rolls nothing back. A Postgres driver takes an async callback. D1 has no interactive transaction; db.batch([...]) runs its statements as one. From examples/entity, over node-sqlite:

ts
const DeletePost = RemoteServer.mutation(DeletePostMutation, ({ input }) =>
  Effect.sync(() => {
    db.transaction(tx => {
      tx.delete(comments).where(eq(comments.postId, input.id)).run()
      tx.delete(posts).where(eq(posts.id, input.id)).run()
    })
    return { output: {}, deleted: [{ entity: 'Post', id: input.id }] }
  }),
)

Compose the server

ts
const Data = Remote.define({
  entities: [User, Project],
  queries: [ProjectsByOwner],
  mutations: [RenameProject],
})

const Server = RemoteServer.make({
  entities: [source(User), source(Project)],
  queries: [ProjectsByOwnerSource],
  mutations: [RenameProjectSource],
})

RemoteServer.validate(Data, Server)

const handlers = RemoteServer.handlers(Server, principal)

Every Drizzle Source contributes DrizzleDatabase to the Effect environment. If a server combines Sources requiring different services, make that union explicit in the RemoteServer.make environment rather than hiding a dependency.

Database service

ts
import { databaseLayer } from 'foldkit-remote-drizzle'

const DatabaseLive = databaseLayer(db)

db can be any Drizzle database with the select-builder surface this compiler uses. databaseLayer contains the structural adaptation once, so Sources simply yield DrizzleDatabase.

The package deliberately does not import drizzle-orm/effect-postgres while its Effect RC compatibility differs from this workspace. Provide the database tag through databaseLayer instead.