@directive-run/sources 0.2.0 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,136 @@
1
1
  # @directive-run/sources
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#55](https://github.com/directive-run/directive/pull/55) [`5c7a2d6`](https://github.com/directive-run/directive/commit/5c7a2d60f71f527e9afd85a67afa36f61fc0bdfc) Thanks [@jasoncomes](https://github.com/jasoncomes)! - R13 audit — 5 remaining Critical fixes to documented surfaces of the source primitive
8
+
9
+ This patch closes the 5 R13 CRITs that affect documented but unreachable
10
+ or misleading public APIs of v1.18.0. With Tier 1 (already merged) +
11
+ this Tier 2, all 10 R13 ship-blocking CRITs are resolved.
12
+
13
+ ### Critical fixes
14
+
15
+ **`System.stopAsync` / `destroyAsync` / `evict` wired through
16
+ `createSystem` wrappers** (R13-C1). Engine implemented these per RFC
17
+ 0009 but neither the single-module wrapper at `system.ts:1178+` nor the
18
+ namespaced wrapper at `system.ts:527+` assigned them, and the
19
+ `SingleModuleSystem` / `NamespacedSystem` / system-config types omitted
20
+ the declarations. Calling `createSystem({...}).stopAsync()` failed at
21
+ TypeScript (`Property 'stopAsync' does not exist`) AND at runtime
22
+ (undefined method). The entire RFC 0009 DO-eviction recipe documented
23
+ in `core/sources.md` was unreachable from the public API. All three
24
+ methods now delegate to the engine; both wrappers participate in the
25
+ `tickInterval` cleanup; added a 6-case regression test
26
+ (`system-async-lifecycle.test.ts`) that exercises the public boundary
27
+ including an async source unsubscribe await.
28
+
29
+ **Cloudflare DO adapters accept `onEvict`** (R13-C5). `sourceFromDOAlarm`
30
+ and `sourceFromWebSocketMessage` are the literal target runtime for RFC
31
+ 0009, yet neither adapter accepted or forwarded an `onEvict` option.
32
+ With this change both adapters expose `onEvict?: () => void | Promise<void>`
33
+ on their options interface. Defaults: `DOAlarm` clears the pending
34
+ alarm via `storage.deleteAlarm()`; `WebSocketMessage` closes the socket
35
+ with code 1001 `"going-away"`. Consumers can override to skip the
36
+ default (e.g. when the runtime hibernates WebSockets natively) or to
37
+ add pre-hibernation work (flush audit log, signal broker). 4 new
38
+ regression tests covering default + custom `onEvict` for both adapters.
39
+
40
+ **`createFactPIIGuardrail` walker recurses into arrays** (R13-C6). The
41
+ walker previously short-circuited on `Array.isArray(value)`, so the
42
+ dominant real-world Supabase realtime shape
43
+ (`payload.new = [{ email, ... }]`) and MCP resource-list notifications
44
+ silently bypassed the Tier 0 guard. The walker now inspects array
45
+ elements at the same depth budget, rebuilding the array if any element
46
+ matched. Maps and Sets remain out of scope by design (consumers must
47
+ wire a `customDetector` for those). 2 new regression tests covering
48
+ both "array of PII objects" and "array of PII strings" shapes.
49
+
50
+ **RFC 0005 `mode` field removed** (R13-C7). The field
51
+ `liveContext.mode: "inject-system-message" | "restart"` shipped on the
52
+ public API but was never read by the impl. The name
53
+ `"inject-system-message"` falsely implied mid-stream injection; the
54
+ actual behavior is abort-and-emit. Since v1.18.0 has not yet shipped,
55
+ the field is removed cleanly (no deprecation tail to maintain). The
56
+ auto-re-prompt semantics will ship in a follow-up RFC + field together
57
+ once their design is settled. RFC 0005 + `ai-sources.md` updated.
58
+
59
+ ### Documentation fixes
60
+
61
+ **MCP source recipe rewritten against the real adapter API** (R13-C8).
62
+ The previous recipe in `ai-sources.md` called `adapter.onConnect(cb) →
63
+ unsubscribe` — a method that doesn't exist on `MCPAdapter`. The actual
64
+ adapter exposes `MCPAdapterConfig.events` as a single callback bag at
65
+ construction time. The rewritten recipe documents the canonical
66
+ "holder + closure" bridge pattern: a `publishRef` variable that the
67
+ source's `attach` populates, with the adapter's `events.onConnect` /
68
+ `onDisconnect` forwarding through it. This is the general pattern for
69
+ bridging any single-callback-bag third-party SDK into a Directive
70
+ source. Recipe also adds the missing `derivations` schema declaration.
71
+
72
+ ### Patch Changes
73
+
74
+ - [#55](https://github.com/directive-run/directive/pull/55) [`9ffd758`](https://github.com/directive-run/directive/commit/9ffd7584914b93ca840ae84372fe3e83c75f29e8) Thanks [@jasoncomes](https://github.com/jasoncomes)! - R13 audit — 5 Critical fixes to documented surfaces of the source primitive
75
+
76
+ The post-merge R13 audit (full 13-lens panel against the merged
77
+ `feat/source-primitive` work) found five Critical issues affecting
78
+ consumer-facing documented APIs of v1.18.0. All five close in this patch.
79
+
80
+ ### Critical fixes
81
+
82
+ **`createFactPIIGuardrail` not exported from `@directive-run/ai/guardrails`
83
+ subpath** (R13-C2). The Tier 0 Mandatory Companion to `liveContext` was
84
+ declared in `guardrails/index.ts` but the actual tsup entry for the
85
+ subpath (`src/guardrails-export.ts`) didn't re-export it. Every recipe in
86
+ `packages/knowledge/ai/ai-sources.md` (Sources × Security section) failed
87
+ at import time: `Module '@directive-run/ai/guardrails' has no exported
88
+ member 'createFactPIIGuardrail'`. Now exported (function + the four
89
+ public types: `FactPIIGuardrailMode`, `FactPIIGuardrailOptions`,
90
+ `FactPIICategory`, `FactPIIMatch`). The internal JSDoc example in
91
+ `fact-pii.ts` also referenced the wrong import path (`@directive-run/ai`
92
+ instead of `@directive-run/ai/guardrails`) — corrected.
93
+
94
+ **`@directive-run/sources` rejected by `@directive-run/sandbox`
95
+ validator** (R13-C3). The sandbox validator's `ALLOWED_DIRECTIVE_PACKAGES`
96
+ set didn't include `sources`, so every playground snippet, MCP
97
+ `run_in_sandbox` call, and docs live runner that imported the umbrella
98
+ package or either subpath (`@directive-run/sources`,
99
+ `@directive-run/sources/supabase`, `@directive-run/sources/cloudflare`)
100
+ hard-failed with `is not allowed in the sandbox` despite the umbrella
101
+ shipping as part of v1.18.0. Added `sources` to the allowlist and added
102
+ two-segment-subpath coverage to the validator test grid.
103
+
104
+ **`sourceFromSupabaseChannel` unsubscribe fires-and-forgets
105
+ `removeChannel`** (R13-C4). The original R5-CR1 issue RFC 0009 was
106
+ designed to close: the adapter returned a sync unsubscribe that did
107
+ `void client.removeChannel(chan)`, so `system.stopAsync()` resolved
108
+ before the Supabase broker dropped the subscription. A subsequent
109
+ `start → stopAsync → start` cycle double-subscribed because the broker
110
+ still held the old channel when the new attach raced in. Per RFC 0009's
111
+ `SourceUnsubscribe = () => void | Promise<void>` widening, the adapter
112
+ now returns `async () => { await client.removeChannel(chan); }`. Engines
113
+ using legacy sync `cleanupAll` still ignore the returned promise — same
114
+ fire-and-forget behavior as before — but the broker drop is now
115
+ observable to consumers using `stopAsync`.
116
+
117
+ ### Documentation fixes
118
+
119
+ **Broken cross-ref anchor** (R13-C9): `packages/knowledge/core/sources.md`
120
+ linked to `ai-security.md#sources-pii--closing-the-fact-injection-bypass`
121
+ with a single hyphen between "sources" and "pii". The actual GFM anchor
122
+ generated from the heading `## Sources × PII — closing the fact-injection
123
+ bypass` has a double hyphen (`×` strips to a kept space). The
124
+ highest-traffic cross-ref in the source primitive doc was landing on a
125
+ 404 anchor. Corrected to `#sources--pii--closing-the-fact-injection-bypass`.
126
+
127
+ **RFCs 0005–0009 status flipped from Draft → Accepted** (R13-C10): all
128
+ five RFCs still carried `Status: Draft (2026-06-07)` even though
129
+ `sources.md` and `ai-sources.md` already cite them as shipped. Readers
130
+ following the link saw Draft headers and concluded the feature was
131
+ design-only. Status now reads: `Accepted — shipped 2026-06-07 in
132
+ feat/source-primitive (PR #52, merge ab97b028); pending v1.18.0 release`.
133
+
3
134
  ## 0.2.0
4
135
 
5
136
  ### Minor Changes
@@ -1,2 +1,2 @@
1
- 'use strict';function u(i){let{storage:e,intervalMs:r,eventName:n,payload:c=()=>({}),onTickRegistered:a}=i;if(r<1)throw new Error(`[Directive] sourceFromDOAlarm: intervalMs must be >= 1, got ${r}`);let o=null;function s(){o&&(o(n,c()),e.setAlarm(Date.now()+r));}return {attach:t=>(o=t,e.setAlarm(Date.now()+r),a?.(s),()=>{o=null,e.deleteAlarm();}),tick:s,meta:{label:`DO alarm: ${n} every ${r}ms`,tags:["source","cloudflare","alarm"]}}}function m(i){let{socket:e,decode:r,closeEvent:n="WEBSOCKET_CLOSED",errorEvent:c="WEBSOCKET_ERROR"}=i;return {attach:a=>{let o=t=>{let d=r(t.data);d!==null&&a(d.name,d.payload);},s=t=>{n!==null&&a(n,{code:t.code??1e3,reason:t.reason??""});},l=()=>{c!==null&&a(c,{});};return e.addEventListener("message",o),e.addEventListener("close",s),e.addEventListener("error",l),()=>{e.removeEventListener("message",o),e.removeEventListener("close",s),e.removeEventListener("error",l);}},meta:{label:"Cloudflare WebSocket message stream",tags:["source","cloudflare","websocket"]}}}exports.sourceFromDOAlarm=u;exports.sourceFromWebSocketMessage=m;//# sourceMappingURL=cloudflare.cjs.map
1
+ 'use strict';function m(i){let{storage:e,intervalMs:o,eventName:n,payload:s=()=>({}),onTickRegistered:c,onEvict:v}=i;if(o<1)throw new Error(`[Directive] sourceFromDOAlarm: intervalMs must be >= 1, got ${o}`);let r=null;function a(){r&&(r(n,s()),e.setAlarm(Date.now()+o));}return {attach:t=>(r=t,e.setAlarm(Date.now()+o),c?.(a),()=>{r=null,e.deleteAlarm();}),onEvict:v??(async()=>{await e.deleteAlarm();}),tick:a,meta:{label:`DO alarm: ${n} every ${o}ms`,tags:["source","cloudflare","alarm"]}}}function g(i){let{socket:e,decode:o,closeEvent:n="WEBSOCKET_CLOSED",errorEvent:s="WEBSOCKET_ERROR",onEvict:c}=i;return {attach:r=>{let a=t=>{let u=o(t.data);u!==null&&r(u.name,u.payload);},l=t=>{n!==null&&r(n,{code:t.code??1e3,reason:t.reason??""});},d=()=>{s!==null&&r(s,{});};return e.addEventListener("message",a),e.addEventListener("close",l),e.addEventListener("error",d),()=>{e.removeEventListener("message",a),e.removeEventListener("close",l),e.removeEventListener("error",d);}},onEvict:c??(()=>{try{e.close(1001,"going-away");}catch{}}),meta:{label:"Cloudflare WebSocket message stream",tags:["source","cloudflare","websocket"]}}}exports.sourceFromDOAlarm=m;exports.sourceFromWebSocketMessage=g;//# sourceMappingURL=cloudflare.cjs.map
2
2
  //# sourceMappingURL=cloudflare.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/cloudflare.ts"],"names":["sourceFromDOAlarm","options","storage","intervalMs","eventName","payload","onTickRegistered","activePublish","tick","publish","sourceFromWebSocketMessage","socket","decode","closeEvent","errorEvent","onMessage","event","decoded","onClose","onError"],"mappings":"aA8GO,SAASA,CAAAA,CACdC,EAC8B,CAC9B,GAAM,CACJ,OAAA,CAAAC,CAAAA,CACA,UAAA,CAAAC,CAAAA,CACA,SAAA,CAAAC,CAAAA,CACA,QAAAC,CAAAA,CAAU,KAAO,EAAC,CAAA,CAClB,gBAAA,CAAAC,CACF,CAAA,CAAIL,CAAAA,CAEJ,GAAIE,CAAAA,CAAa,CAAA,CACf,MAAM,IAAI,KAAA,CACR,CAAA,4DAAA,EAA+DA,CAAU,CAAA,CAC3E,CAAA,CAGF,IAAII,CAAAA,CAAsC,IAAA,CAE1C,SAASC,CAAAA,EAAa,CACfD,CAAAA,GACLA,EAAcH,CAAAA,CAAWC,CAAAA,EAAS,CAAA,CAI7BH,CAAAA,CAAQ,QAAA,CAAS,KAAK,GAAA,EAAI,CAAIC,CAAU,CAAA,EAC/C,CAuBA,OArB0C,CACxC,MAAA,CAASM,CAAAA,GACPF,EAAgBE,CAAAA,CAGXP,CAAAA,CAAQ,SAAS,IAAA,CAAK,GAAA,EAAI,CAAIC,CAAU,CAAA,CAC7CG,CAAAA,GAAmBE,CAAI,CAAA,CAChB,IAAM,CACXD,CAAAA,CAAgB,IAAA,CAGXL,CAAAA,CAAQ,cACf,CAAA,CAAA,CAEF,IAAA,CAAAM,CAAAA,CACA,IAAA,CAAM,CACJ,MAAO,CAAA,UAAA,EAAaJ,CAAS,UAAUD,CAAU,CAAA,EAAA,CAAA,CACjD,KAAM,CAAC,QAAA,CAAU,YAAA,CAAc,OAAO,CACxC,CACF,CAGF,CAyDO,SAASO,CAAAA,CACdT,CAAAA,CACW,CACX,GAAM,CACJ,MAAA,CAAAU,CAAAA,CACA,MAAA,CAAAC,CAAAA,CACA,UAAA,CAAAC,CAAAA,CAAa,mBACb,UAAA,CAAAC,CAAAA,CAAa,iBACf,CAAA,CAAIb,CAAAA,CAEJ,OAAO,CACL,MAAA,CAASQ,CAAAA,EAA2B,CAClC,IAAMM,CAAAA,CAAaC,GAA8B,CAC/C,IAAMC,CAAAA,CAAUL,CAAAA,CAAOI,CAAAA,CAAM,IAAI,EAC7BC,CAAAA,GAAY,IAAA,EAChBR,CAAAA,CAAQQ,CAAAA,CAAQ,IAAA,CAAMA,CAAAA,CAAQ,OAAO,EACvC,CAAA,CACMC,EAAWF,CAAAA,EAA8C,CACzDH,IAAe,IAAA,EACnBJ,CAAAA,CAAQI,CAAAA,CAAY,CAClB,IAAA,CAAMG,CAAAA,CAAM,MAAQ,GAAA,CACpB,MAAA,CAAQA,CAAAA,CAAM,MAAA,EAAU,EAC1B,CAAC,EACH,CAAA,CACMG,CAAAA,CAAU,IAAM,CAChBL,CAAAA,GAAe,IAAA,EACnBL,EAAQK,CAAAA,CAAY,EAAE,EACxB,CAAA,CAEA,OAAAH,CAAAA,CAAO,gBAAA,CAAiB,SAAA,CAAWI,CAAS,CAAA,CAC5CJ,CAAAA,CAAO,iBAAiB,OAAA,CAASO,CAAO,CAAA,CACxCP,CAAAA,CAAO,gBAAA,CAAiB,OAAA,CAASQ,CAAO,CAAA,CAEjC,IAAM,CACXR,CAAAA,CAAO,mBAAA,CAAoB,SAAA,CAAWI,CAAS,CAAA,CAC/CJ,CAAAA,CAAO,oBAAoB,OAAA,CAASO,CAAO,EAC3CP,CAAAA,CAAO,mBAAA,CAAoB,OAAA,CAASQ,CAAO,EAC7C,CACF,EACA,IAAA,CAAM,CACJ,KAAA,CAAO,qCAAA,CACP,IAAA,CAAM,CAAC,SAAU,YAAA,CAAc,WAAW,CAC5C,CACF,CACF","file":"cloudflare.cjs","sourcesContent":["/**\n * @directive-run/sources/cloudflare\n *\n * Bridges Cloudflare-specific runtime primitives into the Directive\n * `source` primitive:\n *\n * - **`sourceFromDOAlarm`** — Durable Object alarms as a typed\n * periodic source. Replaces hand-rolled `setInterval` inside `attach`\n * (which dies on hibernation) with a storage-backed alarm that\n * survives eviction.\n * - **`sourceFromWebSocketMessage`** — DO `WebSocket` connection\n * (Cloudflare's `webSocketAccept` flow) as a typed message source.\n *\n * Both adapters integrate with the source primitive's lifecycle, so\n * `system.stop()` cleans up via the storage / socket teardown paths.\n *\n * @example DO alarm as a 30-second tick source\n * ```ts\n * import { sourceFromDOAlarm } from '@directive-run/sources/cloudflare';\n *\n * export class TickerDO {\n * constructor(public state: DurableObjectState) {}\n *\n * async fetch(req: Request) {\n * const system = createSystem({\n * module: createModule('ticker', {\n * schema: {\n * facts: { lastTick: t.number() },\n * events: { TICK: { at: t.number() } },\n * },\n * init: (f) => { f.lastTick = 0; },\n * events: { TICK: (f, p) => { f.lastTick = p.at; } },\n * sources: {\n * alarm: sourceFromDOAlarm({\n * storage: this.state.storage,\n * intervalMs: 30_000,\n * eventName: 'TICK',\n * payload: () => ({ at: Date.now() }),\n * }),\n * },\n * }),\n * });\n * system.start();\n * return new Response('ok');\n * }\n *\n * // DO runtime calls alarm() on each scheduled tick; the source\n * // adapter's storage key triggers a publish on the active system.\n * async alarm() {\n * // The DO runtime calls this; the source publishes via the\n * // shared module-level event bus (the active system observes).\n * }\n * }\n * ```\n */\n\nimport type { SourceDef, SourcePublish } from \"@directive-run/core\";\n\n// ============================================================================\n// Type stubs for the Cloudflare Workers / DO API surface we touch\n// ============================================================================\n\ninterface DurableObjectStorage {\n setAlarm(scheduledTime: number | Date): Promise<void>;\n deleteAlarm(): Promise<void>;\n getAlarm(): Promise<number | null>;\n}\n\n// ============================================================================\n// sourceFromDOAlarm — Durable Object alarm as a periodic source\n// ============================================================================\n\nexport interface DOAlarmSourceOptions {\n /** The DO's `state.storage` handle. */\n storage: DurableObjectStorage;\n /** Tick interval in milliseconds. Minimum 1ms. */\n intervalMs: number;\n /**\n * Event name to publish on every tick. Must match an event declared\n * on the module's schema (otherwise the engine drops the publish with\n * `lastDropReason: 'invalid-event-name'` per the R6 telemetry).\n */\n eventName: string;\n /**\n * Payload factory. Called on every tick. Default: `() => ({})`.\n */\n payload?: () => Record<string, unknown>;\n /**\n * Optional hook for the consumer to wire the DO's `alarm()` callback\n * back into this source. The adapter cannot intercept the DO runtime's\n * `alarm()` call directly (it's a class method); the consumer's\n * `alarm()` handler should call `adapter.tick()` to drive the publish.\n *\n * Returned from `sourceFromDOAlarm.exposeTick(source)` (see below).\n */\n onTickRegistered?: (tick: () => void) => void;\n}\n\n/**\n * Build a `SourceDef` that schedules a DO alarm every `intervalMs` and\n * publishes on every tick. The adapter manages alarm scheduling via\n * `state.storage.setAlarm()`; on `system.stop()` it clears the alarm.\n *\n * **Important wiring step:** the DO's `alarm()` instance method MUST\n * call the adapter's tick callback. Capture it via `onTickRegistered`\n * (or by stashing the source in a class field and invoking\n * `source.tick()` from your alarm method).\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromDOAlarm(\n options: DOAlarmSourceOptions,\n): SourceDef & { tick(): void } {\n const {\n storage,\n intervalMs,\n eventName,\n payload = () => ({}),\n onTickRegistered,\n } = options;\n\n if (intervalMs < 1) {\n throw new Error(\n `[Directive] sourceFromDOAlarm: intervalMs must be >= 1, got ${intervalMs}`,\n );\n }\n\n let activePublish: SourcePublish | null = null;\n\n function tick(): void {\n if (!activePublish) return;\n activePublish(eventName, payload());\n // Schedule the next alarm. Fire-and-forget — the host runtime\n // handles delivery; failures land via the source primitive's\n // observation events.\n void storage.setAlarm(Date.now() + intervalMs);\n }\n\n const def: SourceDef & { tick(): void } = {\n attach: (publish: SourcePublish) => {\n activePublish = publish;\n // Schedule the first alarm. The DO's `alarm()` method must call\n // `tick()` to drive subsequent publishes.\n void storage.setAlarm(Date.now() + intervalMs);\n onTickRegistered?.(tick);\n return () => {\n activePublish = null;\n // Clear any pending alarm so the DO doesn't wake the dead\n // system after stop.\n void storage.deleteAlarm();\n };\n },\n tick,\n meta: {\n label: `DO alarm: ${eventName} every ${intervalMs}ms`,\n tags: [\"source\", \"cloudflare\", \"alarm\"],\n },\n };\n\n return def;\n}\n\n// ============================================================================\n// sourceFromWebSocketMessage — DO WebSocket message stream as a source\n// ============================================================================\n\ninterface CloudflareWebSocket {\n send(data: string | ArrayBuffer): void;\n close(code?: number, reason?: string): void;\n addEventListener(\n type: \"message\" | \"close\" | \"error\",\n handler: (event: {\n data?: unknown;\n code?: number;\n reason?: string;\n }) => void,\n ): void;\n removeEventListener(\n type: \"message\" | \"close\" | \"error\",\n handler: (event: {\n data?: unknown;\n code?: number;\n reason?: string;\n }) => void,\n ): void;\n}\n\nexport interface WebSocketMessageSourceOptions {\n /** The Cloudflare WebSocket (`server` half of the pair from `webSocketAccept`). */\n socket: CloudflareWebSocket;\n /**\n * Decode each `MessageEvent.data` into a typed Directive event.\n * Return `null` to drop the message (e.g. ping frames).\n */\n decode: (\n data: unknown,\n ) => { name: string; payload: Record<string, unknown> } | null;\n /**\n * Event name to publish when the socket closes. Default `\"WEBSOCKET_CLOSED\"`.\n * Set `null` to skip publishing on close.\n */\n closeEvent?: string | null;\n /**\n * Event name to publish on socket errors. Default `\"WEBSOCKET_ERROR\"`.\n * Set `null` to skip publishing on error.\n */\n errorEvent?: string | null;\n}\n\n/**\n * Build a `SourceDef` that listens on a Cloudflare WebSocket and\n * publishes each decoded message as a typed Directive event. Wraps\n * the standard `addEventListener('message' | 'close' | 'error')`\n * surface.\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromWebSocketMessage(\n options: WebSocketMessageSourceOptions,\n): SourceDef {\n const {\n socket,\n decode,\n closeEvent = \"WEBSOCKET_CLOSED\",\n errorEvent = \"WEBSOCKET_ERROR\",\n } = options;\n\n return {\n attach: (publish: SourcePublish) => {\n const onMessage = (event: { data?: unknown }) => {\n const decoded = decode(event.data);\n if (decoded === null) return;\n publish(decoded.name, decoded.payload);\n };\n const onClose = (event: { code?: number; reason?: string }) => {\n if (closeEvent === null) return;\n publish(closeEvent, {\n code: event.code ?? 1000,\n reason: event.reason ?? \"\",\n });\n };\n const onError = () => {\n if (errorEvent === null) return;\n publish(errorEvent, {});\n };\n\n socket.addEventListener(\"message\", onMessage);\n socket.addEventListener(\"close\", onClose);\n socket.addEventListener(\"error\", onError);\n\n return () => {\n socket.removeEventListener(\"message\", onMessage);\n socket.removeEventListener(\"close\", onClose);\n socket.removeEventListener(\"error\", onError);\n };\n },\n meta: {\n label: \"Cloudflare WebSocket message stream\",\n tags: [\"source\", \"cloudflare\", \"websocket\"],\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/cloudflare.ts"],"names":["sourceFromDOAlarm","options","storage","intervalMs","eventName","payload","onTickRegistered","onEvict","activePublish","tick","publish","sourceFromWebSocketMessage","socket","decode","closeEvent","errorEvent","onMessage","event","decoded","onClose","onError"],"mappings":"aAuHO,SAASA,CAAAA,CACdC,CAAAA,CAC8B,CAC9B,GAAM,CACJ,OAAA,CAAAC,CAAAA,CACA,UAAA,CAAAC,CAAAA,CACA,SAAA,CAAAC,CAAAA,CACA,OAAA,CAAAC,CAAAA,CAAU,KAAO,EAAC,CAAA,CAClB,gBAAA,CAAAC,CAAAA,CACA,OAAA,CAAAC,CACF,CAAA,CAAIN,CAAAA,CAEJ,GAAIE,CAAAA,CAAa,CAAA,CACf,MAAM,IAAI,KAAA,CACR,+DAA+DA,CAAU,CAAA,CAC3E,CAAA,CAGF,IAAIK,CAAAA,CAAsC,IAAA,CAE1C,SAASC,CAAAA,EAAa,CACfD,CAAAA,GACLA,CAAAA,CAAcJ,CAAAA,CAAWC,CAAAA,EAAS,EAI7BH,CAAAA,CAAQ,QAAA,CAAS,IAAA,CAAK,GAAA,EAAI,CAAIC,CAAU,CAAA,EAC/C,CAiCA,OAtB0C,CACxC,MAAA,CAASO,CAAAA,GACPF,CAAAA,CAAgBE,CAAAA,CAGXR,EAAQ,QAAA,CAAS,IAAA,CAAK,GAAA,EAAI,CAAIC,CAAU,CAAA,CAC7CG,CAAAA,GAAmBG,CAAI,CAAA,CAChB,IAAM,CACXD,CAAAA,CAAgB,IAAA,CAGXN,CAAAA,CAAQ,cACf,CAAA,CAAA,CAEF,OAAA,CAnBAK,CAAAA,GACC,SAAY,CACX,MAAML,CAAAA,CAAQ,WAAA,GAChB,CAAA,CAAA,CAiBA,IAAA,CAAAO,CAAAA,CACA,IAAA,CAAM,CACJ,KAAA,CAAO,CAAA,UAAA,EAAaL,CAAS,CAAA,OAAA,EAAUD,CAAU,CAAA,EAAA,CAAA,CACjD,IAAA,CAAM,CAAC,QAAA,CAAU,YAAA,CAAc,OAAO,CACxC,CACF,CAGF,CAmEO,SAASQ,CAAAA,CACdV,CAAAA,CACW,CACX,GAAM,CACJ,MAAA,CAAAW,CAAAA,CACA,MAAA,CAAAC,CAAAA,CACA,UAAA,CAAAC,CAAAA,CAAa,kBAAA,CACb,UAAA,CAAAC,CAAAA,CAAa,kBACb,OAAA,CAAAR,CACF,CAAA,CAAIN,CAAAA,CAgBJ,OAAO,CACL,MAAA,CAASS,CAAAA,EAA2B,CAClC,IAAMM,CAAAA,CAAaC,CAAAA,EAA8B,CAC/C,IAAMC,EAAUL,CAAAA,CAAOI,CAAAA,CAAM,IAAI,CAAA,CAC7BC,CAAAA,GAAY,IAAA,EAChBR,CAAAA,CAAQQ,CAAAA,CAAQ,IAAA,CAAMA,CAAAA,CAAQ,OAAO,EACvC,CAAA,CACMC,CAAAA,CAAWF,GAA8C,CACzDH,CAAAA,GAAe,IAAA,EACnBJ,CAAAA,CAAQI,CAAAA,CAAY,CAClB,IAAA,CAAMG,CAAAA,CAAM,IAAA,EAAQ,GAAA,CACpB,MAAA,CAAQA,CAAAA,CAAM,MAAA,EAAU,EAC1B,CAAC,EACH,CAAA,CACMG,CAAAA,CAAU,IAAM,CAChBL,CAAAA,GAAe,IAAA,EACnBL,CAAAA,CAAQK,CAAAA,CAAY,EAAE,EACxB,CAAA,CAEA,OAAAH,CAAAA,CAAO,iBAAiB,SAAA,CAAWI,CAAS,CAAA,CAC5CJ,CAAAA,CAAO,gBAAA,CAAiB,OAAA,CAASO,CAAO,CAAA,CACxCP,CAAAA,CAAO,gBAAA,CAAiB,OAAA,CAASQ,CAAO,CAAA,CAEjC,IAAM,CACXR,CAAAA,CAAO,mBAAA,CAAoB,SAAA,CAAWI,CAAS,CAAA,CAC/CJ,CAAAA,CAAO,mBAAA,CAAoB,OAAA,CAASO,CAAO,CAAA,CAC3CP,CAAAA,CAAO,mBAAA,CAAoB,OAAA,CAASQ,CAAO,EAC7C,CACF,CAAA,CACA,OAAA,CAtCAb,CAAAA,GACC,IAAM,CACL,GAAI,CACFK,CAAAA,CAAO,KAAA,CAAM,IAAA,CAAM,YAAY,EACjC,CAAA,KAAQ,CAER,CACF,CAAA,CAAA,CAgCA,IAAA,CAAM,CACJ,KAAA,CAAO,qCAAA,CACP,IAAA,CAAM,CAAC,QAAA,CAAU,YAAA,CAAc,WAAW,CAC5C,CACF,CACF","file":"cloudflare.cjs","sourcesContent":["/**\n * @directive-run/sources/cloudflare\n *\n * Bridges Cloudflare-specific runtime primitives into the Directive\n * `source` primitive:\n *\n * - **`sourceFromDOAlarm`** — Durable Object alarms as a typed\n * periodic source. Replaces hand-rolled `setInterval` inside `attach`\n * (which dies on hibernation) with a storage-backed alarm that\n * survives eviction.\n * - **`sourceFromWebSocketMessage`** — DO `WebSocket` connection\n * (Cloudflare's `webSocketAccept` flow) as a typed message source.\n *\n * Both adapters integrate with the source primitive's lifecycle, so\n * `system.stop()` cleans up via the storage / socket teardown paths.\n *\n * @example DO alarm as a 30-second tick source\n * ```ts\n * import { sourceFromDOAlarm } from '@directive-run/sources/cloudflare';\n *\n * export class TickerDO {\n * constructor(public state: DurableObjectState) {}\n *\n * async fetch(req: Request) {\n * const system = createSystem({\n * module: createModule('ticker', {\n * schema: {\n * facts: { lastTick: t.number() },\n * events: { TICK: { at: t.number() } },\n * },\n * init: (f) => { f.lastTick = 0; },\n * events: { TICK: (f, p) => { f.lastTick = p.at; } },\n * sources: {\n * alarm: sourceFromDOAlarm({\n * storage: this.state.storage,\n * intervalMs: 30_000,\n * eventName: 'TICK',\n * payload: () => ({ at: Date.now() }),\n * }),\n * },\n * }),\n * });\n * system.start();\n * return new Response('ok');\n * }\n *\n * // DO runtime calls alarm() on each scheduled tick; the source\n * // adapter's storage key triggers a publish on the active system.\n * async alarm() {\n * // The DO runtime calls this; the source publishes via the\n * // shared module-level event bus (the active system observes).\n * }\n * }\n * ```\n */\n\nimport type { SourceDef, SourcePublish } from \"@directive-run/core\";\n\n// ============================================================================\n// Type stubs for the Cloudflare Workers / DO API surface we touch\n// ============================================================================\n\ninterface DurableObjectStorage {\n setAlarm(scheduledTime: number | Date): Promise<void>;\n deleteAlarm(): Promise<void>;\n getAlarm(): Promise<number | null>;\n}\n\n// ============================================================================\n// sourceFromDOAlarm — Durable Object alarm as a periodic source\n// ============================================================================\n\nexport interface DOAlarmSourceOptions {\n /** The DO's `state.storage` handle. */\n storage: DurableObjectStorage;\n /** Tick interval in milliseconds. Minimum 1ms. */\n intervalMs: number;\n /**\n * Event name to publish on every tick. Must match an event declared\n * on the module's schema (otherwise the engine drops the publish with\n * `lastDropReason: 'invalid-event-name'` per the R6 telemetry).\n */\n eventName: string;\n /**\n * Payload factory. Called on every tick. Default: `() => ({})`.\n */\n payload?: () => Record<string, unknown>;\n /**\n * Optional hook for the consumer to wire the DO's `alarm()` callback\n * back into this source. The adapter cannot intercept the DO runtime's\n * `alarm()` call directly (it's a class method); the consumer's\n * `alarm()` handler should call `adapter.tick()` to drive the publish.\n *\n * Returned from `sourceFromDOAlarm.exposeTick(source)` (see below).\n */\n onTickRegistered?: (tick: () => void) => void;\n /**\n * RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the\n * DO runtime signals an upcoming hibernation. Clear the alarm here so\n * the DO doesn't wake just to fire a publish into a torn-down system.\n * The default `onEvict` (used when this option is omitted) deletes the\n * alarm via `storage.deleteAlarm()`; supply your own to add custom\n * pre-hibernation work (flush an audit log, signal the broker, etc.).\n */\n onEvict?: () => void | Promise<void>;\n}\n\n/**\n * Build a `SourceDef` that schedules a DO alarm every `intervalMs` and\n * publishes on every tick. The adapter manages alarm scheduling via\n * `state.storage.setAlarm()`; on `system.stop()` it clears the alarm.\n *\n * **Important wiring step:** the DO's `alarm()` instance method MUST\n * call the adapter's tick callback. Capture it via `onTickRegistered`\n * (or by stashing the source in a class field and invoking\n * `source.tick()` from your alarm method).\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromDOAlarm(\n options: DOAlarmSourceOptions,\n): SourceDef & { tick(): void } {\n const {\n storage,\n intervalMs,\n eventName,\n payload = () => ({}),\n onTickRegistered,\n onEvict,\n } = options;\n\n if (intervalMs < 1) {\n throw new Error(\n `[Directive] sourceFromDOAlarm: intervalMs must be >= 1, got ${intervalMs}`,\n );\n }\n\n let activePublish: SourcePublish | null = null;\n\n function tick(): void {\n if (!activePublish) return;\n activePublish(eventName, payload());\n // Schedule the next alarm. Fire-and-forget — the host runtime\n // handles delivery; failures land via the source primitive's\n // observation events.\n void storage.setAlarm(Date.now() + intervalMs);\n }\n\n // Default onEvict: drop the pending alarm so the DO doesn't wake\n // post-hibernation just to fire into a torn-down system. RFC 0009\n // motivates the hook; this adapter is the literal target runtime.\n const evictHandler =\n onEvict ??\n (async () => {\n await storage.deleteAlarm();\n });\n\n const def: SourceDef & { tick(): void } = {\n attach: (publish: SourcePublish) => {\n activePublish = publish;\n // Schedule the first alarm. The DO's `alarm()` method must call\n // `tick()` to drive subsequent publishes.\n void storage.setAlarm(Date.now() + intervalMs);\n onTickRegistered?.(tick);\n return () => {\n activePublish = null;\n // Clear any pending alarm so the DO doesn't wake the dead\n // system after stop.\n void storage.deleteAlarm();\n };\n },\n onEvict: evictHandler,\n tick,\n meta: {\n label: `DO alarm: ${eventName} every ${intervalMs}ms`,\n tags: [\"source\", \"cloudflare\", \"alarm\"],\n },\n };\n\n return def;\n}\n\n// ============================================================================\n// sourceFromWebSocketMessage — DO WebSocket message stream as a source\n// ============================================================================\n\ninterface CloudflareWebSocket {\n send(data: string | ArrayBuffer): void;\n close(code?: number, reason?: string): void;\n addEventListener(\n type: \"message\" | \"close\" | \"error\",\n handler: (event: {\n data?: unknown;\n code?: number;\n reason?: string;\n }) => void,\n ): void;\n removeEventListener(\n type: \"message\" | \"close\" | \"error\",\n handler: (event: {\n data?: unknown;\n code?: number;\n reason?: string;\n }) => void,\n ): void;\n}\n\nexport interface WebSocketMessageSourceOptions {\n /** The Cloudflare WebSocket (`server` half of the pair from `webSocketAccept`). */\n socket: CloudflareWebSocket;\n /**\n * Decode each `MessageEvent.data` into a typed Directive event.\n * Return `null` to drop the message (e.g. ping frames).\n */\n decode: (\n data: unknown,\n ) => { name: string; payload: Record<string, unknown> } | null;\n /**\n * Event name to publish when the socket closes. Default `\"WEBSOCKET_CLOSED\"`.\n * Set `null` to skip publishing on close.\n */\n closeEvent?: string | null;\n /**\n * Event name to publish on socket errors. Default `\"WEBSOCKET_ERROR\"`.\n * Set `null` to skip publishing on error.\n */\n errorEvent?: string | null;\n /**\n * RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the\n * DO runtime signals an upcoming hibernation. The default `onEvict`\n * closes the socket with code 1001 (`\"going-away\"`) so the broker sees\n * a clean disconnect and a re-attach after hibernation negotiates a\n * fresh session. Supply your own to skip the close (e.g., when the\n * runtime is hibernating WebSockets natively and the broker holds the\n * connection for resumption) or to add custom pre-hibernation work.\n */\n onEvict?: () => void | Promise<void>;\n}\n\n/**\n * Build a `SourceDef` that listens on a Cloudflare WebSocket and\n * publishes each decoded message as a typed Directive event. Wraps\n * the standard `addEventListener('message' | 'close' | 'error')`\n * surface.\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromWebSocketMessage(\n options: WebSocketMessageSourceOptions,\n): SourceDef {\n const {\n socket,\n decode,\n closeEvent = \"WEBSOCKET_CLOSED\",\n errorEvent = \"WEBSOCKET_ERROR\",\n onEvict,\n } = options;\n\n // Default onEvict: close the socket so the broker sees a clean\n // disconnect ahead of the DO hibernating. Consumers who let the\n // runtime hibernate the WebSocket natively can opt out by supplying\n // their own no-op `onEvict`.\n const evictHandler =\n onEvict ??\n (() => {\n try {\n socket.close(1001, \"going-away\");\n } catch {\n // Already closed; ignore.\n }\n });\n\n return {\n attach: (publish: SourcePublish) => {\n const onMessage = (event: { data?: unknown }) => {\n const decoded = decode(event.data);\n if (decoded === null) return;\n publish(decoded.name, decoded.payload);\n };\n const onClose = (event: { code?: number; reason?: string }) => {\n if (closeEvent === null) return;\n publish(closeEvent, {\n code: event.code ?? 1000,\n reason: event.reason ?? \"\",\n });\n };\n const onError = () => {\n if (errorEvent === null) return;\n publish(errorEvent, {});\n };\n\n socket.addEventListener(\"message\", onMessage);\n socket.addEventListener(\"close\", onClose);\n socket.addEventListener(\"error\", onError);\n\n return () => {\n socket.removeEventListener(\"message\", onMessage);\n socket.removeEventListener(\"close\", onClose);\n socket.removeEventListener(\"error\", onError);\n };\n },\n onEvict: evictHandler,\n meta: {\n label: \"Cloudflare WebSocket message stream\",\n tags: [\"source\", \"cloudflare\", \"websocket\"],\n },\n };\n}\n"]}
@@ -85,6 +85,15 @@ interface DOAlarmSourceOptions {
85
85
  * Returned from `sourceFromDOAlarm.exposeTick(source)` (see below).
86
86
  */
87
87
  onTickRegistered?: (tick: () => void) => void;
88
+ /**
89
+ * RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the
90
+ * DO runtime signals an upcoming hibernation. Clear the alarm here so
91
+ * the DO doesn't wake just to fire a publish into a torn-down system.
92
+ * The default `onEvict` (used when this option is omitted) deletes the
93
+ * alarm via `storage.deleteAlarm()`; supply your own to add custom
94
+ * pre-hibernation work (flush an audit log, signal the broker, etc.).
95
+ */
96
+ onEvict?: () => void | Promise<void>;
88
97
  }
89
98
  /**
90
99
  * Build a `SourceDef` that schedules a DO alarm every `intervalMs` and
@@ -136,6 +145,16 @@ interface WebSocketMessageSourceOptions {
136
145
  * Set `null` to skip publishing on error.
137
146
  */
138
147
  errorEvent?: string | null;
148
+ /**
149
+ * RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the
150
+ * DO runtime signals an upcoming hibernation. The default `onEvict`
151
+ * closes the socket with code 1001 (`"going-away"`) so the broker sees
152
+ * a clean disconnect and a re-attach after hibernation negotiates a
153
+ * fresh session. Supply your own to skip the close (e.g., when the
154
+ * runtime is hibernating WebSockets natively and the broker holds the
155
+ * connection for resumption) or to add custom pre-hibernation work.
156
+ */
157
+ onEvict?: () => void | Promise<void>;
139
158
  }
140
159
  /**
141
160
  * Build a `SourceDef` that listens on a Cloudflare WebSocket and
@@ -85,6 +85,15 @@ interface DOAlarmSourceOptions {
85
85
  * Returned from `sourceFromDOAlarm.exposeTick(source)` (see below).
86
86
  */
87
87
  onTickRegistered?: (tick: () => void) => void;
88
+ /**
89
+ * RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the
90
+ * DO runtime signals an upcoming hibernation. Clear the alarm here so
91
+ * the DO doesn't wake just to fire a publish into a torn-down system.
92
+ * The default `onEvict` (used when this option is omitted) deletes the
93
+ * alarm via `storage.deleteAlarm()`; supply your own to add custom
94
+ * pre-hibernation work (flush an audit log, signal the broker, etc.).
95
+ */
96
+ onEvict?: () => void | Promise<void>;
88
97
  }
89
98
  /**
90
99
  * Build a `SourceDef` that schedules a DO alarm every `intervalMs` and
@@ -136,6 +145,16 @@ interface WebSocketMessageSourceOptions {
136
145
  * Set `null` to skip publishing on error.
137
146
  */
138
147
  errorEvent?: string | null;
148
+ /**
149
+ * RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the
150
+ * DO runtime signals an upcoming hibernation. The default `onEvict`
151
+ * closes the socket with code 1001 (`"going-away"`) so the broker sees
152
+ * a clean disconnect and a re-attach after hibernation negotiates a
153
+ * fresh session. Supply your own to skip the close (e.g., when the
154
+ * runtime is hibernating WebSockets natively and the broker holds the
155
+ * connection for resumption) or to add custom pre-hibernation work.
156
+ */
157
+ onEvict?: () => void | Promise<void>;
139
158
  }
140
159
  /**
141
160
  * Build a `SourceDef` that listens on a Cloudflare WebSocket and
@@ -1,2 +1,2 @@
1
- function u(i){let{storage:e,intervalMs:r,eventName:n,payload:c=()=>({}),onTickRegistered:a}=i;if(r<1)throw new Error(`[Directive] sourceFromDOAlarm: intervalMs must be >= 1, got ${r}`);let o=null;function s(){o&&(o(n,c()),e.setAlarm(Date.now()+r));}return {attach:t=>(o=t,e.setAlarm(Date.now()+r),a?.(s),()=>{o=null,e.deleteAlarm();}),tick:s,meta:{label:`DO alarm: ${n} every ${r}ms`,tags:["source","cloudflare","alarm"]}}}function m(i){let{socket:e,decode:r,closeEvent:n="WEBSOCKET_CLOSED",errorEvent:c="WEBSOCKET_ERROR"}=i;return {attach:a=>{let o=t=>{let d=r(t.data);d!==null&&a(d.name,d.payload);},s=t=>{n!==null&&a(n,{code:t.code??1e3,reason:t.reason??""});},l=()=>{c!==null&&a(c,{});};return e.addEventListener("message",o),e.addEventListener("close",s),e.addEventListener("error",l),()=>{e.removeEventListener("message",o),e.removeEventListener("close",s),e.removeEventListener("error",l);}},meta:{label:"Cloudflare WebSocket message stream",tags:["source","cloudflare","websocket"]}}}export{u as sourceFromDOAlarm,m as sourceFromWebSocketMessage};//# sourceMappingURL=cloudflare.js.map
1
+ function m(i){let{storage:e,intervalMs:o,eventName:n,payload:s=()=>({}),onTickRegistered:c,onEvict:v}=i;if(o<1)throw new Error(`[Directive] sourceFromDOAlarm: intervalMs must be >= 1, got ${o}`);let r=null;function a(){r&&(r(n,s()),e.setAlarm(Date.now()+o));}return {attach:t=>(r=t,e.setAlarm(Date.now()+o),c?.(a),()=>{r=null,e.deleteAlarm();}),onEvict:v??(async()=>{await e.deleteAlarm();}),tick:a,meta:{label:`DO alarm: ${n} every ${o}ms`,tags:["source","cloudflare","alarm"]}}}function g(i){let{socket:e,decode:o,closeEvent:n="WEBSOCKET_CLOSED",errorEvent:s="WEBSOCKET_ERROR",onEvict:c}=i;return {attach:r=>{let a=t=>{let u=o(t.data);u!==null&&r(u.name,u.payload);},l=t=>{n!==null&&r(n,{code:t.code??1e3,reason:t.reason??""});},d=()=>{s!==null&&r(s,{});};return e.addEventListener("message",a),e.addEventListener("close",l),e.addEventListener("error",d),()=>{e.removeEventListener("message",a),e.removeEventListener("close",l),e.removeEventListener("error",d);}},onEvict:c??(()=>{try{e.close(1001,"going-away");}catch{}}),meta:{label:"Cloudflare WebSocket message stream",tags:["source","cloudflare","websocket"]}}}export{m as sourceFromDOAlarm,g as sourceFromWebSocketMessage};//# sourceMappingURL=cloudflare.js.map
2
2
  //# sourceMappingURL=cloudflare.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/cloudflare.ts"],"names":["sourceFromDOAlarm","options","storage","intervalMs","eventName","payload","onTickRegistered","activePublish","tick","publish","sourceFromWebSocketMessage","socket","decode","closeEvent","errorEvent","onMessage","event","decoded","onClose","onError"],"mappings":"AA8GO,SAASA,CAAAA,CACdC,EAC8B,CAC9B,GAAM,CACJ,OAAA,CAAAC,CAAAA,CACA,UAAA,CAAAC,CAAAA,CACA,SAAA,CAAAC,CAAAA,CACA,QAAAC,CAAAA,CAAU,KAAO,EAAC,CAAA,CAClB,gBAAA,CAAAC,CACF,CAAA,CAAIL,CAAAA,CAEJ,GAAIE,CAAAA,CAAa,CAAA,CACf,MAAM,IAAI,KAAA,CACR,CAAA,4DAAA,EAA+DA,CAAU,CAAA,CAC3E,CAAA,CAGF,IAAII,CAAAA,CAAsC,IAAA,CAE1C,SAASC,CAAAA,EAAa,CACfD,CAAAA,GACLA,EAAcH,CAAAA,CAAWC,CAAAA,EAAS,CAAA,CAI7BH,CAAAA,CAAQ,QAAA,CAAS,KAAK,GAAA,EAAI,CAAIC,CAAU,CAAA,EAC/C,CAuBA,OArB0C,CACxC,MAAA,CAASM,CAAAA,GACPF,EAAgBE,CAAAA,CAGXP,CAAAA,CAAQ,SAAS,IAAA,CAAK,GAAA,EAAI,CAAIC,CAAU,CAAA,CAC7CG,CAAAA,GAAmBE,CAAI,CAAA,CAChB,IAAM,CACXD,CAAAA,CAAgB,IAAA,CAGXL,CAAAA,CAAQ,cACf,CAAA,CAAA,CAEF,IAAA,CAAAM,CAAAA,CACA,IAAA,CAAM,CACJ,MAAO,CAAA,UAAA,EAAaJ,CAAS,UAAUD,CAAU,CAAA,EAAA,CAAA,CACjD,KAAM,CAAC,QAAA,CAAU,YAAA,CAAc,OAAO,CACxC,CACF,CAGF,CAyDO,SAASO,CAAAA,CACdT,CAAAA,CACW,CACX,GAAM,CACJ,MAAA,CAAAU,CAAAA,CACA,MAAA,CAAAC,CAAAA,CACA,UAAA,CAAAC,CAAAA,CAAa,mBACb,UAAA,CAAAC,CAAAA,CAAa,iBACf,CAAA,CAAIb,CAAAA,CAEJ,OAAO,CACL,MAAA,CAASQ,CAAAA,EAA2B,CAClC,IAAMM,CAAAA,CAAaC,GAA8B,CAC/C,IAAMC,CAAAA,CAAUL,CAAAA,CAAOI,CAAAA,CAAM,IAAI,EAC7BC,CAAAA,GAAY,IAAA,EAChBR,CAAAA,CAAQQ,CAAAA,CAAQ,IAAA,CAAMA,CAAAA,CAAQ,OAAO,EACvC,CAAA,CACMC,EAAWF,CAAAA,EAA8C,CACzDH,IAAe,IAAA,EACnBJ,CAAAA,CAAQI,CAAAA,CAAY,CAClB,IAAA,CAAMG,CAAAA,CAAM,MAAQ,GAAA,CACpB,MAAA,CAAQA,CAAAA,CAAM,MAAA,EAAU,EAC1B,CAAC,EACH,CAAA,CACMG,CAAAA,CAAU,IAAM,CAChBL,CAAAA,GAAe,IAAA,EACnBL,EAAQK,CAAAA,CAAY,EAAE,EACxB,CAAA,CAEA,OAAAH,CAAAA,CAAO,gBAAA,CAAiB,SAAA,CAAWI,CAAS,CAAA,CAC5CJ,CAAAA,CAAO,iBAAiB,OAAA,CAASO,CAAO,CAAA,CACxCP,CAAAA,CAAO,gBAAA,CAAiB,OAAA,CAASQ,CAAO,CAAA,CAEjC,IAAM,CACXR,CAAAA,CAAO,mBAAA,CAAoB,SAAA,CAAWI,CAAS,CAAA,CAC/CJ,CAAAA,CAAO,oBAAoB,OAAA,CAASO,CAAO,EAC3CP,CAAAA,CAAO,mBAAA,CAAoB,OAAA,CAASQ,CAAO,EAC7C,CACF,EACA,IAAA,CAAM,CACJ,KAAA,CAAO,qCAAA,CACP,IAAA,CAAM,CAAC,SAAU,YAAA,CAAc,WAAW,CAC5C,CACF,CACF","file":"cloudflare.js","sourcesContent":["/**\n * @directive-run/sources/cloudflare\n *\n * Bridges Cloudflare-specific runtime primitives into the Directive\n * `source` primitive:\n *\n * - **`sourceFromDOAlarm`** — Durable Object alarms as a typed\n * periodic source. Replaces hand-rolled `setInterval` inside `attach`\n * (which dies on hibernation) with a storage-backed alarm that\n * survives eviction.\n * - **`sourceFromWebSocketMessage`** — DO `WebSocket` connection\n * (Cloudflare's `webSocketAccept` flow) as a typed message source.\n *\n * Both adapters integrate with the source primitive's lifecycle, so\n * `system.stop()` cleans up via the storage / socket teardown paths.\n *\n * @example DO alarm as a 30-second tick source\n * ```ts\n * import { sourceFromDOAlarm } from '@directive-run/sources/cloudflare';\n *\n * export class TickerDO {\n * constructor(public state: DurableObjectState) {}\n *\n * async fetch(req: Request) {\n * const system = createSystem({\n * module: createModule('ticker', {\n * schema: {\n * facts: { lastTick: t.number() },\n * events: { TICK: { at: t.number() } },\n * },\n * init: (f) => { f.lastTick = 0; },\n * events: { TICK: (f, p) => { f.lastTick = p.at; } },\n * sources: {\n * alarm: sourceFromDOAlarm({\n * storage: this.state.storage,\n * intervalMs: 30_000,\n * eventName: 'TICK',\n * payload: () => ({ at: Date.now() }),\n * }),\n * },\n * }),\n * });\n * system.start();\n * return new Response('ok');\n * }\n *\n * // DO runtime calls alarm() on each scheduled tick; the source\n * // adapter's storage key triggers a publish on the active system.\n * async alarm() {\n * // The DO runtime calls this; the source publishes via the\n * // shared module-level event bus (the active system observes).\n * }\n * }\n * ```\n */\n\nimport type { SourceDef, SourcePublish } from \"@directive-run/core\";\n\n// ============================================================================\n// Type stubs for the Cloudflare Workers / DO API surface we touch\n// ============================================================================\n\ninterface DurableObjectStorage {\n setAlarm(scheduledTime: number | Date): Promise<void>;\n deleteAlarm(): Promise<void>;\n getAlarm(): Promise<number | null>;\n}\n\n// ============================================================================\n// sourceFromDOAlarm — Durable Object alarm as a periodic source\n// ============================================================================\n\nexport interface DOAlarmSourceOptions {\n /** The DO's `state.storage` handle. */\n storage: DurableObjectStorage;\n /** Tick interval in milliseconds. Minimum 1ms. */\n intervalMs: number;\n /**\n * Event name to publish on every tick. Must match an event declared\n * on the module's schema (otherwise the engine drops the publish with\n * `lastDropReason: 'invalid-event-name'` per the R6 telemetry).\n */\n eventName: string;\n /**\n * Payload factory. Called on every tick. Default: `() => ({})`.\n */\n payload?: () => Record<string, unknown>;\n /**\n * Optional hook for the consumer to wire the DO's `alarm()` callback\n * back into this source. The adapter cannot intercept the DO runtime's\n * `alarm()` call directly (it's a class method); the consumer's\n * `alarm()` handler should call `adapter.tick()` to drive the publish.\n *\n * Returned from `sourceFromDOAlarm.exposeTick(source)` (see below).\n */\n onTickRegistered?: (tick: () => void) => void;\n}\n\n/**\n * Build a `SourceDef` that schedules a DO alarm every `intervalMs` and\n * publishes on every tick. The adapter manages alarm scheduling via\n * `state.storage.setAlarm()`; on `system.stop()` it clears the alarm.\n *\n * **Important wiring step:** the DO's `alarm()` instance method MUST\n * call the adapter's tick callback. Capture it via `onTickRegistered`\n * (or by stashing the source in a class field and invoking\n * `source.tick()` from your alarm method).\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromDOAlarm(\n options: DOAlarmSourceOptions,\n): SourceDef & { tick(): void } {\n const {\n storage,\n intervalMs,\n eventName,\n payload = () => ({}),\n onTickRegistered,\n } = options;\n\n if (intervalMs < 1) {\n throw new Error(\n `[Directive] sourceFromDOAlarm: intervalMs must be >= 1, got ${intervalMs}`,\n );\n }\n\n let activePublish: SourcePublish | null = null;\n\n function tick(): void {\n if (!activePublish) return;\n activePublish(eventName, payload());\n // Schedule the next alarm. Fire-and-forget — the host runtime\n // handles delivery; failures land via the source primitive's\n // observation events.\n void storage.setAlarm(Date.now() + intervalMs);\n }\n\n const def: SourceDef & { tick(): void } = {\n attach: (publish: SourcePublish) => {\n activePublish = publish;\n // Schedule the first alarm. The DO's `alarm()` method must call\n // `tick()` to drive subsequent publishes.\n void storage.setAlarm(Date.now() + intervalMs);\n onTickRegistered?.(tick);\n return () => {\n activePublish = null;\n // Clear any pending alarm so the DO doesn't wake the dead\n // system after stop.\n void storage.deleteAlarm();\n };\n },\n tick,\n meta: {\n label: `DO alarm: ${eventName} every ${intervalMs}ms`,\n tags: [\"source\", \"cloudflare\", \"alarm\"],\n },\n };\n\n return def;\n}\n\n// ============================================================================\n// sourceFromWebSocketMessage — DO WebSocket message stream as a source\n// ============================================================================\n\ninterface CloudflareWebSocket {\n send(data: string | ArrayBuffer): void;\n close(code?: number, reason?: string): void;\n addEventListener(\n type: \"message\" | \"close\" | \"error\",\n handler: (event: {\n data?: unknown;\n code?: number;\n reason?: string;\n }) => void,\n ): void;\n removeEventListener(\n type: \"message\" | \"close\" | \"error\",\n handler: (event: {\n data?: unknown;\n code?: number;\n reason?: string;\n }) => void,\n ): void;\n}\n\nexport interface WebSocketMessageSourceOptions {\n /** The Cloudflare WebSocket (`server` half of the pair from `webSocketAccept`). */\n socket: CloudflareWebSocket;\n /**\n * Decode each `MessageEvent.data` into a typed Directive event.\n * Return `null` to drop the message (e.g. ping frames).\n */\n decode: (\n data: unknown,\n ) => { name: string; payload: Record<string, unknown> } | null;\n /**\n * Event name to publish when the socket closes. Default `\"WEBSOCKET_CLOSED\"`.\n * Set `null` to skip publishing on close.\n */\n closeEvent?: string | null;\n /**\n * Event name to publish on socket errors. Default `\"WEBSOCKET_ERROR\"`.\n * Set `null` to skip publishing on error.\n */\n errorEvent?: string | null;\n}\n\n/**\n * Build a `SourceDef` that listens on a Cloudflare WebSocket and\n * publishes each decoded message as a typed Directive event. Wraps\n * the standard `addEventListener('message' | 'close' | 'error')`\n * surface.\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromWebSocketMessage(\n options: WebSocketMessageSourceOptions,\n): SourceDef {\n const {\n socket,\n decode,\n closeEvent = \"WEBSOCKET_CLOSED\",\n errorEvent = \"WEBSOCKET_ERROR\",\n } = options;\n\n return {\n attach: (publish: SourcePublish) => {\n const onMessage = (event: { data?: unknown }) => {\n const decoded = decode(event.data);\n if (decoded === null) return;\n publish(decoded.name, decoded.payload);\n };\n const onClose = (event: { code?: number; reason?: string }) => {\n if (closeEvent === null) return;\n publish(closeEvent, {\n code: event.code ?? 1000,\n reason: event.reason ?? \"\",\n });\n };\n const onError = () => {\n if (errorEvent === null) return;\n publish(errorEvent, {});\n };\n\n socket.addEventListener(\"message\", onMessage);\n socket.addEventListener(\"close\", onClose);\n socket.addEventListener(\"error\", onError);\n\n return () => {\n socket.removeEventListener(\"message\", onMessage);\n socket.removeEventListener(\"close\", onClose);\n socket.removeEventListener(\"error\", onError);\n };\n },\n meta: {\n label: \"Cloudflare WebSocket message stream\",\n tags: [\"source\", \"cloudflare\", \"websocket\"],\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/cloudflare.ts"],"names":["sourceFromDOAlarm","options","storage","intervalMs","eventName","payload","onTickRegistered","onEvict","activePublish","tick","publish","sourceFromWebSocketMessage","socket","decode","closeEvent","errorEvent","onMessage","event","decoded","onClose","onError"],"mappings":"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","file":"cloudflare.js","sourcesContent":["/**\n * @directive-run/sources/cloudflare\n *\n * Bridges Cloudflare-specific runtime primitives into the Directive\n * `source` primitive:\n *\n * - **`sourceFromDOAlarm`** — Durable Object alarms as a typed\n * periodic source. Replaces hand-rolled `setInterval` inside `attach`\n * (which dies on hibernation) with a storage-backed alarm that\n * survives eviction.\n * - **`sourceFromWebSocketMessage`** — DO `WebSocket` connection\n * (Cloudflare's `webSocketAccept` flow) as a typed message source.\n *\n * Both adapters integrate with the source primitive's lifecycle, so\n * `system.stop()` cleans up via the storage / socket teardown paths.\n *\n * @example DO alarm as a 30-second tick source\n * ```ts\n * import { sourceFromDOAlarm } from '@directive-run/sources/cloudflare';\n *\n * export class TickerDO {\n * constructor(public state: DurableObjectState) {}\n *\n * async fetch(req: Request) {\n * const system = createSystem({\n * module: createModule('ticker', {\n * schema: {\n * facts: { lastTick: t.number() },\n * events: { TICK: { at: t.number() } },\n * },\n * init: (f) => { f.lastTick = 0; },\n * events: { TICK: (f, p) => { f.lastTick = p.at; } },\n * sources: {\n * alarm: sourceFromDOAlarm({\n * storage: this.state.storage,\n * intervalMs: 30_000,\n * eventName: 'TICK',\n * payload: () => ({ at: Date.now() }),\n * }),\n * },\n * }),\n * });\n * system.start();\n * return new Response('ok');\n * }\n *\n * // DO runtime calls alarm() on each scheduled tick; the source\n * // adapter's storage key triggers a publish on the active system.\n * async alarm() {\n * // The DO runtime calls this; the source publishes via the\n * // shared module-level event bus (the active system observes).\n * }\n * }\n * ```\n */\n\nimport type { SourceDef, SourcePublish } from \"@directive-run/core\";\n\n// ============================================================================\n// Type stubs for the Cloudflare Workers / DO API surface we touch\n// ============================================================================\n\ninterface DurableObjectStorage {\n setAlarm(scheduledTime: number | Date): Promise<void>;\n deleteAlarm(): Promise<void>;\n getAlarm(): Promise<number | null>;\n}\n\n// ============================================================================\n// sourceFromDOAlarm — Durable Object alarm as a periodic source\n// ============================================================================\n\nexport interface DOAlarmSourceOptions {\n /** The DO's `state.storage` handle. */\n storage: DurableObjectStorage;\n /** Tick interval in milliseconds. Minimum 1ms. */\n intervalMs: number;\n /**\n * Event name to publish on every tick. Must match an event declared\n * on the module's schema (otherwise the engine drops the publish with\n * `lastDropReason: 'invalid-event-name'` per the R6 telemetry).\n */\n eventName: string;\n /**\n * Payload factory. Called on every tick. Default: `() => ({})`.\n */\n payload?: () => Record<string, unknown>;\n /**\n * Optional hook for the consumer to wire the DO's `alarm()` callback\n * back into this source. The adapter cannot intercept the DO runtime's\n * `alarm()` call directly (it's a class method); the consumer's\n * `alarm()` handler should call `adapter.tick()` to drive the publish.\n *\n * Returned from `sourceFromDOAlarm.exposeTick(source)` (see below).\n */\n onTickRegistered?: (tick: () => void) => void;\n /**\n * RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the\n * DO runtime signals an upcoming hibernation. Clear the alarm here so\n * the DO doesn't wake just to fire a publish into a torn-down system.\n * The default `onEvict` (used when this option is omitted) deletes the\n * alarm via `storage.deleteAlarm()`; supply your own to add custom\n * pre-hibernation work (flush an audit log, signal the broker, etc.).\n */\n onEvict?: () => void | Promise<void>;\n}\n\n/**\n * Build a `SourceDef` that schedules a DO alarm every `intervalMs` and\n * publishes on every tick. The adapter manages alarm scheduling via\n * `state.storage.setAlarm()`; on `system.stop()` it clears the alarm.\n *\n * **Important wiring step:** the DO's `alarm()` instance method MUST\n * call the adapter's tick callback. Capture it via `onTickRegistered`\n * (or by stashing the source in a class field and invoking\n * `source.tick()` from your alarm method).\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromDOAlarm(\n options: DOAlarmSourceOptions,\n): SourceDef & { tick(): void } {\n const {\n storage,\n intervalMs,\n eventName,\n payload = () => ({}),\n onTickRegistered,\n onEvict,\n } = options;\n\n if (intervalMs < 1) {\n throw new Error(\n `[Directive] sourceFromDOAlarm: intervalMs must be >= 1, got ${intervalMs}`,\n );\n }\n\n let activePublish: SourcePublish | null = null;\n\n function tick(): void {\n if (!activePublish) return;\n activePublish(eventName, payload());\n // Schedule the next alarm. Fire-and-forget — the host runtime\n // handles delivery; failures land via the source primitive's\n // observation events.\n void storage.setAlarm(Date.now() + intervalMs);\n }\n\n // Default onEvict: drop the pending alarm so the DO doesn't wake\n // post-hibernation just to fire into a torn-down system. RFC 0009\n // motivates the hook; this adapter is the literal target runtime.\n const evictHandler =\n onEvict ??\n (async () => {\n await storage.deleteAlarm();\n });\n\n const def: SourceDef & { tick(): void } = {\n attach: (publish: SourcePublish) => {\n activePublish = publish;\n // Schedule the first alarm. The DO's `alarm()` method must call\n // `tick()` to drive subsequent publishes.\n void storage.setAlarm(Date.now() + intervalMs);\n onTickRegistered?.(tick);\n return () => {\n activePublish = null;\n // Clear any pending alarm so the DO doesn't wake the dead\n // system after stop.\n void storage.deleteAlarm();\n };\n },\n onEvict: evictHandler,\n tick,\n meta: {\n label: `DO alarm: ${eventName} every ${intervalMs}ms`,\n tags: [\"source\", \"cloudflare\", \"alarm\"],\n },\n };\n\n return def;\n}\n\n// ============================================================================\n// sourceFromWebSocketMessage — DO WebSocket message stream as a source\n// ============================================================================\n\ninterface CloudflareWebSocket {\n send(data: string | ArrayBuffer): void;\n close(code?: number, reason?: string): void;\n addEventListener(\n type: \"message\" | \"close\" | \"error\",\n handler: (event: {\n data?: unknown;\n code?: number;\n reason?: string;\n }) => void,\n ): void;\n removeEventListener(\n type: \"message\" | \"close\" | \"error\",\n handler: (event: {\n data?: unknown;\n code?: number;\n reason?: string;\n }) => void,\n ): void;\n}\n\nexport interface WebSocketMessageSourceOptions {\n /** The Cloudflare WebSocket (`server` half of the pair from `webSocketAccept`). */\n socket: CloudflareWebSocket;\n /**\n * Decode each `MessageEvent.data` into a typed Directive event.\n * Return `null` to drop the message (e.g. ping frames).\n */\n decode: (\n data: unknown,\n ) => { name: string; payload: Record<string, unknown> } | null;\n /**\n * Event name to publish when the socket closes. Default `\"WEBSOCKET_CLOSED\"`.\n * Set `null` to skip publishing on close.\n */\n closeEvent?: string | null;\n /**\n * Event name to publish on socket errors. Default `\"WEBSOCKET_ERROR\"`.\n * Set `null` to skip publishing on error.\n */\n errorEvent?: string | null;\n /**\n * RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the\n * DO runtime signals an upcoming hibernation. The default `onEvict`\n * closes the socket with code 1001 (`\"going-away\"`) so the broker sees\n * a clean disconnect and a re-attach after hibernation negotiates a\n * fresh session. Supply your own to skip the close (e.g., when the\n * runtime is hibernating WebSockets natively and the broker holds the\n * connection for resumption) or to add custom pre-hibernation work.\n */\n onEvict?: () => void | Promise<void>;\n}\n\n/**\n * Build a `SourceDef` that listens on a Cloudflare WebSocket and\n * publishes each decoded message as a typed Directive event. Wraps\n * the standard `addEventListener('message' | 'close' | 'error')`\n * surface.\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromWebSocketMessage(\n options: WebSocketMessageSourceOptions,\n): SourceDef {\n const {\n socket,\n decode,\n closeEvent = \"WEBSOCKET_CLOSED\",\n errorEvent = \"WEBSOCKET_ERROR\",\n onEvict,\n } = options;\n\n // Default onEvict: close the socket so the broker sees a clean\n // disconnect ahead of the DO hibernating. Consumers who let the\n // runtime hibernate the WebSocket natively can opt out by supplying\n // their own no-op `onEvict`.\n const evictHandler =\n onEvict ??\n (() => {\n try {\n socket.close(1001, \"going-away\");\n } catch {\n // Already closed; ignore.\n }\n });\n\n return {\n attach: (publish: SourcePublish) => {\n const onMessage = (event: { data?: unknown }) => {\n const decoded = decode(event.data);\n if (decoded === null) return;\n publish(decoded.name, decoded.payload);\n };\n const onClose = (event: { code?: number; reason?: string }) => {\n if (closeEvent === null) return;\n publish(closeEvent, {\n code: event.code ?? 1000,\n reason: event.reason ?? \"\",\n });\n };\n const onError = () => {\n if (errorEvent === null) return;\n publish(errorEvent, {});\n };\n\n socket.addEventListener(\"message\", onMessage);\n socket.addEventListener(\"close\", onClose);\n socket.addEventListener(\"error\", onError);\n\n return () => {\n socket.removeEventListener(\"message\", onMessage);\n socket.removeEventListener(\"close\", onClose);\n socket.removeEventListener(\"error\", onError);\n };\n },\n onEvict: evictHandler,\n meta: {\n label: \"Cloudflare WebSocket message stream\",\n tags: [\"source\", \"cloudflare\", \"websocket\"],\n },\n };\n}\n"]}
package/dist/supabase.cjs CHANGED
@@ -1,2 +1,2 @@
1
- 'use strict';function b(l){let{client:s,channel:i,events:u,onStatus:c,redactRow:o=a=>a}=l;return {attach:a=>{let n=s.channel(i);for(let e of u)n=n.on("postgres_changes",{event:e.event,schema:e.schema??"public",table:e.table,...e.filter!==void 0?{filter:e.filter}:{}},t=>{let p={...t,new:o(t.new),old:o(t.old)},r=e.map(p);r!==null&&a(r.name,r.payload);});return n.subscribe(e=>c?.(e)),()=>{s.removeChannel(n);}},meta:{label:`Supabase realtime: ${i}`,tags:["source","supabase"]}}}exports.sourceFromSupabaseChannel=b;//# sourceMappingURL=supabase.cjs.map
1
+ 'use strict';function b(o){let{client:r,channel:i,events:u,onStatus:c,redactRow:l=a=>a}=o;return {attach:a=>{let n=r.channel(i);for(let e of u)n=n.on("postgres_changes",{event:e.event,schema:e.schema??"public",table:e.table,...e.filter!==void 0?{filter:e.filter}:{}},t=>{let p={...t,new:l(t.new),old:l(t.old)},s=e.map(p);s!==null&&a(s.name,s.payload);});return n.subscribe(e=>c?.(e)),async()=>{await r.removeChannel(n);}},meta:{label:`Supabase realtime: ${i}`,tags:["source","supabase"]}}}exports.sourceFromSupabaseChannel=b;//# sourceMappingURL=supabase.cjs.map
2
2
  //# sourceMappingURL=supabase.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/supabase.ts"],"names":["sourceFromSupabaseChannel","options","client","channelName","events","onStatus","redactRow","row","publish","chan","binding","payload","safe","result","status"],"mappings":"aA8IO,SAASA,CAAAA,CACdC,CAAAA,CACW,CACX,GAAM,CACJ,MAAA,CAAAC,CAAAA,CACA,OAAA,CAASC,CAAAA,CACT,MAAA,CAAAC,CAAAA,CACA,QAAA,CAAAC,CAAAA,CACA,SAAA,CAAAC,CAAAA,CAAaC,CAAAA,EAAQA,CACvB,CAAA,CAAIN,CAAAA,CAEJ,OAAO,CACL,MAAA,CAASO,CAAAA,EAA2B,CAIlC,IAAIC,CAAAA,CAAOP,CAAAA,CAAO,OAAA,CAAQC,CAAW,EACrC,IAAA,IAAWO,CAAAA,IAAWN,CAAAA,CACpBK,CAAAA,CAAOA,CAAAA,CAAK,EAAA,CACV,kBAAA,CACA,CACE,KAAA,CAAOC,CAAAA,CAAQ,KAAA,CACf,MAAA,CAAQA,CAAAA,CAAQ,MAAA,EAAU,QAAA,CAC1B,KAAA,CAAOA,CAAAA,CAAQ,KAAA,CACf,GAAIA,CAAAA,CAAQ,MAAA,GAAW,MAAA,CAAY,CAAE,MAAA,CAAQA,CAAAA,CAAQ,MAAO,CAAA,CAAI,EAClE,CAAA,CACCC,GAAY,CAKX,IAAMC,CAAAA,CAAwB,CAC5B,GAAGD,CAAAA,CACH,GAAA,CAAKL,CAAAA,CAAUK,CAAAA,CAAQ,GAAG,CAAA,CAC1B,GAAA,CAAKL,CAAAA,CAAUK,CAAAA,CAAQ,GAAG,CAC5B,CAAA,CACME,CAAAA,CAASH,CAAAA,CAAQ,GAAA,CAAIE,CAAI,CAAA,CAC3BC,CAAAA,GAAW,IAAA,EACfL,CAAAA,CAAQK,CAAAA,CAAO,IAAA,CAAMA,CAAAA,CAAO,OAAO,EACrC,CACF,CAAA,CAOF,OAAAJ,CAAAA,CAAK,SAAA,CAAWK,CAAAA,EAAWT,CAAAA,GAAWS,CAAM,CAAC,CAAA,CAOtC,IAAM,CACNZ,CAAAA,CAAO,aAAA,CAAcO,CAAI,EAChC,CACF,CAAA,CACA,IAAA,CAAM,CACJ,KAAA,CAAO,CAAA,mBAAA,EAAsBN,CAAW,CAAA,CAAA,CACxC,IAAA,CAAM,CAAC,QAAA,CAAU,UAAU,CAC7B,CACF,CACF","file":"supabase.cjs","sourcesContent":["/**\n * @directive-run/sources/supabase\n *\n * Bridges Supabase realtime channels into the Directive `source` primitive.\n * One factory call → one declared source on your module → the engine\n * owns the subscription lifecycle. No `useEffect` bridges, no manual\n * cleanup, no leaked channels.\n *\n * @example Wire a Supabase realtime channel for a single game row\n * ```ts\n * import { createClient } from '@supabase/supabase-js';\n * import { createModule, t } from '@directive-run/core';\n * import { sourceFromSupabaseChannel } from '@directive-run/sources/supabase';\n *\n * const supabase = createClient(url, key);\n *\n * const gameUpdates = createModule('gameUpdates', {\n * schema: {\n * facts: { snapshot: t.object<GameSnapshot>().nullable() },\n * events: { GAME_UPDATED: { snapshot: t.object<GameSnapshot>() } },\n * },\n * init: (f) => { f.snapshot = null; },\n * events: { GAME_UPDATED: (f, p) => { f.snapshot = p.snapshot; } },\n * sources: {\n * gameChannel: sourceFromSupabaseChannel({\n * client: supabase,\n * channel: `game:${gameId}`,\n * events: [{\n * table: 'games',\n * filter: `id=eq.${gameId}`,\n * event: 'UPDATE',\n * map: (row) => ({ name: 'GAME_UPDATED', payload: { snapshot: mapRow(row) } }),\n * }],\n * }),\n * },\n * });\n * ```\n */\n\nimport type { SourceDef, SourcePublish } from \"@directive-run/core\";\n\n// ============================================================================\n// Type stubs for the Supabase API surface we touch\n// ============================================================================\n\n// We deliberately do NOT import from `@supabase/supabase-js` so this file\n// type-checks without the optional peerDependency installed. The runtime\n// shape matches Supabase's RealtimeChannel + SupabaseClient surfaces;\n// consumers who install the package get full typing through their own\n// `createClient(...)` instance.\n\ninterface SupabasePayload {\n schema: string;\n table: string;\n commit_timestamp?: string;\n eventType: \"INSERT\" | \"UPDATE\" | \"DELETE\";\n new: Record<string, unknown>;\n old: Record<string, unknown>;\n}\n\ntype SupabaseHandler = (payload: SupabasePayload) => void;\n\ninterface SupabaseRealtimeChannel {\n on(\n type: \"postgres_changes\",\n filter: {\n event: \"INSERT\" | \"UPDATE\" | \"DELETE\" | \"*\";\n schema?: string;\n table?: string;\n filter?: string;\n },\n handler: SupabaseHandler,\n ): SupabaseRealtimeChannel;\n subscribe(callback?: (status: string) => void): SupabaseRealtimeChannel;\n unsubscribe(): Promise<\"ok\" | \"timed out\" | \"error\">;\n}\n\ninterface SupabaseRealtimeClient {\n channel(name: string): SupabaseRealtimeChannel;\n removeChannel(\n channel: SupabaseRealtimeChannel,\n ): Promise<\"ok\" | \"timed out\" | \"error\">;\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Declarative mapping from a Supabase `postgres_changes` event to a typed\n * Directive event publish. `map` runs on every matching payload; return\n * `null` to skip publishing for that row (useful for soft-filters).\n */\nexport interface SupabaseEventBinding {\n /** Postgres event type. Use `'*'` for any. */\n event: \"INSERT\" | \"UPDATE\" | \"DELETE\" | \"*\";\n /** Table to listen on. */\n table: string;\n /** Postgres schema. Default `'public'`. */\n schema?: string;\n /** Server-side filter expression (e.g. `id=eq.123`). */\n filter?: string;\n /**\n * Map the Supabase payload to a typed Directive event. Return `null` to\n * skip this publish (e.g. filter out updates that don't affect the\n * tracked fields).\n */\n map: (\n payload: SupabasePayload,\n ) => { name: string; payload: Record<string, unknown> } | null;\n}\n\nexport interface SupabaseChannelOptions {\n /** The Supabase client (`createClient(url, key)`). */\n client: SupabaseRealtimeClient;\n /** Channel name. Must be unique per system instance. */\n channel: string;\n /** One or more event bindings on this channel. */\n events: readonly SupabaseEventBinding[];\n /**\n * Optional status hook. Fires with Supabase's connection status\n * (`'SUBSCRIBED'`, `'CHANNEL_ERROR'`, `'TIMED_OUT'`, `'CLOSED'`).\n * Wire to your logging / health-check telemetry.\n */\n onStatus?: (status: string) => void;\n /**\n * Optional pii-redaction hook applied to every payload row BEFORE the\n * `map` callback runs. Useful for blanking columns the schema author\n * marked as PII when the consumer's `createFactPIIGuardrail` cannot\n * reach the field shape (e.g. nested Supabase JSON column).\n *\n * Default: identity (no redaction).\n */\n redactRow?: (row: Record<string, unknown>) => Record<string, unknown>;\n}\n\n/**\n * Build a `SourceDef` that subscribes to a Supabase realtime channel\n * and publishes typed Directive events for each matching row change.\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromSupabaseChannel(\n options: SupabaseChannelOptions,\n): SourceDef {\n const {\n client,\n channel: channelName,\n events,\n onStatus,\n redactRow = (row) => row,\n } = options;\n\n return {\n attach: (publish: SourcePublish) => {\n // Build the channel + register every binding. Each binding's\n // `map` runs on every matching row; the source publishes the\n // returned Directive event.\n let chan = client.channel(channelName);\n for (const binding of events) {\n chan = chan.on(\n \"postgres_changes\",\n {\n event: binding.event,\n schema: binding.schema ?? \"public\",\n table: binding.table,\n ...(binding.filter !== undefined ? { filter: binding.filter } : {}),\n },\n (payload) => {\n // Redact at the row boundary so the `map` callback (which\n // typically embeds row fields into the event payload) sees\n // only the redacted version. The fact-level\n // createFactPIIGuardrail is the second line of defense.\n const safe: SupabasePayload = {\n ...payload,\n new: redactRow(payload.new),\n old: redactRow(payload.old),\n };\n const result = binding.map(safe);\n if (result === null) return;\n publish(result.name, result.payload);\n },\n );\n }\n\n // Open the subscription. `subscribe()` is async on the wire but\n // returns the channel synchronously — the status callback fires\n // once the realtime server acknowledges. Our `attach` contract is\n // sync; we capture the status callback for the consumer.\n chan.subscribe((status) => onStatus?.(status));\n\n // Cleanup: unsubscribe via the client so internal channel registry\n // is cleared. `removeChannel` returns a Promise; the engine's\n // current `SourceUnsubscribeFn` is sync we kick off the cleanup\n // and let it complete in the background. RFC 0009 widens this to\n // an awaitable signature.\n return () => {\n void client.removeChannel(chan);\n };\n },\n meta: {\n label: `Supabase realtime: ${channelName}`,\n tags: [\"source\", \"supabase\"],\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/supabase.ts"],"names":["sourceFromSupabaseChannel","options","client","channelName","events","onStatus","redactRow","row","publish","chan","binding","payload","safe","result","status"],"mappings":"aA8IO,SAASA,CAAAA,CACdC,CAAAA,CACW,CACX,GAAM,CACJ,MAAA,CAAAC,CAAAA,CACA,OAAA,CAASC,CAAAA,CACT,MAAA,CAAAC,CAAAA,CACA,QAAA,CAAAC,CAAAA,CACA,SAAA,CAAAC,CAAAA,CAAaC,CAAAA,EAAQA,CACvB,CAAA,CAAIN,CAAAA,CAEJ,OAAO,CACL,MAAA,CAASO,CAAAA,EAA2B,CAIlC,IAAIC,CAAAA,CAAOP,CAAAA,CAAO,OAAA,CAAQC,CAAW,CAAA,CACrC,IAAA,IAAWO,CAAAA,IAAWN,CAAAA,CACpBK,CAAAA,CAAOA,CAAAA,CAAK,EAAA,CACV,kBAAA,CACA,CACE,KAAA,CAAOC,CAAAA,CAAQ,KAAA,CACf,MAAA,CAAQA,CAAAA,CAAQ,MAAA,EAAU,QAAA,CAC1B,KAAA,CAAOA,CAAAA,CAAQ,KAAA,CACf,GAAIA,CAAAA,CAAQ,MAAA,GAAW,MAAA,CAAY,CAAE,MAAA,CAAQA,CAAAA,CAAQ,MAAO,CAAA,CAAI,EAClE,CAAA,CACCC,CAAAA,EAAY,CAKX,IAAMC,CAAAA,CAAwB,CAC5B,GAAGD,CAAAA,CACH,GAAA,CAAKL,CAAAA,CAAUK,CAAAA,CAAQ,GAAG,CAAA,CAC1B,GAAA,CAAKL,CAAAA,CAAUK,CAAAA,CAAQ,GAAG,CAC5B,CAAA,CACME,CAAAA,CAASH,CAAAA,CAAQ,GAAA,CAAIE,CAAI,CAAA,CAC3BC,CAAAA,GAAW,IAAA,EACfL,CAAAA,CAAQK,CAAAA,CAAO,IAAA,CAAMA,CAAAA,CAAO,OAAO,EACrC,CACF,CAAA,CAOF,OAAAJ,CAAAA,CAAK,SAAA,CAAWK,CAAAA,EAAWT,CAAAA,GAAWS,CAAM,CAAC,CAAA,CAYtC,SAAY,CACjB,MAAMZ,CAAAA,CAAO,aAAA,CAAcO,CAAI,EACjC,CACF,CAAA,CACA,IAAA,CAAM,CACJ,KAAA,CAAO,CAAA,mBAAA,EAAsBN,CAAW,CAAA,CAAA,CACxC,IAAA,CAAM,CAAC,QAAA,CAAU,UAAU,CAC7B,CACF,CACF","file":"supabase.cjs","sourcesContent":["/**\n * @directive-run/sources/supabase\n *\n * Bridges Supabase realtime channels into the Directive `source` primitive.\n * One factory call → one declared source on your module → the engine\n * owns the subscription lifecycle. No `useEffect` bridges, no manual\n * cleanup, no leaked channels.\n *\n * @example Wire a Supabase realtime channel for a single game row\n * ```ts\n * import { createClient } from '@supabase/supabase-js';\n * import { createModule, t } from '@directive-run/core';\n * import { sourceFromSupabaseChannel } from '@directive-run/sources/supabase';\n *\n * const supabase = createClient(url, key);\n *\n * const gameUpdates = createModule('gameUpdates', {\n * schema: {\n * facts: { snapshot: t.object<GameSnapshot>().nullable() },\n * events: { GAME_UPDATED: { snapshot: t.object<GameSnapshot>() } },\n * },\n * init: (f) => { f.snapshot = null; },\n * events: { GAME_UPDATED: (f, p) => { f.snapshot = p.snapshot; } },\n * sources: {\n * gameChannel: sourceFromSupabaseChannel({\n * client: supabase,\n * channel: `game:${gameId}`,\n * events: [{\n * table: 'games',\n * filter: `id=eq.${gameId}`,\n * event: 'UPDATE',\n * map: (row) => ({ name: 'GAME_UPDATED', payload: { snapshot: mapRow(row) } }),\n * }],\n * }),\n * },\n * });\n * ```\n */\n\nimport type { SourceDef, SourcePublish } from \"@directive-run/core\";\n\n// ============================================================================\n// Type stubs for the Supabase API surface we touch\n// ============================================================================\n\n// We deliberately do NOT import from `@supabase/supabase-js` so this file\n// type-checks without the optional peerDependency installed. The runtime\n// shape matches Supabase's RealtimeChannel + SupabaseClient surfaces;\n// consumers who install the package get full typing through their own\n// `createClient(...)` instance.\n\ninterface SupabasePayload {\n schema: string;\n table: string;\n commit_timestamp?: string;\n eventType: \"INSERT\" | \"UPDATE\" | \"DELETE\";\n new: Record<string, unknown>;\n old: Record<string, unknown>;\n}\n\ntype SupabaseHandler = (payload: SupabasePayload) => void;\n\ninterface SupabaseRealtimeChannel {\n on(\n type: \"postgres_changes\",\n filter: {\n event: \"INSERT\" | \"UPDATE\" | \"DELETE\" | \"*\";\n schema?: string;\n table?: string;\n filter?: string;\n },\n handler: SupabaseHandler,\n ): SupabaseRealtimeChannel;\n subscribe(callback?: (status: string) => void): SupabaseRealtimeChannel;\n unsubscribe(): Promise<\"ok\" | \"timed out\" | \"error\">;\n}\n\ninterface SupabaseRealtimeClient {\n channel(name: string): SupabaseRealtimeChannel;\n removeChannel(\n channel: SupabaseRealtimeChannel,\n ): Promise<\"ok\" | \"timed out\" | \"error\">;\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Declarative mapping from a Supabase `postgres_changes` event to a typed\n * Directive event publish. `map` runs on every matching payload; return\n * `null` to skip publishing for that row (useful for soft-filters).\n */\nexport interface SupabaseEventBinding {\n /** Postgres event type. Use `'*'` for any. */\n event: \"INSERT\" | \"UPDATE\" | \"DELETE\" | \"*\";\n /** Table to listen on. */\n table: string;\n /** Postgres schema. Default `'public'`. */\n schema?: string;\n /** Server-side filter expression (e.g. `id=eq.123`). */\n filter?: string;\n /**\n * Map the Supabase payload to a typed Directive event. Return `null` to\n * skip this publish (e.g. filter out updates that don't affect the\n * tracked fields).\n */\n map: (\n payload: SupabasePayload,\n ) => { name: string; payload: Record<string, unknown> } | null;\n}\n\nexport interface SupabaseChannelOptions {\n /** The Supabase client (`createClient(url, key)`). */\n client: SupabaseRealtimeClient;\n /** Channel name. Must be unique per system instance. */\n channel: string;\n /** One or more event bindings on this channel. */\n events: readonly SupabaseEventBinding[];\n /**\n * Optional status hook. Fires with Supabase's connection status\n * (`'SUBSCRIBED'`, `'CHANNEL_ERROR'`, `'TIMED_OUT'`, `'CLOSED'`).\n * Wire to your logging / health-check telemetry.\n */\n onStatus?: (status: string) => void;\n /**\n * Optional pii-redaction hook applied to every payload row BEFORE the\n * `map` callback runs. Useful for blanking columns the schema author\n * marked as PII when the consumer's `createFactPIIGuardrail` cannot\n * reach the field shape (e.g. nested Supabase JSON column).\n *\n * Default: identity (no redaction).\n */\n redactRow?: (row: Record<string, unknown>) => Record<string, unknown>;\n}\n\n/**\n * Build a `SourceDef` that subscribes to a Supabase realtime channel\n * and publishes typed Directive events for each matching row change.\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromSupabaseChannel(\n options: SupabaseChannelOptions,\n): SourceDef {\n const {\n client,\n channel: channelName,\n events,\n onStatus,\n redactRow = (row) => row,\n } = options;\n\n return {\n attach: (publish: SourcePublish) => {\n // Build the channel + register every binding. Each binding's\n // `map` runs on every matching row; the source publishes the\n // returned Directive event.\n let chan = client.channel(channelName);\n for (const binding of events) {\n chan = chan.on(\n \"postgres_changes\",\n {\n event: binding.event,\n schema: binding.schema ?? \"public\",\n table: binding.table,\n ...(binding.filter !== undefined ? { filter: binding.filter } : {}),\n },\n (payload) => {\n // Redact at the row boundary so the `map` callback (which\n // typically embeds row fields into the event payload) sees\n // only the redacted version. The fact-level\n // createFactPIIGuardrail is the second line of defense.\n const safe: SupabasePayload = {\n ...payload,\n new: redactRow(payload.new),\n old: redactRow(payload.old),\n };\n const result = binding.map(safe);\n if (result === null) return;\n publish(result.name, result.payload);\n },\n );\n }\n\n // Open the subscription. `subscribe()` is async on the wire but\n // returns the channel synchronously — the status callback fires\n // once the realtime server acknowledges. Our `attach` contract is\n // sync; we capture the status callback for the consumer.\n chan.subscribe((status) => onStatus?.(status));\n\n // Cleanup: unsubscribe via the client so the internal channel\n // registry is cleared. `removeChannel` returns a Promise; per RFC\n // 0009 we await it so `system.stopAsync()` does not resolve\n // before the broker has dropped the subscription. Without this\n // await, a `start stopAsync → start` cycle double-subscribes\n // (the broker still holds the old channel when the new attach\n // races in). Engines pre-RFC-0009 that call the legacy sync\n // `cleanupAll` ignore the returned promise — that's the same\n // fire-and-forget behavior as before, just with the broker drop\n // observable to consumers who DO use `stopAsync`.\n return async () => {\n await client.removeChannel(chan);\n };\n },\n meta: {\n label: `Supabase realtime: ${channelName}`,\n tags: [\"source\", \"supabase\"],\n },\n };\n}\n"]}
package/dist/supabase.js CHANGED
@@ -1,2 +1,2 @@
1
- function b(l){let{client:s,channel:i,events:u,onStatus:c,redactRow:o=a=>a}=l;return {attach:a=>{let n=s.channel(i);for(let e of u)n=n.on("postgres_changes",{event:e.event,schema:e.schema??"public",table:e.table,...e.filter!==void 0?{filter:e.filter}:{}},t=>{let p={...t,new:o(t.new),old:o(t.old)},r=e.map(p);r!==null&&a(r.name,r.payload);});return n.subscribe(e=>c?.(e)),()=>{s.removeChannel(n);}},meta:{label:`Supabase realtime: ${i}`,tags:["source","supabase"]}}}export{b as sourceFromSupabaseChannel};//# sourceMappingURL=supabase.js.map
1
+ function b(o){let{client:r,channel:i,events:u,onStatus:c,redactRow:l=a=>a}=o;return {attach:a=>{let n=r.channel(i);for(let e of u)n=n.on("postgres_changes",{event:e.event,schema:e.schema??"public",table:e.table,...e.filter!==void 0?{filter:e.filter}:{}},t=>{let p={...t,new:l(t.new),old:l(t.old)},s=e.map(p);s!==null&&a(s.name,s.payload);});return n.subscribe(e=>c?.(e)),async()=>{await r.removeChannel(n);}},meta:{label:`Supabase realtime: ${i}`,tags:["source","supabase"]}}}export{b as sourceFromSupabaseChannel};//# sourceMappingURL=supabase.js.map
2
2
  //# sourceMappingURL=supabase.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/supabase.ts"],"names":["sourceFromSupabaseChannel","options","client","channelName","events","onStatus","redactRow","row","publish","chan","binding","payload","safe","result","status"],"mappings":"AA8IO,SAASA,CAAAA,CACdC,CAAAA,CACW,CACX,GAAM,CACJ,MAAA,CAAAC,CAAAA,CACA,OAAA,CAASC,CAAAA,CACT,MAAA,CAAAC,CAAAA,CACA,QAAA,CAAAC,CAAAA,CACA,SAAA,CAAAC,CAAAA,CAAaC,CAAAA,EAAQA,CACvB,CAAA,CAAIN,CAAAA,CAEJ,OAAO,CACL,MAAA,CAASO,CAAAA,EAA2B,CAIlC,IAAIC,CAAAA,CAAOP,CAAAA,CAAO,OAAA,CAAQC,CAAW,EACrC,IAAA,IAAWO,CAAAA,IAAWN,CAAAA,CACpBK,CAAAA,CAAOA,CAAAA,CAAK,EAAA,CACV,kBAAA,CACA,CACE,KAAA,CAAOC,CAAAA,CAAQ,KAAA,CACf,MAAA,CAAQA,CAAAA,CAAQ,MAAA,EAAU,QAAA,CAC1B,KAAA,CAAOA,CAAAA,CAAQ,KAAA,CACf,GAAIA,CAAAA,CAAQ,MAAA,GAAW,MAAA,CAAY,CAAE,MAAA,CAAQA,CAAAA,CAAQ,MAAO,CAAA,CAAI,EAClE,CAAA,CACCC,GAAY,CAKX,IAAMC,CAAAA,CAAwB,CAC5B,GAAGD,CAAAA,CACH,GAAA,CAAKL,CAAAA,CAAUK,CAAAA,CAAQ,GAAG,CAAA,CAC1B,GAAA,CAAKL,CAAAA,CAAUK,CAAAA,CAAQ,GAAG,CAC5B,CAAA,CACME,CAAAA,CAASH,CAAAA,CAAQ,GAAA,CAAIE,CAAI,CAAA,CAC3BC,CAAAA,GAAW,IAAA,EACfL,CAAAA,CAAQK,CAAAA,CAAO,IAAA,CAAMA,CAAAA,CAAO,OAAO,EACrC,CACF,CAAA,CAOF,OAAAJ,CAAAA,CAAK,SAAA,CAAWK,CAAAA,EAAWT,CAAAA,GAAWS,CAAM,CAAC,CAAA,CAOtC,IAAM,CACNZ,CAAAA,CAAO,aAAA,CAAcO,CAAI,EAChC,CACF,CAAA,CACA,IAAA,CAAM,CACJ,KAAA,CAAO,CAAA,mBAAA,EAAsBN,CAAW,CAAA,CAAA,CACxC,IAAA,CAAM,CAAC,QAAA,CAAU,UAAU,CAC7B,CACF,CACF","file":"supabase.js","sourcesContent":["/**\n * @directive-run/sources/supabase\n *\n * Bridges Supabase realtime channels into the Directive `source` primitive.\n * One factory call → one declared source on your module → the engine\n * owns the subscription lifecycle. No `useEffect` bridges, no manual\n * cleanup, no leaked channels.\n *\n * @example Wire a Supabase realtime channel for a single game row\n * ```ts\n * import { createClient } from '@supabase/supabase-js';\n * import { createModule, t } from '@directive-run/core';\n * import { sourceFromSupabaseChannel } from '@directive-run/sources/supabase';\n *\n * const supabase = createClient(url, key);\n *\n * const gameUpdates = createModule('gameUpdates', {\n * schema: {\n * facts: { snapshot: t.object<GameSnapshot>().nullable() },\n * events: { GAME_UPDATED: { snapshot: t.object<GameSnapshot>() } },\n * },\n * init: (f) => { f.snapshot = null; },\n * events: { GAME_UPDATED: (f, p) => { f.snapshot = p.snapshot; } },\n * sources: {\n * gameChannel: sourceFromSupabaseChannel({\n * client: supabase,\n * channel: `game:${gameId}`,\n * events: [{\n * table: 'games',\n * filter: `id=eq.${gameId}`,\n * event: 'UPDATE',\n * map: (row) => ({ name: 'GAME_UPDATED', payload: { snapshot: mapRow(row) } }),\n * }],\n * }),\n * },\n * });\n * ```\n */\n\nimport type { SourceDef, SourcePublish } from \"@directive-run/core\";\n\n// ============================================================================\n// Type stubs for the Supabase API surface we touch\n// ============================================================================\n\n// We deliberately do NOT import from `@supabase/supabase-js` so this file\n// type-checks without the optional peerDependency installed. The runtime\n// shape matches Supabase's RealtimeChannel + SupabaseClient surfaces;\n// consumers who install the package get full typing through their own\n// `createClient(...)` instance.\n\ninterface SupabasePayload {\n schema: string;\n table: string;\n commit_timestamp?: string;\n eventType: \"INSERT\" | \"UPDATE\" | \"DELETE\";\n new: Record<string, unknown>;\n old: Record<string, unknown>;\n}\n\ntype SupabaseHandler = (payload: SupabasePayload) => void;\n\ninterface SupabaseRealtimeChannel {\n on(\n type: \"postgres_changes\",\n filter: {\n event: \"INSERT\" | \"UPDATE\" | \"DELETE\" | \"*\";\n schema?: string;\n table?: string;\n filter?: string;\n },\n handler: SupabaseHandler,\n ): SupabaseRealtimeChannel;\n subscribe(callback?: (status: string) => void): SupabaseRealtimeChannel;\n unsubscribe(): Promise<\"ok\" | \"timed out\" | \"error\">;\n}\n\ninterface SupabaseRealtimeClient {\n channel(name: string): SupabaseRealtimeChannel;\n removeChannel(\n channel: SupabaseRealtimeChannel,\n ): Promise<\"ok\" | \"timed out\" | \"error\">;\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Declarative mapping from a Supabase `postgres_changes` event to a typed\n * Directive event publish. `map` runs on every matching payload; return\n * `null` to skip publishing for that row (useful for soft-filters).\n */\nexport interface SupabaseEventBinding {\n /** Postgres event type. Use `'*'` for any. */\n event: \"INSERT\" | \"UPDATE\" | \"DELETE\" | \"*\";\n /** Table to listen on. */\n table: string;\n /** Postgres schema. Default `'public'`. */\n schema?: string;\n /** Server-side filter expression (e.g. `id=eq.123`). */\n filter?: string;\n /**\n * Map the Supabase payload to a typed Directive event. Return `null` to\n * skip this publish (e.g. filter out updates that don't affect the\n * tracked fields).\n */\n map: (\n payload: SupabasePayload,\n ) => { name: string; payload: Record<string, unknown> } | null;\n}\n\nexport interface SupabaseChannelOptions {\n /** The Supabase client (`createClient(url, key)`). */\n client: SupabaseRealtimeClient;\n /** Channel name. Must be unique per system instance. */\n channel: string;\n /** One or more event bindings on this channel. */\n events: readonly SupabaseEventBinding[];\n /**\n * Optional status hook. Fires with Supabase's connection status\n * (`'SUBSCRIBED'`, `'CHANNEL_ERROR'`, `'TIMED_OUT'`, `'CLOSED'`).\n * Wire to your logging / health-check telemetry.\n */\n onStatus?: (status: string) => void;\n /**\n * Optional pii-redaction hook applied to every payload row BEFORE the\n * `map` callback runs. Useful for blanking columns the schema author\n * marked as PII when the consumer's `createFactPIIGuardrail` cannot\n * reach the field shape (e.g. nested Supabase JSON column).\n *\n * Default: identity (no redaction).\n */\n redactRow?: (row: Record<string, unknown>) => Record<string, unknown>;\n}\n\n/**\n * Build a `SourceDef` that subscribes to a Supabase realtime channel\n * and publishes typed Directive events for each matching row change.\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromSupabaseChannel(\n options: SupabaseChannelOptions,\n): SourceDef {\n const {\n client,\n channel: channelName,\n events,\n onStatus,\n redactRow = (row) => row,\n } = options;\n\n return {\n attach: (publish: SourcePublish) => {\n // Build the channel + register every binding. Each binding's\n // `map` runs on every matching row; the source publishes the\n // returned Directive event.\n let chan = client.channel(channelName);\n for (const binding of events) {\n chan = chan.on(\n \"postgres_changes\",\n {\n event: binding.event,\n schema: binding.schema ?? \"public\",\n table: binding.table,\n ...(binding.filter !== undefined ? { filter: binding.filter } : {}),\n },\n (payload) => {\n // Redact at the row boundary so the `map` callback (which\n // typically embeds row fields into the event payload) sees\n // only the redacted version. The fact-level\n // createFactPIIGuardrail is the second line of defense.\n const safe: SupabasePayload = {\n ...payload,\n new: redactRow(payload.new),\n old: redactRow(payload.old),\n };\n const result = binding.map(safe);\n if (result === null) return;\n publish(result.name, result.payload);\n },\n );\n }\n\n // Open the subscription. `subscribe()` is async on the wire but\n // returns the channel synchronously — the status callback fires\n // once the realtime server acknowledges. Our `attach` contract is\n // sync; we capture the status callback for the consumer.\n chan.subscribe((status) => onStatus?.(status));\n\n // Cleanup: unsubscribe via the client so internal channel registry\n // is cleared. `removeChannel` returns a Promise; the engine's\n // current `SourceUnsubscribeFn` is sync we kick off the cleanup\n // and let it complete in the background. RFC 0009 widens this to\n // an awaitable signature.\n return () => {\n void client.removeChannel(chan);\n };\n },\n meta: {\n label: `Supabase realtime: ${channelName}`,\n tags: [\"source\", \"supabase\"],\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/supabase.ts"],"names":["sourceFromSupabaseChannel","options","client","channelName","events","onStatus","redactRow","row","publish","chan","binding","payload","safe","result","status"],"mappings":"AA8IO,SAASA,CAAAA,CACdC,CAAAA,CACW,CACX,GAAM,CACJ,MAAA,CAAAC,CAAAA,CACA,OAAA,CAASC,CAAAA,CACT,MAAA,CAAAC,CAAAA,CACA,QAAA,CAAAC,CAAAA,CACA,SAAA,CAAAC,CAAAA,CAAaC,CAAAA,EAAQA,CACvB,CAAA,CAAIN,CAAAA,CAEJ,OAAO,CACL,MAAA,CAASO,CAAAA,EAA2B,CAIlC,IAAIC,CAAAA,CAAOP,CAAAA,CAAO,OAAA,CAAQC,CAAW,CAAA,CACrC,IAAA,IAAWO,CAAAA,IAAWN,CAAAA,CACpBK,CAAAA,CAAOA,CAAAA,CAAK,EAAA,CACV,kBAAA,CACA,CACE,KAAA,CAAOC,CAAAA,CAAQ,KAAA,CACf,MAAA,CAAQA,CAAAA,CAAQ,MAAA,EAAU,QAAA,CAC1B,KAAA,CAAOA,CAAAA,CAAQ,KAAA,CACf,GAAIA,CAAAA,CAAQ,MAAA,GAAW,MAAA,CAAY,CAAE,MAAA,CAAQA,CAAAA,CAAQ,MAAO,CAAA,CAAI,EAClE,CAAA,CACCC,CAAAA,EAAY,CAKX,IAAMC,CAAAA,CAAwB,CAC5B,GAAGD,CAAAA,CACH,GAAA,CAAKL,CAAAA,CAAUK,CAAAA,CAAQ,GAAG,CAAA,CAC1B,GAAA,CAAKL,CAAAA,CAAUK,CAAAA,CAAQ,GAAG,CAC5B,CAAA,CACME,CAAAA,CAASH,CAAAA,CAAQ,GAAA,CAAIE,CAAI,CAAA,CAC3BC,CAAAA,GAAW,IAAA,EACfL,CAAAA,CAAQK,CAAAA,CAAO,IAAA,CAAMA,CAAAA,CAAO,OAAO,EACrC,CACF,CAAA,CAOF,OAAAJ,CAAAA,CAAK,SAAA,CAAWK,CAAAA,EAAWT,CAAAA,GAAWS,CAAM,CAAC,CAAA,CAYtC,SAAY,CACjB,MAAMZ,CAAAA,CAAO,aAAA,CAAcO,CAAI,EACjC,CACF,CAAA,CACA,IAAA,CAAM,CACJ,KAAA,CAAO,CAAA,mBAAA,EAAsBN,CAAW,CAAA,CAAA,CACxC,IAAA,CAAM,CAAC,QAAA,CAAU,UAAU,CAC7B,CACF,CACF","file":"supabase.js","sourcesContent":["/**\n * @directive-run/sources/supabase\n *\n * Bridges Supabase realtime channels into the Directive `source` primitive.\n * One factory call → one declared source on your module → the engine\n * owns the subscription lifecycle. No `useEffect` bridges, no manual\n * cleanup, no leaked channels.\n *\n * @example Wire a Supabase realtime channel for a single game row\n * ```ts\n * import { createClient } from '@supabase/supabase-js';\n * import { createModule, t } from '@directive-run/core';\n * import { sourceFromSupabaseChannel } from '@directive-run/sources/supabase';\n *\n * const supabase = createClient(url, key);\n *\n * const gameUpdates = createModule('gameUpdates', {\n * schema: {\n * facts: { snapshot: t.object<GameSnapshot>().nullable() },\n * events: { GAME_UPDATED: { snapshot: t.object<GameSnapshot>() } },\n * },\n * init: (f) => { f.snapshot = null; },\n * events: { GAME_UPDATED: (f, p) => { f.snapshot = p.snapshot; } },\n * sources: {\n * gameChannel: sourceFromSupabaseChannel({\n * client: supabase,\n * channel: `game:${gameId}`,\n * events: [{\n * table: 'games',\n * filter: `id=eq.${gameId}`,\n * event: 'UPDATE',\n * map: (row) => ({ name: 'GAME_UPDATED', payload: { snapshot: mapRow(row) } }),\n * }],\n * }),\n * },\n * });\n * ```\n */\n\nimport type { SourceDef, SourcePublish } from \"@directive-run/core\";\n\n// ============================================================================\n// Type stubs for the Supabase API surface we touch\n// ============================================================================\n\n// We deliberately do NOT import from `@supabase/supabase-js` so this file\n// type-checks without the optional peerDependency installed. The runtime\n// shape matches Supabase's RealtimeChannel + SupabaseClient surfaces;\n// consumers who install the package get full typing through their own\n// `createClient(...)` instance.\n\ninterface SupabasePayload {\n schema: string;\n table: string;\n commit_timestamp?: string;\n eventType: \"INSERT\" | \"UPDATE\" | \"DELETE\";\n new: Record<string, unknown>;\n old: Record<string, unknown>;\n}\n\ntype SupabaseHandler = (payload: SupabasePayload) => void;\n\ninterface SupabaseRealtimeChannel {\n on(\n type: \"postgres_changes\",\n filter: {\n event: \"INSERT\" | \"UPDATE\" | \"DELETE\" | \"*\";\n schema?: string;\n table?: string;\n filter?: string;\n },\n handler: SupabaseHandler,\n ): SupabaseRealtimeChannel;\n subscribe(callback?: (status: string) => void): SupabaseRealtimeChannel;\n unsubscribe(): Promise<\"ok\" | \"timed out\" | \"error\">;\n}\n\ninterface SupabaseRealtimeClient {\n channel(name: string): SupabaseRealtimeChannel;\n removeChannel(\n channel: SupabaseRealtimeChannel,\n ): Promise<\"ok\" | \"timed out\" | \"error\">;\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Declarative mapping from a Supabase `postgres_changes` event to a typed\n * Directive event publish. `map` runs on every matching payload; return\n * `null` to skip publishing for that row (useful for soft-filters).\n */\nexport interface SupabaseEventBinding {\n /** Postgres event type. Use `'*'` for any. */\n event: \"INSERT\" | \"UPDATE\" | \"DELETE\" | \"*\";\n /** Table to listen on. */\n table: string;\n /** Postgres schema. Default `'public'`. */\n schema?: string;\n /** Server-side filter expression (e.g. `id=eq.123`). */\n filter?: string;\n /**\n * Map the Supabase payload to a typed Directive event. Return `null` to\n * skip this publish (e.g. filter out updates that don't affect the\n * tracked fields).\n */\n map: (\n payload: SupabasePayload,\n ) => { name: string; payload: Record<string, unknown> } | null;\n}\n\nexport interface SupabaseChannelOptions {\n /** The Supabase client (`createClient(url, key)`). */\n client: SupabaseRealtimeClient;\n /** Channel name. Must be unique per system instance. */\n channel: string;\n /** One or more event bindings on this channel. */\n events: readonly SupabaseEventBinding[];\n /**\n * Optional status hook. Fires with Supabase's connection status\n * (`'SUBSCRIBED'`, `'CHANNEL_ERROR'`, `'TIMED_OUT'`, `'CLOSED'`).\n * Wire to your logging / health-check telemetry.\n */\n onStatus?: (status: string) => void;\n /**\n * Optional pii-redaction hook applied to every payload row BEFORE the\n * `map` callback runs. Useful for blanking columns the schema author\n * marked as PII when the consumer's `createFactPIIGuardrail` cannot\n * reach the field shape (e.g. nested Supabase JSON column).\n *\n * Default: identity (no redaction).\n */\n redactRow?: (row: Record<string, unknown>) => Record<string, unknown>;\n}\n\n/**\n * Build a `SourceDef` that subscribes to a Supabase realtime channel\n * and publishes typed Directive events for each matching row change.\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromSupabaseChannel(\n options: SupabaseChannelOptions,\n): SourceDef {\n const {\n client,\n channel: channelName,\n events,\n onStatus,\n redactRow = (row) => row,\n } = options;\n\n return {\n attach: (publish: SourcePublish) => {\n // Build the channel + register every binding. Each binding's\n // `map` runs on every matching row; the source publishes the\n // returned Directive event.\n let chan = client.channel(channelName);\n for (const binding of events) {\n chan = chan.on(\n \"postgres_changes\",\n {\n event: binding.event,\n schema: binding.schema ?? \"public\",\n table: binding.table,\n ...(binding.filter !== undefined ? { filter: binding.filter } : {}),\n },\n (payload) => {\n // Redact at the row boundary so the `map` callback (which\n // typically embeds row fields into the event payload) sees\n // only the redacted version. The fact-level\n // createFactPIIGuardrail is the second line of defense.\n const safe: SupabasePayload = {\n ...payload,\n new: redactRow(payload.new),\n old: redactRow(payload.old),\n };\n const result = binding.map(safe);\n if (result === null) return;\n publish(result.name, result.payload);\n },\n );\n }\n\n // Open the subscription. `subscribe()` is async on the wire but\n // returns the channel synchronously — the status callback fires\n // once the realtime server acknowledges. Our `attach` contract is\n // sync; we capture the status callback for the consumer.\n chan.subscribe((status) => onStatus?.(status));\n\n // Cleanup: unsubscribe via the client so the internal channel\n // registry is cleared. `removeChannel` returns a Promise; per RFC\n // 0009 we await it so `system.stopAsync()` does not resolve\n // before the broker has dropped the subscription. Without this\n // await, a `start stopAsync → start` cycle double-subscribes\n // (the broker still holds the old channel when the new attach\n // races in). Engines pre-RFC-0009 that call the legacy sync\n // `cleanupAll` ignore the returned promise — that's the same\n // fire-and-forget behavior as before, just with the broker drop\n // observable to consumers who DO use `stopAsync`.\n return async () => {\n await client.removeChannel(chan);\n };\n },\n meta: {\n label: `Supabase realtime: ${channelName}`,\n tags: [\"source\", \"supabase\"],\n },\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directive-run/sources",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Source adapters for Directive — wrap Supabase realtime, Cloudflare DO alarms, WebSocket, Sentry, etc. as typed `source` primitives. One package, one install, subpath exports per vendor.",
5
5
  "license": "(MIT OR Apache-2.0)",
6
6
  "author": "Jason Comes",
@@ -74,7 +74,7 @@
74
74
  "tsup": "^8.3.5",
75
75
  "typescript": "^5.7.2",
76
76
  "vitest": "^2.1.9",
77
- "@directive-run/core": "1.18.0"
77
+ "@directive-run/core": "1.19.0"
78
78
  },
79
79
  "scripts": {
80
80
  "build": "tsup",