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.
Files changed (50) hide show
  1. package/README.md +2 -2
  2. package/build/background-jobs/client.js +36 -6
  3. package/build/configuration-types.js +3 -3
  4. package/build/database/query/alter-table-base.js +11 -1
  5. package/build/src/background-jobs/client.d.ts.map +1 -1
  6. package/build/src/background-jobs/client.js +35 -6
  7. package/build/src/configuration-types.d.ts +8 -4
  8. package/build/src/configuration-types.d.ts.map +1 -1
  9. package/build/src/configuration-types.js +4 -4
  10. package/build/src/database/query/alter-table-base.d.ts.map +1 -1
  11. package/build/src/database/query/alter-table-base.js +12 -2
  12. package/build/src/sync/sync-api-client.d.ts +36 -10
  13. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  14. package/build/src/sync/sync-api-client.js +58 -14
  15. package/build/src/sync/sync-client-types.d.ts +41 -2
  16. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  17. package/build/src/sync/sync-client-types.js +20 -3
  18. package/build/src/sync/sync-client.d.ts +112 -3
  19. package/build/src/sync/sync-client.d.ts.map +1 -1
  20. package/build/src/sync/sync-client.js +306 -60
  21. package/build/src/sync/sync-publisher-types.d.ts +36 -3
  22. package/build/src/sync/sync-publisher-types.d.ts.map +1 -1
  23. package/build/src/sync/sync-publisher-types.js +16 -2
  24. package/build/src/sync/sync-publisher.d.ts +22 -3
  25. package/build/src/sync/sync-publisher.d.ts.map +1 -1
  26. package/build/src/sync/sync-publisher.js +134 -13
  27. package/build/src/sync/sync-realtime-bridge.d.ts +11 -5
  28. package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
  29. package/build/src/sync/sync-realtime-bridge.js +37 -13
  30. package/build/src/sync/sync-scope-store.d.ts +32 -0
  31. package/build/src/sync/sync-scope-store.d.ts.map +1 -1
  32. package/build/src/sync/sync-scope-store.js +74 -4
  33. package/build/sync/sync-api-client.js +58 -13
  34. package/build/sync/sync-client-types.js +22 -2
  35. package/build/sync/sync-client.js +333 -63
  36. package/build/sync/sync-publisher-types.js +17 -1
  37. package/build/sync/sync-publisher.js +156 -15
  38. package/build/sync/sync-realtime-bridge.js +37 -12
  39. package/build/sync/sync-scope-store.js +79 -3
  40. package/package.json +1 -1
  41. package/src/background-jobs/client.js +36 -6
  42. package/src/configuration-types.js +3 -3
  43. package/src/database/query/alter-table-base.js +11 -1
  44. package/src/sync/sync-api-client.js +58 -13
  45. package/src/sync/sync-client-types.js +22 -2
  46. package/src/sync/sync-client.js +333 -63
  47. package/src/sync/sync-publisher-types.js +17 -1
  48. package/src/sync/sync-publisher.js +156 -15
  49. package/src/sync/sync-realtime-bridge.js +37 -12
  50. 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 syncRow = await this.upsertPublishedSyncRow(attributes, operationScope)
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 (scopePlanEntry.resolver) {
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 reuse and
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
- const existingSync = await syncModel
322
- .where({
323
- [this.config.actorForeignKeyColumn]: null,
324
- resource_id: attributes.resource_id,
325
- resource_type: attributes.resource_type
326
- })
327
- .first()
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
- return await upsertSyncRow({attributes, existingSync, syncModel})
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
- scopePlan: scopePlanFor({eventId, modelClass, modelName, scopeAttributes, syncModel, syncScopeAttributes}),
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: Record<string, string> | undefined, syncModel: ReturnType<typeof JSON.parse>, syncScopeAttributes: string[] | null}} args - Declaration and sync-model scope args.
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.reportError(/** @type {Error} */ (error))
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
- return this._memoryScopes.get(scopeRow.scopeDigest)?.cursorPayload ?? null
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
@@ -3,7 +3,7 @@
3
3
  "velocious": "build/bin/velocious.js"
4
4
  },
5
5
  "name": "velocious",
6
- "version": "1.0.663",
6
+ "version": "1.0.665",
7
7
  "main": "build/index.js",
8
8
  "types": "build/index.d.ts",
9
9
  "files": [
@@ -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
- return await timeout({
71
- errorMessage: `Background job enqueue acknowledgement timed out after ${this.enqueueTimeoutMs}ms`,
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}) => await request.run({
74
- signal: control.signal,
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>}) => Promise<{json: () => ReturnType<typeof JSON.parse>}>} post - Posts one request and resolves a response with a json accessor.
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
- sql += databaseType == "mssql" ? "ADD " : "ADD COLUMN "
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