Application migration recipes
These recipes are the migration companion to the
managed application kernel. Their canonical source is
the packed-consumer fixture
application-migration-recipes.ts:
the consumer lane installs the package tarball, typechecks that file and runs it
only through stitchkit/application exports.
Database connection
Wrap connection allocation and readiness in start, and make close safe after
partial setup. Stitchkit invokes cleanup for every attempted start, including a
start that allocates a client and then rejects.
const database = defineManagedResource({
id: 'database',
async start({ signal, reportHealth }) {
await databaseClient.connect(signal)
await databaseClient.assertReady()
reportHealth('healthy')
},
close: () => databaseClient.close(),
})
The application still owns the client, ORM configuration, transactions, migrations and reconnect policy. The executable recipe deliberately fails after allocation and proves one rollback close.
Stateful clients, including MTProto
An application-owned client is an ordinary managed resource. Keep SDK creation,
credentials, authorization and reconnect policy in the application; no Telegram
client SDK is required by Stitchkit. Create a fresh client inside each start,
publish it after connection/readiness succeeds, and share one memoized destroy
promise between partial-start cleanup, close and force:
let generation: { destroy(): Promise<void> } | undefined
const client = defineManagedResource({
id: 'client',
async start({ signal }) {
const connection = createClient() // Your SDK and configuration.
let destruction: Promise<void> | undefined
const destroy = () => destruction ??= Promise.resolve()
.then(() => connection.destroy())
generation = { destroy }
let ready = false
let startFailure: unknown
let startFailed = false
let cleanupFailure: unknown
let cleanupFailed = false
try {
await connection.connect(signal)
await connection.assertReady()
signal.throwIfAborted()
ready = true
} catch (error) {
startFailure = error
startFailed = true
} finally {
if (!ready) {
try { await destroy() }
catch (cleanup) {
cleanupFailure = cleanup
cleanupFailed = true
}
}
}
if (startFailed) {
if (cleanupFailed) throw new AggregateError([startFailure, cleanupFailure],
'Client startup and cleanup failed', { cause: startFailure })
throw startFailure
}
return { value: connection }
},
close: () => generation?.destroy(),
force: () => generation?.destroy(),
})
const api = defineManagedResource({
id: 'api',
dependsOn: [client],
start(context) {
const connection = context.use(client)
return { value: async () => {
const lease = context.admission.acquire()
if (!lease) throw new ApplicationAdmissionError()
try { return await connection.invoke() }
finally { lease.release() }
} }
},
})
createClient supplies a client whose destroy safely cleans partial connection
setup; its adapter must honor cancellation or otherwise reconcile late SDK effects.
The destroy cache belongs to one generation and is reset by the next start.
Successful startup keeps the connection alive. Dependants start after readiness,
requests acquire admission before invoking the client, and dependent HTTP resources
close before the client. A subtree restart builds a new client and new dependants.
The packed-consumer fixture
managed-client-composition.mjs
proves readiness/admission, partial startup, close order, forced cleanup and restart
with a fake client on Bun and Node. It does not claim a live provider acceptance test.
Custom StateStore transaction guards
Transitions now receive (current, context). Keep ordinary callbacks that only use
current; update custom store implementations to pass a real protected context.
For an in-memory test store, queue transactions and invalidate the context when
the transaction finishes:
function memoryStore<T>(): StateStore<T> {
let state: T | null = null
let tail: Promise<unknown> = Promise.resolve()
return {
read: async () => state,
update(transition) {
const work = tail.then(async () => {
let active = true
const context: StateStoreUpdateContext = {
async assertHeld() {
if (!active) throw new Error('Transaction is no longer active')
},
}
try {
const next = await transition(state, context)
await context.assertHeld()
state = next.state
return next.result
} finally { active = false }
})
tail = work.catch(() => undefined)
return work
},
}
}
This recipe is process-local and has no restart durability. A database store must
guard its real transaction/generation and commit atomically; an escaped or lost
guard must refuse. Filesystem mutations made inside inbox transactions call
assertHeld immediately before publication or cleanup.
For createFileStateStore, remove staleLockMs and retryMs from configuration.
lockTimeoutMs is the acquisition budget. Timestamp age never revokes
a live or unknown writer. Stop and verify old writers before recovering a legacy
pid-only lock; do not invent owner metadata. Temporary files are never
deleted by age. See the durable intake contract.
Long-running poller
Return separate readiness and completion promises. Readiness says dependants may start; completion represents the whole background lifetime.
const poller = defineManagedResource({
id: 'poller',
start: ({ signal }) => ({
ready: providerPoller.ready,
completion: providerPoller.run(signal),
}),
stopAdmission: () => providerPoller.stop(),
drain: () => providerPoller.completion,
close: () => providerPoller.close(),
})
Completion before readiness fails startup and rolls back. Completion after readiness removes application readiness. Provider cursor persistence, retry, backoff and restart policy remain application/provider concerns.
Queue consumer
Acquire the application lease only after provider delivery/claim. If admission is already closed, reject the delivery through the provider’s own nack/requeue primitive. Work admitted before shutdown releases its lease after ack/nack and therefore participates in application drain.
async function handleDelivery(delivery: Delivery) {
const lease = app.admission.acquire()
if (!lease) return delivery.nack({ requeue: true })
try {
await processClaim(delivery.claim)
await delivery.ack()
} catch (error) {
await delivery.nack({ requeue: shouldRetry(error) })
throw error
} finally {
lease.release()
}
}
Stitchkit owns only process-local admission accounting and drain. Durable claims, visibility timeouts, deduplication, idempotency and retry classification remain in the queue/product layer.
Operational publisher
Project anonymous aggregate activity, then feed absolute snapshots into the latest-value sink. A slow transport holds one write plus one replaceable latest revision rather than an unbounded event queue.
const publisher = createApplicationSnapshotSink({
write: (snapshot) => monitoring.publish(snapshot),
})
const activity = createActivityProjection({
id: 'generation',
stages: ['queued', 'running'],
})
const unsubscribe = activity.subscribe((snapshot) => {
publisher.publish(snapshot)
})
// cleanup boundary
unsubscribe()
publisher.publish(activity.getSnapshot())
await publisher.close()
The monitoring backend, transport retry and cross-process aggregation remain
outside Stitchkit. The executable recipe blocks revision 0, coalesces
intermediate revisions, explicitly admits the final absolute snapshot after
unsubscribe and proves that close() flushes revision 3. Do not rely on an
asynchronous subscriber callback racing cleanup: publish getSnapshot() before
closing the outer sink, so any older or duplicate late delivery is rejected as
stale instead of dropping the final state.
Bound a handler and a local worker with one lease policy
Use a bounded admission when two entry paths consume the same finite local
capacity. Composing it with application.admission keeps readiness and shutdown
as the upstream gate:
const work = createBoundedAdmission({
upstream: app.admission,
policy: {
global: { maxConcurrent: 4 },
perKey: { maxConcurrent: 1, maxKeys: 1_000 },
},
})
const fromHttp = (key: string, signal: AbortSignal) =>
work.run(key, (context) => render(context.signal), { signal, timeoutMs: 20_000 })
const fromWorker = (key: string) =>
work.run(key, (context) => reconcile(context.signal))
A timeout in fromHttp does not free a permit while render remains active.
At shutdown call work.stopAdmission() with the other admission owners and
await work.drain(...); the result reports the actual remainder.
Replace ad-hoc output and progress queues
Ordered output and replaceable progress are separate declarations:
const lines = createBoundedChannel<string>({
policy: 'ordered', maxItems: 100, maxBytes: 1_000_000,
sizeOf: (line) => new TextEncoder().encode(line).byteLength,
})
const state = createBoundedChannel<{ revision: number; percent: number }>({
policy: 'latest', maxItems: 1, maxBytes: 64,
sizeOf: () => 64,
})
const lineResult = lines.offer('one durable-in-process ordering unit')
const stateResult = state.offer({ revision: 2, percent: 50 })
Handle refused from lines at the protocol boundary; do not turn it into
implicit loss. A coalesced state result is expected latest-value behaviour,
not evidence that an ordered event was delivered. Neither channel is durable;
persist first when restart replay is required.
Handing a handle to the resources that depend on it
dependsOn carries ordering. To carry the object as well, return a value from
start and read it with context.use(...). This replaces the module-local
let handle: T | null with the guard the graph makes unreachable.
const database = defineManagedResource({
id: 'database',
start: async () => ({ value: await connect(env.DATABASE_URL) }),
})
const worker = defineManagedResource({
id: 'worker',
dependsOn: [database],
start(context) {
const db = context.use(database) // Connection — not Connection | null
},
})
Declare the dependency with the resource whenever you intend to read from
it: that is the form use can type, and it stops the declaration and the read
from drifting. A string still expresses order on its own.
The value is published when start resolves and stays readable from activate
and from the shutdown phases. use refuses a resource that was not declared in
dependsOn — that only ever worked by luck of declaration order — and refuses a
resource that published nothing, which the compiler refuses too. The executable
recipe proves both the read and the refusal.
Managed HTTP server
The main resource of a web backend, and the one whose ownership rule is
counter-intuitive: give managedServerResource a thunk and it creates the
server during start, after its dependencies are ready. Give it an
already-listening server and it adopts that one instead.
const http = managedServerResource({
id: 'http',
dependsOn: [database],
server: (context) => {
const db = context.use(database)
return createServer({ port: env.PORT, services: createServices(db) })
},
})
const app = createApplication({ id: 'service', resources: [database, http] })
await app.start() // the port is bound; the snapshot means it
The server resource publishes its ManagedServerHandle, so anything that needs
the running server — a Socket.IO attachment, a URL to log, a probe — reads it
with context.use(http) rather than through a module-local.
The factory itself receives ManagedResourceContext too. It can read only the
dependencies declared on the server resource and shares the graph’s startup
signal. A dependency failure prevents the factory from binding; a factory
rejection is rolled back without invoking the factory a second time.
Do not spread this resource over your own start to control creation order:
that shape exists only as a workaround for the version whose start was empty,
and dependsOn now expresses the order directly. The executable recipe binds a
real port, proves a request reaches it after start() resolves, and proves the
port is closed after shutdown().
Deletion checklist
After the cutover, remove the old generic lifecycle path completely:
- duplicate
process.on(...)handlers and shutdown promise caches; - manual readiness waiters and resource close fan-out;
- raw interval handles, overlap flags and timer drain code;
- global in-flight counters and waiter sets replaced by application leases;
- progress delta queues replaced by absolute snapshot publishing.
Keep the product code that still owns durable state and policy:
- database schema, transactions and migrations;
- queue claims, ack/nack, deduplication and external-effect idempotency;
- provider cursor, retry/backoff and credentials;
- monitoring transport configuration and durable retention;
- process exit code, hard-exit and deployment/supervisor policy.
Run the old and new lifecycle paths separately during development if needed, but do not ship both. Before cutover, verify there is exactly one signal binding, one timer owner, one admission gate and one resource cleanup chain.