Sync / foldkit-durable
External effects and the crash gap
A committed operation may imply work outside SQLite: send an email, charge a card. No local database can commit atomically with an external provider, and the dangerous window is:
provider succeeds
|
CRASH
|
record the successAfter a restart the application cannot know whether the provider acted. The journal does not claim exactly-once. Its effect ledger records a stable intent identity and its outcome, so recovery can tell known success from uncertain work and apply an explicit policy.
runEffect(key, run) reuses a recorded success without calling run again,
shares concurrent calls for one key within one Journal instance, and by default
retries pending and failed records. Pass { retryFailed: false } to fail a
recorded failure with EffectFailedError instead, or a predicate over the
record.
| Record | Recovery can conclude | Policy |
|---|---|---|
| none | no run was recorded; a committed operation may still need work | derive the intent from the operation and start it |
pending or failed | the provider's outcome is unknown | retry only with provider idempotency or proof repeating is safe; else reconcile or resolve by hand |
succeeded | the result is known | runEffect reuses it without contacting the provider |
Choose the key before acting, from the document, the operation, and the semantic action, and hand the same key to the provider as its idempotency key:
const confirmationKey = (order: Order) =>
JSON.stringify(['orders', order.opId, 'send-confirmation:v1'])
const sendConfirmation = (journal: Orders, order: Order) => {
const key = confirmationKey(order)
return journal.runEffect(
key,
Effect.tryPromise(() => provider.sendConfirmation({ orderId: order.id, idempotencyKey: key })),
)
}Effect keys are global to the database. Never derive one from a Command's array position: reordering would hand an old result to a different action. A new semantic name denotes new work, not a retry. Without provider idempotency, an email sent just before a crash can be sent twice; the journal cannot prove otherwise.
Recovery. A pending record is created when runEffect starts, not when
the operation commits, so recovery must also find committed operations whose
effects never started. journal.recover walks a document's committed
operations after an application-owned cursor, runs or reuses each intent, stops
before the first one that failed or was skipped, and returns the cursor through
which everything settled:
const settle = (journal: Orders, from: Cursor) =>
journal.recover({
key: DocumentId.make('orders'),
from,
intents: order => [
{
key: confirmationKey(order),
run: Effect.tryPromise(() =>
provider.sendConfirmation({ orderId: order.id, idempotencyKey: confirmationKey(order) }),
),
},
],
// Default: retry. `skip` stops before this operation for manual resolution.
onUnresolved: (_intent, record) =>
Option.isSome(record) && record.value.status === 'failed' ? 'skip' : 'retry',
})The application owns when that runs, which documents it covers (keys()), and
where the cursor is persisted. Do not compact past it, or keep the intent
identity in the snapshot. unfinished() lists pending and failed records;
effect(key) reads one; clearEffect(key) removes one after resolution.
One executor per database. The in-flight registry lives in one Journal.make
instance; a pending row is not a lease. Two handles or two processes can run
the same key. Route execution to one owner. Process-crash tests in
test/effectRecovery.test.ts show both the
idempotent recovery and the duplicate that remains possible without it.