@directive-run/claude-plugin 1.17.2 → 1.19.0

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.
@@ -0,0 +1,424 @@
1
+ # Sources
2
+
3
+ > Covers `@directive-run/core` — `source` primitive: typed external event sources, the inbound dual of effects. Use for Supabase realtime channels, WebSocket message streams, polling timers, browser event listeners — any inbound external event that maps into the module's own event dispatch surface.
4
+
5
+ Sources sit on the same lifecycle plane as effects but mount once per system instance instead of on every fact change. `attach(publish)` runs at `system.start()`; the returned unsubscribe runs at `system.stop()`.
6
+
7
+ ## When to use `source` vs `effect`
8
+
9
+ | Use a **source** when... | Use an **effect** when... |
10
+ |---|---|
11
+ | You're subscribing to an external event stream (WebSocket, Supabase realtime, browser `addEventListener`, polling timer) | You're updating an external system based on fact changes (logging, DOM mutation, analytics ping) |
12
+ | The subscription is **mount-once** for the system's lifetime | You need to re-run when a fact changes (`deps` or `on` predicate) |
13
+ | You want to publish events INTO the system | You only need to react to fact changes |
14
+ | You don't read facts inside the handler | You read facts (`facts.someValue`) inside `run` |
15
+
16
+ If you're hand-rolling `useEffect(() => { const ch = supabase.channel(...).subscribe(); return () => ch.unsubscribe(); }, [])` in React and dispatching `sys.events.X(payload)` from the callback — that's the signature pattern sources replace. Move the subscription into the module declaration; the React hook collapses to `useFact` reads.
17
+
18
+ ## Decision Tree: "How should this subscription work?"
19
+
20
+ ```
21
+ Do I need to subscribe to an external event stream?
22
+ ├── Yes
23
+ │ │
24
+ │ ├─ Does the subscription depend on a fact (e.g. "subscribe when status = active")?
25
+ │ │ ├── Yes → Use `effect` with `deps` or `on` predicate. Effects re-run when facts change.
26
+ │ │ └── No → Use `source`. Mount once at system.start(); tear down at system.stop().
27
+ │ │
28
+ │ ├─ Does the subscription need to read facts inside the callback?
29
+ │ │ ├── Yes → Consider hoisting the fact read up + using `effect`. Sources never read facts.
30
+ │ │ └── No → Use `source`. Pass any static config via closure.
31
+ │ │
32
+ │ └─ Do I need to publish events INTO the system from external input?
33
+ │ └── Yes → Use `source`. The `publish` callback dispatches into the same queue as `system.events.X()`.
34
+
35
+ └── No → You probably want an effect, resolver, or event handler instead.
36
+ ```
37
+
38
+ ## Basic Source
39
+
40
+ ```typescript
41
+ import { createModule, t } from '@directive-run/core';
42
+
43
+ const counter = createModule('counter', {
44
+ schema: {
45
+ facts: { count: t.number() },
46
+ events: { TICK: { delta: t.number() } },
47
+ },
48
+ init: (f) => { f.count = 0; },
49
+ events: {
50
+ TICK: (f, payload) => { f.count = f.count + payload.delta; },
51
+ },
52
+ sources: {
53
+ heartbeat: {
54
+ attach: (publish) => {
55
+ // Subscribe to the external source. The `publish` callback dispatches
56
+ // into the system's event queue (same path as system.events.X()).
57
+ const id = setInterval(() => publish('TICK', { delta: 1 }), 1000);
58
+ // ALWAYS return a cleanup function. The engine calls it at system.stop().
59
+ return () => clearInterval(id);
60
+ },
61
+ },
62
+ },
63
+ });
64
+ ```
65
+
66
+ ## Supabase realtime channel
67
+
68
+ ```typescript
69
+ import { createModule, t } from '@directive-run/core';
70
+ import { createClient } from '@supabase/supabase-js';
71
+
72
+ interface GameUpdateModuleDeps {
73
+ readonly gameId: string;
74
+ readonly subscribeToGameUpdates: (
75
+ onUpdate: (snapshot: GameSnapshot) => void,
76
+ ) => () => void;
77
+ }
78
+
79
+ export function createGameUpdateModule(deps: GameUpdateModuleDeps) {
80
+ return createModule('gameUpdate', {
81
+ schema: {
82
+ facts: { snapshot: t.object<GameSnapshot>().nullable() },
83
+ events: { REALTIME_UPDATE: { snapshot: t.object<GameSnapshot>() } },
84
+ },
85
+ init: (f) => { f.snapshot = null; },
86
+ events: {
87
+ REALTIME_UPDATE: (f, payload) => { f.snapshot = payload.snapshot; },
88
+ },
89
+ sources: {
90
+ // apps/web supplies the Supabase channel via deps. The module
91
+ // declaration stays pure — the source just calls deps and threads
92
+ // the publish callback through.
93
+ gameUpdates: {
94
+ attach: (publish) =>
95
+ deps.subscribeToGameUpdates((snapshot) =>
96
+ publish('REALTIME_UPDATE', { snapshot }),
97
+ ),
98
+ },
99
+ },
100
+ });
101
+ }
102
+
103
+ // In your bootstrap (e.g. apps/web):
104
+ const supabase = createClient(/* ... */);
105
+ const module = createGameUpdateModule({
106
+ gameId: 'g-123',
107
+ subscribeToGameUpdates: (onUpdate) => {
108
+ const channel = supabase
109
+ .channel(`game:g-123`)
110
+ .on('postgres_changes', { event: 'UPDATE', table: 'games', filter: 'id=eq.g-123' },
111
+ (payload) => onUpdate(mapRow(payload.new)))
112
+ .subscribe();
113
+ return () => { supabase.removeChannel(channel); };
114
+ },
115
+ });
116
+ ```
117
+
118
+ ## Browser event listener
119
+
120
+ ```typescript
121
+ sources: {
122
+ online: {
123
+ attach: (publish) => {
124
+ const onOnline = () => publish('CONNECTIVITY_CHANGED', { online: true });
125
+ const onOffline = () => publish('CONNECTIVITY_CHANGED', { online: false });
126
+ window.addEventListener('online', onOnline);
127
+ window.addEventListener('offline', onOffline);
128
+ // Cleanup must remove BOTH listeners.
129
+ return () => {
130
+ window.removeEventListener('online', onOnline);
131
+ window.removeEventListener('offline', onOffline);
132
+ };
133
+ },
134
+ },
135
+ }
136
+ ```
137
+
138
+ ## Lifecycle
139
+
140
+ | Phase | What happens |
141
+ |---|---|
142
+ | `createSystem({ module })` | Sources are recorded in the manager but NOT attached yet |
143
+ | `system.start()` | Each source's `attach(publish)` runs in registration order. Errors are isolated — one failed source does not block others |
144
+ | `system.registerModule(newModule)` (system already running) | New module's sources attach IMMEDIATELY (no need to restart) |
145
+ | `system.stop()` | Each source's returned unsubscribe runs in **reverse** registration order |
146
+ | `system.destroy()` | Calls `stop()` first, so sources detach. After destroy, any stale publish callback the source closed over no-ops (the engine guards against post-destroy dispatch). |
147
+ | `system.start()` again (after stop) | Sources re-attach cleanly. The manager handles the start → stop → start lifecycle without leaking subscriptions. |
148
+
149
+ ## Typed publish
150
+
151
+ The raw `publish: (event: string, payload?: unknown) => void` deliberately uses unchecked string event names — sources don't see the module schema and shouldn't be type-coupled to it. If you want compile-time event-name + payload safety, wrap `publish` once per source:
152
+
153
+ ```typescript
154
+ import type { SourcePublish } from '@directive-run/core';
155
+
156
+ // Build a typed publisher up-front. Match the module's `events:` keys
157
+ // + payload shapes exactly. The TS compiler now catches typos.
158
+ function createPublisher(publish: SourcePublish) {
159
+ return {
160
+ TICK: (delta: number) => publish('TICK', { delta }),
161
+ HEARTBEAT: () => publish('HEARTBEAT'),
162
+ };
163
+ }
164
+
165
+ sources: {
166
+ ticker: {
167
+ attach: (publish) => {
168
+ const p = createPublisher(publish);
169
+ const id = setInterval(() => p.TICK(1), 1000); // ← typed
170
+ return () => clearInterval(id);
171
+ },
172
+ },
173
+ }
174
+ ```
175
+
176
+ The wrapper is purely a DX convention; the runtime path is unchanged. Recommended for any non-trivial source.
177
+
178
+ ## Observation
179
+
180
+ Source lifecycle is fully observable. Subscribe via `system.observe()`:
181
+
182
+ ```typescript
183
+ const unsub = system.observe((event) => {
184
+ switch (event.type) {
185
+ case 'source.attach': console.log(`${event.moduleId}::${event.id} attached`); break;
186
+ case 'source.publish': console.log(`${event.id} → ${event.eventName}`); break;
187
+ case 'source.detach': console.log(`${event.moduleId}::${event.id} detached`); break;
188
+ case 'source.error':
189
+ console.error(`${event.moduleId}::${event.id} ${event.phase} threw:`, event.error);
190
+ break;
191
+ }
192
+ });
193
+ ```
194
+
195
+ `system.inspect().sources` lists the declared sources + `system.inspect().attachedSourceCount` reports how many are currently bound.
196
+
197
+ ## Error handling — runtime errors via `reportError`
198
+
199
+ Per RFC 0008, `attach` receives an optional second argument: a
200
+ `reportError` callback. Authors route mid-flight errors from the
201
+ underlying stream (WebSocket disconnect, Supabase channel goes stale,
202
+ polling fetch throws) through this callback instead of inventing
203
+ magic event names like `STREAM_ERROR`. The manager fires
204
+ `onSourceError` with `phase: "runtime"` — distinct from `"attach"`
205
+ (setup failed) and `"cleanup"` (unsubscribe threw) — so the audit
206
+ ledger, logging plugin, and `inspect().sources[i].lastError`
207
+ attribute mid-flight failures correctly.
208
+
209
+ ```typescript
210
+ sources: {
211
+ socket: {
212
+ attach: (publish, reportError) => {
213
+ const sock = new WebSocket(url);
214
+ sock.addEventListener("message", (e) =>
215
+ publish("MSG", JSON.parse(e.data)),
216
+ );
217
+ sock.addEventListener("error", () =>
218
+ reportError?.(new Error("WebSocket lost connection")),
219
+ );
220
+ return () => sock.close();
221
+ },
222
+ },
223
+ }
224
+ ```
225
+
226
+ `reportError` is optional — short-lived transports that don't surface
227
+ mid-flight errors can ignore it. The callback type is exported as
228
+ `SourceReportError` from `@directive-run/core`. Error messages are
229
+ truncated at 256 chars at the manager boundary so authors who embed
230
+ payloads in error messages get a bounded leak surface.
231
+
232
+ ## Backpressure — `coalesce: "lastWriteWins"`
233
+
234
+ Per RFC 0007, high-frequency sources (cursor moves, sensor telemetry,
235
+ price ticks, Supabase channel storms) declare `coalesce:
236
+ "lastWriteWins"` so the manager debounces same-event-name publishes
237
+ within one microtask. Per-event-name keying means a `priceTick` storm
238
+ coalesces while a one-shot `connected` event in the same tick still
239
+ dispatches.
240
+
241
+ ```typescript
242
+ sources: {
243
+ ticker: {
244
+ attach: (publish) => {
245
+ const id = setInterval(() => publish("TICK", { v: liveValue() }), 4);
246
+ return () => clearInterval(id);
247
+ },
248
+ coalesce: "lastWriteWins", // 250 publishes/sec → 1 dispatch/microtask
249
+ },
250
+ }
251
+ ```
252
+
253
+ Coalesce-dropped publishes bump `dropCount` + record
254
+ `lastDropReason: "coalesced"` on `inspect().sources` so operators
255
+ verify the debouncing is firing on the right sources. The strategy
256
+ is per-source (uniform across event names from that source) —
257
+ splitting strategies per event name requires two source declarations.
258
+
259
+ ## Async-aware teardown — `system.stopAsync()` + DO `onEvict`
260
+
261
+ Per RFC 0009, sources with async unsubscribes (Supabase realtime's
262
+ `channel.unsubscribe()` returns a Promise; Cloudflare DO storage
263
+ flushes return Promises) work with both sync and async teardown:
264
+
265
+ - `system.stop()` (sync) — fires unsubscribes; awaits each one's
266
+ return synchronously (Promise returns are fire-and-forget).
267
+ - `system.stopAsync()` (RFC 0009) — awaits each Promise-returning
268
+ unsubscribe. Use when the caller needs teardown to actually
269
+ complete (e.g. the external broker must drop the subscription
270
+ before the next call).
271
+ - `system.evict(deadline?)` — Cloudflare DO eviction: fires each
272
+ source's optional `onEvict()` in registration order, then
273
+ `destroyAsync()`. Optional deadline races teardown against a
274
+ wall-clock cutoff so the runtime can evict the isolate even if
275
+ some sources hang.
276
+
277
+ ```typescript
278
+ sources: {
279
+ channel: {
280
+ attach: (publish) => {
281
+ const ch = supabase.channel("game").subscribe();
282
+ ch.on("postgres_changes", { event: "UPDATE" }, (p) =>
283
+ publish("ROW_UPDATED", p),
284
+ );
285
+ return () => ch.unsubscribe(); // returns a Promise
286
+ },
287
+ onEvict: async () => {
288
+ // Cloudflare DO hibernate signal: close the channel actively
289
+ // so the broker drops the subscription before the isolate dies.
290
+ await supabase.removeChannel(ch);
291
+ },
292
+ },
293
+ }
294
+ ```
295
+
296
+ Inside a DO `alarm()` or `webSocketClose()` handler:
297
+ `await system.evict(/* deadline */ Date.now() + 5000)`.
298
+
299
+ ## Common Patterns
300
+
301
+ ### Pattern: Source supplies inbound; resolver supplies outbound
302
+
303
+ A Supabase realtime channel both INGESTS messages AND can SEND them back. The split is:
304
+
305
+ ```typescript
306
+ // INGESTING — use a source. Mount once at system.start().
307
+ sources: {
308
+ inbound: {
309
+ attach: (publish) => channel.on('postgres_changes', ..., (p) => publish('UPDATE', p)).subscribe(),
310
+ },
311
+ },
312
+ // SENDING — use a resolver. Fires on a constraint trigger.
313
+ resolvers: {
314
+ sendMessage: {
315
+ requirement: 'SEND_MESSAGE',
316
+ resolve: async (req) => { await supabase.channel('chat').send(req); },
317
+ },
318
+ }
319
+ ```
320
+
321
+ ### Pattern: Roster + reconnect
322
+
323
+ A source for the channel + an effect keyed on `isReconnecting` for refresh logic:
324
+
325
+ ```typescript
326
+ sources: {
327
+ channel: { attach: (publish) => connect(publish) },
328
+ },
329
+ effects: {
330
+ refreshOnReconnect: {
331
+ deps: ['isReconnecting'],
332
+ run: (facts) => { if (facts.isReconnecting) refetch(); },
333
+ },
334
+ }
335
+ ```
336
+
337
+ ### Anti-pattern: subscribe twice
338
+
339
+ Don't subscribe to the same channel inside BOTH an effect and a source. The effect re-runs on fact changes; the source mounts once. You'll get 2× messages with silent duplicates.
340
+
341
+ ```typescript
342
+ // ❌ WRONG
343
+ sources: { channel: { attach: (p) => connect(p) } },
344
+ effects: {
345
+ reconnect: {
346
+ deps: ['userId'],
347
+ run: (f) => { connect(p); }, // ← second subscription!
348
+ },
349
+ },
350
+
351
+ // ✓ RIGHT — single source, key the resubscribe by re-registering the module
352
+ sources: { channel: { attach: (p) => connect(p) } },
353
+ // to change the channel, use system.registerModule() with a fresh module
354
+ // whose source attaches with new parameters.
355
+ ```
356
+
357
+ ### Anti-pattern: async `attach`
358
+
359
+ ```typescript
360
+ // ❌ WRONG — attach is sync. A returned Promise is discarded; the engine
361
+ // has no cleanup function and any await result is unreachable.
362
+ sources: {
363
+ bad: {
364
+ attach: async (publish) => {
365
+ const token = await getAuthToken();
366
+ const ch = subscribe(token, publish);
367
+ return () => ch.unsubscribe(); // ← never registered as cleanup
368
+ },
369
+ },
370
+ },
371
+
372
+ // ✓ RIGHT — do async work inside the subscription's own internals.
373
+ sources: {
374
+ good: {
375
+ attach: (publish) => {
376
+ const ch = subscribe(publish); // sync subscribe stub
377
+ // The subscribe impl can await internally and start publishing once ready.
378
+ // The cleanup function is registered immediately.
379
+ return () => ch.unsubscribe();
380
+ },
381
+ },
382
+ },
383
+ ```
384
+
385
+ ### Anti-pattern: forget the cleanup function
386
+
387
+ ```typescript
388
+ // ❌ WRONG — returns undefined. The engine logs an error + skips the source.
389
+ sources: {
390
+ bad: {
391
+ attach: (publish) => {
392
+ setInterval(() => publish('TICK'), 1000);
393
+ // ← missing return!
394
+ },
395
+ },
396
+ },
397
+
398
+ // ✓ RIGHT — always return a function, even if there's nothing to clean up.
399
+ sources: {
400
+ good: {
401
+ attach: (publish) => {
402
+ const id = setInterval(() => publish('TICK'), 1000);
403
+ return () => clearInterval(id);
404
+ },
405
+ },
406
+ // Or if the source genuinely has no teardown:
407
+ fireAndForget: {
408
+ attach: (publish) => {
409
+ publish('READY');
410
+ return () => undefined;
411
+ },
412
+ },
413
+ }
414
+ ```
415
+
416
+ ## Related
417
+
418
+ - `core-patterns.md` — where sources sit in the constraint-driven module model (decision tree).
419
+ - `multi-module.md` — declaring sources on multiple modules; collision rules.
420
+ - `system-api.md` — `system.start()` / `system.stop()` / `system.inspect()` / `system.observe()`.
421
+ - `anti-patterns.md` (#20) — "subscribe inside an effect / useEffect" anti-pattern; prefer sources.
422
+ - `naming.md` — canonical-term entry for `sources` + cross-paradigm aliases (RxJS Observable, DOM EventTarget, XState callback actor, Supabase realtime).
423
+ - [`../ai/ai-sources.md`](../ai/ai-sources.md) — AI integration: `runStream({ liveContext })`, MCP lifecycle as a source, the `@directive-run/sources/*` adapter subpaths.
424
+ - [`../ai/ai-security.md`](../ai/ai-security.md#sources--pii--closing-the-fact-injection-bypass) — `createFactPIIGuardrail`: closes the source → fact → agent-prompt PII bypass. Wire whenever sources publish into facts the agent reads.