@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.
- package/LICENSE +21 -0
- package/esm/_dnt.shims.d.ts +2 -0
- package/esm/_dnt.shims.d.ts.map +1 -0
- package/esm/_dnt.shims.js +57 -0
- package/esm/byte-entity-shim.d.ts +47 -0
- package/esm/byte-entity-shim.d.ts.map +1 -0
- package/esm/byte-entity-shim.js +138 -0
- package/esm/clients/mod.d.ts +15 -0
- package/esm/clients/mod.d.ts.map +1 -0
- package/esm/clients/mod.js +14 -0
- package/esm/clients/save-client.d.ts +87 -0
- package/esm/clients/save-client.d.ts.map +1 -0
- package/esm/clients/save-client.js +174 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/mod.d.ts +40 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/mod.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/mod.js +31 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/client-console/client.d.ts +30 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/client-console/client.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/client-console/client.js +70 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/encoding/encoding.d.ts +16 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/encoding/encoding.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/encoding/encoding.js +50 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/encrypt/mod.d.ts +235 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/encrypt/mod.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/encrypt/mod.js +619 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/functional-client/functional-client.d.ts +43 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/functional-client/functional-client.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/functional-client/functional-client.js +54 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/match-pattern/match-pattern.d.ts +47 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/match-pattern/match-pattern.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/match-pattern/match-pattern.js +134 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/decorators.d.ts +20 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/decorators.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/decorators.js +34 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/network.d.ts +46 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/network.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/network.js +131 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/peer.d.ts +27 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/peer.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/peer.js +25 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/flood.d.ts +59 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/flood.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/flood.js +188 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/path-vector.d.ts +35 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/path-vector.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/path-vector.js +57 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/tell-and-read.d.ts +117 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/tell-and-read.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/tell-and-read.js +125 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/types.d.ts +112 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/types.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/types.js +21 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/observe-emitter/observe-emitter.d.ts +35 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/observe-emitter/observe-emitter.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/observe-emitter/observe-emitter.js +106 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/connection.d.ts +88 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/connection.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/connection.js +86 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/events.d.ts +83 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/events.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/events.js +115 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/hooks.d.ts +164 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/hooks.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/hooks.js +67 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/identity.d.ts +99 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/identity.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/identity.js +190 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/operation-handle.d.ts +185 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/operation-handle.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/operation-handle.js +103 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/reactions.d.ts +58 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/reactions.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/reactions.js +64 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/rig.d.ts +295 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/rig.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/rig.js +898 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/types.d.ts +167 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/types.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/types.js +5 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/types/types.d.ts +278 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/types/types.d.ts.map +1 -0
- package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/types/types.js +89 -0
- package/esm/dispatch.d.ts +35 -0
- package/esm/dispatch.d.ts.map +1 -0
- package/esm/dispatch.js +40 -0
- package/esm/elasticsearch/mod.d.ts +31 -0
- package/esm/elasticsearch/mod.d.ts.map +1 -0
- package/esm/elasticsearch/mod.js +8 -0
- package/esm/elasticsearch/store.d.ts +44 -0
- package/esm/elasticsearch/store.d.ts.map +1 -0
- package/esm/elasticsearch/store.js +213 -0
- package/esm/entity-store.d.ts +106 -0
- package/esm/entity-store.d.ts.map +1 -0
- package/esm/entity-store.js +46 -0
- package/esm/entity.d.ts +125 -0
- package/esm/entity.d.ts.map +1 -0
- package/esm/entity.js +66 -0
- package/esm/errors.d.ts +23 -0
- package/esm/errors.d.ts.map +1 -0
- package/esm/errors.js +25 -0
- package/esm/fs/mod.d.ts +28 -0
- package/esm/fs/mod.d.ts.map +1 -0
- package/esm/fs/mod.js +13 -0
- package/esm/fs/store.d.ts +45 -0
- package/esm/fs/store.d.ts.map +1 -0
- package/esm/fs/store.js +194 -0
- package/esm/indexeddb/mod.d.ts +9 -0
- package/esm/indexeddb/mod.d.ts.map +1 -0
- package/esm/indexeddb/mod.js +8 -0
- package/esm/indexeddb/store.d.ts +47 -0
- package/esm/indexeddb/store.d.ts.map +1 -0
- package/esm/indexeddb/store.js +299 -0
- package/esm/ipfs/mod.d.ts +24 -0
- package/esm/ipfs/mod.d.ts.map +1 -0
- package/esm/ipfs/mod.js +13 -0
- package/esm/ipfs/store.d.ts +42 -0
- package/esm/ipfs/store.d.ts.map +1 -0
- package/esm/ipfs/store.js +197 -0
- package/esm/localstorage/mod.d.ts +9 -0
- package/esm/localstorage/mod.d.ts.map +1 -0
- package/esm/localstorage/mod.js +8 -0
- package/esm/localstorage/store.d.ts +45 -0
- package/esm/localstorage/store.d.ts.map +1 -0
- package/esm/localstorage/store.js +147 -0
- package/esm/memory/mod.d.ts +12 -0
- package/esm/memory/mod.d.ts.map +1 -0
- package/esm/memory/mod.js +11 -0
- package/esm/memory/store.d.ts +73 -0
- package/esm/memory/store.d.ts.map +1 -0
- package/esm/memory/store.js +300 -0
- package/esm/mod.d.ts +36 -0
- package/esm/mod.d.ts.map +1 -0
- package/esm/mod.js +31 -0
- package/esm/mongo/mod.d.ts +35 -0
- package/esm/mongo/mod.d.ts.map +1 -0
- package/esm/mongo/mod.js +8 -0
- package/esm/mongo/store.d.ts +42 -0
- package/esm/mongo/store.d.ts.map +1 -0
- package/esm/mongo/store.js +185 -0
- package/esm/package.json +3 -0
- package/esm/payload.d.ts +24 -0
- package/esm/payload.d.ts.map +1 -0
- package/esm/payload.js +30 -0
- package/esm/postgres/columns.d.ts +33 -0
- package/esm/postgres/columns.d.ts.map +1 -0
- package/esm/postgres/columns.js +51 -0
- package/esm/postgres/mod.d.ts +19 -0
- package/esm/postgres/mod.d.ts.map +1 -0
- package/esm/postgres/mod.js +9 -0
- package/esm/postgres/store.d.ts +75 -0
- package/esm/postgres/store.d.ts.map +1 -0
- package/esm/postgres/store.js +364 -0
- package/esm/read.d.ts +40 -0
- package/esm/read.d.ts.map +1 -0
- package/esm/read.js +67 -0
- package/esm/s3/mod.d.ts +23 -0
- package/esm/s3/mod.d.ts.map +1 -0
- package/esm/s3/mod.js +13 -0
- package/esm/s3/store.d.ts +46 -0
- package/esm/s3/store.d.ts.map +1 -0
- package/esm/s3/store.js +183 -0
- package/esm/sqlite/mod.d.ts +18 -0
- package/esm/sqlite/mod.d.ts.map +1 -0
- package/esm/sqlite/mod.js +8 -0
- package/esm/sqlite/schema.d.ts +10 -0
- package/esm/sqlite/schema.d.ts.map +1 -0
- package/esm/sqlite/schema.js +26 -0
- package/esm/sqlite/store.d.ts +42 -0
- package/esm/sqlite/store.d.ts.map +1 -0
- package/esm/sqlite/store.js +184 -0
- package/esm/types.d.ts +101 -0
- package/esm/types.d.ts.map +1 -0
- package/esm/types.js +21 -0
- package/esm/url.d.ts +108 -0
- package/esm/url.d.ts.map +1 -0
- package/esm/url.js +151 -0
- 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;
|