velocious 1.0.717 → 1.0.718

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,298 @@
1
+ // @ts-check
2
+
3
+ import TableData from "../database/table-data/index.js"
4
+ import TableIndex from "../database/table-data/table-index.js"
5
+ import UUID from "pure-uuid"
6
+
7
+ /**
8
+ * @typedef {object} SyncAppliedSequenceRow
9
+ * @property {string} resourceId - Resource identifier the change applied to.
10
+ * @property {string} resourceType - Resource/model name the change applied to.
11
+ * @property {number} serverSequence - Highest applied server sequence for the resource.
12
+ * @property {string} storeIdentity - Physical store identity owning this row.
13
+ */
14
+ const TABLE_NAME = "velocious_sync_applied_sequences"
15
+ const IDENTITY_INDEX_NAME = "index_velocious_sync_applied_sequences_on_identity"
16
+
17
+ /**
18
+ * Builds the stable in-memory key for one resource identity.
19
+ * @param {{resourceType: string, resourceId: string}} args - Resource identity.
20
+ * @returns {string} Memory key.
21
+ */
22
+ function memoryKey({resourceId, resourceType}) {
23
+ return `${resourceType}:${resourceId}`
24
+ }
25
+
26
+ /**
27
+ * Framework-owned local bookkeeping of the newest server change sequence
28
+ * already applied to each record.
29
+ *
30
+ * The server change feed is at-least-once: a realtime push does not advance a
31
+ * pull cursor, so the next pull re-delivers the same `serverSequence`, and a
32
+ * resumed page can repeat a row. Applying such a duplicate after a newer local
33
+ * provisional write used to let the older server payload win. Tracking the
34
+ * highest applied sequence per record makes the applier idempotent: a change at
35
+ * or below the recorded sequence is skipped, while any higher sequence still
36
+ * applies.
37
+ *
38
+ * Backed by an auto-created `velocious_sync_applied_sequences` table on the
39
+ * configured database, with a process-local memory fallback when no database is
40
+ * configured (mirroring the scope store).
41
+ */
42
+ export default class SyncAppliedSequenceStore {
43
+ /**
44
+ * Creates an applied-sequence store.
45
+ * @param {object} args - Options.
46
+ * @param {import("../configuration.js").default} args.configuration - Configuration owning the database.
47
+ * @param {string} [args.databaseIdentifier] - Database identifier.
48
+ * @param {import("../tenants/tenant-handle.js").default} [args.tenantHandle] - Immutable tenant handle owning the physical store.
49
+ */
50
+ constructor({configuration, databaseIdentifier = "default", tenantHandle}) {
51
+ if (tenantHandle) tenantHandle.assertConfiguration(configuration)
52
+
53
+ this.configuration = configuration
54
+ this.databaseIdentifier = databaseIdentifier
55
+ this.tenantHandle = tenantHandle
56
+ this.storeIdentity = tenantHandle
57
+ ? tenantHandle.databaseIdentity(databaseIdentifier)
58
+ : `configuration:${databaseIdentifier}`
59
+ /** @type {Map<string, number>} */
60
+ this._memorySequences = new Map()
61
+ /** @type {Map<string, Promise<void>>} */
62
+ this._writeQueues = new Map()
63
+ this._isReady = false
64
+ /** @type {Promise<void> | null} */
65
+ this._readyPromise = null
66
+ /** @type {WeakMap<import("../database/drivers/base.js").default, {completion: Promise<void>, promise: Promise<void>}>} */
67
+ this._transactionReadyPromises = new WeakMap()
68
+ }
69
+
70
+ /**
71
+ * Ensures the backing table exists.
72
+ * @returns {Promise<void>} Resolves when ready.
73
+ */
74
+ async ensureReady() {
75
+ if (this._isReady) return
76
+
77
+ if (this._usesMemoryStorage()) {
78
+ this._isReady = true
79
+ return
80
+ }
81
+
82
+ await this._withDb(async (db) => await this._ensureReadyWithDb(db))
83
+ }
84
+
85
+ /**
86
+ * Returns the highest server sequence already applied to a record.
87
+ * @param {{resourceType: string, resourceId: string}} identity - Resource identity.
88
+ * @returns {Promise<number | null>} Applied sequence, or null when none is recorded.
89
+ */
90
+ async lastAppliedSequence(identity) {
91
+ await this.ensureReady()
92
+
93
+ if (this._usesMemoryStorage()) {
94
+ return this._memorySequences.get(memoryKey(identity)) ?? null
95
+ }
96
+
97
+ return await this._withDb(async (db) => {
98
+ const row = await this._rowByResource(db, identity)
99
+
100
+ return row ? row.serverSequence : null
101
+ })
102
+ }
103
+
104
+ /**
105
+ * Records a successfully applied server sequence for a record, keeping only
106
+ * the highest value. Writes for the same record are serialized in-process so
107
+ * concurrent pull/realtime applies cannot move the watermark backwards.
108
+ * @param {{resourceType: string, resourceId: string, serverSequence: number}} args - Applied change identity and sequence.
109
+ * @returns {Promise<void>} Resolves when recorded.
110
+ */
111
+ async recordAppliedSequence({resourceId, resourceType, serverSequence}) {
112
+ await this.ensureReady()
113
+
114
+ const identity = {resourceId, resourceType}
115
+ const key = memoryKey(identity)
116
+ const previousWrite = this._writeQueues.get(key) || Promise.resolve()
117
+ const write = previousWrite
118
+ .then(async () => await this._recordSequential({identity, key, serverSequence}))
119
+ .finally(() => {
120
+ if (this._writeQueues.get(key) === write) this._writeQueues.delete(key)
121
+ })
122
+
123
+ this._writeQueues.set(key, write)
124
+ await write
125
+ }
126
+
127
+ /**
128
+ * Applies one serialized watermark write.
129
+ * @param {{identity: {resourceType: string, resourceId: string}, key: string, serverSequence: number}} args - Write args.
130
+ * @returns {Promise<void>} Resolves when written.
131
+ */
132
+ async _recordSequential({identity, key, serverSequence}) {
133
+ const currentSequence = await this.lastAppliedSequence(identity)
134
+
135
+ if (currentSequence !== null && currentSequence >= serverSequence) return
136
+
137
+ if (this._usesMemoryStorage()) {
138
+ this._memorySequences.set(key, serverSequence)
139
+ return
140
+ }
141
+
142
+ await this._withDb(async (db) => {
143
+ await db.upsert({
144
+ conflictColumns: ["resource_type", "resource_id"],
145
+ data: {
146
+ created_at: new Date(),
147
+ id: new UUID(4).format(),
148
+ resource_id: identity.resourceId,
149
+ resource_type: identity.resourceType,
150
+ server_sequence: serverSequence,
151
+ updated_at: new Date()
152
+ },
153
+ tableName: TABLE_NAME,
154
+ updateColumns: ["server_sequence", "updated_at"]
155
+ })
156
+ })
157
+ }
158
+
159
+ /**
160
+ * Coordinates durable and transaction-local readiness on one connection.
161
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
162
+ * @returns {Promise<void>} Resolves when this caller can use the table.
163
+ */
164
+ async _ensureReadyWithDb(db) {
165
+ if (this._isReady) return
166
+
167
+ const transactionCompletion = db.insideTransaction() ? db.transactionCompletion() : null
168
+ const transactionReady = this._transactionReadyPromises.get(db)
169
+
170
+ if (transactionCompletion && transactionReady?.completion === transactionCompletion) {
171
+ await transactionReady.promise
172
+ return
173
+ }
174
+
175
+ if (this._readyPromise) {
176
+ const readyPromise = this._readyPromise
177
+
178
+ await readyPromise
179
+ if (this._readyPromise === readyPromise) this._readyPromise = null
180
+ if (this._isReady) return
181
+
182
+ await this.ensureReady()
183
+ return
184
+ }
185
+
186
+ if (transactionCompletion) {
187
+ const tableReadyPromise = this._ensureTable(db)
188
+ const transactionReadyPromise = tableReadyPromise.then(() => undefined)
189
+
190
+ const durableReadyPromise = tableReadyPromise.then(async (created) => {
191
+ if (!created) {
192
+ this._isReady = true
193
+ return
194
+ }
195
+
196
+ await transactionCompletion
197
+ })
198
+
199
+ this._transactionReadyPromises.set(db, {completion: transactionCompletion, promise: transactionReadyPromise})
200
+ this._readyPromise = durableReadyPromise
201
+ await transactionReadyPromise
202
+ return
203
+ }
204
+
205
+ this._readyPromise = this._ensureTable(db).then(() => {
206
+ this._isReady = true
207
+ })
208
+
209
+ try {
210
+ await this._readyPromise
211
+ } finally {
212
+ if (!this._isReady) this._readyPromise = null
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Whether the store runs without a configured database.
218
+ * @returns {boolean} Whether memory storage is used.
219
+ */
220
+ _usesMemoryStorage() {
221
+ try {
222
+ return !this.configuration.getDatabaseConfiguration()[this.databaseIdentifier]
223
+ } catch {
224
+ return true
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Runs a callback with a database connection.
230
+ * @template Result
231
+ * @param {(db: import("../database/drivers/base.js").default) => Promise<Result>} callback - Database callback.
232
+ * @returns {Promise<Result>} Callback result.
233
+ */
234
+ async _withDb(callback) {
235
+ if (this.tenantHandle) {
236
+ return await this.tenantHandle.databaseOperation({databaseIdentifier: this.databaseIdentifier, name: "Tenant sync applied-sequence store"}, async (operation) => {
237
+ return await callback(operation.connection())
238
+ })
239
+ }
240
+
241
+ return await this.configuration.ensureConnections({databaseIdentifiers: [this.databaseIdentifier], name: "Sync applied-sequence store"}, async (dbs) => {
242
+ const db = dbs[this.databaseIdentifier]
243
+
244
+ if (!db) throw new Error(`No database connection available for identifier: ${this.databaseIdentifier}`)
245
+
246
+ return await callback(db)
247
+ })
248
+ }
249
+
250
+ /**
251
+ * Ensures the applied-sequences table exists.
252
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
253
+ * @returns {Promise<boolean>} Whether the table had to be created.
254
+ */
255
+ async _ensureTable(db) {
256
+ if (await db.tableExists(TABLE_NAME)) return false
257
+
258
+ const table = new TableData(TABLE_NAME, {ifNotExists: true})
259
+
260
+ table.string("id", {null: false, primaryKey: true})
261
+ table.string("resource_type", {null: false})
262
+ table.string("resource_id", {null: false})
263
+ table.bigint("server_sequence", {null: false})
264
+ table.datetime("created_at", {null: false})
265
+ table.datetime("updated_at", {null: false})
266
+ table.addIndex(new TableIndex(["resource_type", "resource_id"], {name: IDENTITY_INDEX_NAME, unique: true}))
267
+
268
+ await db.createTable(table)
269
+
270
+ return true
271
+ }
272
+
273
+ /**
274
+ * Resolves the stored row for one resource identity.
275
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
276
+ * @param {{resourceType: string, resourceId: string}} identity - Resource identity.
277
+ * @returns {Promise<SyncAppliedSequenceRow | null>} Stored row, or null.
278
+ */
279
+ async _rowByResource(db, identity) {
280
+ const rows = /** @type {Array<Record<string, ReturnType<typeof JSON.parse>>>} */ (await db
281
+ .newQuery()
282
+ .from(TABLE_NAME)
283
+ .where({resource_id: identity.resourceId, resource_type: identity.resourceType})
284
+ .limit(1)
285
+ .results())
286
+
287
+ const row = rows[0]
288
+
289
+ if (!row) return null
290
+
291
+ return {
292
+ resourceId: String(row.resource_id),
293
+ resourceType: String(row.resource_type),
294
+ serverSequence: Number(row.server_sequence),
295
+ storeIdentity: this.storeIdentity
296
+ }
297
+ }
298
+ }
@@ -10,6 +10,7 @@ import VelociousWebsocketClient from "../http-client/websocket-client.js"
10
10
 
11
11
  import {serializedScopeFromQuery} from "./query-scope.js"
12
12
  import SyncApiClient from "./sync-api-client.js"
13
+ import SyncAppliedSequenceStore from "./sync-applied-sequence-store.js"
13
14
  import SyncOfflineSession from "./sync-offline-session.js"
14
15
  import SyncRealtimeBridge from "./sync-realtime-bridge.js"
15
16
  import SyncScopeStore from "./sync-scope-store.js"
@@ -183,6 +184,8 @@ export default class SyncClient {
183
184
  this._userScopeState = "unsubscribed"
184
185
  /** @type {import("./sync-scope-store.js").default | null} */
185
186
  this._scopeStore = scopeStore || null
187
+ /** @type {import("./sync-applied-sequence-store.js").default | null} */
188
+ this._appliedSequenceStore = null
186
189
  /** @type {Promise<void> | null} */
187
190
  this._scheduledReplay = null
188
191
  /** @type {((reason: "mutation" | "realtime") => Promise<void>) | null} */
@@ -832,6 +835,13 @@ export default class SyncClient {
832
835
  * applies through the declared resource configs, registers each written record
833
836
  * for echo suppression (tracked resources do not re-queue applied changes), and
834
837
  * fails loudly instead of silently skipping unconfigured resources.
838
+ *
839
+ * Applies are idempotent per record: the feed is at-least-once (a realtime push
840
+ * does not advance the pull cursor, so the same `serverSequence` arrives again
841
+ * on the next pull). A change whose sequence is not greater than the newest one
842
+ * already applied to that record is skipped, so a duplicate cannot overwrite
843
+ * newer local state. Sources without a sequence (e.g. legacy broadcasts) keep
844
+ * applying unchanged.
835
845
  * @param {{source?: string}} [args] - Error context describing where the change came from.
836
846
  * @returns {(sync: import("./sync-api-client-types.js").SyncChangeEnvelope) => Promise<import("./sync-api-client-types.js").SyncChangeApplyResult>} Loud remote-change applier.
837
847
  */
@@ -846,6 +856,18 @@ export default class SyncClient {
846
856
 
847
857
  return await this.withTenantOperation(async (operation) => {
848
858
  const data = sync.data()
859
+ const resourceId = String(sync.resourceId())
860
+ const serverSequence = sync.serverSequence()
861
+ const appliedSequences = this.appliedSequenceStore()
862
+
863
+ if (serverSequence !== null) {
864
+ const lastAppliedSequence = await appliedSequences.lastAppliedSequence({resourceId, resourceType})
865
+
866
+ if (lastAppliedSequence !== null && serverSequence <= lastAppliedSequence) {
867
+ return {changed: false, resourceType}
868
+ }
869
+ }
870
+
849
871
  const versionAttribute = this.config.resources[resourceType].conflictTracking?.versionAttribute
850
872
 
851
873
  if (versionAttribute) {
@@ -853,7 +875,7 @@ export default class SyncClient {
853
875
  ? /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (data)
854
876
  : {}
855
877
 
856
- this.noteRemoteVersion({resourceId: String(sync.resourceId()), resourceType, version: dataAttributes[versionAttribute]})
878
+ this.noteRemoteVersion({resourceId, resourceType, version: dataAttributes[versionAttribute]})
857
879
  }
858
880
 
859
881
  const pullResourceConfigs = this.pullResourceConfigs(operation)
@@ -863,7 +885,13 @@ export default class SyncClient {
863
885
  return this.markRemoteApply(record)
864
886
  })
865
887
 
866
- return await applier(sync)
888
+ const result = await applier(sync)
889
+
890
+ if (serverSequence !== null) {
891
+ await appliedSequences.recordAppliedSequence({resourceId, resourceType, serverSequence})
892
+ }
893
+
894
+ return result
867
895
  })
868
896
  }
869
897
  }
@@ -1620,6 +1648,27 @@ export default class SyncClient {
1620
1648
  return this._scopeStore
1621
1649
  }
1622
1650
 
1651
+ /**
1652
+ * Returns the per-record applied-sequence store that keeps remote applies
1653
+ * idempotent across pull/realtime duplication.
1654
+ * @returns {import("./sync-applied-sequence-store.js").default} Applied-sequence store.
1655
+ */
1656
+ appliedSequenceStore() {
1657
+ this.assertTenantReady()
1658
+
1659
+ if (this._appliedSequenceStore && this._databaseIdentity && this._appliedSequenceStore.storeIdentity !== this._databaseIdentity) {
1660
+ throw new Error("SyncClient applied-sequence store belongs to another or unresolved physical tenant database")
1661
+ }
1662
+
1663
+ this._appliedSequenceStore ||= new SyncAppliedSequenceStore({
1664
+ configuration: this.config.configuration,
1665
+ databaseIdentifier: this.config.databaseIdentifier,
1666
+ tenantHandle: this.config.tenantHandle
1667
+ })
1668
+
1669
+ return this._appliedSequenceStore
1670
+ }
1671
+
1623
1672
  /**
1624
1673
  * Resolves the declared resource config for a local record.
1625
1674
  * @param {ReturnType<typeof JSON.parse>} resource - Local model record.