@kyneta/changefeed 1.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.
@@ -0,0 +1,82 @@
1
+ // callable — the createCallable combinator.
2
+ //
3
+ // Wraps a Changefeed<S, C> in a callable function-object so that
4
+ // `feed()` returns `feed.current`. The callable preserves the full
5
+ // changefeed contract: [CHANGEFEED], .current, .subscribe().
6
+ //
7
+ // This is the same function-object pattern used by LocalRef in
8
+ // @kyneta/cast — a function with properties attached.
9
+
10
+ import type { ChangeBase } from "./change.js"
11
+ import type { Changefeed, ChangefeedProtocol, Changeset } from "./changefeed.js"
12
+ import { CHANGEFEED } from "./changefeed.js"
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Type
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * A changefeed that is also callable — `feed()` returns `feed.current`.
20
+ *
21
+ * This is the intersection of `Changefeed<S, C>` and `() => S`.
22
+ * The call signature provides ergonomic read access without `.current`.
23
+ */
24
+ export type CallableChangefeed<
25
+ S,
26
+ C extends ChangeBase = ChangeBase,
27
+ > = Changefeed<S, C> & (() => S)
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Factory
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Wrap a `Changefeed<S, C>` in a callable function-object.
35
+ *
36
+ * The returned object:
37
+ * - `feed()` → `feed.current` (callable)
38
+ * - `feed.current` → delegated getter
39
+ * - `feed.subscribe(cb)` → delegated
40
+ * - `feed[CHANGEFEED]` → delegated protocol
41
+ * - `hasChangefeed(feed)` → `true`
42
+ *
43
+ * ```ts
44
+ * const [source, emit] = createChangefeed(() => count)
45
+ * const feed = createCallable(source)
46
+ * feed() // read current value
47
+ * feed.current // same as feed()
48
+ * feed.subscribe(cb) // subscribe to changes
49
+ * ```
50
+ */
51
+ export function createCallable<S, C extends ChangeBase>(
52
+ feed: Changefeed<S, C>,
53
+ ): CallableChangefeed<S, C> {
54
+ const callable: any = () => feed.current
55
+
56
+ // [CHANGEFEED] — non-enumerable getter delegating to source
57
+ Object.defineProperty(callable, CHANGEFEED, {
58
+ get(): ChangefeedProtocol<S, C> {
59
+ return feed[CHANGEFEED]
60
+ },
61
+ enumerable: false,
62
+ configurable: false,
63
+ })
64
+
65
+ // .current — getter delegating to source
66
+ Object.defineProperty(callable, "current", {
67
+ get(): S {
68
+ return feed.current
69
+ },
70
+ enumerable: true,
71
+ configurable: false,
72
+ })
73
+
74
+ // .subscribe — delegating to source
75
+ callable.subscribe = (
76
+ callback: (changeset: Changeset<C>) => void,
77
+ ): (() => void) => {
78
+ return feed.subscribe(callback)
79
+ }
80
+
81
+ return callable as CallableChangefeed<S, C>
82
+ }
package/src/change.ts ADDED
@@ -0,0 +1,28 @@
1
+ // ChangeBase — the universal base type for all changes.
2
+ //
3
+ // A change describes a delta to a reactive value. The same change
4
+ // structure flows in both directions:
5
+ // - Going in (producer → consumer): the change describes what happened
6
+ // - Coming out (consumer → subscriber): the change describes the delta
7
+ //
8
+ // Changes are an open protocol identified by a string discriminant.
9
+ // Built-in change types (TextChange, SequenceChange, etc.) live in
10
+ // @kyneta/schema — they are schema vocabulary, not contract primitives.
11
+ // Third-party producers extend ChangeBase with their own types.
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Base protocol
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * All changes carry a string `type` discriminant. Built-in change types
19
+ * use well-known strings ("text", "sequence", "map", "replace", "tree").
20
+ * Third-party producers extend this with their own types.
21
+ *
22
+ * Provenance metadata (e.g. "local", "sync") is carried at the batch
23
+ * level on `Changeset.origin`, not on individual changes. See
24
+ * `Changeset` in `changefeed.ts`.
25
+ */
26
+ export interface ChangeBase {
27
+ readonly type: string
28
+ }
@@ -0,0 +1,250 @@
1
+ // Changefeed — the universal reactive contract.
2
+ //
3
+ // A changefeed is a reactive value with a current state and a stream
4
+ // of future changes. You read `current` to see what's there now;
5
+ // you subscribe to learn what changes next.
6
+ //
7
+ // The changefeed protocol is expressed through a single symbol: CHANGEFEED.
8
+ //
9
+ // Changes are delivered as `Changeset<C>` — a batch of one or more
10
+ // changes with optional provenance metadata. Auto-commit wraps a
11
+ // single change in a degenerate changeset of one; transactions and
12
+ // `applyChanges` deliver multi-change batches. The subscriber API
13
+ // is uniform regardless of batch size.
14
+ //
15
+ // This module is the canonical home of the reactive contract. It has
16
+ // zero dependencies — no schema, no paths, no interpreters.
17
+
18
+ import type { ChangeBase } from "./change.js"
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Symbol
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * The single symbol that marks a value as a changefeed. Accessing
26
+ * `obj[CHANGEFEED]` yields a `ChangefeedProtocol<S, C>` — the current
27
+ * value and a stream of future changes.
28
+ *
29
+ * Uses `Symbol.for` so that multiple copies of this module (e.g. in
30
+ * different bundle chunks) share the same symbol identity.
31
+ */
32
+ export const CHANGEFEED: unique symbol = Symbol.for("kyneta:changefeed") as any
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Changeset — the unit of batch delivery
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * A changeset is the unit of delivery through the changefeed protocol.
40
+ * It wraps one or more changes with optional batch-level metadata.
41
+ *
42
+ * - Auto-commit produces a degenerate changeset of one change.
43
+ * - Transactions and `applyChanges` produce multi-change batches.
44
+ * - `origin` carries provenance for the entire batch (e.g. "sync",
45
+ * "undo", "local"). Individual changes do not carry provenance —
46
+ * the batch does.
47
+ *
48
+ * The subscriber API always receives a `Changeset`, making it uniform
49
+ * regardless of how the changes were produced.
50
+ */
51
+ export interface Changeset<C = ChangeBase> {
52
+ /** The individual changes in this batch. */
53
+ readonly changes: readonly C[]
54
+ /** Provenance of the batch (e.g. "sync", "undo", "local"). */
55
+ readonly origin?: string
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Core interfaces — protocol layer
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * The protocol object that sits behind the `[CHANGEFEED]` symbol.
64
+ *
65
+ * A coalgebra: `current` gives the live state, `subscribe` gives the
66
+ * stream of future changes. In automata-theory terms this is a Moore
67
+ * machine with a push-based transition stream.
68
+ *
69
+ * Properties:
70
+ * - `current` is a getter — always returns the live current value
71
+ * - `subscribe` returns an unsubscribe function
72
+ * - Subscribers receive a `Changeset<C>` — a batch of changes with
73
+ * optional provenance. For auto-commit (single mutation), the
74
+ * changeset contains exactly one change.
75
+ * - Static (non-reactive) sources return a protocol whose tail never emits:
76
+ * `{ current: value, subscribe: () => () => {} }`
77
+ *
78
+ * This is internal plumbing — developers interact with `Changefeed<S, C>`
79
+ * (the developer-facing type that includes `[CHANGEFEED]`, `.current`,
80
+ * and `.subscribe()` in one interface).
81
+ */
82
+ export interface ChangefeedProtocol<S, C extends ChangeBase = ChangeBase> {
83
+ /** The current value, always live (a getter). */
84
+ readonly current: S
85
+ /** Subscribe to future changes. Returns an unsubscribe function. */
86
+ subscribe(callback: (changeset: Changeset<C>) => void): () => void
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Core interfaces — developer-facing type
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /**
94
+ * The developer-facing changefeed type: a reactive value with direct
95
+ * access to `.current`, `.subscribe()`, and the `[CHANGEFEED]` marker.
96
+ *
97
+ * Developers write `readonly peers: Changefeed<PeerMap, PeerChange>` —
98
+ * no `Has` prefix, no separate protocol object, no triple declaration.
99
+ *
100
+ * A `Changefeed<S, C>` is the intersection of:
101
+ * - The `[CHANGEFEED]` marker (for compiler detection and runtime protocol)
102
+ * - Direct `.current` and `.subscribe()` access (for developer ergonomics)
103
+ *
104
+ * Use `changefeed(source)` to project any `HasChangefeed` into a
105
+ * `Changefeed`, or `createChangefeed()` to build one from scratch.
106
+ */
107
+ export interface Changefeed<S, C extends ChangeBase = ChangeBase> {
108
+ /** The protocol object behind the symbol. */
109
+ readonly [CHANGEFEED]: ChangefeedProtocol<S, C>
110
+ /** The current value, always live (a getter). */
111
+ readonly current: S
112
+ /** Subscribe to future changes. Returns an unsubscribe function. */
113
+ subscribe(callback: (changeset: Changeset<C>) => void): () => void
114
+ }
115
+
116
+ /**
117
+ * An object that carries a changefeed protocol under the `[CHANGEFEED]`
118
+ * symbol.
119
+ *
120
+ * Any ref, interpreted node, or enriched value can implement this
121
+ * interface to participate in the reactive protocol.
122
+ */
123
+ export interface HasChangefeed<S = unknown, A extends ChangeBase = ChangeBase> {
124
+ readonly [CHANGEFEED]: ChangefeedProtocol<S, A>
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Type guard
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /**
132
+ * Returns `true` if `value` has a `[CHANGEFEED]` property, i.e. it
133
+ * implements the `HasChangefeed` interface.
134
+ */
135
+ export function hasChangefeed<S = unknown, A extends ChangeBase = ChangeBase>(
136
+ value: unknown,
137
+ ): value is HasChangefeed<S, A> {
138
+ return (
139
+ value !== null &&
140
+ value !== undefined &&
141
+ (typeof value === "object" || typeof value === "function") &&
142
+ CHANGEFEED in (value as object)
143
+ )
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Static feed helper
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /**
151
+ * Creates a changefeed protocol that never emits changes — useful for
152
+ * static/non-reactive data sources that still need to participate in
153
+ * the changefeed protocol.
154
+ */
155
+ export function staticChangefeed<S>(head: S): ChangefeedProtocol<S, never> {
156
+ return {
157
+ get current() {
158
+ return head
159
+ },
160
+ subscribe() {
161
+ return () => {}
162
+ },
163
+ }
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Projector — lift HasChangefeed to Changefeed
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /**
171
+ * Project any object with `[CHANGEFEED]` into a developer-facing
172
+ * `Changefeed<S, C>` — lifting the hidden protocol surface to direct
173
+ * `.current` and `.subscribe()` accessibility.
174
+ *
175
+ * ```ts
176
+ * const feed = changefeed(doc.title)
177
+ * feed.current // live value
178
+ * feed.subscribe(cb) // subscribe to changes
179
+ * feed[CHANGEFEED] // the protocol object (same as doc.title[CHANGEFEED])
180
+ * ```
181
+ */
182
+ export function changefeed<S, C extends ChangeBase>(
183
+ source: HasChangefeed<S, C>,
184
+ ): Changefeed<S, C> {
185
+ const protocol = source[CHANGEFEED]
186
+ return {
187
+ [CHANGEFEED]: protocol,
188
+ get current(): S {
189
+ return protocol.current
190
+ },
191
+ subscribe(callback: (changeset: Changeset<C>) => void): () => void {
192
+ return protocol.subscribe(callback)
193
+ },
194
+ }
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Factory — create standalone Changefeed values
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /**
202
+ * Create a standalone `Changefeed<S, C>` with push semantics.
203
+ *
204
+ * Returns a tuple of the feed and an emit function. The feed's
205
+ * `[CHANGEFEED]` returns the protocol view of itself. Manages its
206
+ * own subscriber set internally.
207
+ *
208
+ * ```ts
209
+ * const [feed, emit] = createChangefeed(() => count)
210
+ * feed.current // read live value
211
+ * feed.subscribe(cs => { ... }) // subscribe
212
+ * hasChangefeed(feed) // true
213
+ * emit({ changes: [{ type: "replace", value: 42 }] }) // push
214
+ * ```
215
+ */
216
+ export function createChangefeed<S, C extends ChangeBase = ChangeBase>(
217
+ getCurrent: () => S,
218
+ ): [feed: Changefeed<S, C>, emit: (changeset: Changeset<C>) => void] {
219
+ const subscribers = new Set<(changeset: Changeset<C>) => void>()
220
+
221
+ const protocol: ChangefeedProtocol<S, C> = {
222
+ get current(): S {
223
+ return getCurrent()
224
+ },
225
+ subscribe(callback: (changeset: Changeset<C>) => void): () => void {
226
+ subscribers.add(callback)
227
+ return () => {
228
+ subscribers.delete(callback)
229
+ }
230
+ },
231
+ }
232
+
233
+ const feed: Changefeed<S, C> = {
234
+ [CHANGEFEED]: protocol,
235
+ get current(): S {
236
+ return getCurrent()
237
+ },
238
+ subscribe(callback: (changeset: Changeset<C>) => void): () => void {
239
+ return protocol.subscribe(callback)
240
+ },
241
+ }
242
+
243
+ const emit = (changeset: Changeset<C>): void => {
244
+ for (const cb of subscribers) {
245
+ cb(changeset)
246
+ }
247
+ }
248
+
249
+ return [feed, emit]
250
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ // @kyneta/changefeed — the universal reactive contract.
2
+ //
3
+ // This barrel re-exports everything from the three source modules
4
+ // that make up the changefeed contract package.
5
+
6
+ // Callable — the createCallable combinator
7
+ export type { CallableChangefeed } from "./callable.js"
8
+ export { createCallable } from "./callable.js"
9
+ // ChangeBase — the universal base type for all changes
10
+ export type { ChangeBase } from "./change.js"
11
+ // Changefeed — symbol, types, type guards, factories, projector
12
+ export type {
13
+ Changefeed,
14
+ ChangefeedProtocol,
15
+ Changeset,
16
+ HasChangefeed,
17
+ } from "./changefeed.js"
18
+ export {
19
+ CHANGEFEED,
20
+ changefeed,
21
+ createChangefeed,
22
+ hasChangefeed,
23
+ staticChangefeed,
24
+ } from "./changefeed.js"
25
+ // ReactiveMap — callable changefeed over a mutable Map
26
+ export type { ReactiveMap, ReactiveMapHandle } from "./reactive-map.js"
27
+ export { createReactiveMap } from "./reactive-map.js"
@@ -0,0 +1,162 @@
1
+ // reactive-map — a callable changefeed over a mutable Map.
2
+ //
3
+ // ReactiveMap<K, V, C> is a CallableChangefeed<ReadonlyMap<K, V>, C>
4
+ // with lifted collection accessors (.get, .has, .keys, .size, iteration).
5
+ // The handle provides raw map mutations (set, delete, clear) without
6
+ // automatic emission — the consumer decides when and what to emit.
7
+ //
8
+ // This extracts the recurring pattern of "callable changefeed over a
9
+ // ReadonlyMap with convenience accessors" (used by exchange.peers,
10
+ // Catalog, and future reactive collections) into a single combinator.
11
+
12
+ import type { CallableChangefeed } from "./callable.js"
13
+ import type { ChangeBase } from "./change.js"
14
+ import type { ChangefeedProtocol, Changeset } from "./changefeed.js"
15
+ import { CHANGEFEED, createChangefeed } from "./changefeed.js"
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * A callable changefeed over a `ReadonlyMap<K, V>` with lifted
23
+ * collection accessors.
24
+ *
25
+ * `reactiveMap()` returns the current `ReadonlyMap<K, V>`.
26
+ * `.get()`, `.has()`, `.keys()`, `.size`, and `[Symbol.iterator]()`
27
+ * delegate to the internal map — no need to unwrap `.current` first.
28
+ *
29
+ * Extends `CallableChangefeed` — assignable anywhere a
30
+ * `CallableChangefeed<ReadonlyMap<K, V>, C>` or `Changefeed` is expected.
31
+ */
32
+ export interface ReactiveMap<K, V, C extends ChangeBase = ChangeBase>
33
+ extends CallableChangefeed<ReadonlyMap<K, V>, C> {
34
+ /** Get the value for a key, or `undefined` if absent. */
35
+ get(key: K): V | undefined
36
+ /** Whether the map contains a key. */
37
+ has(key: K): boolean
38
+ /** An iterator over all keys. */
39
+ keys(): IterableIterator<K>
40
+ /** The number of entries. */
41
+ readonly size: number
42
+ /** Iterate over `[key, value]` pairs. */
43
+ [Symbol.iterator](): IterableIterator<[K, V]>
44
+ }
45
+
46
+ /**
47
+ * The producer-side handle for a `ReactiveMap`.
48
+ *
49
+ * Provides raw map mutations (`set`, `delete`, `clear`) that modify
50
+ * the internal map **without** emitting changes. Call `emit()` with
51
+ * the appropriate changeset after mutations are complete.
52
+ *
53
+ * This separation lets the consumer batch mutations and emit a single
54
+ * changeset — e.g. `clear()` → N × `set()` → one `emit()`.
55
+ */
56
+ export interface ReactiveMapHandle<K, V, C extends ChangeBase> {
57
+ /** Insert or overwrite an entry. Does NOT emit. */
58
+ set(key: K, value: V): void
59
+ /** Remove an entry. Returns `true` if the key was present. Does NOT emit. */
60
+ delete(key: K): boolean
61
+ /** Remove all entries. Does NOT emit. */
62
+ clear(): void
63
+ /** Push a changeset to all subscribers. */
64
+ emit(changeset: Changeset<C>): void
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Factory
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Create a `ReactiveMap<K, V, C>` and its producer-side handle.
73
+ *
74
+ * The reactive map owns its internal `Map<K, V>`. Consumers read via
75
+ * the `ReactiveMap` surface (call signature, `.get()`, `.has()`, etc.).
76
+ * Producers mutate via the `ReactiveMapHandle` (`set`, `delete`,
77
+ * `clear`) and push notifications via `emit`.
78
+ *
79
+ * ```ts
80
+ * const [peers, handle] = createReactiveMap<PeerId, PeerInfo, PeerChange>()
81
+ *
82
+ * handle.set("alice", aliceInfo)
83
+ * handle.emit({ changes: [{ type: "peer-joined", peer: aliceInfo }] })
84
+ *
85
+ * peers() // ReadonlyMap with one entry
86
+ * peers.get("alice") // aliceInfo
87
+ * peers.size // 1
88
+ * ```
89
+ */
90
+ export function createReactiveMap<K, V, C extends ChangeBase = ChangeBase>(): [
91
+ ReactiveMap<K, V, C>,
92
+ ReactiveMapHandle<K, V, C>,
93
+ ] {
94
+ const map = new Map<K, V>()
95
+
96
+ // Create the base changefeed + emit pair.
97
+ // The thunk reads the same Map instance — never reassigned.
98
+ const [feed, emit] = createChangefeed<ReadonlyMap<K, V>, C>(() => map)
99
+
100
+ // Build the callable function-object.
101
+ // We construct it manually (rather than using createCallable) so we
102
+ // can attach the collection accessors in one pass.
103
+ const callable: any = () => map as ReadonlyMap<K, V>
104
+
105
+ // ── Changefeed protocol ──
106
+
107
+ Object.defineProperty(callable, CHANGEFEED, {
108
+ get(): ChangefeedProtocol<ReadonlyMap<K, V>, C> {
109
+ return feed[CHANGEFEED]
110
+ },
111
+ enumerable: false,
112
+ configurable: false,
113
+ })
114
+
115
+ Object.defineProperty(callable, "current", {
116
+ get(): ReadonlyMap<K, V> {
117
+ return map
118
+ },
119
+ enumerable: true,
120
+ configurable: false,
121
+ })
122
+
123
+ callable.subscribe = (
124
+ callback: (changeset: Changeset<C>) => void,
125
+ ): (() => void) => {
126
+ return feed.subscribe(callback)
127
+ }
128
+
129
+ // ── Lifted collection accessors ──
130
+
131
+ callable.get = (key: K): V | undefined => map.get(key)
132
+ callable.has = (key: K): boolean => map.has(key)
133
+ callable.keys = (): IterableIterator<K> => map.keys()
134
+
135
+ Object.defineProperty(callable, "size", {
136
+ get(): number {
137
+ return map.size
138
+ },
139
+ enumerable: true,
140
+ configurable: false,
141
+ })
142
+
143
+ callable[Symbol.iterator] = (): IterableIterator<[K, V]> =>
144
+ map[Symbol.iterator]()
145
+
146
+ // ── Handle (producer side) ──
147
+
148
+ const handle: ReactiveMapHandle<K, V, C> = {
149
+ set(key: K, value: V): void {
150
+ map.set(key, value)
151
+ },
152
+ delete(key: K): boolean {
153
+ return map.delete(key)
154
+ },
155
+ clear(): void {
156
+ map.clear()
157
+ },
158
+ emit,
159
+ }
160
+
161
+ return [callable as ReactiveMap<K, V, C>, handle]
162
+ }