@tanstack/offline-transactions 0.0.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 (138) hide show
  1. package/README.md +219 -0
  2. package/dist/cjs/OfflineExecutor.cjs +266 -0
  3. package/dist/cjs/OfflineExecutor.cjs.map +1 -0
  4. package/dist/cjs/OfflineExecutor.d.cts +39 -0
  5. package/dist/cjs/api/OfflineAction.cjs +47 -0
  6. package/dist/cjs/api/OfflineAction.cjs.map +1 -0
  7. package/dist/cjs/api/OfflineAction.d.cts +3 -0
  8. package/dist/cjs/api/OfflineTransaction.cjs +96 -0
  9. package/dist/cjs/api/OfflineTransaction.cjs.map +1 -0
  10. package/dist/cjs/api/OfflineTransaction.d.cts +18 -0
  11. package/dist/cjs/connectivity/OnlineDetector.cjs +73 -0
  12. package/dist/cjs/connectivity/OnlineDetector.cjs.map +1 -0
  13. package/dist/cjs/connectivity/OnlineDetector.d.cts +15 -0
  14. package/dist/cjs/coordination/BroadcastChannelLeader.cjs +146 -0
  15. package/dist/cjs/coordination/BroadcastChannelLeader.cjs.map +1 -0
  16. package/dist/cjs/coordination/BroadcastChannelLeader.d.cts +26 -0
  17. package/dist/cjs/coordination/LeaderElection.cjs +31 -0
  18. package/dist/cjs/coordination/LeaderElection.cjs.map +1 -0
  19. package/dist/cjs/coordination/LeaderElection.d.cts +10 -0
  20. package/dist/cjs/coordination/WebLocksLeader.cjs +71 -0
  21. package/dist/cjs/coordination/WebLocksLeader.cjs.map +1 -0
  22. package/dist/cjs/coordination/WebLocksLeader.d.cts +10 -0
  23. package/dist/cjs/executor/KeyScheduler.cjs +106 -0
  24. package/dist/cjs/executor/KeyScheduler.cjs.map +1 -0
  25. package/dist/cjs/executor/KeyScheduler.d.cts +18 -0
  26. package/dist/cjs/executor/TransactionExecutor.cjs +236 -0
  27. package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -0
  28. package/dist/cjs/executor/TransactionExecutor.d.cts +28 -0
  29. package/dist/cjs/index.cjs +34 -0
  30. package/dist/cjs/index.cjs.map +1 -0
  31. package/dist/cjs/index.d.cts +16 -0
  32. package/dist/cjs/outbox/OutboxManager.cjs +114 -0
  33. package/dist/cjs/outbox/OutboxManager.cjs.map +1 -0
  34. package/dist/cjs/outbox/OutboxManager.d.cts +18 -0
  35. package/dist/cjs/outbox/TransactionSerializer.cjs +135 -0
  36. package/dist/cjs/outbox/TransactionSerializer.cjs.map +1 -0
  37. package/dist/cjs/outbox/TransactionSerializer.d.cts +15 -0
  38. package/dist/cjs/retry/BackoffCalculator.cjs +14 -0
  39. package/dist/cjs/retry/BackoffCalculator.cjs.map +1 -0
  40. package/dist/cjs/retry/BackoffCalculator.d.cts +5 -0
  41. package/dist/cjs/retry/NonRetriableError.d.cts +1 -0
  42. package/dist/cjs/retry/RetryPolicy.cjs +33 -0
  43. package/dist/cjs/retry/RetryPolicy.cjs.map +1 -0
  44. package/dist/cjs/retry/RetryPolicy.d.cts +8 -0
  45. package/dist/cjs/storage/IndexedDBAdapter.cjs +104 -0
  46. package/dist/cjs/storage/IndexedDBAdapter.cjs.map +1 -0
  47. package/dist/cjs/storage/IndexedDBAdapter.d.cts +14 -0
  48. package/dist/cjs/storage/LocalStorageAdapter.cjs +71 -0
  49. package/dist/cjs/storage/LocalStorageAdapter.cjs.map +1 -0
  50. package/dist/cjs/storage/LocalStorageAdapter.d.cts +11 -0
  51. package/dist/cjs/storage/StorageAdapter.cjs +6 -0
  52. package/dist/cjs/storage/StorageAdapter.cjs.map +1 -0
  53. package/dist/cjs/storage/StorageAdapter.d.cts +9 -0
  54. package/dist/cjs/telemetry/tracer.cjs +91 -0
  55. package/dist/cjs/telemetry/tracer.cjs.map +1 -0
  56. package/dist/cjs/telemetry/tracer.d.cts +29 -0
  57. package/dist/cjs/types.cjs +10 -0
  58. package/dist/cjs/types.cjs.map +1 -0
  59. package/dist/cjs/types.d.cts +101 -0
  60. package/dist/esm/OfflineExecutor.d.ts +39 -0
  61. package/dist/esm/OfflineExecutor.js +266 -0
  62. package/dist/esm/OfflineExecutor.js.map +1 -0
  63. package/dist/esm/api/OfflineAction.d.ts +3 -0
  64. package/dist/esm/api/OfflineAction.js +47 -0
  65. package/dist/esm/api/OfflineAction.js.map +1 -0
  66. package/dist/esm/api/OfflineTransaction.d.ts +18 -0
  67. package/dist/esm/api/OfflineTransaction.js +96 -0
  68. package/dist/esm/api/OfflineTransaction.js.map +1 -0
  69. package/dist/esm/connectivity/OnlineDetector.d.ts +15 -0
  70. package/dist/esm/connectivity/OnlineDetector.js +73 -0
  71. package/dist/esm/connectivity/OnlineDetector.js.map +1 -0
  72. package/dist/esm/coordination/BroadcastChannelLeader.d.ts +26 -0
  73. package/dist/esm/coordination/BroadcastChannelLeader.js +146 -0
  74. package/dist/esm/coordination/BroadcastChannelLeader.js.map +1 -0
  75. package/dist/esm/coordination/LeaderElection.d.ts +10 -0
  76. package/dist/esm/coordination/LeaderElection.js +31 -0
  77. package/dist/esm/coordination/LeaderElection.js.map +1 -0
  78. package/dist/esm/coordination/WebLocksLeader.d.ts +10 -0
  79. package/dist/esm/coordination/WebLocksLeader.js +71 -0
  80. package/dist/esm/coordination/WebLocksLeader.js.map +1 -0
  81. package/dist/esm/executor/KeyScheduler.d.ts +18 -0
  82. package/dist/esm/executor/KeyScheduler.js +106 -0
  83. package/dist/esm/executor/KeyScheduler.js.map +1 -0
  84. package/dist/esm/executor/TransactionExecutor.d.ts +28 -0
  85. package/dist/esm/executor/TransactionExecutor.js +236 -0
  86. package/dist/esm/executor/TransactionExecutor.js.map +1 -0
  87. package/dist/esm/index.d.ts +16 -0
  88. package/dist/esm/index.js +34 -0
  89. package/dist/esm/index.js.map +1 -0
  90. package/dist/esm/outbox/OutboxManager.d.ts +18 -0
  91. package/dist/esm/outbox/OutboxManager.js +114 -0
  92. package/dist/esm/outbox/OutboxManager.js.map +1 -0
  93. package/dist/esm/outbox/TransactionSerializer.d.ts +15 -0
  94. package/dist/esm/outbox/TransactionSerializer.js +135 -0
  95. package/dist/esm/outbox/TransactionSerializer.js.map +1 -0
  96. package/dist/esm/retry/BackoffCalculator.d.ts +5 -0
  97. package/dist/esm/retry/BackoffCalculator.js +14 -0
  98. package/dist/esm/retry/BackoffCalculator.js.map +1 -0
  99. package/dist/esm/retry/NonRetriableError.d.ts +1 -0
  100. package/dist/esm/retry/RetryPolicy.d.ts +8 -0
  101. package/dist/esm/retry/RetryPolicy.js +33 -0
  102. package/dist/esm/retry/RetryPolicy.js.map +1 -0
  103. package/dist/esm/storage/IndexedDBAdapter.d.ts +14 -0
  104. package/dist/esm/storage/IndexedDBAdapter.js +104 -0
  105. package/dist/esm/storage/IndexedDBAdapter.js.map +1 -0
  106. package/dist/esm/storage/LocalStorageAdapter.d.ts +11 -0
  107. package/dist/esm/storage/LocalStorageAdapter.js +71 -0
  108. package/dist/esm/storage/LocalStorageAdapter.js.map +1 -0
  109. package/dist/esm/storage/StorageAdapter.d.ts +9 -0
  110. package/dist/esm/storage/StorageAdapter.js +6 -0
  111. package/dist/esm/storage/StorageAdapter.js.map +1 -0
  112. package/dist/esm/telemetry/tracer.d.ts +29 -0
  113. package/dist/esm/telemetry/tracer.js +91 -0
  114. package/dist/esm/telemetry/tracer.js.map +1 -0
  115. package/dist/esm/types.d.ts +101 -0
  116. package/dist/esm/types.js +10 -0
  117. package/dist/esm/types.js.map +1 -0
  118. package/package.json +66 -0
  119. package/src/OfflineExecutor.ts +360 -0
  120. package/src/api/OfflineAction.ts +68 -0
  121. package/src/api/OfflineTransaction.ts +134 -0
  122. package/src/connectivity/OnlineDetector.ts +87 -0
  123. package/src/coordination/BroadcastChannelLeader.ts +181 -0
  124. package/src/coordination/LeaderElection.ts +35 -0
  125. package/src/coordination/WebLocksLeader.ts +82 -0
  126. package/src/executor/KeyScheduler.ts +123 -0
  127. package/src/executor/TransactionExecutor.ts +330 -0
  128. package/src/index.ts +47 -0
  129. package/src/outbox/OutboxManager.ts +141 -0
  130. package/src/outbox/TransactionSerializer.ts +163 -0
  131. package/src/retry/BackoffCalculator.ts +13 -0
  132. package/src/retry/NonRetriableError.ts +1 -0
  133. package/src/retry/RetryPolicy.ts +41 -0
  134. package/src/storage/IndexedDBAdapter.ts +119 -0
  135. package/src/storage/LocalStorageAdapter.ts +79 -0
  136. package/src/storage/StorageAdapter.ts +11 -0
  137. package/src/telemetry/tracer.ts +156 -0
  138. package/src/types.ts +133 -0
@@ -0,0 +1,181 @@
1
+ import { BaseLeaderElection } from "./LeaderElection"
2
+
3
+ interface LeaderMessage {
4
+ type: `heartbeat` | `election` | `leadership-claim`
5
+ tabId: string
6
+ timestamp: number
7
+ }
8
+
9
+ export class BroadcastChannelLeader extends BaseLeaderElection {
10
+ private channelName: string
11
+ private tabId: string
12
+ private channel: BroadcastChannel | null = null
13
+ private heartbeatInterval: number | null = null
14
+ private electionTimeout: number | null = null
15
+ private lastLeaderHeartbeat = 0
16
+ private readonly heartbeatIntervalMs = 5000
17
+ private readonly electionTimeoutMs = 10000
18
+
19
+ constructor(channelName = `offline-executor-leader`) {
20
+ super()
21
+ this.channelName = channelName
22
+ this.tabId = crypto.randomUUID()
23
+ this.setupChannel()
24
+ }
25
+
26
+ private setupChannel(): void {
27
+ if (!this.isBroadcastChannelSupported()) {
28
+ return
29
+ }
30
+
31
+ this.channel = new BroadcastChannel(this.channelName)
32
+ this.channel.addEventListener(`message`, this.handleMessage)
33
+ }
34
+
35
+ private handleMessage = (event: MessageEvent<LeaderMessage>): void => {
36
+ const { type, tabId, timestamp } = event.data
37
+
38
+ if (tabId === this.tabId) {
39
+ return
40
+ }
41
+
42
+ switch (type) {
43
+ case `heartbeat`:
44
+ if (this.isLeaderState && tabId < this.tabId) {
45
+ this.releaseLeadership()
46
+ } else if (!this.isLeaderState) {
47
+ this.lastLeaderHeartbeat = timestamp
48
+ this.cancelElection()
49
+ }
50
+ break
51
+
52
+ case `election`:
53
+ if (this.isLeaderState) {
54
+ this.sendHeartbeat()
55
+ } else if (tabId > this.tabId) {
56
+ this.startElection()
57
+ }
58
+ break
59
+
60
+ case `leadership-claim`:
61
+ if (this.isLeaderState && tabId < this.tabId) {
62
+ this.releaseLeadership()
63
+ }
64
+ break
65
+ }
66
+ }
67
+
68
+ async requestLeadership(): Promise<boolean> {
69
+ if (!this.isBroadcastChannelSupported()) {
70
+ return false
71
+ }
72
+
73
+ if (this.isLeaderState) {
74
+ return true
75
+ }
76
+
77
+ this.startElection()
78
+
79
+ return new Promise((resolve) => {
80
+ setTimeout(() => {
81
+ resolve(this.isLeaderState)
82
+ }, 1000)
83
+ })
84
+ }
85
+
86
+ private startElection(): void {
87
+ if (this.electionTimeout) {
88
+ return
89
+ }
90
+
91
+ this.sendMessage({
92
+ type: `election`,
93
+ tabId: this.tabId,
94
+ timestamp: Date.now(),
95
+ })
96
+
97
+ this.electionTimeout = window.setTimeout(() => {
98
+ const timeSinceLastHeartbeat = Date.now() - this.lastLeaderHeartbeat
99
+
100
+ if (timeSinceLastHeartbeat > this.electionTimeoutMs) {
101
+ this.claimLeadership()
102
+ }
103
+
104
+ this.electionTimeout = null
105
+ }, this.electionTimeoutMs)
106
+ }
107
+
108
+ private cancelElection(): void {
109
+ if (this.electionTimeout) {
110
+ clearTimeout(this.electionTimeout)
111
+ this.electionTimeout = null
112
+ }
113
+ }
114
+
115
+ private claimLeadership(): void {
116
+ this.notifyLeadershipChange(true)
117
+ this.sendMessage({
118
+ type: `leadership-claim`,
119
+ tabId: this.tabId,
120
+ timestamp: Date.now(),
121
+ })
122
+ this.startHeartbeat()
123
+ }
124
+
125
+ private startHeartbeat(): void {
126
+ if (this.heartbeatInterval) {
127
+ return
128
+ }
129
+
130
+ this.sendHeartbeat()
131
+
132
+ this.heartbeatInterval = window.setInterval(() => {
133
+ this.sendHeartbeat()
134
+ }, this.heartbeatIntervalMs)
135
+ }
136
+
137
+ private stopHeartbeat(): void {
138
+ if (this.heartbeatInterval) {
139
+ clearInterval(this.heartbeatInterval)
140
+ this.heartbeatInterval = null
141
+ }
142
+ }
143
+
144
+ private sendHeartbeat(): void {
145
+ this.sendMessage({
146
+ type: `heartbeat`,
147
+ tabId: this.tabId,
148
+ timestamp: Date.now(),
149
+ })
150
+ }
151
+
152
+ private sendMessage(message: LeaderMessage): void {
153
+ if (this.channel) {
154
+ this.channel.postMessage(message)
155
+ }
156
+ }
157
+
158
+ releaseLeadership(): void {
159
+ this.stopHeartbeat()
160
+ this.cancelElection()
161
+ this.notifyLeadershipChange(false)
162
+ }
163
+
164
+ private isBroadcastChannelSupported(): boolean {
165
+ return typeof BroadcastChannel !== `undefined`
166
+ }
167
+
168
+ static isSupported(): boolean {
169
+ return typeof BroadcastChannel !== `undefined`
170
+ }
171
+
172
+ dispose(): void {
173
+ this.releaseLeadership()
174
+
175
+ if (this.channel) {
176
+ this.channel.removeEventListener(`message`, this.handleMessage)
177
+ this.channel.close()
178
+ this.channel = null
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,35 @@
1
+ import type { LeaderElection } from "../types"
2
+
3
+ export abstract class BaseLeaderElection implements LeaderElection {
4
+ protected isLeaderState = false
5
+ protected listeners: Set<(isLeader: boolean) => void> = new Set()
6
+
7
+ abstract requestLeadership(): Promise<boolean>
8
+ abstract releaseLeadership(): void
9
+
10
+ isLeader(): boolean {
11
+ return this.isLeaderState
12
+ }
13
+
14
+ onLeadershipChange(callback: (isLeader: boolean) => void): () => void {
15
+ this.listeners.add(callback)
16
+
17
+ return () => {
18
+ this.listeners.delete(callback)
19
+ }
20
+ }
21
+
22
+ protected notifyLeadershipChange(isLeader: boolean): void {
23
+ if (this.isLeaderState !== isLeader) {
24
+ this.isLeaderState = isLeader
25
+
26
+ for (const listener of this.listeners) {
27
+ try {
28
+ listener(isLeader)
29
+ } catch (error) {
30
+ console.warn(`Leadership change listener error:`, error)
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,82 @@
1
+ import { BaseLeaderElection } from "./LeaderElection"
2
+
3
+ export class WebLocksLeader extends BaseLeaderElection {
4
+ private lockName: string
5
+ private releaseLock: (() => void) | null = null
6
+
7
+ constructor(lockName = `offline-executor-leader`) {
8
+ super()
9
+ this.lockName = lockName
10
+ }
11
+
12
+ async requestLeadership(): Promise<boolean> {
13
+ if (!this.isWebLocksSupported()) {
14
+ return false
15
+ }
16
+
17
+ if (this.isLeaderState) {
18
+ return true
19
+ }
20
+
21
+ try {
22
+ // First try to acquire the lock with ifAvailable
23
+ const available = await navigator.locks.request(
24
+ this.lockName,
25
+ {
26
+ mode: `exclusive`,
27
+ ifAvailable: true,
28
+ },
29
+ (lock) => {
30
+ return lock !== null
31
+ }
32
+ )
33
+
34
+ if (!available) {
35
+ return false
36
+ }
37
+
38
+ // Lock is available, now acquire it for real and hold it
39
+ navigator.locks.request(
40
+ this.lockName,
41
+ {
42
+ mode: `exclusive`,
43
+ },
44
+ async (lock) => {
45
+ if (lock) {
46
+ this.notifyLeadershipChange(true)
47
+ // Hold the lock until released
48
+ return new Promise<void>((resolve) => {
49
+ this.releaseLock = () => {
50
+ this.notifyLeadershipChange(false)
51
+ resolve()
52
+ }
53
+ })
54
+ }
55
+ }
56
+ )
57
+
58
+ return true
59
+ } catch (error) {
60
+ if (error instanceof Error && error.name === `AbortError`) {
61
+ return false
62
+ }
63
+ console.warn(`Web Locks leadership request failed:`, error)
64
+ return false
65
+ }
66
+ }
67
+
68
+ releaseLeadership(): void {
69
+ if (this.releaseLock) {
70
+ this.releaseLock()
71
+ this.releaseLock = null
72
+ }
73
+ }
74
+
75
+ private isWebLocksSupported(): boolean {
76
+ return typeof navigator !== `undefined` && `locks` in navigator
77
+ }
78
+
79
+ static isSupported(): boolean {
80
+ return typeof navigator !== `undefined` && `locks` in navigator
81
+ }
82
+ }
@@ -0,0 +1,123 @@
1
+ import { withSyncSpan } from "../telemetry/tracer"
2
+ import type { OfflineTransaction } from "../types"
3
+
4
+ export class KeyScheduler {
5
+ private pendingTransactions: Array<OfflineTransaction> = []
6
+ private isRunning = false
7
+
8
+ schedule(transaction: OfflineTransaction): void {
9
+ withSyncSpan(
10
+ `scheduler.schedule`,
11
+ {
12
+ "transaction.id": transaction.id,
13
+ queueLength: this.pendingTransactions.length,
14
+ },
15
+ () => {
16
+ this.pendingTransactions.push(transaction)
17
+ // Sort by creation time to maintain FIFO order
18
+ this.pendingTransactions.sort(
19
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
20
+ )
21
+ }
22
+ )
23
+ }
24
+
25
+ getNextBatch(_maxConcurrency: number): Array<OfflineTransaction> {
26
+ return withSyncSpan(
27
+ `scheduler.getNextBatch`,
28
+ { pendingCount: this.pendingTransactions.length },
29
+ (span) => {
30
+ // For sequential processing, we ignore maxConcurrency and only process one transaction at a time
31
+ if (this.isRunning || this.pendingTransactions.length === 0) {
32
+ span.setAttribute(`result`, `empty`)
33
+ return []
34
+ }
35
+
36
+ // Find the first transaction that's ready to run
37
+ const readyTransaction = this.pendingTransactions.find((tx) =>
38
+ this.isReadyToRun(tx)
39
+ )
40
+
41
+ if (readyTransaction) {
42
+ span.setAttribute(`result`, `found`)
43
+ span.setAttribute(`transaction.id`, readyTransaction.id)
44
+ } else {
45
+ span.setAttribute(`result`, `none_ready`)
46
+ }
47
+
48
+ return readyTransaction ? [readyTransaction] : []
49
+ }
50
+ )
51
+ }
52
+
53
+ private isReadyToRun(transaction: OfflineTransaction): boolean {
54
+ return Date.now() >= transaction.nextAttemptAt
55
+ }
56
+
57
+ markStarted(_transaction: OfflineTransaction): void {
58
+ this.isRunning = true
59
+ }
60
+
61
+ markCompleted(transaction: OfflineTransaction): void {
62
+ this.removeTransaction(transaction)
63
+ this.isRunning = false
64
+ }
65
+
66
+ markFailed(_transaction: OfflineTransaction): void {
67
+ this.isRunning = false
68
+ }
69
+
70
+ private removeTransaction(transaction: OfflineTransaction): void {
71
+ const index = this.pendingTransactions.findIndex(
72
+ (tx) => tx.id === transaction.id
73
+ )
74
+ if (index >= 0) {
75
+ this.pendingTransactions.splice(index, 1)
76
+ }
77
+ }
78
+
79
+ updateTransaction(transaction: OfflineTransaction): void {
80
+ const index = this.pendingTransactions.findIndex(
81
+ (tx) => tx.id === transaction.id
82
+ )
83
+ if (index >= 0) {
84
+ this.pendingTransactions[index] = transaction
85
+ // Re-sort to maintain FIFO order after update
86
+ this.pendingTransactions.sort(
87
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
88
+ )
89
+ }
90
+ }
91
+
92
+ getPendingCount(): number {
93
+ return this.pendingTransactions.length
94
+ }
95
+
96
+ getRunningCount(): number {
97
+ return this.isRunning ? 1 : 0
98
+ }
99
+
100
+ clear(): void {
101
+ this.pendingTransactions = []
102
+ this.isRunning = false
103
+ }
104
+
105
+ getAllPendingTransactions(): Array<OfflineTransaction> {
106
+ return [...this.pendingTransactions]
107
+ }
108
+
109
+ updateTransactions(updatedTransactions: Array<OfflineTransaction>): void {
110
+ for (const updatedTx of updatedTransactions) {
111
+ const index = this.pendingTransactions.findIndex(
112
+ (tx) => tx.id === updatedTx.id
113
+ )
114
+ if (index >= 0) {
115
+ this.pendingTransactions[index] = updatedTx
116
+ }
117
+ }
118
+ // Re-sort to maintain FIFO order after updates
119
+ this.pendingTransactions.sort(
120
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
121
+ )
122
+ }
123
+ }