@vira-ui/cli 1.1.2 → 1.2.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.
Files changed (40) hide show
  1. package/README.md +454 -1029
  2. package/dist/go/appYaml.js +30 -30
  3. package/dist/go/backendEnvExample.js +17 -17
  4. package/dist/go/backendReadme.js +14 -14
  5. package/dist/go/channelHelpers.js +25 -25
  6. package/dist/go/configGo.js +258 -258
  7. package/dist/go/dbGo.js +43 -43
  8. package/dist/go/dbYaml.js +7 -7
  9. package/dist/go/dockerCompose.js +48 -48
  10. package/dist/go/dockerComposeProd.js +78 -78
  11. package/dist/go/dockerfile.js +15 -15
  12. package/dist/go/eventHandlerTemplate.js +22 -22
  13. package/dist/go/eventsAPI.js +411 -411
  14. package/dist/go/goMod.js +16 -16
  15. package/dist/go/kafkaGo.js +67 -67
  16. package/dist/go/kafkaYaml.js +6 -6
  17. package/dist/go/kanbanHandlers.js +216 -216
  18. package/dist/go/mainGo.js +558 -558
  19. package/dist/go/readme.js +27 -27
  20. package/dist/go/redisGo.js +31 -31
  21. package/dist/go/redisYaml.js +4 -4
  22. package/dist/go/registryGo.js +38 -38
  23. package/dist/go/sqlcYaml.js +13 -13
  24. package/dist/go/stateStore.js +115 -115
  25. package/dist/go/typesGo.js +11 -11
  26. package/dist/index.js +472 -24
  27. package/dist/react/envExample.js +3 -3
  28. package/dist/react/envLocal.js +1 -1
  29. package/dist/react/indexCss.js +17 -17
  30. package/dist/react/indexHtml.js +12 -12
  31. package/dist/react/kanbanAppTsx.js +29 -29
  32. package/dist/react/kanbanBoard.js +58 -58
  33. package/dist/react/kanbanCard.js +60 -60
  34. package/dist/react/kanbanColumn.js +62 -62
  35. package/dist/react/kanbanModels.js +32 -32
  36. package/dist/react/mainTsx.js +12 -12
  37. package/dist/react/viteConfig.js +27 -27
  38. package/package.json +47 -45
  39. package/dist/go/useViraState.js +0 -160
  40. package/dist/go/useViraStream.js +0 -167
@@ -1,415 +1,415 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.eventsAPI = void 0;
4
- exports.eventsAPI = `package events
5
-
6
- import (
7
- "context"
8
- "encoding/json"
9
- "sync"
10
- "time"
11
-
12
- jsonpatch "github.com/evanphx/json-patch/v5"
13
- "github.com/gorilla/websocket"
14
- )
15
-
16
- // VRP_VERSION is the current protocol version.
17
- const VRP_VERSION = "0.1"
18
-
19
- // ProtocolVersion returns the current VRP version.
20
- func ProtocolVersion() string {
21
- return VRP_VERSION
22
- }
23
-
24
- // WSMessage matches protocol message schema.
25
- type WSMessage struct {
26
- Type string \`json:"type"\`
27
- Name string \`json:"name,omitempty"\`
28
- Channel string \`json:"channel,omitempty"\`
29
- Channels []string \`json:"channels,omitempty"\`
30
- Data json.RawMessage \`json:"data,omitempty"\`
31
- Patch json.RawMessage \`json:"patch,omitempty"\`
32
- Ts int64 \`json:"ts,omitempty"\`
33
- Client string \`json:"client,omitempty"\`
34
- Version string \`json:"version,omitempty"\`
35
- Auth string \`json:"authToken,omitempty"\`
36
- Session string \`json:"session,omitempty"\`
37
- Interval int64 \`json:"interval,omitempty"\`
38
- VersionNo int64 \`json:"versionNo,omitempty"\`
39
- MsgID string \`json:"msgId,omitempty"\` // for idempotency
40
- Code string \`json:"code,omitempty"\` // error code
41
- Message string \`json:"message,omitempty"\` // error message
42
- Retry bool \`json:"retry,omitempty"\` // error retry flag
43
- }
44
-
45
- // EventHandler signature for domain events.
46
- type EventHandler func(ctx context.Context, hub EventEmitter, conn *websocket.Conn, msg WSMessage)
47
-
48
- // EventEmitter exposes server-side emit/update/diff for handlers.
49
- type EventEmitter interface {
50
- Emit(channel string, payload any)
51
- Update(channel string, payload any)
52
- Diff(channel string, patch any)
53
- Snapshot(channel string) (json.RawMessage, int64, bool)
54
- }
55
-
56
- // DiffMode controls how diffs are generated.
57
- type DiffMode int
58
-
59
- const (
60
- DiffModeMerge DiffMode = iota // JSON Merge Patch (RFC 7396)
61
- DiffModePatch // JSON Patch (RFC 6902)
62
- )
63
-
64
- // Hub is an in-memory event hub with state and versions.
65
- type Hub struct {
66
- mu sync.Mutex
67
- clients map[*websocket.Conn]bool
68
- subs map[string]map[*websocket.Conn]bool
69
- sessions map[*websocket.Conn]string
70
- state map[string]json.RawMessage
71
- versions map[string]int64
72
- events map[string]EventHandler
73
- diffMode DiffMode
74
- history map[string][]StateSnapshot // for replay
75
- maxHistory int
76
- store StateStore
77
- ttlSec int
78
- msgIDs map[string]int64 // msgId -> timestamp cache for dedup (bounded)
79
- maxMsgIDs int
80
- }
81
-
82
- // StateSnapshot stores a versioned state snapshot.
83
- type StateSnapshot struct {
84
- Data json.RawMessage
85
- VersionNo int64
86
- Ts int64
87
- }
88
-
89
- func NewHub() *Hub {
90
- return &Hub{
91
- clients: make(map[*websocket.Conn]bool),
92
- subs: make(map[string]map[*websocket.Conn]bool),
93
- sessions: make(map[*websocket.Conn]string),
94
- state: make(map[string]json.RawMessage),
95
- versions: make(map[string]int64),
96
- events: make(map[string]EventHandler),
97
- diffMode: DiffModeMerge,
98
- history: make(map[string][]StateSnapshot),
99
- maxHistory: 100, // keep last 100 versions per channel
100
- store: MemoryStore{},
101
- ttlSec: 0,
102
- msgIDs: make(map[string]int64),
103
- maxMsgIDs: 1000, // keep last 1000 msgIds for dedup
104
- }
105
- }
106
-
107
- // SetDiffMode sets the diff generation mode.
108
- func (h *Hub) SetDiffMode(mode DiffMode) {
109
- h.mu.Lock()
110
- defer h.mu.Unlock()
111
- h.diffMode = mode
112
- }
113
-
114
- // SetHistoryLimit limits how many snapshots are stored per channel.
115
- func (h *Hub) SetHistoryLimit(limit int) {
116
- h.mu.Lock()
117
- defer h.mu.Unlock()
118
- if limit > 0 {
119
- h.maxHistory = limit
120
- }
121
- }
122
-
123
- // SetStore sets the state store (memory/redis).
124
- func (h *Hub) SetStore(store StateStore) {
125
- h.mu.Lock()
126
- defer h.mu.Unlock()
127
- if store != nil {
128
- h.store = store
129
- }
130
- }
131
-
132
- // SetTTL sets TTL for persisted entries.
133
- func (h *Hub) SetTTL(ttlSec int) {
134
- h.mu.Lock()
135
- defer h.mu.Unlock()
136
- if ttlSec >= 0 {
137
- h.ttlSec = ttlSec
138
- }
139
- }
140
-
141
- // CheckMsgID returns true if msgId was already seen (and records it), false otherwise.
142
- // Used for idempotency: duplicate messages with same msgId are ignored.
143
- func (h *Hub) CheckMsgID(msgID string) bool {
144
- if msgID == "" {
145
- return false
146
- }
147
- h.mu.Lock()
148
- defer h.mu.Unlock()
149
- now := time.Now().UnixMilli()
150
- // Clean old entries (older than 5 minutes)
151
- cutoff := now - 5*60*1000
152
- for id, ts := range h.msgIDs {
153
- if ts < cutoff {
154
- delete(h.msgIDs, id)
155
- }
156
- }
157
- // Check if exists
158
- if _, exists := h.msgIDs[msgID]; exists {
159
- return true // duplicate
160
- }
161
- // Record
162
- h.msgIDs[msgID] = now
163
- // Trim if too many
164
- if len(h.msgIDs) > h.maxMsgIDs {
165
- // Remove oldest 100 entries
166
- type entry struct {
167
- id string
168
- ts int64
169
- }
170
- var entries []entry
171
- for id, ts := range h.msgIDs {
172
- entries = append(entries, entry{id, ts})
173
- }
174
- // Sort by timestamp (oldest first)
175
- for i := 0; i < len(entries)-1; i++ {
176
- for j := i + 1; j < len(entries); j++ {
177
- if entries[i].ts > entries[j].ts {
178
- entries[i], entries[j] = entries[j], entries[i]
179
- }
180
- }
181
- }
182
- // Remove oldest 100
183
- for i := 0; i < 100 && i < len(entries); i++ {
184
- delete(h.msgIDs, entries[i].id)
185
- }
186
- }
187
- return false // new message
188
- }
189
-
190
- // Emit aliases Update with force update.
191
- func (h *Hub) Emit(channel string, payload any) {
192
- h.applyUpdate(channel, payload, true)
193
- }
194
-
195
- // Update applies merge-patch optimization when possible.
196
- func (h *Hub) Update(channel string, payload any) {
197
- h.applyUpdate(channel, payload, false)
198
- }
199
-
200
- // Diff applies a raw patch and broadcasts.
201
- func (h *Hub) Diff(channel string, patch any) {
202
- h.applyDiff(channel, patch)
203
- }
204
-
205
- func (h *Hub) Snapshot(channel string) (json.RawMessage, int64, bool) {
206
- h.mu.Lock()
207
- snap, ok := h.state[channel]
208
- v := h.versions[channel]
209
- store := h.store
210
- h.mu.Unlock()
211
-
212
- if ok {
213
- return snap, v, true
214
- }
215
-
216
- // Try loading from store if available
217
- if store != nil {
218
- if snap, ok, _ := store.LoadSnapshot(context.Background(), channel); ok {
219
- // Update in-memory cache
220
- h.mu.Lock()
221
- h.state[channel] = snap.Data
222
- h.versions[channel] = snap.VersionNo
223
- h.mu.Unlock()
224
- return snap.Data, snap.VersionNo, true
225
- }
226
- }
227
-
228
- return nil, 0, false
229
- }
230
-
231
- // Replay returns state snapshots for a channel from a given version.
232
- func (h *Hub) Replay(channel string, fromVersion int64) []StateSnapshot {
233
- h.mu.Lock()
234
- hist := h.history[channel]
235
- store := h.store
236
- h.mu.Unlock()
237
-
238
- var result []StateSnapshot
239
- for _, snap := range hist {
240
- if snap.VersionNo > fromVersion {
241
- result = append(result, snap)
242
- }
243
- }
244
-
245
- // Try loading from store if available and in-memory is empty/incomplete
246
- if store != nil && len(result) == 0 {
247
- if stored, err := store.LoadHistory(context.Background(), channel, fromVersion); err == nil {
248
- result = stored
249
- }
250
- }
251
-
252
- return result
253
- }
254
-
255
- // internal helpers
256
- func (h *Hub) applyUpdate(channel string, payload any, force bool) {
257
- newData, err := json.Marshal(payload)
258
- if err != nil {
259
- return
260
- }
261
- var prev []byte
262
- h.mu.Lock()
263
- if s, ok := h.state[channel]; ok {
264
- prev = append([]byte{}, s...)
265
- }
266
- h.state[channel] = newData
267
- h.versions[channel]++
268
- version := h.versions[channel]
269
-
270
- // Save snapshot for replay
271
- snap := StateSnapshot{
272
- Data: append([]byte{}, newData...),
273
- VersionNo: version,
274
- Ts: time.Now().UnixMilli(),
275
- }
276
- hist := h.history[channel]
277
- hist = append(hist, snap)
278
- if len(hist) > h.maxHistory {
279
- hist = hist[len(hist)-h.maxHistory:]
280
- }
281
- h.history[channel] = hist
282
- store := h.store
283
- ttl := h.ttlSec
284
-
285
- h.mu.Unlock()
286
-
287
- if store != nil {
288
- _ = store.SaveSnapshot(context.Background(), channel, snap, ttl)
289
- _ = store.AppendHistory(context.Background(), channel, snap, h.maxHistory, ttl)
290
- }
291
-
292
- if !force && prev != nil {
293
- var patch json.RawMessage
294
- var err error
295
- // Note: json-patch/v5 only supports CreateMergePatch (RFC 7396)
296
- // For RFC 6902 JSON Patch, we'd need a different library or custom implementation
297
- // For now, always use merge patch regardless of diffMode setting
298
- patch, err = jsonpatch.CreateMergePatch(prev, newData)
299
- if err == nil && len(patch) > 2 {
300
- h.applyDiff(channel, patch)
301
- return
302
- }
303
- }
304
-
305
- msg := WSMessage{
306
- Type: "update",
307
- Channel: channel,
308
- Data: newData,
309
- VersionNo: version,
310
- Ts: time.Now().UnixMilli(),
311
- }
312
- raw, err := json.Marshal(msg)
313
- if err != nil {
314
- return
315
- }
316
- h.broadcast(channel, raw)
317
- }
318
-
319
- func (h *Hub) applyDiff(channel string, patch any) {
320
- data, err := json.Marshal(patch)
321
- if err != nil {
322
- return
323
- }
324
- h.mu.Lock()
325
- prev := h.state[channel]
326
- merged := prev
327
- if prev != nil {
328
- if h.diffMode == DiffModePatch {
329
- // Apply RFC 6902 JSON Patch
330
- ops, err := jsonpatch.DecodePatch(data)
331
- if err == nil {
332
- applied, err := ops.Apply(prev)
333
- if err == nil {
334
- merged = applied
335
- }
336
- }
337
- } else {
338
- // Apply RFC 7396 JSON Merge Patch
339
- if applied, err := jsonpatch.MergePatch(prev, data); err == nil {
340
- merged = applied
341
- }
342
- }
343
- } else {
344
- merged = data
345
- }
346
- h.state[channel] = merged
347
- h.versions[channel]++
348
- version := h.versions[channel]
349
-
350
- // Save snapshot
351
- snap := StateSnapshot{
352
- Data: append([]byte{}, merged...),
353
- VersionNo: version,
354
- Ts: time.Now().UnixMilli(),
355
- }
356
- hist := h.history[channel]
357
- hist = append(hist, snap)
358
- if len(hist) > h.maxHistory {
359
- hist = hist[len(hist)-h.maxHistory:]
360
- }
361
- h.history[channel] = hist
362
- store := h.store
363
- ttl := h.ttlSec
364
-
365
- h.mu.Unlock()
366
-
367
- if store != nil {
368
- _ = store.SaveSnapshot(context.Background(), channel, snap, ttl)
369
- _ = store.AppendHistory(context.Background(), channel, snap, h.maxHistory, ttl)
370
- }
371
-
372
- msg := WSMessage{
373
- Type: "diff",
374
- Channel: channel,
375
- Patch: data,
376
- Ts: time.Now().UnixMilli(),
377
- VersionNo: version,
378
- }
379
- raw, err := json.Marshal(msg)
380
- if err != nil {
381
- return
382
- }
383
- h.broadcast(channel, raw)
384
- }
385
-
386
- // Broadcast sends a message to all subscribers of a channel.
387
- // This is called by applyUpdate/applyDiff after creating the message.
388
- func (h *Hub) Broadcast(channel string, raw json.RawMessage) {
389
- // This will be implemented by wsHub wrapper in main.go
390
- // Hub only manages state, wsHub manages connections
391
- }
392
-
393
- // Get returns an event handler by name.
394
- func (h *Hub) Get(name string) (EventHandler, bool) {
395
- h.mu.Lock()
396
- defer h.mu.Unlock()
397
- handler, ok := h.events[name]
398
- return handler, ok
399
- }
400
-
401
- // SetBroadcaster sets a custom broadcast function (used by wsHub).
402
- type Broadcaster func(channel string, raw json.RawMessage)
403
-
404
- var globalBroadcaster Broadcaster
405
-
406
- func SetBroadcaster(fn Broadcaster) {
407
- globalBroadcaster = fn
408
- }
409
-
410
- func (h *Hub) broadcast(channel string, raw json.RawMessage) {
411
- if globalBroadcaster != nil {
412
- globalBroadcaster(channel, raw)
413
- }
414
- }
4
+ exports.eventsAPI = `package events
5
+
6
+ import (
7
+ "context"
8
+ "encoding/json"
9
+ "sync"
10
+ "time"
11
+
12
+ jsonpatch "github.com/evanphx/json-patch/v5"
13
+ "github.com/gorilla/websocket"
14
+ )
15
+
16
+ // VRP_VERSION is the current protocol version.
17
+ const VRP_VERSION = "0.1"
18
+
19
+ // ProtocolVersion returns the current VRP version.
20
+ func ProtocolVersion() string {
21
+ return VRP_VERSION
22
+ }
23
+
24
+ // WSMessage matches protocol message schema.
25
+ type WSMessage struct {
26
+ Type string \`json:"type"\`
27
+ Name string \`json:"name,omitempty"\`
28
+ Channel string \`json:"channel,omitempty"\`
29
+ Channels []string \`json:"channels,omitempty"\`
30
+ Data json.RawMessage \`json:"data,omitempty"\`
31
+ Patch json.RawMessage \`json:"patch,omitempty"\`
32
+ Ts int64 \`json:"ts,omitempty"\`
33
+ Client string \`json:"client,omitempty"\`
34
+ Version string \`json:"version,omitempty"\`
35
+ Auth string \`json:"authToken,omitempty"\`
36
+ Session string \`json:"session,omitempty"\`
37
+ Interval int64 \`json:"interval,omitempty"\`
38
+ VersionNo int64 \`json:"versionNo,omitempty"\`
39
+ MsgID string \`json:"msgId,omitempty"\` // for idempotency
40
+ Code string \`json:"code,omitempty"\` // error code
41
+ Message string \`json:"message,omitempty"\` // error message
42
+ Retry bool \`json:"retry,omitempty"\` // error retry flag
43
+ }
44
+
45
+ // EventHandler signature for domain events.
46
+ type EventHandler func(ctx context.Context, hub EventEmitter, conn *websocket.Conn, msg WSMessage)
47
+
48
+ // EventEmitter exposes server-side emit/update/diff for handlers.
49
+ type EventEmitter interface {
50
+ Emit(channel string, payload any)
51
+ Update(channel string, payload any)
52
+ Diff(channel string, patch any)
53
+ Snapshot(channel string) (json.RawMessage, int64, bool)
54
+ }
55
+
56
+ // DiffMode controls how diffs are generated.
57
+ type DiffMode int
58
+
59
+ const (
60
+ DiffModeMerge DiffMode = iota // JSON Merge Patch (RFC 7396)
61
+ DiffModePatch // JSON Patch (RFC 6902)
62
+ )
63
+
64
+ // Hub is an in-memory event hub with state and versions.
65
+ type Hub struct {
66
+ mu sync.Mutex
67
+ clients map[*websocket.Conn]bool
68
+ subs map[string]map[*websocket.Conn]bool
69
+ sessions map[*websocket.Conn]string
70
+ state map[string]json.RawMessage
71
+ versions map[string]int64
72
+ events map[string]EventHandler
73
+ diffMode DiffMode
74
+ history map[string][]StateSnapshot // for replay
75
+ maxHistory int
76
+ store StateStore
77
+ ttlSec int
78
+ msgIDs map[string]int64 // msgId -> timestamp cache for dedup (bounded)
79
+ maxMsgIDs int
80
+ }
81
+
82
+ // StateSnapshot stores a versioned state snapshot.
83
+ type StateSnapshot struct {
84
+ Data json.RawMessage
85
+ VersionNo int64
86
+ Ts int64
87
+ }
88
+
89
+ func NewHub() *Hub {
90
+ return &Hub{
91
+ clients: make(map[*websocket.Conn]bool),
92
+ subs: make(map[string]map[*websocket.Conn]bool),
93
+ sessions: make(map[*websocket.Conn]string),
94
+ state: make(map[string]json.RawMessage),
95
+ versions: make(map[string]int64),
96
+ events: make(map[string]EventHandler),
97
+ diffMode: DiffModeMerge,
98
+ history: make(map[string][]StateSnapshot),
99
+ maxHistory: 100, // keep last 100 versions per channel
100
+ store: MemoryStore{},
101
+ ttlSec: 0,
102
+ msgIDs: make(map[string]int64),
103
+ maxMsgIDs: 1000, // keep last 1000 msgIds for dedup
104
+ }
105
+ }
106
+
107
+ // SetDiffMode sets the diff generation mode.
108
+ func (h *Hub) SetDiffMode(mode DiffMode) {
109
+ h.mu.Lock()
110
+ defer h.mu.Unlock()
111
+ h.diffMode = mode
112
+ }
113
+
114
+ // SetHistoryLimit limits how many snapshots are stored per channel.
115
+ func (h *Hub) SetHistoryLimit(limit int) {
116
+ h.mu.Lock()
117
+ defer h.mu.Unlock()
118
+ if limit > 0 {
119
+ h.maxHistory = limit
120
+ }
121
+ }
122
+
123
+ // SetStore sets the state store (memory/redis).
124
+ func (h *Hub) SetStore(store StateStore) {
125
+ h.mu.Lock()
126
+ defer h.mu.Unlock()
127
+ if store != nil {
128
+ h.store = store
129
+ }
130
+ }
131
+
132
+ // SetTTL sets TTL for persisted entries.
133
+ func (h *Hub) SetTTL(ttlSec int) {
134
+ h.mu.Lock()
135
+ defer h.mu.Unlock()
136
+ if ttlSec >= 0 {
137
+ h.ttlSec = ttlSec
138
+ }
139
+ }
140
+
141
+ // CheckMsgID returns true if msgId was already seen (and records it), false otherwise.
142
+ // Used for idempotency: duplicate messages with same msgId are ignored.
143
+ func (h *Hub) CheckMsgID(msgID string) bool {
144
+ if msgID == "" {
145
+ return false
146
+ }
147
+ h.mu.Lock()
148
+ defer h.mu.Unlock()
149
+ now := time.Now().UnixMilli()
150
+ // Clean old entries (older than 5 minutes)
151
+ cutoff := now - 5*60*1000
152
+ for id, ts := range h.msgIDs {
153
+ if ts < cutoff {
154
+ delete(h.msgIDs, id)
155
+ }
156
+ }
157
+ // Check if exists
158
+ if _, exists := h.msgIDs[msgID]; exists {
159
+ return true // duplicate
160
+ }
161
+ // Record
162
+ h.msgIDs[msgID] = now
163
+ // Trim if too many
164
+ if len(h.msgIDs) > h.maxMsgIDs {
165
+ // Remove oldest 100 entries
166
+ type entry struct {
167
+ id string
168
+ ts int64
169
+ }
170
+ var entries []entry
171
+ for id, ts := range h.msgIDs {
172
+ entries = append(entries, entry{id, ts})
173
+ }
174
+ // Sort by timestamp (oldest first)
175
+ for i := 0; i < len(entries)-1; i++ {
176
+ for j := i + 1; j < len(entries); j++ {
177
+ if entries[i].ts > entries[j].ts {
178
+ entries[i], entries[j] = entries[j], entries[i]
179
+ }
180
+ }
181
+ }
182
+ // Remove oldest 100
183
+ for i := 0; i < 100 && i < len(entries); i++ {
184
+ delete(h.msgIDs, entries[i].id)
185
+ }
186
+ }
187
+ return false // new message
188
+ }
189
+
190
+ // Emit aliases Update with force update.
191
+ func (h *Hub) Emit(channel string, payload any) {
192
+ h.applyUpdate(channel, payload, true)
193
+ }
194
+
195
+ // Update applies merge-patch optimization when possible.
196
+ func (h *Hub) Update(channel string, payload any) {
197
+ h.applyUpdate(channel, payload, false)
198
+ }
199
+
200
+ // Diff applies a raw patch and broadcasts.
201
+ func (h *Hub) Diff(channel string, patch any) {
202
+ h.applyDiff(channel, patch)
203
+ }
204
+
205
+ func (h *Hub) Snapshot(channel string) (json.RawMessage, int64, bool) {
206
+ h.mu.Lock()
207
+ snap, ok := h.state[channel]
208
+ v := h.versions[channel]
209
+ store := h.store
210
+ h.mu.Unlock()
211
+
212
+ if ok {
213
+ return snap, v, true
214
+ }
215
+
216
+ // Try loading from store if available
217
+ if store != nil {
218
+ if snap, ok, _ := store.LoadSnapshot(context.Background(), channel); ok {
219
+ // Update in-memory cache
220
+ h.mu.Lock()
221
+ h.state[channel] = snap.Data
222
+ h.versions[channel] = snap.VersionNo
223
+ h.mu.Unlock()
224
+ return snap.Data, snap.VersionNo, true
225
+ }
226
+ }
227
+
228
+ return nil, 0, false
229
+ }
230
+
231
+ // Replay returns state snapshots for a channel from a given version.
232
+ func (h *Hub) Replay(channel string, fromVersion int64) []StateSnapshot {
233
+ h.mu.Lock()
234
+ hist := h.history[channel]
235
+ store := h.store
236
+ h.mu.Unlock()
237
+
238
+ var result []StateSnapshot
239
+ for _, snap := range hist {
240
+ if snap.VersionNo > fromVersion {
241
+ result = append(result, snap)
242
+ }
243
+ }
244
+
245
+ // Try loading from store if available and in-memory is empty/incomplete
246
+ if store != nil && len(result) == 0 {
247
+ if stored, err := store.LoadHistory(context.Background(), channel, fromVersion); err == nil {
248
+ result = stored
249
+ }
250
+ }
251
+
252
+ return result
253
+ }
254
+
255
+ // internal helpers
256
+ func (h *Hub) applyUpdate(channel string, payload any, force bool) {
257
+ newData, err := json.Marshal(payload)
258
+ if err != nil {
259
+ return
260
+ }
261
+ var prev []byte
262
+ h.mu.Lock()
263
+ if s, ok := h.state[channel]; ok {
264
+ prev = append([]byte{}, s...)
265
+ }
266
+ h.state[channel] = newData
267
+ h.versions[channel]++
268
+ version := h.versions[channel]
269
+
270
+ // Save snapshot for replay
271
+ snap := StateSnapshot{
272
+ Data: append([]byte{}, newData...),
273
+ VersionNo: version,
274
+ Ts: time.Now().UnixMilli(),
275
+ }
276
+ hist := h.history[channel]
277
+ hist = append(hist, snap)
278
+ if len(hist) > h.maxHistory {
279
+ hist = hist[len(hist)-h.maxHistory:]
280
+ }
281
+ h.history[channel] = hist
282
+ store := h.store
283
+ ttl := h.ttlSec
284
+
285
+ h.mu.Unlock()
286
+
287
+ if store != nil {
288
+ _ = store.SaveSnapshot(context.Background(), channel, snap, ttl)
289
+ _ = store.AppendHistory(context.Background(), channel, snap, h.maxHistory, ttl)
290
+ }
291
+
292
+ if !force && prev != nil {
293
+ var patch json.RawMessage
294
+ var err error
295
+ // Note: json-patch/v5 only supports CreateMergePatch (RFC 7396)
296
+ // For RFC 6902 JSON Patch, we'd need a different library or custom implementation
297
+ // For now, always use merge patch regardless of diffMode setting
298
+ patch, err = jsonpatch.CreateMergePatch(prev, newData)
299
+ if err == nil && len(patch) > 2 {
300
+ h.applyDiff(channel, patch)
301
+ return
302
+ }
303
+ }
304
+
305
+ msg := WSMessage{
306
+ Type: "update",
307
+ Channel: channel,
308
+ Data: newData,
309
+ VersionNo: version,
310
+ Ts: time.Now().UnixMilli(),
311
+ }
312
+ raw, err := json.Marshal(msg)
313
+ if err != nil {
314
+ return
315
+ }
316
+ h.broadcast(channel, raw)
317
+ }
318
+
319
+ func (h *Hub) applyDiff(channel string, patch any) {
320
+ data, err := json.Marshal(patch)
321
+ if err != nil {
322
+ return
323
+ }
324
+ h.mu.Lock()
325
+ prev := h.state[channel]
326
+ merged := prev
327
+ if prev != nil {
328
+ if h.diffMode == DiffModePatch {
329
+ // Apply RFC 6902 JSON Patch
330
+ ops, err := jsonpatch.DecodePatch(data)
331
+ if err == nil {
332
+ applied, err := ops.Apply(prev)
333
+ if err == nil {
334
+ merged = applied
335
+ }
336
+ }
337
+ } else {
338
+ // Apply RFC 7396 JSON Merge Patch
339
+ if applied, err := jsonpatch.MergePatch(prev, data); err == nil {
340
+ merged = applied
341
+ }
342
+ }
343
+ } else {
344
+ merged = data
345
+ }
346
+ h.state[channel] = merged
347
+ h.versions[channel]++
348
+ version := h.versions[channel]
349
+
350
+ // Save snapshot
351
+ snap := StateSnapshot{
352
+ Data: append([]byte{}, merged...),
353
+ VersionNo: version,
354
+ Ts: time.Now().UnixMilli(),
355
+ }
356
+ hist := h.history[channel]
357
+ hist = append(hist, snap)
358
+ if len(hist) > h.maxHistory {
359
+ hist = hist[len(hist)-h.maxHistory:]
360
+ }
361
+ h.history[channel] = hist
362
+ store := h.store
363
+ ttl := h.ttlSec
364
+
365
+ h.mu.Unlock()
366
+
367
+ if store != nil {
368
+ _ = store.SaveSnapshot(context.Background(), channel, snap, ttl)
369
+ _ = store.AppendHistory(context.Background(), channel, snap, h.maxHistory, ttl)
370
+ }
371
+
372
+ msg := WSMessage{
373
+ Type: "diff",
374
+ Channel: channel,
375
+ Patch: data,
376
+ Ts: time.Now().UnixMilli(),
377
+ VersionNo: version,
378
+ }
379
+ raw, err := json.Marshal(msg)
380
+ if err != nil {
381
+ return
382
+ }
383
+ h.broadcast(channel, raw)
384
+ }
385
+
386
+ // Broadcast sends a message to all subscribers of a channel.
387
+ // This is called by applyUpdate/applyDiff after creating the message.
388
+ func (h *Hub) Broadcast(channel string, raw json.RawMessage) {
389
+ // This will be implemented by wsHub wrapper in main.go
390
+ // Hub only manages state, wsHub manages connections
391
+ }
392
+
393
+ // Get returns an event handler by name.
394
+ func (h *Hub) Get(name string) (EventHandler, bool) {
395
+ h.mu.Lock()
396
+ defer h.mu.Unlock()
397
+ handler, ok := h.events[name]
398
+ return handler, ok
399
+ }
400
+
401
+ // SetBroadcaster sets a custom broadcast function (used by wsHub).
402
+ type Broadcaster func(channel string, raw json.RawMessage)
403
+
404
+ var globalBroadcaster Broadcaster
405
+
406
+ func SetBroadcaster(fn Broadcaster) {
407
+ globalBroadcaster = fn
408
+ }
409
+
410
+ func (h *Hub) broadcast(channel string, raw json.RawMessage) {
411
+ if globalBroadcaster != nil {
412
+ globalBroadcaster(channel, raw)
413
+ }
414
+ }
415
415
  `;