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.
- package/build/application.js +2 -0
- package/build/src/application.d.ts.map +1 -1
- package/build/src/application.js +3 -1
- package/build/src/sync/sync-change-fanout.d.ts +71 -0
- package/build/src/sync/sync-change-fanout.d.ts.map +1 -0
- package/build/src/sync/sync-change-fanout.js +54 -0
- package/build/src/sync/sync-client-types.d.ts +6 -2
- package/build/src/sync/sync-client-types.d.ts.map +1 -1
- package/build/src/sync/sync-client-types.js +1 -1
- package/build/src/sync/sync-client.d.ts +71 -4
- package/build/src/sync/sync-client.d.ts.map +1 -1
- package/build/src/sync/sync-client.js +126 -18
- package/build/src/sync/sync-envelope-replay-service.d.ts +15 -4
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +60 -42
- package/build/src/sync/sync-publish-suppression.d.ts +31 -0
- package/build/src/sync/sync-publish-suppression.d.ts.map +1 -0
- package/build/src/sync/sync-publish-suppression.js +54 -0
- package/build/src/sync/sync-publisher-types.d.ts +146 -0
- package/build/src/sync/sync-publisher-types.d.ts.map +1 -0
- package/build/src/sync/sync-publisher-types.js +3 -0
- package/build/src/sync/sync-publisher.d.ts +137 -0
- package/build/src/sync/sync-publisher.d.ts.map +1 -0
- package/build/src/sync/sync-publisher.js +305 -0
- package/build/src/testing/test-runner.d.ts +8 -0
- package/build/src/testing/test-runner.d.ts.map +1 -1
- package/build/src/testing/test-runner.js +64 -2
- package/build/sync/sync-change-fanout.js +58 -0
- package/build/sync/sync-client-types.js +3 -2
- package/build/sync/sync-client.js +136 -18
- package/build/sync/sync-envelope-replay-service.js +60 -41
- package/build/sync/sync-publish-suppression.js +59 -0
- package/build/sync/sync-publisher-types.js +64 -0
- package/build/sync/sync-publisher.js +351 -0
- package/build/testing/test-runner.js +69 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/application.js +2 -0
- package/src/sync/sync-change-fanout.js +58 -0
- package/src/sync/sync-client-types.js +3 -2
- package/src/sync/sync-client.js +136 -18
- package/src/sync/sync-envelope-replay-service.js +60 -41
- package/src/sync/sync-publish-suppression.js +59 -0
- package/src/sync/sync-publisher-types.js +64 -0
- package/src/sync/sync-publisher.js +351 -0
- 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] -
|
|
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] -
|
|
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
|
*/
|
package/src/sync/sync-client.js
CHANGED
|
@@ -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
|
|
113
|
-
* local
|
|
114
|
-
* immediate replay attempt, without
|
|
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 ===
|
|
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.
|
|
205
|
+
if (this.isTrackingSuppressed(record)) return
|
|
180
206
|
|
|
181
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
510
|
-
*
|
|
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
|
|
527
|
-
|
|
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
|
|
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
|
-
|
|
615
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
645
|
+
try {
|
|
646
|
+
await record.save()
|
|
647
|
+
} catch (error) {
|
|
648
|
+
throw this.routedReplaySaveError(error)
|
|
649
|
+
}
|
|
622
650
|
|
|
623
|
-
|
|
651
|
+
const ability = resource.ability
|
|
624
652
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
632
|
-
|
|
659
|
+
if (memberIds.length === 0) {
|
|
660
|
+
await record.destroy()
|
|
633
661
|
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {}
|