velocious 1.0.663 → 1.0.665
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/README.md +2 -2
- package/build/background-jobs/client.js +36 -6
- package/build/configuration-types.js +3 -3
- package/build/database/query/alter-table-base.js +11 -1
- package/build/src/background-jobs/client.d.ts.map +1 -1
- package/build/src/background-jobs/client.js +35 -6
- package/build/src/configuration-types.d.ts +8 -4
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +4 -4
- package/build/src/database/query/alter-table-base.d.ts.map +1 -1
- package/build/src/database/query/alter-table-base.js +12 -2
- package/build/src/sync/sync-api-client.d.ts +36 -10
- package/build/src/sync/sync-api-client.d.ts.map +1 -1
- package/build/src/sync/sync-api-client.js +58 -14
- package/build/src/sync/sync-client-types.d.ts +41 -2
- package/build/src/sync/sync-client-types.d.ts.map +1 -1
- package/build/src/sync/sync-client-types.js +20 -3
- package/build/src/sync/sync-client.d.ts +112 -3
- package/build/src/sync/sync-client.d.ts.map +1 -1
- package/build/src/sync/sync-client.js +306 -60
- package/build/src/sync/sync-publisher-types.d.ts +36 -3
- package/build/src/sync/sync-publisher-types.d.ts.map +1 -1
- package/build/src/sync/sync-publisher-types.js +16 -2
- package/build/src/sync/sync-publisher.d.ts +22 -3
- package/build/src/sync/sync-publisher.d.ts.map +1 -1
- package/build/src/sync/sync-publisher.js +134 -13
- package/build/src/sync/sync-realtime-bridge.d.ts +11 -5
- package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
- package/build/src/sync/sync-realtime-bridge.js +37 -13
- package/build/src/sync/sync-scope-store.d.ts +32 -0
- package/build/src/sync/sync-scope-store.d.ts.map +1 -1
- package/build/src/sync/sync-scope-store.js +74 -4
- package/build/sync/sync-api-client.js +58 -13
- package/build/sync/sync-client-types.js +22 -2
- package/build/sync/sync-client.js +333 -63
- package/build/sync/sync-publisher-types.js +17 -1
- package/build/sync/sync-publisher.js +156 -15
- package/build/sync/sync-realtime-bridge.js +37 -12
- package/build/sync/sync-scope-store.js +79 -3
- package/package.json +1 -1
- package/src/background-jobs/client.js +36 -6
- package/src/configuration-types.js +3 -3
- package/src/database/query/alter-table-base.js +11 -1
- package/src/sync/sync-api-client.js +58 -13
- package/src/sync/sync-client-types.js +22 -2
- package/src/sync/sync-client.js +333 -63
- package/src/sync/sync-publisher-types.js +17 -1
- package/src/sync/sync-publisher.js +156 -15
- package/src/sync/sync-realtime-bridge.js +37 -12
- package/src/sync/sync-scope-store.js +79 -3
|
@@ -4,6 +4,8 @@ import Configuration from "../configuration.js"
|
|
|
4
4
|
import Logger from "../logger.js"
|
|
5
5
|
import {scalarModelPrimaryKeyValue} from "../utils/model-primary-key.js"
|
|
6
6
|
import restArgsError from "../utils/rest-args-error.js"
|
|
7
|
+
import sha256Hex from "../utils/sha256-hex.js"
|
|
8
|
+
import stableJsonStringify from "../utils/stable-json.js"
|
|
7
9
|
|
|
8
10
|
import {declaredSyncScopeAttributes} from "./sync-scope-attributes.js"
|
|
9
11
|
import {deliverDeclaredBroadcasts, upsertSyncRow} from "./sync-change-fanout.js"
|
|
@@ -211,7 +213,8 @@ export default class SyncPublisher {
|
|
|
211
213
|
|
|
212
214
|
await record.connection().afterCommit(async () => {
|
|
213
215
|
try {
|
|
214
|
-
const
|
|
216
|
+
const scopeColumnNames = resourceConfig.scopePlan.map(({columnName}) => columnName)
|
|
217
|
+
const syncRow = await this.upsertPublishedSyncRow(attributes, operationScope, scopeColumnNames)
|
|
215
218
|
|
|
216
219
|
await this.broadcaster()({
|
|
217
220
|
body: {
|
|
@@ -250,12 +253,17 @@ export default class SyncPublisher {
|
|
|
250
253
|
const columns = {}
|
|
251
254
|
/** @type {Record<string, string | null>} */
|
|
252
255
|
const params = {}
|
|
256
|
+
const computedScopeAttributes = resourceConfig.scopeAttributesResolver
|
|
257
|
+
? await this.resolveComputedScopeAttributes({record, resourceConfig})
|
|
258
|
+
: null
|
|
253
259
|
|
|
254
260
|
for (const scopePlanEntry of resourceConfig.scopePlan) {
|
|
255
261
|
/** @type {ReturnType<typeof JSON.parse>} */
|
|
256
262
|
let rawValue
|
|
257
263
|
|
|
258
|
-
if (
|
|
264
|
+
if (computedScopeAttributes) {
|
|
265
|
+
rawValue = computedScopeAttributes[scopePlanEntry.scopeAttribute]
|
|
266
|
+
} else if (scopePlanEntry.resolver) {
|
|
259
267
|
rawValue = await scopePlanEntry.resolver(record)
|
|
260
268
|
} else if (scopePlanEntry.recordAttribute) {
|
|
261
269
|
rawValue = record.readAttribute(scopePlanEntry.recordAttribute)
|
|
@@ -272,6 +280,52 @@ export default class SyncPublisher {
|
|
|
272
280
|
return {columns, params}
|
|
273
281
|
}
|
|
274
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Resolves and validates one computed scope-attributes declaration. The
|
|
285
|
+
* resolver runs once per published mutation with the exact connection that
|
|
286
|
+
* owns that mutation; every declared scope value is then reused for row
|
|
287
|
+
* persistence and broadcast routing so those two identities cannot drift.
|
|
288
|
+
* @param {{record: ReturnType<typeof JSON.parse>, resourceConfig: import("./sync-publisher-types.js").SyncPublisherResourceConfig}} args - Record and resource configuration.
|
|
289
|
+
* @returns {Promise<Record<string, string | number | null>>} Complete computed scope values.
|
|
290
|
+
*/
|
|
291
|
+
async resolveComputedScopeAttributes({record, resourceConfig}) {
|
|
292
|
+
const resolver = resourceConfig.scopeAttributesResolver
|
|
293
|
+
|
|
294
|
+
if (!resolver) throw new Error(`No computed scope-attributes resolver configured for ${resourceConfig.resourceType}`)
|
|
295
|
+
|
|
296
|
+
const resolved = await resolver({
|
|
297
|
+
configuration: this.config.configuration,
|
|
298
|
+
connection: record.connection(),
|
|
299
|
+
record
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
if (!isPlainObject(resolved)) {
|
|
303
|
+
throw new Error(`${resourceConfig.resourceType} static sync publish scopeAttributes resolver must resolve to a plain object`)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const declaredAttributes = resourceConfig.scopePlan.map(({scopeAttribute}) => scopeAttribute)
|
|
307
|
+
|
|
308
|
+
for (const scopeAttribute of Object.keys(resolved)) {
|
|
309
|
+
if (!declaredAttributes.includes(scopeAttribute)) {
|
|
310
|
+
throw new Error(`${resourceConfig.resourceType} static sync publish scopeAttributes resolver returned unknown scope attribute: ${scopeAttribute} (the sync model declares: ${declaredAttributes.join(", ")})`)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const scopeAttribute of declaredAttributes) {
|
|
315
|
+
if (!Object.hasOwn(resolved, scopeAttribute)) {
|
|
316
|
+
throw new Error(`${resourceConfig.resourceType} static sync publish scopeAttributes resolver must resolve the declared scope attribute ${scopeAttribute}`)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const value = resolved[scopeAttribute]
|
|
320
|
+
|
|
321
|
+
if (value !== null && typeof value !== "string" && typeof value !== "number") {
|
|
322
|
+
throw new Error(`${resourceConfig.resourceType} static sync publish scope attribute ${scopeAttribute} must be a string, number, or null`)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return /** @type {Record<string, string | number | null>} */ (resolved)
|
|
327
|
+
}
|
|
328
|
+
|
|
275
329
|
/**
|
|
276
330
|
* Builds the framework sync channel entry for one published change: the
|
|
277
331
|
* snapshotted payload plus the persisted sync row's public exact-row metadata
|
|
@@ -311,22 +365,50 @@ export default class SyncPublisher {
|
|
|
311
365
|
/**
|
|
312
366
|
* Upserts the published server-origin sync row for a resource identity:
|
|
313
367
|
* server-origin rows carry a null actor column (no device to echo the
|
|
314
|
-
* change back to), so repeated server changes to one resource
|
|
315
|
-
* re-sequence one feed row.
|
|
368
|
+
* change back to), so repeated server changes to one complete resource and
|
|
369
|
+
* scope identity reuse and re-sequence one feed row. A database advisory
|
|
370
|
+
* lock serializes reconciliation plus the shared upsert because unique
|
|
371
|
+
* constraints containing nullable actor/scope columns do not enforce this
|
|
372
|
+
* identity portably across supported databases. Reconciliation retains the
|
|
373
|
+
* row with the newest feed sequence (then lowest id for a deterministic tie)
|
|
374
|
+
* and removes older matching server-origin rows before applying the current
|
|
375
|
+
* mutation.
|
|
316
376
|
* @param {Record<string, ReturnType<typeof JSON.parse>>} attributes - Snapshotted sync row attributes.
|
|
317
377
|
* @param {ReturnType<typeof JSON.parse>} syncModel - Operation-bound or static Sync model interface.
|
|
378
|
+
* @param {string[]} scopeColumnNames - Persisted scope columns participating in the complete identity.
|
|
318
379
|
* @returns {Promise<ReturnType<typeof JSON.parse>>} Upserted sync row.
|
|
319
380
|
*/
|
|
320
|
-
async upsertPublishedSyncRow(attributes, syncModel = this.config.syncModel) {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
381
|
+
async upsertPublishedSyncRow(attributes, syncModel = this.config.syncModel, scopeColumnNames = []) {
|
|
382
|
+
/** @type {Record<string, ReturnType<typeof JSON.parse>>} */
|
|
383
|
+
const identity = {
|
|
384
|
+
[this.config.actorForeignKeyColumn]: null,
|
|
385
|
+
resource_id: attributes.resource_id,
|
|
386
|
+
resource_type: attributes.resource_type
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const columnName of scopeColumnNames) {
|
|
390
|
+
if (!Object.hasOwn(attributes, columnName)) {
|
|
391
|
+
throw new Error(`Published sync row identity is missing the scope column ${columnName}`)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
identity[columnName] = attributes[columnName]
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return await this.config.syncModel.withAdvisoryLock(syncPublisherIdentityLockName(identity), async () => {
|
|
398
|
+
const matchingSyncs = await syncModel
|
|
399
|
+
.where(identity)
|
|
400
|
+
.toArray()
|
|
328
401
|
|
|
329
|
-
|
|
402
|
+
matchingSyncs.sort(comparePublishedSyncRowsByRecency)
|
|
403
|
+
|
|
404
|
+
const [existingSync, ...duplicateSyncs] = matchingSyncs
|
|
405
|
+
|
|
406
|
+
for (const duplicateSync of duplicateSyncs) {
|
|
407
|
+
await duplicateSync.destroy()
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return await upsertSyncRow({attributes, existingSync, syncModel})
|
|
411
|
+
}, {dedicatedConnection: true})
|
|
330
412
|
}
|
|
331
413
|
|
|
332
414
|
/**
|
|
@@ -383,6 +465,40 @@ export default class SyncPublisher {
|
|
|
383
465
|
}
|
|
384
466
|
}
|
|
385
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Returns a deterministic, MySQL-safe advisory-lock name for one complete
|
|
470
|
+
* server-origin publisher identity. Stable JSON preserves null actor/scope
|
|
471
|
+
* components distinctly from strings, and the truncated SHA-256 digest keeps
|
|
472
|
+
* the final name below MySQL/MariaDB's 64-character `GET_LOCK` limit.
|
|
473
|
+
* @param {Record<string, ReturnType<typeof JSON.parse>>} identity - Complete sync-row identity in column form.
|
|
474
|
+
* @returns {string} Advisory-lock name.
|
|
475
|
+
*/
|
|
476
|
+
function syncPublisherIdentityLockName(identity) {
|
|
477
|
+
const hash = sha256Hex(stableJsonStringify(identity)).slice(0, 32)
|
|
478
|
+
|
|
479
|
+
return `vsp:${hash}`
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Orders matching published rows by the feed's public recency contract so
|
|
484
|
+
* legacy duplicates have one deterministic survivor. Server sequences are
|
|
485
|
+
* positive and monotonic; a legacy null sequence is older than any assigned
|
|
486
|
+
* sequence, and the immutable row id breaks otherwise-equal ties.
|
|
487
|
+
* @param {ReturnType<typeof JSON.parse>} left - First matching sync row.
|
|
488
|
+
* @param {ReturnType<typeof JSON.parse>} right - Second matching sync row.
|
|
489
|
+
* @returns {number} Sort comparison with the canonical survivor first.
|
|
490
|
+
*/
|
|
491
|
+
function comparePublishedSyncRowsByRecency(left, right) {
|
|
492
|
+
const leftSequence = left.serverSequence()
|
|
493
|
+
const rightSequence = right.serverSequence()
|
|
494
|
+
|
|
495
|
+
if (leftSequence === null && rightSequence !== null) return 1
|
|
496
|
+
if (leftSequence !== null && rightSequence === null) return -1
|
|
497
|
+
if (leftSequence !== rightSequence) return rightSequence - leftSequence
|
|
498
|
+
|
|
499
|
+
return left.id().localeCompare(right.id())
|
|
500
|
+
}
|
|
501
|
+
|
|
386
502
|
/**
|
|
387
503
|
* Resolves a model class's active publish declaration from `static sync`.
|
|
388
504
|
* Opted-out (`publish: false`) and undeclared models resolve to null; every
|
|
@@ -434,12 +550,15 @@ function resourceConfigFromPublishDeclaration({modelClass, publish, scopeAttribu
|
|
|
434
550
|
}
|
|
435
551
|
}
|
|
436
552
|
|
|
553
|
+
const scopePlan = scopePlanFor({eventId, modelClass, modelName, scopeAttributes, syncModel, syncScopeAttributes})
|
|
554
|
+
|
|
437
555
|
return {
|
|
438
556
|
broadcasts,
|
|
439
557
|
modelClass,
|
|
440
558
|
operations: operations === undefined ? DEFAULT_PUBLISHED_OPERATIONS : operations,
|
|
441
559
|
resourceType: resourceType === undefined ? modelName : resourceType,
|
|
442
|
-
|
|
560
|
+
scopeAttributesResolver: typeof scopeAttributes === "function" ? scopeAttributes : undefined,
|
|
561
|
+
scopePlan,
|
|
443
562
|
serialize: serialize === undefined ? defaultSerializedAttributes : serialize
|
|
444
563
|
}
|
|
445
564
|
}
|
|
@@ -452,7 +571,7 @@ function resourceConfigFromPublishDeclaration({modelClass, publish, scopeAttribu
|
|
|
452
571
|
* name map), or the record's own id when the model has no such attribute
|
|
453
572
|
* (scope-root models). The deprecated `eventId` declaration forms map to a
|
|
454
573
|
* fixed `eventId`/`event_id` plan for 1.0.503 compatibility.
|
|
455
|
-
* @param {{eventId: import("./sync-publisher-types.js").SyncPublishDeclarationConfig["eventId"], modelClass: ReturnType<typeof JSON.parse>, modelName: string, scopeAttributes:
|
|
574
|
+
* @param {{eventId: import("./sync-publisher-types.js").SyncPublishDeclarationConfig["eventId"], modelClass: ReturnType<typeof JSON.parse>, modelName: string, scopeAttributes: import("./sync-publisher-types.js").SyncPublishDeclarationConfig["scopeAttributes"], syncModel: ReturnType<typeof JSON.parse>, syncScopeAttributes: string[] | null}} args - Declaration and sync-model scope args.
|
|
456
575
|
* @returns {Array<import("./sync-publisher-types.js").SyncPublisherScopePlanEntry>} Derived scope plan.
|
|
457
576
|
*/
|
|
458
577
|
function scopePlanFor({eventId, modelClass, modelName, scopeAttributes, syncModel, syncScopeAttributes}) {
|
|
@@ -481,6 +600,15 @@ function scopePlanFor({eventId, modelClass, modelName, scopeAttributes, syncMode
|
|
|
481
600
|
|
|
482
601
|
if (!syncScopeAttributes) return []
|
|
483
602
|
|
|
603
|
+
if (typeof scopeAttributes === "function") {
|
|
604
|
+
return syncScopeAttributes.map((scopeAttribute) => ({
|
|
605
|
+
columnName: syncScopeColumnName({scopeAttribute, syncModel}),
|
|
606
|
+
recordAttribute: null,
|
|
607
|
+
resolver: undefined,
|
|
608
|
+
scopeAttribute
|
|
609
|
+
}))
|
|
610
|
+
}
|
|
611
|
+
|
|
484
612
|
if (scopeAttributes !== undefined && (typeof scopeAttributes !== "object" || Array.isArray(scopeAttributes))) {
|
|
485
613
|
throw new Error(`${modelName} static sync publish scopeAttributes must be an object mapping scope attributes to record attribute names, got: ${String(scopeAttributes)}`)
|
|
486
614
|
}
|
|
@@ -511,6 +639,19 @@ function scopePlanFor({eventId, modelClass, modelName, scopeAttributes, syncMode
|
|
|
511
639
|
})
|
|
512
640
|
}
|
|
513
641
|
|
|
642
|
+
/**
|
|
643
|
+
* Checks that a computed declaration returned an ordinary key/value object.
|
|
644
|
+
* @param {unknown} value - Resolver result.
|
|
645
|
+
* @returns {value is Record<string, unknown>} Whether the value is a plain object.
|
|
646
|
+
*/
|
|
647
|
+
function isPlainObject(value) {
|
|
648
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
|
649
|
+
|
|
650
|
+
const prototype = Object.getPrototypeOf(value)
|
|
651
|
+
|
|
652
|
+
return prototype === Object.prototype || prototype === null
|
|
653
|
+
}
|
|
654
|
+
|
|
514
655
|
/**
|
|
515
656
|
* Resolves the sync-row column persisting a declared scope attribute.
|
|
516
657
|
* @param {{scopeAttribute: string, syncModel: ReturnType<typeof JSON.parse>}} args - Scope attribute and sync model.
|
|
@@ -52,13 +52,14 @@ export default class SyncRealtimeBridge {
|
|
|
52
52
|
* an active subscription is kept as-is and a concurrent subscribe awaits the
|
|
53
53
|
* in-flight attempt. Call `unsubscribe()` first to change the context.
|
|
54
54
|
* @param {ReturnType<typeof JSON.parse>} [context] - App context passed to the deprecated `sync.client.realtime.channels` callback (runtime scope values).
|
|
55
|
+
* @param {{signal?: AbortSignal}} [options] - Subscription lifecycle options.
|
|
55
56
|
* @returns {Promise<void>}
|
|
56
57
|
*/
|
|
57
|
-
async subscribe(context) {
|
|
58
|
+
async subscribe(context, {signal} = {}) {
|
|
58
59
|
if (this._state === "subscribed") return
|
|
59
60
|
|
|
60
61
|
if (!this._subscribePromise) {
|
|
61
|
-
this._subscribePromise = this._subscribe(context).finally(() => {
|
|
62
|
+
this._subscribePromise = this._subscribe(context, signal).finally(() => {
|
|
62
63
|
this._subscribePromise = null
|
|
63
64
|
})
|
|
64
65
|
}
|
|
@@ -74,9 +75,10 @@ export default class SyncRealtimeBridge {
|
|
|
74
75
|
* is live; an unsubscribe arriving during any await marks this attempt stale
|
|
75
76
|
* and it tears its own resources down instead of resubscribing.
|
|
76
77
|
* @param {ReturnType<typeof JSON.parse>} context - App context passed to the channels callback.
|
|
78
|
+
* @param {AbortSignal | undefined} signal - Subscription lifecycle signal.
|
|
77
79
|
* @returns {Promise<void>}
|
|
78
80
|
*/
|
|
79
|
-
async _subscribe(context) {
|
|
81
|
+
async _subscribe(context, signal) {
|
|
80
82
|
const generation = this._generation
|
|
81
83
|
|
|
82
84
|
this._state = "subscribing"
|
|
@@ -103,10 +105,13 @@ export default class SyncRealtimeBridge {
|
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
try {
|
|
108
|
+
throwIfAborted(signal)
|
|
109
|
+
|
|
106
110
|
const sharedClient = this.syncClient.syncConnection()
|
|
107
111
|
const realtime = this.requireClientSource(sharedClient)
|
|
108
112
|
const channelDescriptors = await this.channelDescriptors(context)
|
|
109
113
|
|
|
114
|
+
throwIfAborted(signal)
|
|
110
115
|
if (generation !== this._generation) return
|
|
111
116
|
|
|
112
117
|
if (sharedClient) {
|
|
@@ -122,8 +127,9 @@ export default class SyncRealtimeBridge {
|
|
|
122
127
|
return
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
await client.connect()
|
|
130
|
+
await client.connect({signal})
|
|
126
131
|
|
|
132
|
+
throwIfAborted(signal)
|
|
127
133
|
if (generation !== this._generation) {
|
|
128
134
|
await teardown()
|
|
129
135
|
return
|
|
@@ -131,6 +137,7 @@ export default class SyncRealtimeBridge {
|
|
|
131
137
|
|
|
132
138
|
const authenticationToken = await this.syncClient.config.authenticationToken()
|
|
133
139
|
|
|
140
|
+
throwIfAborted(signal)
|
|
134
141
|
if (generation !== this._generation) {
|
|
135
142
|
await teardown()
|
|
136
143
|
return
|
|
@@ -148,16 +155,17 @@ export default class SyncRealtimeBridge {
|
|
|
148
155
|
params: {...channelDescriptor.params, authenticationToken}
|
|
149
156
|
})
|
|
150
157
|
const subscription = client.subscribeChannel(channelDescriptor.channel, {
|
|
151
|
-
onMessage: (body) => this.enqueueApply({body, resourceType}),
|
|
152
|
-
onResume: () => this.schedulePull(),
|
|
158
|
+
onMessage: (body) => this.enqueueApply({body, generation, resourceType}),
|
|
159
|
+
onResume: () => this.schedulePull(generation),
|
|
153
160
|
params
|
|
154
161
|
})
|
|
155
162
|
|
|
156
163
|
channels.push({channel: channelDescriptor.channel, resourceType, subscription})
|
|
157
164
|
}
|
|
158
165
|
|
|
159
|
-
await Promise.all(channels.map(({subscription}) => subscription.waitForReady()))
|
|
166
|
+
await Promise.all(channels.map(({subscription}) => subscription.waitForReady({signal})))
|
|
160
167
|
|
|
168
|
+
throwIfAborted(signal)
|
|
161
169
|
if (generation !== this._generation) {
|
|
162
170
|
await teardown()
|
|
163
171
|
return
|
|
@@ -167,7 +175,7 @@ export default class SyncRealtimeBridge {
|
|
|
167
175
|
this._client = client
|
|
168
176
|
this._ownsClient = ownsClient
|
|
169
177
|
this._state = "subscribed"
|
|
170
|
-
this.schedulePull()
|
|
178
|
+
this.schedulePull(generation)
|
|
171
179
|
} catch (error) {
|
|
172
180
|
await teardown()
|
|
173
181
|
|
|
@@ -333,11 +341,13 @@ export default class SyncRealtimeBridge {
|
|
|
333
341
|
/**
|
|
334
342
|
* Chains one pushed message onto the serialized apply queue so changes apply
|
|
335
343
|
* in arrival order; failures go to the sync client's error reporting.
|
|
336
|
-
* @param {{body: ReturnType<typeof JSON.parse>, resourceType: string | null}} args - Message args.
|
|
344
|
+
* @param {{body: ReturnType<typeof JSON.parse>, generation?: number, resourceType: string | null}} args - Message args.
|
|
337
345
|
* @returns {void}
|
|
338
346
|
*/
|
|
339
|
-
enqueueApply({body, resourceType}) {
|
|
347
|
+
enqueueApply({body, generation = this._generation, resourceType}) {
|
|
340
348
|
this._applyPromise = this._applyPromise.then(async () => {
|
|
349
|
+
if (generation !== this._generation) return
|
|
350
|
+
|
|
341
351
|
try {
|
|
342
352
|
await this.applyMessage({body, resourceType})
|
|
343
353
|
} catch (error) {
|
|
@@ -383,19 +393,34 @@ export default class SyncRealtimeBridge {
|
|
|
383
393
|
* Schedules a coalesced background pull closing offline gaps after
|
|
384
394
|
* (re)subscription readiness. Resumes arriving while a pull is already
|
|
385
395
|
* scheduled or in flight coalesce into that pull instead of stacking.
|
|
396
|
+
* @param {number} [generation] - Subscription generation owning the resume/readiness callback.
|
|
386
397
|
* @returns {void}
|
|
387
398
|
*/
|
|
388
|
-
schedulePull() {
|
|
399
|
+
schedulePull(generation = this._generation) {
|
|
400
|
+
if (generation !== this._generation) return
|
|
389
401
|
if (this.realtimeConfiguration()?.pullOnReconnect === false) return
|
|
390
402
|
|
|
391
403
|
this._scheduledPull ||= (async () => {
|
|
392
404
|
try {
|
|
393
405
|
await this.syncClient.pull()
|
|
394
406
|
} catch (error) {
|
|
395
|
-
this.syncClient.
|
|
407
|
+
if (!this.syncClient.isLifecycleAbort(error)) {
|
|
408
|
+
this.syncClient.reportError(/** @type {Error} */ (error))
|
|
409
|
+
}
|
|
396
410
|
} finally {
|
|
397
411
|
this._scheduledPull = null
|
|
398
412
|
}
|
|
399
413
|
})()
|
|
400
414
|
}
|
|
401
415
|
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Throws the lifecycle abort reason when the subscription is no longer owned.
|
|
419
|
+
* @param {AbortSignal | undefined} signal - Subscription lifecycle signal.
|
|
420
|
+
* @returns {void}
|
|
421
|
+
*/
|
|
422
|
+
function throwIfAborted(signal) {
|
|
423
|
+
if (!signal?.aborted) return
|
|
424
|
+
|
|
425
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("Sync realtime subscription was aborted")
|
|
426
|
+
}
|
|
@@ -237,13 +237,15 @@ export default class SyncScopeStore {
|
|
|
237
237
|
await this.ensureReady()
|
|
238
238
|
|
|
239
239
|
if (this._usesMemoryStorage()) {
|
|
240
|
-
|
|
240
|
+
const currentScope = this._memoryScopes.get(scopeRow.scopeDigest)
|
|
241
|
+
|
|
242
|
+
return currentScope?.id === scopeRow.id ? currentScope.cursorPayload : null
|
|
241
243
|
}
|
|
242
244
|
|
|
243
245
|
return await this._withDb(async (db) => {
|
|
244
246
|
const row = await this._rowByScopeDigest(db, scopeRow.scopeDigest)
|
|
245
247
|
|
|
246
|
-
return row ? row.cursorPayload : null
|
|
248
|
+
return row?.id === scopeRow.id ? row.cursorPayload : null
|
|
247
249
|
})
|
|
248
250
|
}
|
|
249
251
|
|
|
@@ -265,6 +267,7 @@ export default class SyncScopeStore {
|
|
|
265
267
|
const memoryScope = this._memoryScopes.get(scopeRow.scopeDigest)
|
|
266
268
|
|
|
267
269
|
if (!memoryScope) throw new Error(`No sync scope found for: ${scopeRow.scopeDigest}`)
|
|
270
|
+
if (memoryScope.id !== scopeRow.id || memoryScope.state !== "active") return
|
|
268
271
|
|
|
269
272
|
memoryScope.cursorPayload = cursorPayload
|
|
270
273
|
return
|
|
@@ -272,7 +275,7 @@ export default class SyncScopeStore {
|
|
|
272
275
|
|
|
273
276
|
await this._withDb(async (db) => {
|
|
274
277
|
await db.update({
|
|
275
|
-
conditions: {scope_digest: scopeRow.scopeDigest},
|
|
278
|
+
conditions: {id: scopeRow.id, scope_digest: scopeRow.scopeDigest, state: "active"},
|
|
276
279
|
data: {cursor_json: cursorPayload, updated_at: new Date()},
|
|
277
280
|
tableName: TABLE_NAME
|
|
278
281
|
})
|
|
@@ -302,6 +305,79 @@ export default class SyncScopeStore {
|
|
|
302
305
|
})
|
|
303
306
|
}
|
|
304
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Atomically deactivates selected scopes, clears their cursors and rotates
|
|
310
|
+
* their row identities. A cursor save carrying a pre-reset row identity can
|
|
311
|
+
* therefore never repopulate the reset scope. The optional cleanup hook runs
|
|
312
|
+
* inside the same database transaction, allowing an app to purge the local
|
|
313
|
+
* rows owned by those scopes without a cursor/cache split.
|
|
314
|
+
* @param {import("./sync-client-types.js").SerializedSyncScope[]} scopes - Selected scopes to reset.
|
|
315
|
+
* @param {object} [options] - Reset options.
|
|
316
|
+
* @param {(args: {connection: import("../database/drivers/base.js").default | null, scopes: import("./sync-client-types.js").SerializedSyncScope[]}) => Promise<void> | void} [options.cleanup] - App-owned local-row cleanup hook.
|
|
317
|
+
* @returns {Promise<void>}
|
|
318
|
+
*/
|
|
319
|
+
async reset(scopes, {cleanup} = {}) {
|
|
320
|
+
if (!Array.isArray(scopes) || scopes.length === 0) throw new Error("SyncScopeStore.reset requires at least one serialized scope")
|
|
321
|
+
if (cleanup !== undefined && typeof cleanup !== "function") throw new Error("SyncScopeStore.reset cleanup must be a function")
|
|
322
|
+
|
|
323
|
+
await this.ensureReady()
|
|
324
|
+
|
|
325
|
+
const uniqueScopes = [...new Map(scopes.map((scope) => [scopeDigestForScope(scope), scope])).values()]
|
|
326
|
+
|
|
327
|
+
if (this._usesMemoryStorage()) {
|
|
328
|
+
if (cleanup) await cleanup({connection: null, scopes: uniqueScopes})
|
|
329
|
+
|
|
330
|
+
for (const scope of uniqueScopes) this._resetMemoryScope(scope)
|
|
331
|
+
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await this._withDb(async (db) => {
|
|
336
|
+
await db.transaction(async () => {
|
|
337
|
+
if (cleanup) await cleanup({connection: db, scopes: uniqueScopes})
|
|
338
|
+
|
|
339
|
+
for (const scope of uniqueScopes) await this._resetDatabaseScope({db, scope})
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Resets one memory-backed scope while preserving repeated-reset idempotence.
|
|
346
|
+
* @param {import("./sync-client-types.js").SerializedSyncScope} scope - Scope to reset.
|
|
347
|
+
* @returns {void}
|
|
348
|
+
*/
|
|
349
|
+
_resetMemoryScope(scope) {
|
|
350
|
+
const digest = scopeDigestForScope(scope)
|
|
351
|
+
const memoryScope = this._memoryScopes.get(digest)
|
|
352
|
+
|
|
353
|
+
if (!memoryScope || (memoryScope.state === "removed" && memoryScope.cursorPayload === null)) return
|
|
354
|
+
|
|
355
|
+
this._memoryScopes.set(digest, {
|
|
356
|
+
...memoryScope,
|
|
357
|
+
cursorPayload: null,
|
|
358
|
+
id: new UUID(4).format(),
|
|
359
|
+
state: "removed"
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Resets one database-backed scope while preserving repeated-reset idempotence.
|
|
365
|
+
* @param {{db: import("../database/drivers/base.js").default, scope: import("./sync-client-types.js").SerializedSyncScope}} args - Database and scope.
|
|
366
|
+
* @returns {Promise<void>}
|
|
367
|
+
*/
|
|
368
|
+
async _resetDatabaseScope({db, scope}) {
|
|
369
|
+
const digest = scopeDigestForScope(scope)
|
|
370
|
+
const existingRow = await this._rowByScopeDigest(db, digest)
|
|
371
|
+
|
|
372
|
+
if (!existingRow || (existingRow.state === "removed" && existingRow.cursorPayload === null)) return
|
|
373
|
+
|
|
374
|
+
await db.update({
|
|
375
|
+
conditions: {id: existingRow.id, scope_digest: digest},
|
|
376
|
+
data: {cursor_json: null, id: new UUID(4).format(), state: "removed", updated_at: new Date()},
|
|
377
|
+
tableName: TABLE_NAME
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
305
381
|
/**
|
|
306
382
|
* Whether the store runs without a configured database.
|
|
307
383
|
* @returns {boolean} Whether memory storage is used.
|
package/package.json
CHANGED
|
@@ -66,18 +66,44 @@ export default class BackgroundJobsClient {
|
|
|
66
66
|
*/
|
|
67
67
|
const enqueueAttempt = async (attemptAcknowledgement) => {
|
|
68
68
|
const request = await this._request()
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
const requestAbortController = new AbortController()
|
|
70
|
+
const timeoutErrorMessage = `Background job enqueue acknowledgement timed out after ${this.enqueueTimeoutMs}ms`
|
|
71
|
+
/**
|
|
72
|
+
* Resolves the pre-send phase when the mutation has entered the socket.
|
|
73
|
+
* @type {() => void}
|
|
74
|
+
*/
|
|
75
|
+
let markRequestSent = () => {}
|
|
76
|
+
const requestSent = new Promise((resolve) => {
|
|
77
|
+
markRequestSent = () => resolve(undefined)
|
|
78
|
+
})
|
|
79
|
+
/**
|
|
80
|
+
* Applies the configured deadline independently to one request phase.
|
|
81
|
+
* @template T
|
|
82
|
+
* @param {() => Promise<T>} callback - Phase work.
|
|
83
|
+
* @returns {Promise<T>} - Phase result.
|
|
84
|
+
*/
|
|
85
|
+
const withEnqueueTimeout = async (callback) => await timeout({
|
|
86
|
+
errorMessage: timeoutErrorMessage,
|
|
72
87
|
timeout: this.enqueueTimeoutMs
|
|
73
|
-
}, async ({control}) =>
|
|
74
|
-
|
|
88
|
+
}, async ({control}) => {
|
|
89
|
+
const abortRequest = () => requestAbortController.abort(control.signal.reason)
|
|
90
|
+
|
|
91
|
+
control.signal.addEventListener("abort", abortRequest)
|
|
92
|
+
try {
|
|
93
|
+
return await callback()
|
|
94
|
+
} finally {
|
|
95
|
+
control.signal.removeEventListener("abort", abortRequest)
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
const requestPromise = request.run({
|
|
99
|
+
signal: requestAbortController.signal,
|
|
75
100
|
onConnect: (jsonSocket) => {
|
|
76
101
|
jsonSocket.send(message)
|
|
77
102
|
if (attemptAcknowledgement) {
|
|
78
103
|
attemptAcknowledgement.generationFenced = Boolean(request.generationId)
|
|
79
104
|
attemptAcknowledgement.requestSent = true
|
|
80
105
|
}
|
|
106
|
+
markRequestSent()
|
|
81
107
|
},
|
|
82
108
|
onMessage: ({message, resolve, reject}) => {
|
|
83
109
|
if (message?.type === "enqueued") {
|
|
@@ -90,7 +116,11 @@ export default class BackgroundJobsClient {
|
|
|
90
116
|
reject(new Error(message.error || "Failed to enqueue job"))
|
|
91
117
|
}
|
|
92
118
|
}
|
|
93
|
-
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
await withEnqueueTimeout(async () => await Promise.race([requestSent, requestPromise]))
|
|
122
|
+
|
|
123
|
+
return await withEnqueueTimeout(async () => await requestPromise)
|
|
94
124
|
}
|
|
95
125
|
|
|
96
126
|
try {
|
|
@@ -501,14 +501,14 @@
|
|
|
501
501
|
* Client-side sync transport owning HTTP POSTs to the framework sync endpoints,
|
|
502
502
|
* matching the frontend-model websocket client post contract.
|
|
503
503
|
* @typedef {object} VelociousSyncClientTransport
|
|
504
|
-
* @property {(path: string, body?: ReturnType<typeof JSON.parse>, options?: {headers?: Record<string, string
|
|
504
|
+
* @property {(path: string, body?: ReturnType<typeof JSON.parse>, options?: {headers?: Record<string, string>, signal?: AbortSignal}) => Promise<{json: () => ReturnType<typeof JSON.parse>}>} post - Posts one request and resolves a response with a json accessor.
|
|
505
505
|
*/
|
|
506
506
|
|
|
507
507
|
/**
|
|
508
508
|
* Websocket client contract required from `sync.client.realtime.createClient`,
|
|
509
509
|
* matching `VelociousWebsocketClient` / snapreq's websocket client.
|
|
510
510
|
* @typedef {object} VelociousSyncRealtimeWebsocketClient
|
|
511
|
-
* @property {() => Promise<ReturnType<typeof JSON.parse>>} connect - Connects the websocket.
|
|
511
|
+
* @property {(options?: {signal?: AbortSignal}) => Promise<ReturnType<typeof JSON.parse>>} connect - Connects the websocket.
|
|
512
512
|
* @property {(channelType: string, options?: {params?: Record<string, ReturnType<typeof JSON.parse>>, onMessage?: (body: ReturnType<typeof JSON.parse>) => void, onResume?: () => void, onClose?: (reason: string) => void}) => VelociousSyncRealtimeSubscription} subscribeChannel - Opens one channel subscription.
|
|
513
513
|
* @property {() => Promise<void>} disconnectAndStopReconnect - Closes the socket and stops auto-reconnect.
|
|
514
514
|
*/
|
|
@@ -518,7 +518,7 @@
|
|
|
518
518
|
* @typedef {object} VelociousSyncRealtimeSubscription
|
|
519
519
|
* @property {() => void} close - Closes the subscription.
|
|
520
520
|
* @property {() => boolean} isReady - Whether the subscription is acknowledged and ready.
|
|
521
|
-
* @property {(params?: {timeoutMs?: number}) => Promise<void>} waitForReady - Resolves once the server acknowledges the subscription.
|
|
521
|
+
* @property {(params?: {signal?: AbortSignal, timeoutMs?: number}) => Promise<void>} waitForReady - Resolves once the server acknowledges the subscription.
|
|
522
522
|
*/
|
|
523
523
|
|
|
524
524
|
/**
|
|
@@ -32,21 +32,30 @@ export default class VelociousDatabaseQueryAlterTableBase extends QueryBase {
|
|
|
32
32
|
|
|
33
33
|
let sql = `ALTER TABLE ${options.quoteTableName(tableData.getName())} `
|
|
34
34
|
let actionCount = 0
|
|
35
|
+
let previousActionWasMssqlColumnAdd = false
|
|
35
36
|
|
|
36
37
|
for (const column of tableData.getColumns()) {
|
|
37
38
|
if (actionCount > 0) sql += ", "
|
|
38
39
|
|
|
39
40
|
if (column.isNewColumn()) {
|
|
40
|
-
|
|
41
|
+
if (databaseType != "mssql") {
|
|
42
|
+
sql += "ADD COLUMN "
|
|
43
|
+
} else if (!previousActionWasMssqlColumnAdd) {
|
|
44
|
+
sql += "ADD "
|
|
45
|
+
}
|
|
46
|
+
|
|
41
47
|
sql += column.getSQL({driver: this.getDriver(), forAlterTable: false})
|
|
48
|
+
previousActionWasMssqlColumnAdd = databaseType == "mssql"
|
|
42
49
|
} else if (column.getNewName()) {
|
|
43
50
|
const newColumnName = column.getNewName()
|
|
44
51
|
|
|
45
52
|
if (!newColumnName) throw new Error(`Expected new column name for ${column.getName()}`)
|
|
46
53
|
|
|
47
54
|
sql += `RENAME COLUMN ${options.quoteColumnName(column.getName())} TO ${options.quoteColumnName(newColumnName)}`
|
|
55
|
+
previousActionWasMssqlColumnAdd = false
|
|
48
56
|
} else if (column.getDropColumn()) {
|
|
49
57
|
sql += `DROP COLUMN ${options.quoteColumnName(column.getName())}`
|
|
58
|
+
previousActionWasMssqlColumnAdd = false
|
|
50
59
|
} else {
|
|
51
60
|
if (databaseType == "mssql" || databaseType == "pgsql") {
|
|
52
61
|
sql += "ALTER COLUMN "
|
|
@@ -55,6 +64,7 @@ export default class VelociousDatabaseQueryAlterTableBase extends QueryBase {
|
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
sql += column.getSQL({driver: this.getDriver(), forAlterTable: true})
|
|
67
|
+
previousActionWasMssqlColumnAdd = false
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
|