dynamodb-reactive 0.1.3 → 0.1.5

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.
@@ -47,6 +47,8 @@ var WebSocketManager = class {
47
47
  messageQueue = [];
48
48
  messageHandlers = /* @__PURE__ */ new Set();
49
49
  stateHandlers = /* @__PURE__ */ new Set();
50
+ _connectionId = null;
51
+ connectionIdResolvers = [];
50
52
  constructor(config) {
51
53
  this.config = {
52
54
  autoReconnect: true,
@@ -55,6 +57,23 @@ var WebSocketManager = class {
55
57
  ...config
56
58
  };
57
59
  }
60
+ /**
61
+ * Get the current connection ID (null if not connected)
62
+ */
63
+ get connectionId() {
64
+ return this._connectionId;
65
+ }
66
+ /**
67
+ * Wait for the connection ID to be available
68
+ */
69
+ waitForConnectionId() {
70
+ if (this._connectionId) {
71
+ return Promise.resolve(this._connectionId);
72
+ }
73
+ return new Promise((resolve) => {
74
+ this.connectionIdResolvers.push(resolve);
75
+ });
76
+ }
58
77
  /**
59
78
  * Get current connection state
60
79
  */
@@ -73,13 +92,12 @@ var WebSocketManager = class {
73
92
  const url = await this.buildUrl();
74
93
  this.ws = new WebSocket(url);
75
94
  this.ws.onopen = () => {
76
- this.setState("connected");
77
95
  this.reconnectAttempts = 0;
78
- this.flushMessageQueue();
79
- this.config.onConnect?.();
96
+ this.ws?.send(JSON.stringify({ type: "init" }));
80
97
  };
81
98
  this.ws.onclose = (event) => {
82
99
  this.ws = null;
100
+ this._connectionId = null;
83
101
  this.setState("disconnected");
84
102
  this.config.onDisconnect?.();
85
103
  if (this.config.autoReconnect && !event.wasClean) {
@@ -93,6 +111,17 @@ var WebSocketManager = class {
93
111
  this.ws.onmessage = (event) => {
94
112
  try {
95
113
  const message = JSON.parse(event.data);
114
+ if (message.type === "connected") {
115
+ this._connectionId = message.connectionId;
116
+ this.setState("connected");
117
+ this.flushMessageQueue();
118
+ this.config.onConnect?.();
119
+ for (const resolve of this.connectionIdResolvers) {
120
+ resolve(message.connectionId);
121
+ }
122
+ this.connectionIdResolvers = [];
123
+ return;
124
+ }
96
125
  this.handleMessage(message);
97
126
  } catch (error) {
98
127
  console.error("Failed to parse WebSocket message:", error);
@@ -120,6 +149,7 @@ var WebSocketManager = class {
120
149
  this.ws.close(1e3, "Client disconnect");
121
150
  this.ws = null;
122
151
  }
152
+ this._connectionId = null;
123
153
  this.setState("disconnected");
124
154
  }
125
155
  /**
@@ -193,17 +223,34 @@ var WebSocketManager = class {
193
223
  };
194
224
 
195
225
  // ../client/src/client.ts
226
+ var DEFAULT_HTTP_URL = "/api/reactive";
196
227
  function generateId() {
197
228
  return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
198
229
  }
199
230
  var ReactiveClient = class {
200
231
  wsManager;
232
+ httpUrl;
233
+ authGetter;
234
+ headersGetter;
201
235
  subscriptions = /* @__PURE__ */ new Map();
202
236
  pendingCalls = /* @__PURE__ */ new Map();
203
237
  connectionState = "disconnected";
204
238
  stateListeners = /* @__PURE__ */ new Set();
239
+ // Stores original data for rollback when optimistic updates fail
240
+ optimisticUpdates = /* @__PURE__ */ new Map();
205
241
  constructor(config) {
206
242
  this.wsManager = new WebSocketManager(config);
243
+ this.httpUrl = config.httpUrl ?? DEFAULT_HTTP_URL;
244
+ if (typeof config.auth === "function") {
245
+ this.authGetter = config.auth;
246
+ } else if (config.auth) {
247
+ this.authGetter = () => config.auth;
248
+ }
249
+ if (typeof config.headers === "function") {
250
+ this.headersGetter = config.headers;
251
+ } else if (config.headers) {
252
+ this.headersGetter = () => config.headers;
253
+ }
207
254
  this.wsManager.onMessage((message) => this.handleMessage(message));
208
255
  this.wsManager.onStateChange((state) => {
209
256
  this.connectionState = state;
@@ -225,6 +272,17 @@ var ReactiveClient = class {
225
272
  disconnect() {
226
273
  this.wsManager.disconnect();
227
274
  }
275
+ /**
276
+ * Update the headers getter for HTTP requests.
277
+ * Useful for dynamically setting auth or user context headers.
278
+ */
279
+ setHeaders(headers) {
280
+ if (typeof headers === "function") {
281
+ this.headersGetter = headers;
282
+ } else {
283
+ this.headersGetter = () => headers;
284
+ }
285
+ }
228
286
  /**
229
287
  * Get current connection state
230
288
  */
@@ -239,7 +297,8 @@ var ReactiveClient = class {
239
297
  return () => this.stateListeners.delete(listener);
240
298
  }
241
299
  /**
242
- * Subscribe to a procedure
300
+ * Subscribe to a procedure via HTTP.
301
+ * Snapshot is returned via HTTP, patches come via WebSocket.
243
302
  */
244
303
  subscribe(path, options = {}) {
245
304
  const id = generateId();
@@ -254,13 +313,14 @@ var ReactiveClient = class {
254
313
  options
255
314
  };
256
315
  this.subscriptions.set(id, state);
257
- this.wsManager.send({
258
- type: "subscribe",
259
- subscriptionId: id,
260
- path,
261
- input: options.input
316
+ this.sendSubscribeRequest(id, path, options.input).catch((error) => {
317
+ state.error = error instanceof Error ? error : new Error(String(error));
318
+ state.loading = false;
319
+ this.notifySubscriptionListeners(id);
320
+ options.onError?.(state.error);
262
321
  });
263
322
  const subscription = {
323
+ id,
264
324
  get data() {
265
325
  return state.data;
266
326
  },
@@ -276,28 +336,165 @@ var ReactiveClient = class {
276
336
  return subscription;
277
337
  }
278
338
  /**
279
- * Call a mutation procedure
339
+ * Send subscribe request via HTTP
280
340
  */
281
- async call(path, input) {
282
- const callId = generateId();
283
- return new Promise((resolve, reject) => {
284
- this.pendingCalls.set(callId, {
285
- resolve,
286
- reject
287
- });
288
- this.wsManager.send({
289
- type: "call",
290
- callId,
341
+ async sendSubscribeRequest(subscriptionId, path, input) {
342
+ const connectionId = await this.wsManager.waitForConnectionId();
343
+ const headers = {
344
+ "Content-Type": "application/json"
345
+ };
346
+ if (this.headersGetter) {
347
+ const customHeaders = await this.headersGetter();
348
+ Object.assign(headers, customHeaders);
349
+ }
350
+ if (this.authGetter) {
351
+ const token = await this.authGetter();
352
+ headers["Authorization"] = `Bearer ${token}`;
353
+ }
354
+ const response = await fetch(this.httpUrl, {
355
+ method: "POST",
356
+ headers,
357
+ body: JSON.stringify({
358
+ type: "subscribe",
359
+ connectionId,
360
+ subscriptionId,
291
361
  path,
292
362
  input
363
+ })
364
+ });
365
+ if (!response.ok) {
366
+ throw new Error(
367
+ `Subscribe failed: ${response.status} ${response.statusText}`
368
+ );
369
+ }
370
+ const result = await response.json();
371
+ if (result.type === "error") {
372
+ throw new Error(result.message ?? "Unknown error");
373
+ }
374
+ if (result.type === "snapshot") {
375
+ this.handleSnapshot(subscriptionId, result.data);
376
+ }
377
+ }
378
+ /**
379
+ * Call a mutation procedure via HTTP with optimistic update support.
380
+ * All optimistic updates are applied INSTANTLY (before HTTP request).
381
+ */
382
+ async call(path, input, options) {
383
+ const invalidatesPath = options?.invalidates;
384
+ const updateType = options?.optimisticUpdate;
385
+ let targetSubscriptionId;
386
+ if (invalidatesPath) {
387
+ targetSubscriptionId = this.findSubscriptionByPath(invalidatesPath);
388
+ }
389
+ if (targetSubscriptionId) {
390
+ const state = this.subscriptions.get(targetSubscriptionId);
391
+ const currentData = state?.data;
392
+ if (updateType === "remove") {
393
+ const inputId = input.id;
394
+ if (inputId) {
395
+ this.applyOptimisticUpdate(targetSubscriptionId, (data) => {
396
+ if (!Array.isArray(data)) return data;
397
+ return data.filter((item) => item.id !== inputId);
398
+ });
399
+ }
400
+ } else if (updateType === "merge") {
401
+ const inputWithId = input;
402
+ if (inputWithId.id) {
403
+ this.applyOptimisticUpdate(targetSubscriptionId, (data) => {
404
+ if (!Array.isArray(data)) return data;
405
+ const idx = data.findIndex(
406
+ (item) => item.id === inputWithId.id
407
+ );
408
+ if (idx >= 0) {
409
+ return [
410
+ ...data.slice(0, idx),
411
+ { ...data[idx], ...input },
412
+ ...data.slice(idx + 1)
413
+ ];
414
+ }
415
+ return data;
416
+ });
417
+ } else if (options?.optimisticData) {
418
+ const optimistic = typeof options.optimisticData === "function" ? options.optimisticData(input, Array.isArray(currentData) ? currentData : []) : options.optimisticData;
419
+ if (optimistic) {
420
+ this.applyOptimisticUpdate(targetSubscriptionId, (data) => {
421
+ if (!Array.isArray(data)) return data;
422
+ return [optimistic, ...data];
423
+ });
424
+ }
425
+ }
426
+ } else if (typeof updateType === "function") {
427
+ this.applyOptimisticUpdate(targetSubscriptionId, (data) => {
428
+ if (!Array.isArray(data)) return data;
429
+ return updateType(data, input, void 0);
430
+ });
431
+ }
432
+ }
433
+ const connectionId = await this.wsManager.waitForConnectionId();
434
+ const headers = {
435
+ "Content-Type": "application/json"
436
+ };
437
+ if (this.headersGetter) {
438
+ const customHeaders = await this.headersGetter();
439
+ Object.assign(headers, customHeaders);
440
+ }
441
+ if (this.authGetter) {
442
+ const token = await this.authGetter();
443
+ headers["Authorization"] = `Bearer ${token}`;
444
+ }
445
+ try {
446
+ const response = await fetch(this.httpUrl, {
447
+ method: "POST",
448
+ headers,
449
+ body: JSON.stringify({
450
+ type: "call",
451
+ connectionId,
452
+ path,
453
+ input
454
+ })
293
455
  });
294
- setTimeout(() => {
295
- if (this.pendingCalls.has(callId)) {
296
- this.pendingCalls.delete(callId);
297
- reject(new Error("Call timeout"));
456
+ if (!response.ok) {
457
+ throw new Error(
458
+ `Call failed: ${response.status} ${response.statusText}`
459
+ );
460
+ }
461
+ const result = await response.json();
462
+ if (result.type === "error") {
463
+ throw new Error(result.message ?? "Unknown error");
464
+ }
465
+ const data = result.data;
466
+ if (targetSubscriptionId && updateType === "merge") {
467
+ const resultWithId = data;
468
+ if (resultWithId && resultWithId.id) {
469
+ this.applyOptimisticUpdate(targetSubscriptionId, (currentData) => {
470
+ if (!Array.isArray(currentData)) return currentData;
471
+ const idx = currentData.findIndex(
472
+ (item) => item.id === resultWithId.id
473
+ );
474
+ if (idx >= 0) {
475
+ return [
476
+ ...currentData.slice(0, idx),
477
+ data,
478
+ ...currentData.slice(idx + 1)
479
+ ];
480
+ } else {
481
+ return [
482
+ data,
483
+ ...currentData.filter(
484
+ (item) => item.id && !item.id.startsWith("temp-")
485
+ )
486
+ ];
487
+ }
488
+ });
298
489
  }
299
- }, 3e4);
300
- });
490
+ }
491
+ return data;
492
+ } catch (error) {
493
+ if (targetSubscriptionId) {
494
+ this.rollbackOptimisticUpdate(targetSubscriptionId);
495
+ }
496
+ throw error;
497
+ }
301
498
  }
302
499
  /**
303
500
  * Unsubscribe from a subscription
@@ -312,25 +509,30 @@ var ReactiveClient = class {
312
509
  });
313
510
  }
314
511
  /**
315
- * Refetch a subscription
512
+ * Refetch a subscription via HTTP
316
513
  */
317
514
  async refetch(id) {
318
515
  const state = this.subscriptions.get(id);
319
516
  if (!state) return;
320
517
  state.loading = true;
321
518
  this.notifySubscriptionListeners(id);
322
- this.wsManager.send({
323
- type: "subscribe",
324
- subscriptionId: id,
325
- path: state.path,
326
- input: state.input
327
- });
519
+ try {
520
+ await this.sendSubscribeRequest(id, state.path, state.input);
521
+ } catch (error) {
522
+ state.error = error instanceof Error ? error : new Error(String(error));
523
+ state.loading = false;
524
+ this.notifySubscriptionListeners(id);
525
+ state.options.onError?.(state.error);
526
+ }
328
527
  }
329
528
  /**
330
- * Handle incoming server messages
529
+ * Handle incoming server messages.
530
+ * Note: 'connected' messages are handled by WebSocketManager before reaching here.
331
531
  */
332
532
  handleMessage(message) {
333
533
  switch (message.type) {
534
+ case "connected":
535
+ break;
334
536
  case "snapshot":
335
537
  this.handleSnapshot(message.subscriptionId, message.data);
336
538
  break;
@@ -351,6 +553,7 @@ var ReactiveClient = class {
351
553
  handleSnapshot(subscriptionId, data) {
352
554
  const state = this.subscriptions.get(subscriptionId);
353
555
  if (!state) return;
556
+ this.clearOptimisticUpdate(subscriptionId);
354
557
  state.data = data;
355
558
  state.loading = false;
356
559
  state.error = void 0;
@@ -363,8 +566,11 @@ var ReactiveClient = class {
363
566
  handlePatch(subscriptionId, patches) {
364
567
  const state = this.subscriptions.get(subscriptionId);
365
568
  if (!state || state.data === void 0) return;
569
+ const originalData = this.optimisticUpdates.get(subscriptionId);
570
+ const baseData = originalData !== void 0 ? originalData : state.data;
571
+ this.clearOptimisticUpdate(subscriptionId);
366
572
  try {
367
- state.data = applyPatches(state.data, patches);
573
+ state.data = applyPatches(baseData, patches);
368
574
  this.notifySubscriptionListeners(subscriptionId);
369
575
  state.options.onData?.(state.data);
370
576
  } catch (error) {
@@ -405,19 +611,21 @@ var ReactiveClient = class {
405
611
  }
406
612
  }
407
613
  /**
408
- * Resubscribe to all subscriptions after reconnect
614
+ * Resubscribe to all subscriptions after reconnect via HTTP
409
615
  */
410
616
  resubscribeAll() {
411
617
  for (const [id, state] of this.subscriptions) {
412
618
  if (state.options.resubscribeOnReconnect !== false) {
413
619
  state.loading = true;
414
620
  this.notifySubscriptionListeners(id);
415
- this.wsManager.send({
416
- type: "subscribe",
417
- subscriptionId: id,
418
- path: state.path,
419
- input: state.input
420
- });
621
+ this.sendSubscribeRequest(id, state.path, state.input).catch(
622
+ (error) => {
623
+ state.error = error instanceof Error ? error : new Error(String(error));
624
+ state.loading = false;
625
+ this.notifySubscriptionListeners(id);
626
+ state.options.onError?.(state.error);
627
+ }
628
+ );
421
629
  }
422
630
  }
423
631
  }
@@ -457,6 +665,51 @@ var ReactiveClient = class {
457
665
  getSubscriptionState(id) {
458
666
  return this.subscriptions.get(id);
459
667
  }
668
+ /**
669
+ * Find subscription by path
670
+ */
671
+ findSubscriptionByPath(path) {
672
+ for (const [id, state] of this.subscriptions) {
673
+ if (state.path === path) {
674
+ return id;
675
+ }
676
+ }
677
+ return void 0;
678
+ }
679
+ /**
680
+ * Apply an optimistic update to a subscription's data
681
+ */
682
+ applyOptimisticUpdate(subscriptionId, updater) {
683
+ const state = this.subscriptions.get(subscriptionId);
684
+ if (!state || state.data === void 0) return;
685
+ if (!this.optimisticUpdates.has(subscriptionId)) {
686
+ this.optimisticUpdates.set(
687
+ subscriptionId,
688
+ JSON.parse(JSON.stringify(state.data))
689
+ );
690
+ }
691
+ state.data = updater(state.data);
692
+ this.notifySubscriptionListeners(subscriptionId);
693
+ }
694
+ /**
695
+ * Rollback an optimistic update
696
+ */
697
+ rollbackOptimisticUpdate(subscriptionId) {
698
+ const original = this.optimisticUpdates.get(subscriptionId);
699
+ if (original === void 0) return;
700
+ const state = this.subscriptions.get(subscriptionId);
701
+ if (state) {
702
+ state.data = original;
703
+ this.notifySubscriptionListeners(subscriptionId);
704
+ }
705
+ this.optimisticUpdates.delete(subscriptionId);
706
+ }
707
+ /**
708
+ * Clear optimistic state (called when real data arrives)
709
+ */
710
+ clearOptimisticUpdate(subscriptionId) {
711
+ this.optimisticUpdates.delete(subscriptionId);
712
+ }
460
713
  };
461
714
  function createReactiveClient(config) {
462
715
  const client = new ReactiveClient(config);
@@ -481,7 +734,12 @@ function createTypedProxy(client, path) {
481
734
  if (prop === "mutate") {
482
735
  return async (input) => {
483
736
  const parentPath = path.join(".");
484
- return client.call(parentPath, input);
737
+ const invalidatesPath = path.length >= 2 ? `${path.slice(0, -1).join(".")}.list` : void 0;
738
+ const updateType = parentPath.endsWith(".delete") ? "remove" : "merge";
739
+ return client.call(parentPath, input, {
740
+ invalidates: invalidatesPath,
741
+ optimisticUpdate: updateType
742
+ });
485
743
  };
486
744
  }
487
745
  return createTypedProxy(client, newPath);
@@ -584,12 +842,23 @@ function useSubscription(path, options = {}) {
584
842
  }
585
843
  });
586
844
  subscriptionRef.current = subscription;
845
+ const unregisterListener = client.addSubscriptionListener(
846
+ subscription.id,
847
+ () => {
848
+ setState({
849
+ data: subscription.data,
850
+ loading: subscription.loading,
851
+ error: subscription.error
852
+ });
853
+ }
854
+ );
587
855
  setState({
588
856
  data: subscription.data,
589
857
  loading: subscription.loading,
590
858
  error: subscription.error
591
859
  });
592
860
  return () => {
861
+ unregisterListener();
593
862
  subscription.unsubscribe();
594
863
  subscriptionRef.current = null;
595
864
  };
@@ -606,20 +875,31 @@ function useSubscription(path, options = {}) {
606
875
  refetch
607
876
  };
608
877
  }
609
- function useMutation(path) {
878
+ function useMutation(path, options) {
610
879
  const client = useReactiveClient();
611
880
  const [state, setState] = useState({
612
881
  data: void 0,
613
882
  loading: false,
614
883
  error: void 0
615
884
  });
885
+ const autoInvalidates = options?.invalidates ?? (() => {
886
+ const parts = path.split(".");
887
+ if (parts.length >= 2) {
888
+ return `${parts.slice(0, -1).join(".")}.list`;
889
+ }
890
+ return void 0;
891
+ })();
892
+ const autoUpdateType = options?.optimisticUpdate ?? (path.endsWith(".delete") ? "remove" : "merge");
616
893
  const mutate = async (input) => {
617
894
  if (!client) {
618
895
  throw new Error("WebSocket not configured - mutations are disabled");
619
896
  }
620
897
  setState((prev) => ({ ...prev, loading: true, error: void 0 }));
621
898
  try {
622
- const result = await client.call(path, input);
899
+ const result = await client.call(path, input, {
900
+ invalidates: autoInvalidates,
901
+ optimisticUpdate: autoUpdateType
902
+ });
623
903
  setState({ data: result, loading: false, error: void 0 });
624
904
  return result;
625
905
  } catch (error) {
@@ -638,10 +918,41 @@ function useMutation(path) {
638
918
  reset
639
919
  };
640
920
  }
921
+ function useClient() {
922
+ const client = useReactiveClient();
923
+ function buildProxy(path) {
924
+ return new Proxy(
925
+ {},
926
+ {
927
+ get(_target, prop) {
928
+ if (prop === "useQuery") {
929
+ return function useQueryHook(input, options) {
930
+ const pathStr = path.join(".");
931
+ return useSubscription(pathStr, { ...options, input });
932
+ };
933
+ }
934
+ if (prop === "useMutation") {
935
+ return function useMutationHook(options) {
936
+ const pathStr = path.join(".");
937
+ return useMutation(pathStr, options);
938
+ };
939
+ }
940
+ if (prop === "setHeaders") {
941
+ return (headers) => {
942
+ client?.setHeaders(headers);
943
+ };
944
+ }
945
+ return buildProxy([...path, prop]);
946
+ }
947
+ }
948
+ );
949
+ }
950
+ return buildProxy([]);
951
+ }
641
952
  function createReactiveHooks() {
642
953
  return {
643
954
  useSubscription: (path, options) => useSubscription(path, options),
644
- useMutation: (path) => useMutation(path),
955
+ useMutation: (path, options) => useMutation(path, options),
645
956
  useConnectionState,
646
957
  useReactiveClient
647
958
  };
@@ -649,10 +960,10 @@ function createReactiveHooks() {
649
960
  function createProcedureHooks(path) {
650
961
  return {
651
962
  useSubscription: (input, options) => useSubscription(path, { ...options, input }),
652
- useMutation: () => useMutation(path)
963
+ useMutation: (options) => useMutation(path, options)
653
964
  };
654
965
  }
655
966
 
656
- export { ReactiveClient, ReactiveClientProvider, WebSocketManager, applyPatches, createProcedureHooks, createReactiveClient, createReactiveHooks, useConnectionState, useMutation, useReactiveClient, useReactiveClientOrThrow, useSubscription };
657
- //# sourceMappingURL=chunk-IPEBRXIL.js.map
658
- //# sourceMappingURL=chunk-IPEBRXIL.js.map
967
+ export { ReactiveClient, ReactiveClientProvider, WebSocketManager, applyPatches, createProcedureHooks, createReactiveClient, createReactiveHooks, useClient, useConnectionState, useMutation, useReactiveClient, useReactiveClientOrThrow, useSubscription };
968
+ //# sourceMappingURL=chunk-MI2ZLLB2.js.map
969
+ //# sourceMappingURL=chunk-MI2ZLLB2.js.map