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 success

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

RecordRecovery can concludePolicy
noneno run was recorded; a committed operation may still need workderive the intent from the operation and start it
pending or failedthe provider's outcome is unknownretry only with provider idempotency or proof repeating is safe; else reconcile or resolve by hand
succeededthe result is knownrunEffect 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:

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

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