dynamodb-reactive 0.1.1 → 0.1.4

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.
@@ -1,8 +1,9 @@
1
- import { applyPatch } from 'fast-json-patch';
1
+ import jsonpatch from 'fast-json-patch';
2
2
  import { createContext, useState, useEffect, useContext, useSyncExternalStore, useRef } from 'react';
3
3
  import { jsx } from 'react/jsx-runtime';
4
4
 
5
5
  // ../client/src/patcher.ts
6
+ var { applyPatch } = jsonpatch;
6
7
  function applyPatches(document, patches) {
7
8
  if (patches.length === 0) {
8
9
  return document;
@@ -46,6 +47,8 @@ var WebSocketManager = class {
46
47
  messageQueue = [];
47
48
  messageHandlers = /* @__PURE__ */ new Set();
48
49
  stateHandlers = /* @__PURE__ */ new Set();
50
+ _connectionId = null;
51
+ connectionIdResolvers = [];
49
52
  constructor(config) {
50
53
  this.config = {
51
54
  autoReconnect: true,
@@ -54,6 +57,23 @@ var WebSocketManager = class {
54
57
  ...config
55
58
  };
56
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
+ }
57
77
  /**
58
78
  * Get current connection state
59
79
  */
@@ -72,13 +92,12 @@ var WebSocketManager = class {
72
92
  const url = await this.buildUrl();
73
93
  this.ws = new WebSocket(url);
74
94
  this.ws.onopen = () => {
75
- this.setState("connected");
76
95
  this.reconnectAttempts = 0;
77
- this.flushMessageQueue();
78
- this.config.onConnect?.();
96
+ this.ws?.send(JSON.stringify({ type: "init" }));
79
97
  };
80
98
  this.ws.onclose = (event) => {
81
99
  this.ws = null;
100
+ this._connectionId = null;
82
101
  this.setState("disconnected");
83
102
  this.config.onDisconnect?.();
84
103
  if (this.config.autoReconnect && !event.wasClean) {
@@ -92,6 +111,17 @@ var WebSocketManager = class {
92
111
  this.ws.onmessage = (event) => {
93
112
  try {
94
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
+ }
95
125
  this.handleMessage(message);
96
126
  } catch (error) {
97
127
  console.error("Failed to parse WebSocket message:", error);
@@ -119,6 +149,7 @@ var WebSocketManager = class {
119
149
  this.ws.close(1e3, "Client disconnect");
120
150
  this.ws = null;
121
151
  }
152
+ this._connectionId = null;
122
153
  this.setState("disconnected");
123
154
  }
124
155
  /**
@@ -192,17 +223,34 @@ var WebSocketManager = class {
192
223
  };
193
224
 
194
225
  // ../client/src/client.ts
226
+ var DEFAULT_HTTP_URL = "/api/reactive";
195
227
  function generateId() {
196
228
  return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
197
229
  }
198
230
  var ReactiveClient = class {
199
231
  wsManager;
232
+ httpUrl;
233
+ authGetter;
234
+ headersGetter;
200
235
  subscriptions = /* @__PURE__ */ new Map();
201
236
  pendingCalls = /* @__PURE__ */ new Map();
202
237
  connectionState = "disconnected";
203
238
  stateListeners = /* @__PURE__ */ new Set();
239
+ // Stores original data for rollback when optimistic updates fail
240
+ optimisticUpdates = /* @__PURE__ */ new Map();
204
241
  constructor(config) {
205
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
+ }
206
254
  this.wsManager.onMessage((message) => this.handleMessage(message));
207
255
  this.wsManager.onStateChange((state) => {
208
256
  this.connectionState = state;
@@ -224,6 +272,17 @@ var ReactiveClient = class {
224
272
  disconnect() {
225
273
  this.wsManager.disconnect();
226
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
+ }
227
286
  /**
228
287
  * Get current connection state
229
288
  */
@@ -238,7 +297,8 @@ var ReactiveClient = class {
238
297
  return () => this.stateListeners.delete(listener);
239
298
  }
240
299
  /**
241
- * Subscribe to a procedure
300
+ * Subscribe to a procedure via HTTP.
301
+ * Snapshot is returned via HTTP, patches come via WebSocket.
242
302
  */
243
303
  subscribe(path, options = {}) {
244
304
  const id = generateId();
@@ -253,13 +313,14 @@ var ReactiveClient = class {
253
313
  options
254
314
  };
255
315
  this.subscriptions.set(id, state);
256
- this.wsManager.send({
257
- type: "subscribe",
258
- subscriptionId: id,
259
- path,
260
- 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);
261
321
  });
262
322
  const subscription = {
323
+ id,
263
324
  get data() {
264
325
  return state.data;
265
326
  },
@@ -275,28 +336,165 @@ var ReactiveClient = class {
275
336
  return subscription;
276
337
  }
277
338
  /**
278
- * Call a mutation procedure
339
+ * Send subscribe request via HTTP
279
340
  */
280
- async call(path, input) {
281
- const callId = generateId();
282
- return new Promise((resolve, reject) => {
283
- this.pendingCalls.set(callId, {
284
- resolve,
285
- reject
286
- });
287
- this.wsManager.send({
288
- type: "call",
289
- 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,
290
361
  path,
291
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
+ })
292
455
  });
293
- setTimeout(() => {
294
- if (this.pendingCalls.has(callId)) {
295
- this.pendingCalls.delete(callId);
296
- 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
+ });
297
489
  }
298
- }, 3e4);
299
- });
490
+ }
491
+ return data;
492
+ } catch (error) {
493
+ if (targetSubscriptionId) {
494
+ this.rollbackOptimisticUpdate(targetSubscriptionId);
495
+ }
496
+ throw error;
497
+ }
300
498
  }
301
499
  /**
302
500
  * Unsubscribe from a subscription
@@ -311,25 +509,30 @@ var ReactiveClient = class {
311
509
  });
312
510
  }
313
511
  /**
314
- * Refetch a subscription
512
+ * Refetch a subscription via HTTP
315
513
  */
316
514
  async refetch(id) {
317
515
  const state = this.subscriptions.get(id);
318
516
  if (!state) return;
319
517
  state.loading = true;
320
518
  this.notifySubscriptionListeners(id);
321
- this.wsManager.send({
322
- type: "subscribe",
323
- subscriptionId: id,
324
- path: state.path,
325
- input: state.input
326
- });
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
+ }
327
527
  }
328
528
  /**
329
- * Handle incoming server messages
529
+ * Handle incoming server messages.
530
+ * Note: 'connected' messages are handled by WebSocketManager before reaching here.
330
531
  */
331
532
  handleMessage(message) {
332
533
  switch (message.type) {
534
+ case "connected":
535
+ break;
333
536
  case "snapshot":
334
537
  this.handleSnapshot(message.subscriptionId, message.data);
335
538
  break;
@@ -350,6 +553,7 @@ var ReactiveClient = class {
350
553
  handleSnapshot(subscriptionId, data) {
351
554
  const state = this.subscriptions.get(subscriptionId);
352
555
  if (!state) return;
556
+ this.clearOptimisticUpdate(subscriptionId);
353
557
  state.data = data;
354
558
  state.loading = false;
355
559
  state.error = void 0;
@@ -362,8 +566,11 @@ var ReactiveClient = class {
362
566
  handlePatch(subscriptionId, patches) {
363
567
  const state = this.subscriptions.get(subscriptionId);
364
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);
365
572
  try {
366
- state.data = applyPatches(state.data, patches);
573
+ state.data = applyPatches(baseData, patches);
367
574
  this.notifySubscriptionListeners(subscriptionId);
368
575
  state.options.onData?.(state.data);
369
576
  } catch (error) {
@@ -404,19 +611,21 @@ var ReactiveClient = class {
404
611
  }
405
612
  }
406
613
  /**
407
- * Resubscribe to all subscriptions after reconnect
614
+ * Resubscribe to all subscriptions after reconnect via HTTP
408
615
  */
409
616
  resubscribeAll() {
410
617
  for (const [id, state] of this.subscriptions) {
411
618
  if (state.options.resubscribeOnReconnect !== false) {
412
619
  state.loading = true;
413
620
  this.notifySubscriptionListeners(id);
414
- this.wsManager.send({
415
- type: "subscribe",
416
- subscriptionId: id,
417
- path: state.path,
418
- input: state.input
419
- });
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
+ );
420
629
  }
421
630
  }
422
631
  }
@@ -456,6 +665,51 @@ var ReactiveClient = class {
456
665
  getSubscriptionState(id) {
457
666
  return this.subscriptions.get(id);
458
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
+ }
459
713
  };
460
714
  function createReactiveClient(config) {
461
715
  const client = new ReactiveClient(config);
@@ -480,7 +734,12 @@ function createTypedProxy(client, path) {
480
734
  if (prop === "mutate") {
481
735
  return async (input) => {
482
736
  const parentPath = path.join(".");
483
- 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
+ });
484
743
  };
485
744
  }
486
745
  return createTypedProxy(client, newPath);
@@ -583,12 +842,23 @@ function useSubscription(path, options = {}) {
583
842
  }
584
843
  });
585
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
+ );
586
855
  setState({
587
856
  data: subscription.data,
588
857
  loading: subscription.loading,
589
858
  error: subscription.error
590
859
  });
591
860
  return () => {
861
+ unregisterListener();
592
862
  subscription.unsubscribe();
593
863
  subscriptionRef.current = null;
594
864
  };
@@ -605,20 +875,31 @@ function useSubscription(path, options = {}) {
605
875
  refetch
606
876
  };
607
877
  }
608
- function useMutation(path) {
878
+ function useMutation(path, options) {
609
879
  const client = useReactiveClient();
610
880
  const [state, setState] = useState({
611
881
  data: void 0,
612
882
  loading: false,
613
883
  error: void 0
614
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");
615
893
  const mutate = async (input) => {
616
894
  if (!client) {
617
895
  throw new Error("WebSocket not configured - mutations are disabled");
618
896
  }
619
897
  setState((prev) => ({ ...prev, loading: true, error: void 0 }));
620
898
  try {
621
- const result = await client.call(path, input);
899
+ const result = await client.call(path, input, {
900
+ invalidates: autoInvalidates,
901
+ optimisticUpdate: autoUpdateType
902
+ });
622
903
  setState({ data: result, loading: false, error: void 0 });
623
904
  return result;
624
905
  } catch (error) {
@@ -637,10 +918,41 @@ function useMutation(path) {
637
918
  reset
638
919
  };
639
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
+ }
640
952
  function createReactiveHooks() {
641
953
  return {
642
954
  useSubscription: (path, options) => useSubscription(path, options),
643
- useMutation: (path) => useMutation(path),
955
+ useMutation: (path, options) => useMutation(path, options),
644
956
  useConnectionState,
645
957
  useReactiveClient
646
958
  };
@@ -648,10 +960,10 @@ function createReactiveHooks() {
648
960
  function createProcedureHooks(path) {
649
961
  return {
650
962
  useSubscription: (input, options) => useSubscription(path, { ...options, input }),
651
- useMutation: () => useMutation(path)
963
+ useMutation: (options) => useMutation(path, options)
652
964
  };
653
965
  }
654
966
 
655
- export { ReactiveClient, ReactiveClientProvider, WebSocketManager, applyPatches, createProcedureHooks, createReactiveClient, createReactiveHooks, useConnectionState, useMutation, useReactiveClient, useReactiveClientOrThrow, useSubscription };
656
- //# sourceMappingURL=chunk-KRZQWA2W.js.map
657
- //# sourceMappingURL=chunk-KRZQWA2W.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