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.
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:
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:
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:
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
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
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.