@requence/service 0.8.2-edge.1155 → 0.8.2-edge.1157

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -255,6 +255,169 @@ LOCKED`, a Redis `SET NX`, one designated attacher), and only act on keys you ho
255
255
  node's [surface](#surfaces--a-ui-of-your-own-on-the-node) — see
256
256
  [`api.render`](#apirenderui-props-options--rendering-from-act).
257
257
 
258
+ ## Callbacks — functions you emit alongside your data
259
+
260
+ A function in what a service emits arrives downstream as a function the graph may
261
+ call back. An email receiver yields a mail *and* a `delete()` a later node may or
262
+ may not use.
263
+
264
+ This section is the reference for callback behaviour in every SDK — the Python SDK
265
+ (`python/README.md`) speaks the same wire and states only where it differs. The
266
+ design authority is
267
+ [`knowledge/durable-callbacks.md`](../../knowledge/durable-callbacks.md).
268
+
269
+ ### Where you write the function decides how long it lives
270
+
271
+ There are two kinds, and you pick by **where you write it** — the same rule as
272
+ [handler props](#handler-props--where-you-write-the-function-decides-how-long-it-can-be-pressed),
273
+ stated for a second mechanism:
274
+
275
+ ```typescript
276
+ import { createDurableCallback, createService } from '@requence/service'
277
+
278
+ // DURABLE. Declared ONCE at module scope, addressed by the name it is given. It
279
+ // closes over nothing per mail, so any instance of this version can run it —
280
+ // this one, or the one that replaces it in a redeploy.
281
+ const deleteMail = createDurableCallback<null, { uid: number }, 'receiver'>(
282
+ 'deleteMail',
283
+ async (_arg, { uid, configuration }) => {
284
+ const client = await connect(configuration) // rebuilt, never captured
285
+ await client.messageDelete([uid], { uid: true })
286
+ client.close()
287
+ },
288
+ )
289
+
290
+ createService('receiver', async function* () {
291
+ for await (const mail of mails) {
292
+ yield {
293
+ subject: mail.subject,
294
+ // A reference, not a closure: one registration, however many mails.
295
+ delete: deleteMail.for({ uid: mail.uid }),
296
+ // CONNECTION-BOUND. Needs the LIVE imap client, so it correctly dies with
297
+ // this message — and holds everything it captured until then.
298
+ stream: () => imap.download(mail.uid),
299
+ }
300
+ }
301
+ })
302
+ ```
303
+
304
+ **Write it inline when it genuinely needs the live process** — an open stream, a
305
+ client the handler is mid-conversation with. **Declare it with
306
+ `createDurableCallback` when it needs only data**, and bind that data with
307
+ `.for(…)`.
308
+
309
+ A connection-bound function is kept in the connection's registry until the message
310
+ **settles**, which for a CONTINUOUS handler is until the task *ends*. So a generator
311
+ minting one per emission grows by one closure per emission for the life of the run,
312
+ and each closure holds on to everything that was in scope when it was written — the
313
+ whole mail, not just the uid. It is also why a connection-bound callback is only
314
+ useful from a generator handler: a LINEAR handler's registrations go the moment it
315
+ returns, so the reference it emitted is dead before anything downstream sees it. A
316
+ durable one registers nothing, so a LINEAR node can emit one and a later node can
317
+ still call it.
318
+
319
+ ### What a durable callback is handed
320
+
321
+ ```typescript
322
+ createDurableCallback<Arg, Bound, Configuration>(name, (arg, context) => …)
323
+ ```
324
+
325
+ | | |
326
+ |---|---|
327
+ | `arg` | what the graph passed when it called the callback |
328
+ | `context.…` | whatever `.for({ … })` bound to this emission, spread |
329
+ | `context.configuration` | the node's **static** configuration |
330
+
331
+ **And that is all of it.** No message, no task, no connection — that is exactly what
332
+ makes it durable: it may run in an instance that never saw the emission, started
333
+ after the emitting process died. So anything it needs it **rebuilds** rather than
334
+ captures, and `configuration` is what it rebuilds from.
335
+
336
+ The three type arguments are the three things the two sides disagree about, and
337
+ `Configuration` takes either a type or the **name of the service version** to look it
338
+ up from (`ConfigurationOf<'receiver'>`). **Prefer the version-name form**: that type
339
+ is generated into `requence-env.d.ts` beside the version it belongs to, so naming the
340
+ version is the only form that cannot drift from what the node actually sends.
341
+
342
+ A binding may not be:
343
+
344
+ - **a scalar.** The bound data *is* the context and it is spread, so a string would
345
+ quietly become `{ 0: 'd', 1: 'e', … }`.
346
+ - **a `configuration` key.** The node's own configuration has that name, and a
347
+ precedence rule either way would mean your data or the node's credentials silently
348
+ disappearing.
349
+
350
+ Both are refused where you wrote them, and again when an invocation is unpacked.
351
+
352
+ A declaration with nothing to bind is emitted as itself — `.for()` is optional there,
353
+ and TypeScript only lets you leave it off when there is genuinely nothing to bind:
354
+
355
+ ```typescript
356
+ const acknowledge = createDurableCallback<null>('acknowledge', () => ack())
357
+
358
+ yield { subject: mail.subject, acknowledge }
359
+ ```
360
+
361
+ **A callback that takes no argument is `RequenceCallback<null>`, not `<void>`** —
362
+ both here and in the output schema the position is declared in. `void` has no root
363
+ type a JSON schema can hold, so the schema cannot be authored at all, and
364
+ `undefined` compiles but silently drops the `callback` marker along with the
365
+ argument, leaving a plain object where the reference has to survive validation. A
366
+ graph then calls it as `context.input.delete(null)`.
367
+
368
+ **Two declarations under the same name throw at registration**, for the reason a
369
+ duplicate `createUI` id does: the name is the whole address, so an ambiguous one is an
370
+ invocation reaching the *wrong* handler — which reads like a bug in the handler — and
371
+ that is worse than a service that refuses to start.
372
+
373
+ ### An invocation waits out a redeploy, not a weekend
374
+
375
+ Consuming the invocations is automatic. The queue is
376
+ `service-<name>@<version>-callbacks`, one per version, created by Requence when the
377
+ version is provisioned and asserted again by the SDK on connect. Whichever instance is
378
+ free serves it, so a rolling restart is invisible to the graph.
379
+
380
+ It is **not a mailbox**: an invocation expires after ~60s, and an expired or
381
+ unroutable one is reported on the run — a system note on the node that emitted the
382
+ callback, saying whether the version has nothing running or is no longer deployed at
383
+ all. A callback invoked today and run whenever somebody next deploys would be a silent
384
+ effect, and nothing checks that a service is up before a graph calls
385
+ `context.input.delete()`.
386
+
387
+ A name this build does not declare, and a handler that throws, are said out loud in
388
+ this process's log — the graph called the callback and moved on, so there is no
389
+ request left to fail. The invocation is **acked either way**: a requeue would rerun a
390
+ side-effecting callback forever.
391
+
392
+ ### The pile-up warning
393
+
394
+ If connection-bound callbacks accumulate, the SDK tells you in its **own log** — once
395
+ when the registry crosses a threshold, then at each power of ten — naming the `yield`
396
+ positions doing it and the fix:
397
+
398
+ | Where | First warning | Then |
399
+ |---|---|---|
400
+ | a dev session (dev overlay) | 100 | ×10 |
401
+ | a deployed version | 1 000 | ×10 (10k, 100k, …) |
402
+
403
+ A dev session warns ten times earlier on purpose: a hundred emissions in `bun run dev`
404
+ already means a generator is minting closures in a loop, and catching it there is worth
405
+ far more than catching it in production. Two counters feed it — this delivery's, which
406
+ identifies the one generator, and the whole connection's, which is what actually
407
+ threatens the process when many tasks each stay under their own threshold.
408
+
409
+ It is a **log line for you**, not a task event: you are the only person who can act on
410
+ it. Nothing is capped and nothing is evicted — a cap would kill a legitimate long-lived
411
+ handle at an arbitrary number you cannot exempt it from.
412
+
413
+ ### Two gaps
414
+
415
+ - **Computed configuration is not available to a durable callback**, and cannot be:
416
+ config functions are evaluated per dispatch, and this call belongs to none. A node
417
+ whose credentials are computed cannot serve one.
418
+ - **A logic node's `createCallback` global is connection-bound only.** There is no
419
+ durable form inside a script.
420
+
258
421
  ## Surfaces — a UI of your own on the node
259
422
 
260
423
  A service can ship its own UI component and have Requence render it **on the