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.
- package/build/src/sync/sync-api-client-types.d.ts +10 -0
- package/build/src/sync/sync-api-client-types.d.ts.map +1 -1
- package/build/src/sync/sync-api-client-types.js +3 -1
- package/build/src/sync/sync-api-client.d.ts +6 -0
- package/build/src/sync/sync-api-client.d.ts.map +1 -1
- package/build/src/sync/sync-api-client.js +17 -2
- package/build/src/sync/sync-applied-sequence-store.d.ts +138 -0
- package/build/src/sync/sync-applied-sequence-store.d.ts.map +1 -0
- package/build/src/sync/sync-applied-sequence-store.js +267 -0
- package/build/src/sync/sync-client.d.ts +15 -0
- package/build/src/sync/sync-client.d.ts.map +1 -1
- package/build/src/sync/sync-client.js +43 -3
- package/build/sync/sync-api-client-types.js +2 -0
- package/build/sync/sync-api-client.js +18 -1
- package/build/sync/sync-applied-sequence-store.js +298 -0
- package/build/sync/sync-client.js +51 -2
- package/package.json +1 -1
- package/src/sync/sync-api-client-types.js +2 -0
- package/src/sync/sync-api-client.js +18 -1
- package/src/sync/sync-applied-sequence-store.js +298 -0
- package/src/sync/sync-client.js +51 -2
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* @property {() => unknown} id - Sync row identifier.
|
|
13
13
|
* @property {() => unknown} resourceId - Resource identifier.
|
|
14
14
|
* @property {() => string | null} resourceType - Resource type name.
|
|
15
|
+
* @property {() => number | null} serverSequence - Monotonic server change sequence, when the source provided one.
|
|
15
16
|
* @property {() => string} syncType - Sync operation type.
|
|
17
|
+
* @property {() => string | null} updatedAt - Server change timestamp, when the source provided one.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -527,10 +527,27 @@ export default class SyncApiClient {
|
|
|
527
527
|
id: () => syncPayload.id,
|
|
528
528
|
resourceId: () => syncPayload.resourceId,
|
|
529
529
|
resourceType: () => syncPayload.resourceType === null || syncPayload.resourceType === undefined ? null : String(syncPayload.resourceType),
|
|
530
|
-
|
|
530
|
+
serverSequence: () => this.normalizedServerSequence(syncPayload.serverSequence),
|
|
531
|
+
syncType: () => syncPayload.syncType === null || syncPayload.syncType === undefined ? "" : String(syncPayload.syncType),
|
|
532
|
+
updatedAt: () => syncPayload.updatedAt === null || syncPayload.updatedAt === undefined ? null : String(syncPayload.updatedAt)
|
|
531
533
|
}
|
|
532
534
|
}
|
|
533
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Normalizes a raw server sequence into a positive safe integer.
|
|
538
|
+
* @param {unknown} value - Raw sequence.
|
|
539
|
+
* @returns {number | null} Normalized sequence, or null when absent/invalid.
|
|
540
|
+
*/
|
|
541
|
+
static normalizedServerSequence(value) {
|
|
542
|
+
if (value === null || value === undefined || value === "") return null
|
|
543
|
+
|
|
544
|
+
const sequence = Number(value)
|
|
545
|
+
|
|
546
|
+
if (!Number.isSafeInteger(sequence) || sequence <= 0) return null
|
|
547
|
+
|
|
548
|
+
return sequence
|
|
549
|
+
}
|
|
550
|
+
|
|
534
551
|
/**
|
|
535
552
|
* Builds an app-configured resource applier for pulled sync rows. The sync
|
|
536
553
|
* mechanics stay here; apps only declare which models/attributes/hooks are
|
|
@@ -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
|
|
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
|
-
|
|
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.
|
package/package.json
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* @property {() => unknown} id - Sync row identifier.
|
|
13
13
|
* @property {() => unknown} resourceId - Resource identifier.
|
|
14
14
|
* @property {() => string | null} resourceType - Resource type name.
|
|
15
|
+
* @property {() => number | null} serverSequence - Monotonic server change sequence, when the source provided one.
|
|
15
16
|
* @property {() => string} syncType - Sync operation type.
|
|
17
|
+
* @property {() => string | null} updatedAt - Server change timestamp, when the source provided one.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -527,10 +527,27 @@ export default class SyncApiClient {
|
|
|
527
527
|
id: () => syncPayload.id,
|
|
528
528
|
resourceId: () => syncPayload.resourceId,
|
|
529
529
|
resourceType: () => syncPayload.resourceType === null || syncPayload.resourceType === undefined ? null : String(syncPayload.resourceType),
|
|
530
|
-
|
|
530
|
+
serverSequence: () => this.normalizedServerSequence(syncPayload.serverSequence),
|
|
531
|
+
syncType: () => syncPayload.syncType === null || syncPayload.syncType === undefined ? "" : String(syncPayload.syncType),
|
|
532
|
+
updatedAt: () => syncPayload.updatedAt === null || syncPayload.updatedAt === undefined ? null : String(syncPayload.updatedAt)
|
|
531
533
|
}
|
|
532
534
|
}
|
|
533
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Normalizes a raw server sequence into a positive safe integer.
|
|
538
|
+
* @param {unknown} value - Raw sequence.
|
|
539
|
+
* @returns {number | null} Normalized sequence, or null when absent/invalid.
|
|
540
|
+
*/
|
|
541
|
+
static normalizedServerSequence(value) {
|
|
542
|
+
if (value === null || value === undefined || value === "") return null
|
|
543
|
+
|
|
544
|
+
const sequence = Number(value)
|
|
545
|
+
|
|
546
|
+
if (!Number.isSafeInteger(sequence) || sequence <= 0) return null
|
|
547
|
+
|
|
548
|
+
return sequence
|
|
549
|
+
}
|
|
550
|
+
|
|
534
551
|
/**
|
|
535
552
|
* Builds an app-configured resource applier for pulled sync rows. The sync
|
|
536
553
|
* mechanics stay here; apps only declare which models/attributes/hooks are
|