@bandeira-tech/b3nd-save 0.8.1

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.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/esm/_dnt.shims.d.ts +2 -0
  3. package/esm/_dnt.shims.d.ts.map +1 -0
  4. package/esm/_dnt.shims.js +57 -0
  5. package/esm/byte-entity-shim.d.ts +47 -0
  6. package/esm/byte-entity-shim.d.ts.map +1 -0
  7. package/esm/byte-entity-shim.js +138 -0
  8. package/esm/clients/mod.d.ts +15 -0
  9. package/esm/clients/mod.d.ts.map +1 -0
  10. package/esm/clients/mod.js +14 -0
  11. package/esm/clients/save-client.d.ts +87 -0
  12. package/esm/clients/save-client.d.ts.map +1 -0
  13. package/esm/clients/save-client.js +174 -0
  14. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/mod.d.ts +40 -0
  15. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/mod.d.ts.map +1 -0
  16. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/mod.js +31 -0
  17. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/client-console/client.d.ts +30 -0
  18. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/client-console/client.d.ts.map +1 -0
  19. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/client-console/client.js +70 -0
  20. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/encoding/encoding.d.ts +16 -0
  21. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/encoding/encoding.d.ts.map +1 -0
  22. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/encoding/encoding.js +50 -0
  23. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/encrypt/mod.d.ts +235 -0
  24. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/encrypt/mod.d.ts.map +1 -0
  25. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/encrypt/mod.js +619 -0
  26. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/functional-client/functional-client.d.ts +43 -0
  27. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/functional-client/functional-client.d.ts.map +1 -0
  28. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/functional-client/functional-client.js +54 -0
  29. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/match-pattern/match-pattern.d.ts +47 -0
  30. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/match-pattern/match-pattern.d.ts.map +1 -0
  31. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/match-pattern/match-pattern.js +134 -0
  32. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/decorators.d.ts +20 -0
  33. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/decorators.d.ts.map +1 -0
  34. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/decorators.js +34 -0
  35. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/network.d.ts +46 -0
  36. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/network.d.ts.map +1 -0
  37. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/network.js +131 -0
  38. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/peer.d.ts +27 -0
  39. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/peer.d.ts.map +1 -0
  40. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/peer.js +25 -0
  41. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/flood.d.ts +59 -0
  42. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/flood.d.ts.map +1 -0
  43. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/flood.js +188 -0
  44. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/path-vector.d.ts +35 -0
  45. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/path-vector.d.ts.map +1 -0
  46. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/path-vector.js +57 -0
  47. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/tell-and-read.d.ts +117 -0
  48. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/tell-and-read.d.ts.map +1 -0
  49. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/tell-and-read.js +125 -0
  50. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/types.d.ts +112 -0
  51. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/types.d.ts.map +1 -0
  52. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/types.js +21 -0
  53. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/observe-emitter/observe-emitter.d.ts +35 -0
  54. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/observe-emitter/observe-emitter.d.ts.map +1 -0
  55. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/observe-emitter/observe-emitter.js +106 -0
  56. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/connection.d.ts +88 -0
  57. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/connection.d.ts.map +1 -0
  58. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/connection.js +86 -0
  59. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/events.d.ts +83 -0
  60. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/events.d.ts.map +1 -0
  61. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/events.js +115 -0
  62. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/hooks.d.ts +164 -0
  63. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/hooks.d.ts.map +1 -0
  64. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/hooks.js +67 -0
  65. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/identity.d.ts +99 -0
  66. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/identity.d.ts.map +1 -0
  67. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/identity.js +190 -0
  68. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/operation-handle.d.ts +185 -0
  69. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/operation-handle.d.ts.map +1 -0
  70. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/operation-handle.js +103 -0
  71. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/reactions.d.ts +58 -0
  72. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/reactions.d.ts.map +1 -0
  73. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/reactions.js +64 -0
  74. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/rig.d.ts +295 -0
  75. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/rig.d.ts.map +1 -0
  76. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/rig.js +898 -0
  77. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/types.d.ts +167 -0
  78. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/types.d.ts.map +1 -0
  79. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/types.js +5 -0
  80. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/types/types.d.ts +278 -0
  81. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/types/types.d.ts.map +1 -0
  82. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/types/types.js +89 -0
  83. package/esm/dispatch.d.ts +35 -0
  84. package/esm/dispatch.d.ts.map +1 -0
  85. package/esm/dispatch.js +40 -0
  86. package/esm/elasticsearch/mod.d.ts +31 -0
  87. package/esm/elasticsearch/mod.d.ts.map +1 -0
  88. package/esm/elasticsearch/mod.js +8 -0
  89. package/esm/elasticsearch/store.d.ts +44 -0
  90. package/esm/elasticsearch/store.d.ts.map +1 -0
  91. package/esm/elasticsearch/store.js +213 -0
  92. package/esm/entity-store.d.ts +106 -0
  93. package/esm/entity-store.d.ts.map +1 -0
  94. package/esm/entity-store.js +46 -0
  95. package/esm/entity.d.ts +125 -0
  96. package/esm/entity.d.ts.map +1 -0
  97. package/esm/entity.js +66 -0
  98. package/esm/errors.d.ts +23 -0
  99. package/esm/errors.d.ts.map +1 -0
  100. package/esm/errors.js +25 -0
  101. package/esm/fs/mod.d.ts +28 -0
  102. package/esm/fs/mod.d.ts.map +1 -0
  103. package/esm/fs/mod.js +13 -0
  104. package/esm/fs/store.d.ts +45 -0
  105. package/esm/fs/store.d.ts.map +1 -0
  106. package/esm/fs/store.js +194 -0
  107. package/esm/indexeddb/mod.d.ts +9 -0
  108. package/esm/indexeddb/mod.d.ts.map +1 -0
  109. package/esm/indexeddb/mod.js +8 -0
  110. package/esm/indexeddb/store.d.ts +47 -0
  111. package/esm/indexeddb/store.d.ts.map +1 -0
  112. package/esm/indexeddb/store.js +299 -0
  113. package/esm/ipfs/mod.d.ts +24 -0
  114. package/esm/ipfs/mod.d.ts.map +1 -0
  115. package/esm/ipfs/mod.js +13 -0
  116. package/esm/ipfs/store.d.ts +42 -0
  117. package/esm/ipfs/store.d.ts.map +1 -0
  118. package/esm/ipfs/store.js +197 -0
  119. package/esm/localstorage/mod.d.ts +9 -0
  120. package/esm/localstorage/mod.d.ts.map +1 -0
  121. package/esm/localstorage/mod.js +8 -0
  122. package/esm/localstorage/store.d.ts +45 -0
  123. package/esm/localstorage/store.d.ts.map +1 -0
  124. package/esm/localstorage/store.js +147 -0
  125. package/esm/memory/mod.d.ts +12 -0
  126. package/esm/memory/mod.d.ts.map +1 -0
  127. package/esm/memory/mod.js +11 -0
  128. package/esm/memory/store.d.ts +73 -0
  129. package/esm/memory/store.d.ts.map +1 -0
  130. package/esm/memory/store.js +300 -0
  131. package/esm/mod.d.ts +36 -0
  132. package/esm/mod.d.ts.map +1 -0
  133. package/esm/mod.js +31 -0
  134. package/esm/mongo/mod.d.ts +35 -0
  135. package/esm/mongo/mod.d.ts.map +1 -0
  136. package/esm/mongo/mod.js +8 -0
  137. package/esm/mongo/store.d.ts +42 -0
  138. package/esm/mongo/store.d.ts.map +1 -0
  139. package/esm/mongo/store.js +185 -0
  140. package/esm/package.json +3 -0
  141. package/esm/payload.d.ts +24 -0
  142. package/esm/payload.d.ts.map +1 -0
  143. package/esm/payload.js +30 -0
  144. package/esm/postgres/columns.d.ts +33 -0
  145. package/esm/postgres/columns.d.ts.map +1 -0
  146. package/esm/postgres/columns.js +51 -0
  147. package/esm/postgres/mod.d.ts +19 -0
  148. package/esm/postgres/mod.d.ts.map +1 -0
  149. package/esm/postgres/mod.js +9 -0
  150. package/esm/postgres/store.d.ts +75 -0
  151. package/esm/postgres/store.d.ts.map +1 -0
  152. package/esm/postgres/store.js +364 -0
  153. package/esm/read.d.ts +40 -0
  154. package/esm/read.d.ts.map +1 -0
  155. package/esm/read.js +67 -0
  156. package/esm/s3/mod.d.ts +23 -0
  157. package/esm/s3/mod.d.ts.map +1 -0
  158. package/esm/s3/mod.js +13 -0
  159. package/esm/s3/store.d.ts +46 -0
  160. package/esm/s3/store.d.ts.map +1 -0
  161. package/esm/s3/store.js +183 -0
  162. package/esm/sqlite/mod.d.ts +18 -0
  163. package/esm/sqlite/mod.d.ts.map +1 -0
  164. package/esm/sqlite/mod.js +8 -0
  165. package/esm/sqlite/schema.d.ts +10 -0
  166. package/esm/sqlite/schema.d.ts.map +1 -0
  167. package/esm/sqlite/schema.js +26 -0
  168. package/esm/sqlite/store.d.ts +42 -0
  169. package/esm/sqlite/store.d.ts.map +1 -0
  170. package/esm/sqlite/store.js +184 -0
  171. package/esm/types.d.ts +101 -0
  172. package/esm/types.d.ts.map +1 -0
  173. package/esm/types.js +21 -0
  174. package/esm/url.d.ts +108 -0
  175. package/esm/url.d.ts.map +1 -0
  176. package/esm/url.js +151 -0
  177. package/package.json +93 -0
@@ -0,0 +1,898 @@
1
+ /**
2
+ * @module
3
+ * Rig — the universal harness for b3nd.
4
+ *
5
+ * Single object that wires up backends, identity, and serving.
6
+ * Two core actions: send (outward to the network) and receive
7
+ * (inward from external sources). Everything else is observation.
8
+ *
9
+ * Supports per-operation client routing, synchronous hooks
10
+ * (pre/post), async events, and URI-pattern reactions.
11
+ */
12
+ import { resolveHooks, runAfter, runBefore, runOnError } from "./hooks.js";
13
+ import { RigEventEmitter } from "./events.js";
14
+ import { ReactionRegistry } from "./reactions.js";
15
+ import { OperationHandleImpl } from "./operation-handle.js";
16
+ /**
17
+ * Rig — pure orchestration for b3nd.
18
+ *
19
+ * The rig is identity-free — it routes, validates, and dispatches.
20
+ * For authenticated operations, use `Identity.sign()` + `message()` +
21
+ * `rig.send()` directly.
22
+ *
23
+ * @example Unsigned operations
24
+ * ```typescript
25
+ * const local = connection(client, ["**"]);
26
+ * const rig = new Rig({
27
+ * routes: { receive: [local], read: [local], observe: [local] },
28
+ * });
29
+ * const results = await rig.receive([["mutable://open/app/x", data]]);
30
+ * ```
31
+ *
32
+ * @example Authenticated send
33
+ * ```typescript
34
+ * // With messageDataHandler registered, the rig decomposes the
35
+ * // envelope into outputs + null-payload deletions for inputs.
36
+ * const id = await Identity.fromSeed("my-secret");
37
+ * const auth = [await id.sign({ inputs: [], outputs })];
38
+ * const envelope = await message({ auth, inputs: [], outputs });
39
+ * await rig.send([envelope]);
40
+ * ```
41
+ *
42
+ * @example With programs, hooks, events, and reactions
43
+ * ```typescript
44
+ * const local = connection(client, ["**"]);
45
+ * const rig = new Rig({
46
+ * routes: { receive: [local], read: [local], observe: [local] },
47
+ * programs,
48
+ * handlers,
49
+ * hooks: {
50
+ * beforeReceive: (ctx) => { rateLimit(ctx.uri); },
51
+ * afterRead: (ctx, result) => { audit(ctx.uri, result); },
52
+ * },
53
+ * on: {
54
+ * "send:success": [audit],
55
+ * },
56
+ * reactions: {
57
+ * "mutable://app/users/*": async (out, _read) => {
58
+ * const id = out[0].split("/").pop();
59
+ * return [[`notify://email/${id}`, { kind: "user-updated" }]];
60
+ * },
61
+ * },
62
+ * });
63
+ * ```
64
+ */
65
+ export class Rig {
66
+ // ── Internal state ──
67
+ _receiveRoutes;
68
+ _readRoutes;
69
+ _observeRoutes;
70
+ _dispatch;
71
+ _programs;
72
+ _handlers;
73
+ _hooks;
74
+ _events;
75
+ _reactors;
76
+ constructor(config) {
77
+ const routes = config.routes;
78
+ const receive = routes?.receive ?? [];
79
+ const read = routes?.read ?? [];
80
+ const observe = routes?.observe ?? [];
81
+ if (receive.length === 0 && read.length === 0 && observe.length === 0) {
82
+ throw new Error("Rig: `routes` must declare at least one of `receive`, `read`, or `observe`.");
83
+ }
84
+ this._receiveRoutes = Object.freeze([...receive]);
85
+ this._readRoutes = Object.freeze([...read]);
86
+ this._observeRoutes = Object.freeze([...observe]);
87
+ this._dispatch = createRouteDispatch({
88
+ receive: this._receiveRoutes,
89
+ read: this._readRoutes,
90
+ observe: this._observeRoutes,
91
+ });
92
+ this._programs = config.programs ?? null;
93
+ this._handlers = config.handlers ?? null;
94
+ this._hooks = resolveHooks(config.hooks);
95
+ // Build event emitter
96
+ this._events = new RigEventEmitter();
97
+ if (config.on) {
98
+ for (const [name, handlers] of Object.entries(config.on)) {
99
+ if (handlers) {
100
+ for (const handler of handlers) {
101
+ this._events.on(name, handler);
102
+ }
103
+ }
104
+ }
105
+ }
106
+ // Build react registry
107
+ this._reactors = new ReactionRegistry();
108
+ if (config.reactions) {
109
+ for (const [pattern, handler] of Object.entries(config.reactions)) {
110
+ this._reactors.add(pattern, handler);
111
+ }
112
+ }
113
+ }
114
+ /**
115
+ * Raw composite client — bypasses hooks, events, and react.
116
+ *
117
+ * Prefer passing the Rig itself (it satisfies `ProtocolInterfaceNode`).
118
+ * This getter exists for third-party code that requires a plain object.
119
+ */
120
+ get client() {
121
+ const d = this._dispatch;
122
+ return {
123
+ receive: (msgs) => d.receive(msgs),
124
+ read: (urls) => d.read(urls),
125
+ observe: (urls, signal) => d.observe(urls, signal),
126
+ status: () => d.status(),
127
+ };
128
+ }
129
+ // ── Core actions ──
130
+ /**
131
+ * Send tuples — the host application acting as the origin.
132
+ *
133
+ * Returns an `OperationHandle` that:
134
+ * - Awaits to `ReceiveResult[]` once the pipeline (process →
135
+ * handle) finishes. Pipeline-stage ack only — broadcast may
136
+ * still be in flight.
137
+ * - Fires per-stage events (`process:done`, `handle:emit`,
138
+ * `route:success`/`route:error`, `settled`) scoped to this call.
139
+ *
140
+ * Dispatch to connections runs in the background; per-route outcomes
141
+ * arrive as events. Wait for `op.settled` for read-after-write
142
+ * guarantees across replicas.
143
+ *
144
+ * Use this when the host is the origin of the content (a button
145
+ * click, a worker emitting state, a signed envelope from
146
+ * `Identity.sign()` + `message()`). Use `receive()` when content arrives from
147
+ * elsewhere (a peer, a webhook, an upstream sync).
148
+ *
149
+ * @example
150
+ * ```typescript
151
+ * const op = rig.send([["mutable://app/state", { value: 42 }]]);
152
+ * op.on("route:error", (e) => retry(e.emission));
153
+ * const [result] = await op; // pipeline ack
154
+ * await op.settled; // routes fully settled
155
+ * ```
156
+ */
157
+ send(outs) {
158
+ return this._pipeline(outs, "send");
159
+ }
160
+ /**
161
+ * Receive tuples — the host application accepting state from elsewhere.
162
+ *
163
+ * Same shape as `send()` but fires `beforeReceive`/`afterReceive`
164
+ * hooks and `receive:*` global events. Returns an `OperationHandle`
165
+ * with the same scoped per-stage events.
166
+ *
167
+ * @example
168
+ * ```typescript
169
+ * const [result] = await rig.receive([
170
+ * ["mutable://open/external", { source: "webhook" }],
171
+ * ]);
172
+ * ```
173
+ */
174
+ receive(outs) {
175
+ return this._pipeline(outs, "receive");
176
+ }
177
+ /**
178
+ * Same as `receive()` but throws if any input tuple is rejected at
179
+ * the pipeline stage. Convenience for callers that don't want to
180
+ * inspect `accepted`.
181
+ */
182
+ async receiveOrThrow(outs) {
183
+ const results = await this.receive(outs);
184
+ const failed = results.find((r) => !r.accepted);
185
+ if (failed)
186
+ throw new Error(failed.error ?? "rig.receive: rejected");
187
+ return results;
188
+ }
189
+ /** Same as `send()` but throws on any pipeline-stage rejection. */
190
+ async sendOrThrow(outs) {
191
+ const results = await this.send(outs);
192
+ const failed = results.find((r) => !r.accepted);
193
+ if (failed)
194
+ throw new Error(failed.error ?? "rig.send: rejected");
195
+ return results;
196
+ }
197
+ /**
198
+ * Classify a batch of tuples — runs registered programs.
199
+ *
200
+ * For each output, finds the program with the longest matching URI
201
+ * prefix and invokes it. Tuples whose URI matches no registered
202
+ * program get a default `{ code: "ok" }` classification (so the
203
+ * default-dispatch path persists them as-is).
204
+ *
205
+ * Pure: returns one `ProgramResult` per input tuple. No side effects.
206
+ */
207
+ async process(outs) {
208
+ const readFn = this._readFn();
209
+ const results = [];
210
+ for (const out of outs) {
211
+ const program = this._findProgram(out[0]);
212
+ results.push(program ? await program(out, undefined, readFn) : { code: "ok" });
213
+ }
214
+ return results;
215
+ }
216
+ /**
217
+ * Run the handler for a classified tuple — returns the emissions.
218
+ *
219
+ * If no handler is registered for the result's code, the input tuple
220
+ * is returned as-is (default-persist). Handlers themselves return the
221
+ * `Output[]` they want the Rig to dispatch; this method surfaces that
222
+ * return for callers that want to compose the pipeline manually.
223
+ *
224
+ * Pure: no dispatch happens here. Use `send()` / `receive()` for the
225
+ * full pipeline including dispatch and reactions.
226
+ */
227
+ async handle(out, result) {
228
+ const handler = this._handlers?.[result.code];
229
+ if (!handler)
230
+ return [out];
231
+ return await handler(out, result, this._readFn());
232
+ }
233
+ /**
234
+ * The pipeline body shared by `send()` and `receive()`.
235
+ *
236
+ * Returns an `OperationHandle` synchronously. The pipeline runs
237
+ * asynchronously inside; awaiting the handle resolves to the
238
+ * pipeline-stage `ReceiveResult[]` (process + handle outcome only).
239
+ * Per-route dispatch runs in the background; route outcomes arrive
240
+ * as events on the handle. `op.settled` resolves once every route
241
+ * has answered.
242
+ *
243
+ * Reactions fire after each emission's routes settle — once per
244
+ * emission, only if at least one route accepted. Their emissions
245
+ * spawn a fresh `rig.send(...)` operation, independent of this one.
246
+ */
247
+ _pipeline(outs, direction) {
248
+ const handle = new OperationHandleImpl();
249
+ // Run the pipeline asynchronously. The handle is returned now;
250
+ // pipeline-ack and route events fire as work completes.
251
+ void this._runPipeline(outs, direction, handle);
252
+ return handle;
253
+ }
254
+ /** Internal: pipeline body driven against an OperationHandleImpl. */
255
+ async _runPipeline(outs, direction, handle) {
256
+ try {
257
+ // Before-hook per tuple — throw to reject.
258
+ const finalOuts = [];
259
+ for (const out of outs) {
260
+ const [uri, payload] = out;
261
+ if (direction === "receive") {
262
+ const ctx = { uri, data: payload };
263
+ const recvCtx = await runBefore(this._hooks.beforeReceive, ctx);
264
+ finalOuts.push([recvCtx.uri, recvCtx.data]);
265
+ }
266
+ else {
267
+ const ctx = { message: out };
268
+ const sendCtx = await runBefore(this._hooks.beforeSend, ctx);
269
+ finalOuts.push(sendCtx.message);
270
+ }
271
+ }
272
+ const results = [];
273
+ const broadcastPromises = [];
274
+ for (let i = 0; i < finalOuts.length; i++) {
275
+ const out = finalOuts[i];
276
+ const [uri, payload] = out;
277
+ // ── process — classify the tuple, isolated so a thrown
278
+ // program doesn't fail the whole batch. ──
279
+ let programResult;
280
+ try {
281
+ programResult = await this._processOne(out);
282
+ }
283
+ catch (err) {
284
+ const message = err instanceof Error ? err.message : String(err);
285
+ handle._emit("process:error", {
286
+ input: out,
287
+ error: message,
288
+ cause: err,
289
+ });
290
+ await runOnError(this._hooks.onError, {
291
+ phase: "process",
292
+ input: out,
293
+ error: message,
294
+ cause: err,
295
+ });
296
+ const result = { accepted: false, error: message };
297
+ results.push(result);
298
+ this._events.emit(`${direction}:error`, {
299
+ op: direction,
300
+ uri,
301
+ error: message,
302
+ ts: Date.now(),
303
+ });
304
+ await this._runAfterFor(direction, out, payload, result);
305
+ continue;
306
+ }
307
+ handle._emit("process:done", { input: out, result: programResult });
308
+ // Structural pre-check: if no connection accepts this URI for
309
+ // receive, the rig has no topology to dispatch through.
310
+ const hasRoute = this._receiveRoutes.some((c) => c.accepts(uri));
311
+ if (!hasRoute) {
312
+ const error = `No connection accepts receive for ${uri}`;
313
+ handle._emit("process:error", { input: out, error });
314
+ await runOnError(this._hooks.onError, {
315
+ phase: "process",
316
+ input: out,
317
+ error,
318
+ });
319
+ const result = { accepted: false, error };
320
+ results.push(result);
321
+ this._events.emit(`${direction}:error`, {
322
+ op: direction,
323
+ uri,
324
+ error,
325
+ ts: Date.now(),
326
+ });
327
+ await this._runAfterFor(direction, out, payload, result);
328
+ continue;
329
+ }
330
+ if (programResult.error) {
331
+ const error = programResult.error;
332
+ handle._emit("process:error", { input: out, error });
333
+ await runOnError(this._hooks.onError, {
334
+ phase: "process",
335
+ input: out,
336
+ error,
337
+ });
338
+ const result = { accepted: false, error };
339
+ results.push(result);
340
+ this._events.emit(`${direction}:error`, {
341
+ op: direction,
342
+ uri,
343
+ error,
344
+ ts: Date.now(),
345
+ });
346
+ await this._runAfterFor(direction, out, payload, result);
347
+ continue;
348
+ }
349
+ // ── handle — run the registered handler. ──
350
+ let emissions;
351
+ try {
352
+ emissions = await this.handle(out, programResult);
353
+ }
354
+ catch (err) {
355
+ const message = err instanceof Error ? err.message : String(err);
356
+ handle._emit("handle:error", {
357
+ input: out,
358
+ classification: programResult,
359
+ error: message,
360
+ cause: err,
361
+ });
362
+ await runOnError(this._hooks.onError, {
363
+ phase: "handle",
364
+ input: out,
365
+ classification: programResult,
366
+ error: message,
367
+ cause: err,
368
+ });
369
+ const result = { accepted: false, error: message };
370
+ results.push(result);
371
+ this._events.emit(`${direction}:error`, {
372
+ op: direction,
373
+ uri,
374
+ error: message,
375
+ ts: Date.now(),
376
+ });
377
+ await this._runAfterFor(direction, out, payload, result);
378
+ continue;
379
+ }
380
+ handle._emit("handle:emit", {
381
+ input: out,
382
+ classification: programResult,
383
+ emissions,
384
+ });
385
+ // Pipeline accepted — record success now, dispatch in background.
386
+ const result = { accepted: true };
387
+ results.push(result);
388
+ this._events.emit(`${direction}:success`, {
389
+ op: direction,
390
+ uri,
391
+ data: payload,
392
+ result,
393
+ ts: Date.now(),
394
+ });
395
+ // Schedule per-route dispatch — fire-and-forget for the pipeline.
396
+ broadcastPromises.push(this._dispatchRouteAware(emissions, handle, out));
397
+ await this._runAfterFor(direction, out, payload, result);
398
+ }
399
+ // Resolve pipeline-ack. Caller's `await op` returns now.
400
+ handle._pipelineDone(results);
401
+ // Wait for all routes to settle (and their reactions to fire),
402
+ // then emit the `settled` event.
403
+ await Promise.all(broadcastPromises);
404
+ handle._emit("settled", { results });
405
+ }
406
+ catch (err) {
407
+ handle._pipelineError(err);
408
+ }
409
+ }
410
+ /**
411
+ * Run process for a single output. Isolated so a thrown program
412
+ * doesn't fail the whole batch.
413
+ */
414
+ async _processOne(out) {
415
+ const program = this._findProgram(out[0]);
416
+ if (!program)
417
+ return { code: "ok" };
418
+ return await program(out, undefined, this._readFn());
419
+ }
420
+ /** Run the direction-appropriate after-hook with the standard payload. */
421
+ async _runAfterFor(direction, out, payload, result) {
422
+ if (direction === "receive") {
423
+ await runAfter(this._hooks.afterReceive, { uri: out[0], data: payload }, result);
424
+ }
425
+ else {
426
+ await runAfter(this._hooks.afterSend, { message: out }, result);
427
+ }
428
+ }
429
+ /**
430
+ * Per-emission, per-connection dispatch with route events.
431
+ *
432
+ * For each emission: find every connection in `routes.receive` that
433
+ * accepts the URI, dispatch the emission to each independently, emit
434
+ * `route:success` / `route:error` per (emission, connection). Once
435
+ * all routes for an emission settle, fire reactions if any route
436
+ * accepted. Reactions' returned tuples spawn a fresh `rig.send`
437
+ * operation (independent of this one).
438
+ *
439
+ * `input` is the original tuple that drove this dispatch — passed
440
+ * through so the `onError` hook can correlate route failures back
441
+ * to the input that triggered them.
442
+ */
443
+ async _dispatchRouteAware(emissions, handle, input) {
444
+ if (emissions.length === 0)
445
+ return;
446
+ const reactionPromises = [];
447
+ for (const emission of emissions) {
448
+ const [uri] = emission;
449
+ const matching = this._receiveRoutes.filter((c) => c.accepts(uri));
450
+ if (matching.length === 0) {
451
+ const error = `No connection accepts receive for ${uri}`;
452
+ handle._emit("route:error", { emission, connectionId: "", error });
453
+ await runOnError(this._hooks.onError, {
454
+ phase: "route",
455
+ input,
456
+ emission,
457
+ connectionId: "",
458
+ error,
459
+ });
460
+ continue;
461
+ }
462
+ const perConnection = matching.map((conn) => conn.client.receive([emission]).then(async (results) => {
463
+ const r = results[0];
464
+ if (r.accepted) {
465
+ handle._emit("route:success", {
466
+ emission,
467
+ connectionId: conn.id,
468
+ });
469
+ return true;
470
+ }
471
+ handle._emit("route:error", {
472
+ emission,
473
+ connectionId: conn.id,
474
+ error: r.error,
475
+ errorDetail: r.errorDetail,
476
+ });
477
+ await runOnError(this._hooks.onError, {
478
+ phase: "route",
479
+ input,
480
+ emission,
481
+ connectionId: conn.id,
482
+ error: r.error ?? "route rejected",
483
+ errorDetail: r.errorDetail,
484
+ });
485
+ return false;
486
+ }, async (err) => {
487
+ const message = err instanceof Error ? err.message : String(err);
488
+ handle._emit("route:error", {
489
+ emission,
490
+ connectionId: conn.id,
491
+ error: message,
492
+ });
493
+ await runOnError(this._hooks.onError, {
494
+ phase: "route",
495
+ input,
496
+ emission,
497
+ connectionId: conn.id,
498
+ error: message,
499
+ cause: err,
500
+ });
501
+ return false;
502
+ }));
503
+ // Once all routes for this emission settle, fire reactions
504
+ // (only if at least one route accepted).
505
+ reactionPromises.push(Promise.all(perConnection).then((flags) => this._fireReactionsForEmission(emission, flags.some((a) => a), handle, input)));
506
+ }
507
+ await Promise.all(reactionPromises);
508
+ }
509
+ /**
510
+ * Fire reactions for a successfully-dispatched emission. Reaction
511
+ * returns are dispatched through a fresh `rig.send(...)` — that
512
+ * spawned operation has its own OperationHandle and is unrelated
513
+ * to the one that triggered it. Loops are usage error.
514
+ */
515
+ async _fireReactionsForEmission(emission, anyAccepted, handle, input) {
516
+ if (!anyAccepted || this._reactors.size === 0)
517
+ return;
518
+ const readFn = this._readFn();
519
+ const matches = this._reactors.matches(emission[0]);
520
+ if (matches.length === 0)
521
+ return;
522
+ const collected = [];
523
+ for (const { handler, pattern } of matches) {
524
+ try {
525
+ const emitted = await handler(emission, readFn);
526
+ collected.push(...emitted);
527
+ }
528
+ catch (err) {
529
+ const message = err instanceof Error ? err.message : String(err);
530
+ handle._emit("reaction:error", {
531
+ emission,
532
+ pattern,
533
+ error: message,
534
+ cause: err,
535
+ });
536
+ await runOnError(this._hooks.onError, {
537
+ phase: "reaction",
538
+ input,
539
+ emission,
540
+ pattern,
541
+ error: message,
542
+ cause: err,
543
+ });
544
+ }
545
+ }
546
+ if (collected.length > 0) {
547
+ // Spawn a fresh send operation — fire-and-forget at this level.
548
+ // The spawned op has its own handle for any caller that wants it.
549
+ const spawn = this.send(collected);
550
+ // Swallow rejections: reactions are observers, not blockers.
551
+ Promise.resolve(spawn).catch((err) => {
552
+ console.warn("[rig] reaction-emitted send error:", err);
553
+ });
554
+ }
555
+ }
556
+ /** Internal read helper bound to the dispatch read interface. */
557
+ _readFn() {
558
+ return async (u) => {
559
+ const results = await this._dispatch.read([u]);
560
+ // Read is 1:1; the slot is always present. Whatever the payload
561
+ // is — including a protocol-defined miss representation — is
562
+ // surfaced as-is.
563
+ return results[0];
564
+ };
565
+ }
566
+ /**
567
+ * Find the program with the longest matching URI prefix.
568
+ */
569
+ _findProgram(uri) {
570
+ if (!this._programs)
571
+ return null;
572
+ let bestMatch = null;
573
+ for (const prefix of Object.keys(this._programs)) {
574
+ if (uri === prefix || uri.startsWith(prefix + "/")) {
575
+ if (!bestMatch || prefix.length > bestMatch.length) {
576
+ bestMatch = prefix;
577
+ }
578
+ }
579
+ }
580
+ return bestMatch ? this._programs[bestMatch] : null;
581
+ }
582
+ // ── Observation ──
583
+ /**
584
+ * Read a batch of locators. Each locator goes to the first connection
585
+ * whose route pattern accepts it. Locators are opaque strings; the
586
+ * executing client owns any grammar interpretation.
587
+ */
588
+ async read(urls) {
589
+ // Before-hook per url. The hook sees just `{ url }`; locators are
590
+ // opaque to the framework, so if a hook wants to inspect the
591
+ // grammar it brings its own parser. Returning `{ ctx: { url:
592
+ // newUrl } }` rewrites; otherwise the url passes through unchanged.
593
+ const finalUrls = [];
594
+ for (const url of urls) {
595
+ const ctx = await runBefore(this._hooks.beforeRead, { url });
596
+ finalUrls.push(ctx.url);
597
+ }
598
+ // Execute
599
+ let outputs;
600
+ try {
601
+ outputs = await this._dispatch.read(finalUrls);
602
+ }
603
+ catch (err) {
604
+ for (const url of finalUrls) {
605
+ this._events.emit("read:error", {
606
+ op: "read",
607
+ uri: url,
608
+ error: err instanceof Error ? err.message : String(err),
609
+ ts: Date.now(),
610
+ });
611
+ }
612
+ throw err;
613
+ }
614
+ // After-hook + event per Output. Option-A doesn't have per-result
615
+ // failures (those throw); every Output is a success.
616
+ for (const output of outputs) {
617
+ const [uri] = output;
618
+ await runAfter(this._hooks.afterRead, { url: uri }, output);
619
+ this._events.emit("read:success", {
620
+ op: "read",
621
+ uri,
622
+ result: output,
623
+ ts: Date.now(),
624
+ });
625
+ }
626
+ return outputs;
627
+ }
628
+ // ── Observe (client-backed streaming) ──
629
+ /**
630
+ * Observe changes matching a URI pattern.
631
+ *
632
+ * Routes to the first connection that accepts `observe` for the pattern,
633
+ * then delegates to the client's native transport (SSE, internal events, etc).
634
+ *
635
+ * @example
636
+ * ```typescript
637
+ * const abort = new AbortController();
638
+ * for await (const uris of rig.observe(["mutable://data/market/**"], abort.signal)) {
639
+ * for (const uri of uris) console.log(uri);
640
+ * }
641
+ * ```
642
+ */
643
+ async *observe(urls, signal) {
644
+ yield* this._dispatch.observe(urls, signal);
645
+ }
646
+ // ── Inspection ──
647
+ /**
648
+ * Get a snapshot of this rig's current state.
649
+ *
650
+ * Pure local inspection, no network calls.
651
+ *
652
+ * @example
653
+ * ```typescript
654
+ * const info = rig.info();
655
+ * console.log(info.behavior.hooks);
656
+ * console.log(info.behavior.reactors);
657
+ * ```
658
+ */
659
+ info() {
660
+ const hooks = [];
661
+ const h = this._hooks;
662
+ if (h.beforeSend)
663
+ hooks.push("beforeSend");
664
+ if (h.afterSend)
665
+ hooks.push("afterSend");
666
+ if (h.beforeReceive)
667
+ hooks.push("beforeReceive");
668
+ if (h.afterReceive)
669
+ hooks.push("afterReceive");
670
+ if (h.beforeRead)
671
+ hooks.push("beforeRead");
672
+ if (h.afterRead)
673
+ hooks.push("afterRead");
674
+ return {
675
+ behavior: {
676
+ hooks,
677
+ events: this._events.counts(),
678
+ reactors: this._reactors.size,
679
+ },
680
+ };
681
+ }
682
+ // ── Infrastructure ──
683
+ /**
684
+ * Status — health + schema.
685
+ * Aggregates client status across all connections.
686
+ */
687
+ async status() {
688
+ return await this._dispatch.status();
689
+ }
690
+ /**
691
+ * Return any in-flight event handler promises and clear the queue.
692
+ */
693
+ drain() {
694
+ return this._events.pending();
695
+ }
696
+ // ── Runtime API: events, react ──
697
+ // Hooks are immutable after init — see createHookChains().
698
+ /**
699
+ * Register an event handler at runtime. Returns an unsubscribe function.
700
+ *
701
+ * @example
702
+ * ```typescript
703
+ * const unsub = rig.on("send:success", (event) => {
704
+ * console.log(`Sent to ${event.uri}`);
705
+ * });
706
+ *
707
+ * // Later:
708
+ * unsub();
709
+ * ```
710
+ */
711
+ on(event, handler) {
712
+ return this._events.on(event, handler);
713
+ }
714
+ /** Remove a specific event handler. */
715
+ off(event, handler) {
716
+ this._events.off(event, handler);
717
+ }
718
+ /**
719
+ * Register a react pattern at runtime. Returns an unsubscribe function.
720
+ *
721
+ * Fires on successful `send()` or `receive()` when the written URI
722
+ * matches the pattern. Fire-and-forget — errors are caught and logged.
723
+ *
724
+ * Reactions return `Output[]` — those tuples flow back through
725
+ * `rig.send` (full pipeline). See chapter 7 of RFC 001 for the
726
+ * productive-observation model.
727
+ *
728
+ * @example
729
+ * ```typescript
730
+ * const unsub = rig.reaction(
731
+ * "mutable://app/users/*",
732
+ * async (out, _read) => {
733
+ * const id = out[0].split("/").pop();
734
+ * return [[`notify://email/${id}`, { kind: "user-updated" }]];
735
+ * },
736
+ * );
737
+ *
738
+ * // Later:
739
+ * unsub();
740
+ * ```
741
+ */
742
+ reaction(pattern, handler) {
743
+ return this._reactors.add(pattern, handler);
744
+ }
745
+ }
746
+ // ── Init helpers ──
747
+ /**
748
+ * Merge a set of `AsyncIterable`s into one stream. Aborts when `signal`
749
+ * fires; per-stream errors are swallowed (one broken peer should not
750
+ * tear down the merged stream).
751
+ */
752
+ async function* mergeStreams(streams, signal) {
753
+ if (streams.length === 0)
754
+ return;
755
+ const queue = [];
756
+ let wake = null;
757
+ const forwarders = streams.map(async (s) => {
758
+ try {
759
+ for await (const r of s) {
760
+ queue.push(r);
761
+ const w = wake;
762
+ if (w) {
763
+ wake = null;
764
+ w();
765
+ }
766
+ }
767
+ }
768
+ catch {
769
+ // intentional
770
+ }
771
+ });
772
+ const onAbort = () => {
773
+ const w = wake;
774
+ if (w) {
775
+ wake = null;
776
+ w();
777
+ }
778
+ };
779
+ signal.addEventListener("abort", onAbort, { once: true });
780
+ try {
781
+ while (true) {
782
+ while (queue.length > 0)
783
+ yield queue.shift();
784
+ if (signal.aborted)
785
+ return;
786
+ await new Promise((resolve) => {
787
+ wake = resolve;
788
+ });
789
+ }
790
+ }
791
+ finally {
792
+ signal.removeEventListener("abort", onAbort);
793
+ await Promise.allSettled(forwarders);
794
+ }
795
+ }
796
+ /**
797
+ * Build dispatch from per-op route arrays.
798
+ *
799
+ * Each operation flows through its dedicated route list:
800
+ * - `receive`: broadcast to ALL matching connections in the receive route.
801
+ * - `read`: per url, first connection that accepts the routing key gets
802
+ * the request — sequential, no aggregation. Aggregation across
803
+ * sources is the job of an aggregating client (e.g. memcache+shards),
804
+ * not the rig.
805
+ * - `observe`: per url, first connection that accepts wins.
806
+ * - `status`: aggregate across unique clients seen on any route.
807
+ */
808
+ function createRouteDispatch(routes) {
809
+ const { receive, read, observe } = routes;
810
+ return {
811
+ async receive(msgs) {
812
+ const results = [];
813
+ for (const msg of msgs) {
814
+ const [uri] = msg;
815
+ const matching = receive.filter((s) => s.accepts(uri));
816
+ if (matching.length === 0) {
817
+ results.push({
818
+ accepted: false,
819
+ error: `No receive route accepts ${uri}`,
820
+ });
821
+ continue;
822
+ }
823
+ // Broadcast to every matching receive-route connection
824
+ const writeResults = await Promise.all(matching.map((s) => s.client.receive([msg]).then((r) => r[0])));
825
+ const failed = writeResults.find((r) => !r.accepted);
826
+ results.push(failed ?? writeResults[0]);
827
+ }
828
+ return results;
829
+ },
830
+ async read(urls) {
831
+ // 1:1 with input. Each url is routed to its accepting connection;
832
+ // we ask that connection for that single url and place the
833
+ // resulting Output<T> in the matching slot.
834
+ const out = new Array(urls.length);
835
+ await Promise.all(urls.map(async (url, i) => {
836
+ const conn = read.find((s) => s.accepts(url));
837
+ if (!conn) {
838
+ // Programmer error — this rig isn't configured for this url.
839
+ throw new Error(`No read route accepts ${url}`);
840
+ }
841
+ const [r] = await conn.client.read([url]);
842
+ out[i] = r;
843
+ }));
844
+ return out;
845
+ },
846
+ async *observe(urls, signal) {
847
+ // Group urls by the first connection that accepts them so each
848
+ // connection sees the subset it owns. No aggregation across
849
+ // connections — that is the aggregating client's job.
850
+ const groups = new Map();
851
+ for (const url of urls) {
852
+ const conn = observe.find((c) => c.accepts(url));
853
+ if (!conn)
854
+ continue;
855
+ const arr = groups.get(conn) ?? [];
856
+ arr.push(url);
857
+ groups.set(conn, arr);
858
+ }
859
+ const streams = [...groups.entries()].map(([c, us]) => c.client.observe(us, signal));
860
+ yield* mergeStreams(streams, signal);
861
+ },
862
+ async status() {
863
+ const seen = new Set();
864
+ const unique = [];
865
+ for (const list of [receive, read, observe]) {
866
+ for (const s of list) {
867
+ if (!seen.has(s.client)) {
868
+ seen.add(s.client);
869
+ unique.push(s.client);
870
+ }
871
+ }
872
+ }
873
+ const results = await Promise.all(unique.map((c) => c.status()));
874
+ const allSchema = new Set();
875
+ const allFns = new Set();
876
+ for (const r of results) {
877
+ if (r.schema) {
878
+ for (const k of r.schema)
879
+ allSchema.add(k);
880
+ }
881
+ if (r.fns) {
882
+ for (const f of r.fns)
883
+ allFns.add(f);
884
+ }
885
+ }
886
+ const fns = allFns.size > 0 ? [...allFns] : undefined;
887
+ const unhealthy = results.find((r) => r.status === "unhealthy");
888
+ if (unhealthy)
889
+ return { ...unhealthy, schema: [...allSchema], fns };
890
+ const degraded = results.find((r) => r.status === "degraded");
891
+ if (degraded)
892
+ return { ...degraded, schema: [...allSchema], fns };
893
+ return { status: "healthy", schema: [...allSchema], fns };
894
+ },
895
+ };
896
+ }
897
+ // Compile-time: Rig structurally satisfies ProtocolInterfaceNode.
898
+ null;