velocious 1.0.501 → 1.0.503

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 (46) hide show
  1. package/build/application.js +2 -0
  2. package/build/src/application.d.ts.map +1 -1
  3. package/build/src/application.js +3 -1
  4. package/build/src/sync/sync-change-fanout.d.ts +71 -0
  5. package/build/src/sync/sync-change-fanout.d.ts.map +1 -0
  6. package/build/src/sync/sync-change-fanout.js +54 -0
  7. package/build/src/sync/sync-client-types.d.ts +6 -2
  8. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  9. package/build/src/sync/sync-client-types.js +1 -1
  10. package/build/src/sync/sync-client.d.ts +71 -4
  11. package/build/src/sync/sync-client.d.ts.map +1 -1
  12. package/build/src/sync/sync-client.js +126 -18
  13. package/build/src/sync/sync-envelope-replay-service.d.ts +15 -4
  14. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  15. package/build/src/sync/sync-envelope-replay-service.js +60 -42
  16. package/build/src/sync/sync-publish-suppression.d.ts +31 -0
  17. package/build/src/sync/sync-publish-suppression.d.ts.map +1 -0
  18. package/build/src/sync/sync-publish-suppression.js +54 -0
  19. package/build/src/sync/sync-publisher-types.d.ts +146 -0
  20. package/build/src/sync/sync-publisher-types.d.ts.map +1 -0
  21. package/build/src/sync/sync-publisher-types.js +3 -0
  22. package/build/src/sync/sync-publisher.d.ts +137 -0
  23. package/build/src/sync/sync-publisher.d.ts.map +1 -0
  24. package/build/src/sync/sync-publisher.js +305 -0
  25. package/build/src/testing/test-runner.d.ts +8 -0
  26. package/build/src/testing/test-runner.d.ts.map +1 -1
  27. package/build/src/testing/test-runner.js +64 -2
  28. package/build/sync/sync-change-fanout.js +58 -0
  29. package/build/sync/sync-client-types.js +3 -2
  30. package/build/sync/sync-client.js +136 -18
  31. package/build/sync/sync-envelope-replay-service.js +60 -41
  32. package/build/sync/sync-publish-suppression.js +59 -0
  33. package/build/sync/sync-publisher-types.js +64 -0
  34. package/build/sync/sync-publisher.js +351 -0
  35. package/build/testing/test-runner.js +69 -1
  36. package/build/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +1 -1
  38. package/src/application.js +2 -0
  39. package/src/sync/sync-change-fanout.js +58 -0
  40. package/src/sync/sync-client-types.js +3 -2
  41. package/src/sync/sync-client.js +136 -18
  42. package/src/sync/sync-envelope-replay-service.js +60 -41
  43. package/src/sync/sync-publish-suppression.js +59 -0
  44. package/src/sync/sync-publisher-types.js +64 -0
  45. package/src/sync/sync-publisher.js +351 -0
  46. package/src/testing/test-runner.js +69 -1
@@ -0,0 +1,58 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * One declarative broadcast delivered through an injected broadcaster. The
5
+ * replay service resolves it with replay args ({actor, applyResult, mutation,
6
+ * ...}) and the sync publisher with publish args
7
+ * ({@link import("./sync-publisher-types.js").SyncPublishBroadcastArgs}).
8
+ * @template [Args=Record<string, ?>]
9
+ * @typedef {object} DeclaredBroadcast
10
+ * @property {string | ((args: Args) => string)} channel - Channel name or resolver.
11
+ * @property {(args: Args) => Record<string, ?>} broadcastParams - Channel routing params.
12
+ * @property {(args: Args) => ?} body - Broadcast body.
13
+ * @property {(args: Args) => boolean} [when] - Optional gate; skipped when it returns false.
14
+ */
15
+
16
+ /**
17
+ * Upserts one sync/change row through the shared sync-model contract: an
18
+ * existing row for the resource identity is reassigned and re-sequenced
19
+ * through `advanceServerSequence()` (the change-feed sequence contract) so
20
+ * feed cursors pick the change up again; otherwise a new row is created
21
+ * (creates allocate their sequence through the model's own hooks). Shared by
22
+ * the replay service's model-backed persistence and the server sync
23
+ * publisher.
24
+ * @param {{attributes: Record<string, ?>, existingSync: ?, syncModel: ?}} args - Row attributes, existing row for the resource identity, and the sync model.
25
+ * @returns {Promise<?>} Upserted sync row.
26
+ */
27
+ export async function upsertSyncRow({attributes, existingSync, syncModel}) {
28
+ if (existingSync) {
29
+ existingSync.assign(attributes)
30
+ await existingSync.advanceServerSequence()
31
+ await existingSync.save()
32
+
33
+ return existingSync
34
+ }
35
+
36
+ return await syncModel.create(attributes)
37
+ }
38
+
39
+ /**
40
+ * Delivers declarative broadcasts through an injected broadcaster: each
41
+ * broadcast's `when` gate is checked, then channel/params/body are resolved
42
+ * from the caller's args. Shared by the replay service's default
43
+ * afterReplayMutation and the server sync publisher.
44
+ * @template Args
45
+ * @param {{args: Args, broadcaster: (broadcast: {channel: string, params: Record<string, ?>, body: ?}) => Promise<void>, broadcasts: Array<DeclaredBroadcast<Args>>}} deliveryArgs - Broadcast resolver args, broadcaster, and declared broadcasts.
46
+ * @returns {Promise<void>}
47
+ */
48
+ export async function deliverDeclaredBroadcasts({args, broadcaster, broadcasts}) {
49
+ for (const broadcast of broadcasts) {
50
+ if (broadcast.when && !broadcast.when(args)) continue
51
+
52
+ await broadcaster({
53
+ body: broadcast.body(args),
54
+ channel: typeof broadcast.channel === "function" ? broadcast.channel(args) : broadcast.channel,
55
+ params: broadcast.broadcastParams(args)
56
+ })
57
+ }
58
+ }
@@ -28,7 +28,7 @@
28
28
  * @property {string[]} [localOnlyAttributes] - Attributes stripped from queued payloads.
29
29
  * @property {"upsert" | ((args: {operation: "create" | "update" | "destroy", record: ?}) => string)} [syncType] - Maps a mutation operation to a sync type. The "upsert" flag queues creates and updates as "update" rows (the server upserts by resource id) and destroys as "delete". Defaults to the operation name with destroy mapped to "delete".
30
30
  * @property {(args: {operation: "create" | "update" | "destroy", record: ?}) => Record<string, ?>} [trackedData] - Custom queued-payload builder for tracked mutations.
31
- * @property {boolean | {operations: Array<"create" | "update" | "destroy">}} [track] - Enables automatic mutation tracking through model lifecycle callbacks.
31
+ * @property {boolean | {operations: Array<"create" | "update" | "destroy">}} [track] - Automatic mutation tracking policy. On by default (creates and updates queue automatically); `false` opts the resource out, `true` adds destroys, `{operations}` narrows the tracked operations.
32
32
  * @property {ModelSyncRealtimeDeclaration} [realtime] - Static realtime channel this resource subscribes through `subscribeRealtime(...)`.
33
33
  */
34
34
 
@@ -43,8 +43,9 @@
43
43
  * @property {import("./sync-api-client-types.js").SyncResourceConfig["findRecord"]} [findRecord] - Custom pull-apply record resolver.
44
44
  * @property {import("./sync-api-client-types.js").SyncResourceConfig["findRecordForDelete"]} [findRecordForDelete] - Custom pull-apply delete resolver.
45
45
  * @property {string[]} [localOnlyAttributes] - Extra local-only attributes merged with the derived primary key, createdAt/updatedAt, and sync bookkeeping attributes.
46
+ * @property {import("./sync-publisher-types.js").SyncPublishDeclaration} [publish] - Server-side publish declaration consumed by `SyncPublisher.fromConfiguration(...)` on the backend; ignored by the client.
46
47
  * @property {"upsert" | ((args: {operation: "create" | "update" | "destroy", record: ?}) => string)} [syncType] - Sync type flag or mapper (see SyncClientResourceConfig).
47
- * @property {boolean | Array<"create" | "update" | "destroy"> | {operations: Array<"create" | "update" | "destroy">}} [track] - Enables automatic mutation tracking; an array is shorthand for {operations}.
48
+ * @property {boolean | Array<"create" | "update" | "destroy"> | {operations: Array<"create" | "update" | "destroy">}} [track] - Automatic mutation tracking policy; an array is shorthand for {operations}. On by default (creates and updates queue automatically); `false` opts the model out (for models written by non-user flows), `true` adds destroys.
48
49
  * @property {(args: {operation: "create" | "update" | "destroy", record: ?}) => Record<string, ?>} [trackedData] - Custom queued-payload builder for tracked mutations.
49
50
  * @property {ModelSyncRealtimeDeclaration} [realtime] - Static realtime channel this resource subscribes through `subscribeRealtime(...)`; use the `sync.client.realtime.channels` callback for channels needing runtime params.
50
51
  */
@@ -2,6 +2,7 @@
2
2
 
3
3
  import Configuration from "../configuration.js"
4
4
  import {isBooleanColumnType} from "../database/column-types.js"
5
+ import Logger from "../logger.js"
5
6
  import restArgsError from "../utils/rest-args-error.js"
6
7
 
7
8
  import {serializedScopeFromQuery} from "./query-scope.js"
@@ -15,6 +16,14 @@ let clientCounter = 0
15
16
  /** @type {{create: "afterCreate", update: "afterUpdate", destroy: "afterDestroy"}} */
16
17
  const TRACKED_CALLBACK_NAMES = {create: "afterCreate", destroy: "afterDestroy", update: "afterUpdate"}
17
18
 
19
+ /**
20
+ * Operations tracked by default for models declaring `static sync` without a
21
+ * `track` key: local creates and updates queue automatically. Destroys are not
22
+ * tracked by default because a local destroy is often cache eviction rather
23
+ * than a server delete; opt in with `track: true` or an operations list.
24
+ * @type {Array<"create" | "update" | "destroy">} */
25
+ const DEFAULT_TRACKED_OPERATIONS = ["create", "update"]
26
+
18
27
  /** Attribute names treated as client-local sync bookkeeping when deriving localOnlyAttributes. */
19
28
  const LOCAL_BOOKKEEPING_ATTRIBUTE_NAMES = ["createdAt", "updatedAt", "lastSyncChangeAt"]
20
29
 
@@ -105,13 +114,18 @@ export default class SyncClient {
105
114
  this._trackedCallbacks = []
106
115
  /** @type {WeakSet<object>} */
107
116
  this._remoteApplyRecords = new WeakSet()
117
+ this._withoutTrackingDepth = 0
118
+ /** @type {Logger | {error: (...messages: Array<?>) => Promise<void>} | null} */
119
+ this._logger = null
108
120
  this._started = false
109
121
  }
110
122
 
111
123
  /**
112
- * Registers automatic mutation tracking for every resource with `track` enabled:
113
- * local model creates/updates/destroys queue pending sync rows and schedule an
114
- * immediate replay attempt, without app-side queue calls.
124
+ * Registers automatic mutation tracking for every declared resource (on by
125
+ * default: local creates and updates queue pending sync rows once their
126
+ * transaction commits and schedule an immediate replay attempt, without
127
+ * app-side queue calls). `track: false` resources are skipped; `track: true`
128
+ * adds destroys; an operations list narrows the tracked operations.
115
129
  * @returns {Promise<void>}
116
130
  */
117
131
  async start() {
@@ -147,13 +161,17 @@ export default class SyncClient {
147
161
 
148
162
  /**
149
163
  * Resolves and validates the tracked operations for a resource config.
164
+ * Tracking is on by default: models declaring `static sync` without a `track`
165
+ * key queue local creates and updates automatically; `track: false` opts a
166
+ * model out (for models written by non-user flows).
150
167
  * @param {{resourceConfig: import("./sync-client-types.js").SyncClientResourceConfig, resourceType: string}} args - Resource config and name.
151
168
  * @returns {Array<"create" | "update" | "destroy">} Tracked operations.
152
169
  */
153
170
  trackedOperations({resourceConfig, resourceType}) {
154
171
  const track = resourceConfig.track
155
172
 
156
- if (track === undefined || track === false) return []
173
+ if (track === false) return []
174
+ if (track === undefined) return DEFAULT_TRACKED_OPERATIONS
157
175
  if (track === true) return ["create", "update", "destroy"]
158
176
 
159
177
  if (!track || typeof track !== "object" || !Array.isArray(track.operations) || track.operations.length === 0) {
@@ -170,25 +188,76 @@ export default class SyncClient {
170
188
  }
171
189
 
172
190
  /**
173
- * Builds the lifecycle callback queueing one tracked mutation.
191
+ * Builds the lifecycle callback queueing one tracked mutation. The queued
192
+ * payload and sync type are snapshotted at mutation-callback time, so
193
+ * afterSave hooks assigning unsaved attributes (or any later drift on the
194
+ * record) cannot change what gets queued vs what was committed. Queueing is
195
+ * deferred through the model connection's afterCommit hook so it only runs
196
+ * once the mutation's transaction has committed (immediately when no
197
+ * transaction is open) - queued syncs never reference rolled-back rows.
198
+ * Post-commit queue failures are reported without rethrowing into the
199
+ * driver's afterCommit chain (see reportAfterCommitError).
174
200
  * @param {{operation: "create" | "update" | "destroy", resourceConfig: import("./sync-client-types.js").SyncClientResourceConfig}} args - Operation and resource config.
175
201
  * @returns {(record: ?) => Promise<void>} Lifecycle callback.
176
202
  */
177
203
  trackedMutationCallback({operation, resourceConfig}) {
178
204
  return async (record) => {
179
- if (this.isRemoteApply(record)) return
205
+ if (this.isTrackingSuppressed(record)) return
180
206
 
181
- await SyncApiClient.queueLocalSync({
207
+ const data = SyncApiClient.queuedSyncData({
182
208
  booleanAttributes: resourceConfig.booleanAttributes || [],
183
209
  data: resourceConfig.trackedData ? resourceConfig.trackedData({operation, record}) : undefined,
184
210
  localOnlyAttributes: resourceConfig.localOnlyAttributes || [],
185
- resource: record,
186
- syncModel: this.config.syncModel,
187
- syncType: this.defaultSyncType({operation, record, resourceConfig})
211
+ resource: record
212
+ })
213
+ const syncType = this.defaultSyncType({operation, record, resourceConfig})
214
+
215
+ await resourceConfig.modelClass.connection().afterCommit(async () => {
216
+ try {
217
+ await SyncApiClient.queueLocalSync({
218
+ data,
219
+ resource: record,
220
+ syncModel: this.config.syncModel,
221
+ syncType
222
+ })
223
+ } catch (error) {
224
+ await this.reportAfterCommitError(/** @type {Error} */ (error))
225
+
226
+ return
227
+ }
228
+
229
+ this.scheduleReplay()
188
230
  })
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Reports a post-commit tracked-queueing failure. The transaction has already
236
+ * committed when afterCommit callbacks run, so rethrowing here would poison
237
+ * the driver's awaited afterCommit chain (breaking unrelated callbacks) -
238
+ * instead the failure goes to the configured sync.client.onError hook, or is
239
+ * logged loudly through the client's logger when none is configured.
240
+ * @param {Error} error - Post-commit queueing failure.
241
+ * @returns {Promise<void>}
242
+ */
243
+ async reportAfterCommitError(error) {
244
+ if (this.config.onError) {
245
+ this.config.onError(error)
189
246
 
190
- this.scheduleReplay()
247
+ return
191
248
  }
249
+
250
+ await this.logger().error("SyncClient failed to queue a tracked mutation after commit", error)
251
+ }
252
+
253
+ /**
254
+ * Returns the lazily built client logger.
255
+ * @returns {Logger | {error: (...messages: Array<?>) => Promise<void>}} Client logger.
256
+ */
257
+ logger() {
258
+ this._logger ||= new Logger("SyncClient", {configuration: this.config.configuration})
259
+
260
+ return this._logger
192
261
  }
193
262
 
194
263
  /**
@@ -200,6 +269,54 @@ export default class SyncClient {
200
269
  return this._remoteApplyRecords.has(record)
201
270
  }
202
271
 
272
+ /**
273
+ * Whether tracked mutation queueing is currently suppressed for a record:
274
+ * either the record was marked as a remote apply (`markRemoteApply`, used by
275
+ * pull and realtime applies) or a `withoutTracking` callback is running on
276
+ * this client.
277
+ * @param {?} record - Local model record.
278
+ * @returns {boolean} Whether tracked queueing is suppressed for the record.
279
+ */
280
+ isTrackingSuppressed(record) {
281
+ return this._withoutTrackingDepth > 0 || this.isRemoteApply(record)
282
+ }
283
+
284
+ /**
285
+ * Runs a callback with tracked mutation queueing suppressed on this client -
286
+ * for code applying server-originated data outside the derived pull/realtime
287
+ * appliers (legacy pull paths, importers, sign-in backfills), so their writes
288
+ * are not echoed back to the server as device changes. Suppression covers the
289
+ * whole async duration of the callback (nested calls stack) and is
290
+ * client-wide while it runs: mutations from concurrently running tasks are
291
+ * also suppressed for that window, so prefer `markRemoteApply(record)` when
292
+ * writes from other flows can interleave.
293
+ * @template T
294
+ * @param {() => Promise<T> | T} callback - Work whose model writes should not queue tracked syncs.
295
+ * @returns {Promise<T>} The callback result.
296
+ */
297
+ async withoutTracking(callback) {
298
+ this._withoutTrackingDepth++
299
+
300
+ try {
301
+ return await callback()
302
+ } finally {
303
+ this._withoutTrackingDepth--
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Marks one record as being written from server-originated data so tracked
309
+ * mutation queueing skips it (record-precise suppression). The derived pull
310
+ * and realtime appliers use this internally around every applied write.
311
+ * @param {?} record - Local model record about to be written.
312
+ * @returns {() => void} Release callback re-enabling tracking for the record.
313
+ */
314
+ markRemoteApply(record) {
315
+ this._remoteApplyRecords.add(record)
316
+
317
+ return () => this._remoteApplyRecords.delete(record)
318
+ }
319
+
203
320
  /**
204
321
  * Registers this client as the app's current sync client.
205
322
  * @returns {void}
@@ -319,11 +436,7 @@ export default class SyncClient {
319
436
  */
320
437
  remoteApplySync({source = "pulled change"} = {}) {
321
438
  const pullResourceConfigs = this.pullResourceConfigs()
322
- const applier = SyncApiClient.resourceApplier(pullResourceConfigs, (record) => {
323
- this._remoteApplyRecords.add(record)
324
-
325
- return () => this._remoteApplyRecords.delete(record)
326
- })
439
+ const applier = SyncApiClient.resourceApplier(pullResourceConfigs, (record) => this.markRemoteApply(record))
327
440
 
328
441
  return async (sync) => {
329
442
  const resourceType = sync.resourceType()
@@ -548,11 +661,16 @@ function resourceConfigFromSyncDeclaration({declaration, modelClass, resourceTyp
548
661
  throw new Error(`${resourceType} static sync must be true or a sync declaration object, got: ${String(declaration)}`)
549
662
  }
550
663
 
551
- const {afterApply, attributes, booleanAttributes, findRecord, findRecordForDelete, localOnlyAttributes, realtime, syncType, track, trackedData, ...restDeclaration} = normalizedDeclaration
664
+ const {afterApply, attributes, booleanAttributes, findRecord, findRecordForDelete, localOnlyAttributes, publish, realtime, syncType, track, trackedData, ...restDeclaration} = normalizedDeclaration
552
665
  const unknownKeys = Object.keys(restDeclaration)
553
666
 
667
+ // `publish` is the server-side half of the shared `static sync` declaration
668
+ // (consumed by SyncPublisher on the backend) - the client derives nothing
669
+ // from it, but models declared once for both sides must stay valid here.
670
+ void publish
671
+
554
672
  if (unknownKeys.length > 0) {
555
- throw new Error(`${resourceType} static sync received unknown keys: ${unknownKeys.join(", ")} (supported: afterApply, attributes, booleanAttributes, findRecord, findRecordForDelete, localOnlyAttributes, realtime, syncType, track, trackedData)`)
673
+ throw new Error(`${resourceType} static sync received unknown keys: ${unknownKeys.join(", ")} (supported: afterApply, attributes, booleanAttributes, findRecord, findRecordForDelete, localOnlyAttributes, publish, realtime, syncType, track, trackedData)`)
556
674
  }
557
675
  if (syncType !== undefined && typeof syncType !== "function" && syncType !== "upsert") {
558
676
  throw new Error(`${resourceType} static sync syncType must be a function or the string "upsert", got: ${String(syncType)}`)
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
 
3
+ import {deliverDeclaredBroadcasts, upsertSyncRow} from "./sync-change-fanout.js"
4
+ import {markServerApply} from "./sync-publish-suppression.js"
3
5
  import {resolveFrontendModelResourceClass} from "../frontend-models/resource-definition.js"
4
6
  import SyncReplayUpsertApplier from "./sync-replay-upsert-applier.js"
5
7
  import {ValidationError} from "../database/record/index.js"
@@ -485,7 +487,11 @@ export default class SyncEnvelopeReplayService {
485
487
  }
486
488
 
487
489
  /**
488
- * Applies a routed delete mutation.
490
+ * Applies a routed delete mutation. The record is marked as a server apply
491
+ * for the duration of the replay-owned destroy - an active SyncPublisher
492
+ * never publishes the replayed delete a second time (the replay owns its
493
+ * own persist and broadcasts), while later server-side writes to the same
494
+ * instance publish normally again.
489
495
  * @param {object} args - Options.
490
496
  * @param {import("./sync-envelope-replay-service.js").SyncReplayMutation} args.mutation - Normalized replay mutation.
491
497
  * @param {import("../frontend-model-resource/base-resource.js").default} args.resource - Routed resource instance.
@@ -496,7 +502,13 @@ export default class SyncEnvelopeReplayService {
496
502
 
497
503
  if (!record) return {created: false, deleted: false, record: null}
498
504
 
499
- await record.destroy()
505
+ const releaseServerApply = markServerApply(record)
506
+
507
+ try {
508
+ await record.destroy()
509
+ } finally {
510
+ releaseServerApply()
511
+ }
500
512
 
501
513
  return {created: false, deleted: true, record}
502
514
  }
@@ -506,8 +518,12 @@ export default class SyncEnvelopeReplayService {
506
518
  * assigned and saved onto the found record (the record layer owns value
507
519
  * casting and validation), and missing records are created with the
508
520
  * client-generated primary key plus a save-then-check membership check.
509
- * Model validation failures become client-safe per-sync failures carrying
510
- * the translated validation message.
521
+ * Written records are marked as server applies for the duration of the
522
+ * replay-owned write - an active SyncPublisher never publishes the replayed
523
+ * mutation a second time (the replay owns its own persist and broadcasts),
524
+ * while later server-side writes to the same instance publish normally
525
+ * again. Model validation failures become client-safe per-sync failures
526
+ * carrying the translated validation message.
511
527
  * @param {object} args - Options.
512
528
  * @param {Record<string, ?>} args.context - Replay context.
513
529
  * @param {import("./sync-envelope-replay-service.js").SyncReplayMutation} args.mutation - Normalized replay mutation.
@@ -523,8 +539,14 @@ export default class SyncEnvelopeReplayService {
523
539
  let created = false
524
540
 
525
541
  if (existingRecord) {
526
- existingRecord.assign(attributes)
527
- await this.saveRoutedReplayRecord(existingRecord)
542
+ const releaseServerApply = markServerApply(existingRecord)
543
+
544
+ try {
545
+ existingRecord.assign(attributes)
546
+ await this.saveRoutedReplayRecord(existingRecord)
547
+ } finally {
548
+ releaseServerApply()
549
+ }
528
550
  } else {
529
551
  record = await this.createRoutedReplayRecord({attributes, mutation, resource})
530
552
  created = true
@@ -588,7 +610,10 @@ export default class SyncEnvelopeReplayService {
588
610
  }
589
611
 
590
612
  /**
591
- * Creates the routed record with the client-generated primary key, then
613
+ * Creates the routed record with the client-generated primary key (marked
614
+ * as a server apply for the duration of the create - including the
615
+ * membership-check compensation destroy - so an active SyncPublisher never
616
+ * publishes the replayed create a second time), then
592
617
  * verifies create-scope membership when an ability is configured: records
593
618
  * outside the ability's create scope are destroyed again and fail the sync
594
619
  * with the resource-declared reason. A record that already exists outside
@@ -611,33 +636,39 @@ export default class SyncEnvelopeReplayService {
611
636
  })
612
637
  }
613
638
 
614
- /** @type {import("../database/record/index.js").default} */
615
- let record
639
+ await ModelClass.ensureInitialized()
640
+
641
+ const record = new ModelClass({[primaryKey]: mutation.resourceId, ...attributes})
642
+ const releaseServerApply = markServerApply(record)
616
643
 
617
644
  try {
618
- record = await ModelClass.create({[primaryKey]: mutation.resourceId, ...attributes})
619
- } catch (error) {
620
- throw this.routedReplaySaveError(error)
621
- }
645
+ try {
646
+ await record.save()
647
+ } catch (error) {
648
+ throw this.routedReplaySaveError(error)
649
+ }
622
650
 
623
- const ability = resource.ability
651
+ const ability = resource.ability
624
652
 
625
- if (ability) {
626
- const memberIds = await ModelClass
627
- .accessibleFor(resource.syncAbilityAction("create"), ability)
628
- .where({[primaryKey]: record.id()})
629
- .pluck(primaryKey)
653
+ if (ability) {
654
+ const memberIds = await ModelClass
655
+ .accessibleFor(resource.syncAbilityAction("create"), ability)
656
+ .where({[primaryKey]: record.id()})
657
+ .pluck(primaryKey)
630
658
 
631
- if (memberIds.length === 0) {
632
- await record.destroy()
659
+ if (memberIds.length === 0) {
660
+ await record.destroy()
633
661
 
634
- throw VelociousError.safe(`Sync create denied for: ${mutation.resourceType}.`, {
635
- code: resource.syncAuthorizationFailureReason({action: "create", mutation}) || "access-denied"
636
- })
662
+ throw VelociousError.safe(`Sync create denied for: ${mutation.resourceType}.`, {
663
+ code: resource.syncAuthorizationFailureReason({action: "create", mutation}) || "access-denied"
664
+ })
665
+ }
637
666
  }
638
- }
639
667
 
640
- return record
668
+ return record
669
+ } finally {
670
+ releaseServerApply()
671
+ }
641
672
  }
642
673
 
643
674
  /**
@@ -709,13 +740,9 @@ export default class SyncEnvelopeReplayService {
709
740
  const existingClientUpdatedAt = this.existingReplaySyncClientUpdatedAt(existingSync)
710
741
 
711
742
  if (existingClientUpdatedAt && mutation.clientUpdatedAt <= existingClientUpdatedAt) return
712
-
713
- existingSync.assign(attributes)
714
- await existingSync.advanceServerSequence()
715
- await existingSync.save()
716
- } else {
717
- await this.syncModel.create(attributes)
718
743
  }
744
+
745
+ await upsertSyncRow({attributes, existingSync, syncModel: this.syncModel})
719
746
  }
720
747
 
721
748
  /**
@@ -748,15 +775,7 @@ export default class SyncEnvelopeReplayService {
748
775
  // would fan out stale side effects (or crash on the default null applyResult).
749
776
  if (!args.shouldApply) return
750
777
 
751
- for (const broadcast of this.broadcasts) {
752
- if (broadcast.when && !broadcast.when(args)) continue
753
-
754
- await this.broadcaster({
755
- body: broadcast.body(args),
756
- channel: typeof broadcast.channel === "function" ? broadcast.channel(args) : broadcast.channel,
757
- params: broadcast.broadcastParams(args)
758
- })
759
- }
778
+ await deliverDeclaredBroadcasts({args, broadcaster: this.broadcaster, broadcasts: this.broadcasts})
760
779
  }
761
780
  }
762
781
 
@@ -0,0 +1,59 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Records currently being written from an already-synced source (sync replay
5
+ * applies, importers applying device-origin data). Module-scoped so the
6
+ * framework's replay apply can mark records without holding a publisher
7
+ * instance.
8
+ * @type {WeakSet<object>}
9
+ */
10
+ const serverApplyRecords = new WeakSet()
11
+
12
+ let withoutPublishingDepth = 0
13
+
14
+ /**
15
+ * Marks one record as being written from an already-synced source so server
16
+ * publish callbacks skip it (record-precise suppression). The framework's
17
+ * routed sync replay apply uses this internally around every applied write, so
18
+ * replayed device mutations never publish a second, server-origin sync change.
19
+ * @param {?} record - Server model record about to be written.
20
+ * @returns {() => void} Release callback re-enabling publishing for the record.
21
+ */
22
+ export function markServerApply(record) {
23
+ serverApplyRecords.add(record)
24
+
25
+ return () => serverApplyRecords.delete(record)
26
+ }
27
+
28
+ /**
29
+ * Runs a callback with server publish callbacks suppressed - for code applying
30
+ * already-synced data outside the framework's replay apply (importers,
31
+ * backfills applying device-origin rows). Suppression covers the whole async
32
+ * duration of the callback (nested calls stack) and is process-wide while it
33
+ * runs: mutations from concurrently running requests are also suppressed for
34
+ * that window, so prefer `markServerApply(record)` when other flows can
35
+ * interleave.
36
+ * @template T
37
+ * @param {() => Promise<T> | T} callback - Work whose model writes should not publish sync changes.
38
+ * @returns {Promise<T>} The callback result.
39
+ */
40
+ export async function withoutPublishing(callback) {
41
+ withoutPublishingDepth++
42
+
43
+ try {
44
+ return await callback()
45
+ } finally {
46
+ withoutPublishingDepth--
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Whether server publishing is currently suppressed for a record: either the
52
+ * record was marked as a server apply (`markServerApply`) or a
53
+ * `withoutPublishing` callback is running.
54
+ * @param {?} record - Server model record being written.
55
+ * @returns {boolean} Whether publishing is suppressed for the record.
56
+ */
57
+ export function isPublishingSuppressed(record) {
58
+ return withoutPublishingDepth > 0 || serverApplyRecords.has(record)
59
+ }
@@ -0,0 +1,64 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Args resolved against a publish declaration's broadcasts after a published
5
+ * server-side mutation commits.
6
+ * @typedef {object} SyncPublishBroadcastArgs
7
+ * @property {Record<string, ?>} data - Payload snapshotted through the declaration's `serialize(record)` at mutation time.
8
+ * @property {"create" | "update" | "destroy"} operation - Mutation operation that published.
9
+ * @property {?} record - Mutated server model record.
10
+ * @property {string} resourceId - Published resource id.
11
+ * @property {string} resourceType - Published resource type.
12
+ * @property {?} syncRow - Upserted sync/change row.
13
+ * @property {string} syncType - Published sync type ("update" for creates/updates, "delete" for destroys).
14
+ */
15
+
16
+ /**
17
+ * One declarative broadcast fanned out after a published server-side mutation
18
+ * commits — same shape the replay service's injected broadcaster consumes.
19
+ * @typedef {object} SyncPublishBroadcast
20
+ * @property {string | ((args: SyncPublishBroadcastArgs) => string)} channel - Channel name or resolver.
21
+ * @property {(args: SyncPublishBroadcastArgs) => Record<string, ?>} broadcastParams - Channel routing params.
22
+ * @property {(args: SyncPublishBroadcastArgs) => ?} body - Broadcast body.
23
+ * @property {(args: SyncPublishBroadcastArgs) => boolean} [when] - Optional gate; skipped when it returns false.
24
+ */
25
+
26
+ /**
27
+ * Server-side publish declaration on a model's `static sync`, consumed by
28
+ * `SyncPublisher.fromConfiguration(...)`. Publishing is on for models
29
+ * declaring it (server-side creates and updates write to the sync change
30
+ * feed and broadcast automatically once their transaction commits);
31
+ * `publish: false` opts a model out explicitly.
32
+ * @typedef {object} SyncPublishDeclarationConfig
33
+ * @property {(record: ?) => Record<string, ?> | Promise<Record<string, ?>>} serialize - Builds the published payload snapshot from the mutated record (snapshotted at mutation time).
34
+ * @property {(record: ?) => string | number | null | Promise<string | number | null>} [eventId] - Resolves the event scope persisted to the sync row's event_id column.
35
+ * @property {SyncPublishBroadcast[]} [broadcasts] - Declarative broadcasts fanned out after the sync row is upserted.
36
+ * @property {Array<"create" | "update" | "destroy">} [operations] - Published operations. Defaults to creates and updates; destroys are opt-in because a server destroy is often cleanup rather than a synced delete.
37
+ * @property {string} [resourceType] - Published resource type. Defaults to the model name.
38
+ */
39
+
40
+ /** @typedef {false | SyncPublishDeclarationConfig} SyncPublishDeclaration */
41
+
42
+ /**
43
+ * Options for building a sync publisher. Published resources are derived from
44
+ * the configuration's registered models (`static sync` publish declarations).
45
+ * @typedef {object} SyncPublisherOptions
46
+ * @property {string} [actorForeignKeyColumn] - Sync model column linking rows to a device actor. Published server-origin rows set it to null (no device to echo). Defaults to "authentication_token_id".
47
+ * @property {(broadcast: {channel: string, params: Record<string, ?>, body: ?}) => Promise<void>} [broadcaster] - Delivers declared broadcasts. Defaults to the configuration's channel broadcast.
48
+ * @property {import("../configuration.js").default} [configuration] - Configuration owning the registered models. Defaults to the current configuration.
49
+ * @property {(error: Error) => void} [onError] - Reports post-commit publish failures. Defaults to loud logging.
50
+ * @property {?} [syncModel] - Sync/change model override. Defaults to the registered "Sync" model.
51
+ */
52
+
53
+ /**
54
+ * Internal derived publish policy for one resource — not an app-facing API.
55
+ * @typedef {object} SyncPublisherResourceConfig
56
+ * @property {SyncPublishBroadcast[] | undefined} broadcasts - Declared broadcasts.
57
+ * @property {SyncPublishDeclarationConfig["eventId"] | undefined} eventId - Event scope resolver.
58
+ * @property {?} modelClass - Server model class for this resource.
59
+ * @property {Array<"create" | "update" | "destroy">} operations - Published operations.
60
+ * @property {string} resourceType - Published resource type.
61
+ * @property {SyncPublishDeclarationConfig["serialize"]} serialize - Payload snapshot builder.
62
+ */
63
+
64
+ export {}