velocious 1.0.720 → 1.0.722

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 (70) hide show
  1. package/build/background-jobs/main.js +173 -4
  2. package/build/configuration-types.js +1 -1
  3. package/build/database/advisory-lock-reentrancy-node.js +9 -0
  4. package/build/database/advisory-lock-reentrancy.js +66 -0
  5. package/build/database/advisory-lock-runner.js +9 -12
  6. package/build/environment-handlers/node.js +1 -0
  7. package/build/routes/base-route.js +3 -2
  8. package/build/routes/basic-route.js +33 -8
  9. package/build/routes/get-route.js +17 -1
  10. package/build/routes/post-route.js +17 -1
  11. package/build/routes/resolver.js +5 -3
  12. package/build/src/background-jobs/main.d.ts +60 -0
  13. package/build/src/background-jobs/main.d.ts.map +1 -1
  14. package/build/src/background-jobs/main.js +164 -6
  15. package/build/src/configuration-types.d.ts +2 -2
  16. package/build/src/configuration-types.js +2 -2
  17. package/build/src/database/advisory-lock-reentrancy-node.d.ts +2 -0
  18. package/build/src/database/advisory-lock-reentrancy-node.d.ts.map +1 -0
  19. package/build/src/database/advisory-lock-reentrancy-node.js +7 -0
  20. package/build/src/database/advisory-lock-reentrancy.d.ts +67 -0
  21. package/build/src/database/advisory-lock-reentrancy.d.ts.map +1 -0
  22. package/build/src/database/advisory-lock-reentrancy.js +60 -0
  23. package/build/src/database/advisory-lock-runner.d.ts +25 -33
  24. package/build/src/database/advisory-lock-runner.d.ts.map +1 -1
  25. package/build/src/database/advisory-lock-runner.js +10 -13
  26. package/build/src/environment-handlers/node.d.ts +1 -0
  27. package/build/src/environment-handlers/node.d.ts.map +1 -1
  28. package/build/src/environment-handlers/node.js +2 -1
  29. package/build/src/routes/base-route.d.ts +3 -2
  30. package/build/src/routes/base-route.d.ts.map +1 -1
  31. package/build/src/routes/base-route.js +4 -3
  32. package/build/src/routes/basic-route.d.ts +19 -6
  33. package/build/src/routes/basic-route.d.ts.map +1 -1
  34. package/build/src/routes/basic-route.js +29 -9
  35. package/build/src/routes/get-route.d.ts +12 -1
  36. package/build/src/routes/get-route.d.ts.map +1 -1
  37. package/build/src/routes/get-route.js +16 -2
  38. package/build/src/routes/post-route.d.ts +12 -1
  39. package/build/src/routes/post-route.d.ts.map +1 -1
  40. package/build/src/routes/post-route.js +16 -2
  41. package/build/src/routes/resolver.d.ts +2 -1
  42. package/build/src/routes/resolver.d.ts.map +1 -1
  43. package/build/src/routes/resolver.js +6 -4
  44. package/build/src/sync/sync-cursor.d.ts +30 -0
  45. package/build/src/sync/sync-cursor.d.ts.map +1 -0
  46. package/build/src/sync/sync-cursor.js +54 -0
  47. package/build/src/sync/sync-realtime-bridge.d.ts +127 -24
  48. package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
  49. package/build/src/sync/sync-realtime-bridge.js +92 -56
  50. package/build/src/sync/sync-websocket-channel.d.ts +49 -0
  51. package/build/src/sync/sync-websocket-channel.d.ts.map +1 -1
  52. package/build/src/sync/sync-websocket-channel.js +138 -2
  53. package/build/sync/sync-cursor.js +56 -0
  54. package/build/sync/sync-realtime-bridge.js +97 -51
  55. package/build/sync/sync-websocket-channel.js +153 -1
  56. package/package.json +1 -1
  57. package/src/background-jobs/main.js +173 -4
  58. package/src/configuration-types.js +1 -1
  59. package/src/database/advisory-lock-reentrancy-node.js +9 -0
  60. package/src/database/advisory-lock-reentrancy.js +66 -0
  61. package/src/database/advisory-lock-runner.js +9 -12
  62. package/src/environment-handlers/node.js +1 -0
  63. package/src/routes/base-route.js +3 -2
  64. package/src/routes/basic-route.js +33 -8
  65. package/src/routes/get-route.js +17 -1
  66. package/src/routes/post-route.js +17 -1
  67. package/src/routes/resolver.js +5 -3
  68. package/src/sync/sync-cursor.js +56 -0
  69. package/src/sync/sync-realtime-bridge.js +97 -51
  70. package/src/sync/sync-websocket-channel.js +153 -1
@@ -6,10 +6,11 @@ export default class VelociousBasicRoute extends BaseRoute {
6
6
  /**
7
7
  * Runs get.
8
8
  * @param {string} name - Route name.
9
+ * @param {{on?: "member" | "collection"} | {skipControllerConnections?: boolean}} [options] - Route options; `skipControllerConnections: true` skips the request-scoped controller connection for this route.
9
10
  */
10
- get(name) {
11
+ get(name, options = {}) {
11
12
  const GetRoute = VelociousBasicRoute.GetRouteType
12
- const route = new GetRoute({name})
13
+ const route = new GetRoute({name, ...options})
13
14
 
14
15
  this.routes.push(route)
15
16
  }
@@ -51,35 +52,59 @@ export default class VelociousBasicRoute extends BaseRoute {
51
52
 
52
53
  /**
53
54
  * Runs namespace.
54
- * @param {string} name - Name.
55
- * @param {(arg: import("./namespace-route.js").default) => void} callback - Callback function.
55
+ * @param {string} name - Route name.
56
+ * @param {{skipControllerConnections?: boolean} | ((arg: import("./namespace-route.js").default) => void)} [options] - Namespace options (`skipControllerConnections: true` skips the request-scoped controller connection for every route declared inside this namespace; a per-route option still overrides it) or the draw callback (legacy form).
57
+ * @param {((arg: import("./namespace-route.js").default) => void) | undefined} [callback] - Callback function.
56
58
  * @returns {void} - No return value.
57
59
  */
58
- namespace(name, callback) {
60
+ namespace(name, options = {}, callback) {
59
61
  const NamespaceRoute = VelociousBasicRoute.NameSpaceRouteType
60
62
 
61
63
  if (!NamespaceRoute) throw new Error("No NamespaceRoute registered")
62
64
 
65
+ // Backward compatible: the second argument may be the draw callback.
66
+ if (typeof options === "function") {
67
+ callback = options
68
+ options = {}
69
+ }
70
+
71
+ const namespaceOptions = /** @type {{skipControllerConnections?: boolean}} */ (options)
72
+ const skipControllerConnections = namespaceOptions.skipControllerConnections === true
73
+
63
74
  const route = new NamespaceRoute({name})
64
75
 
65
76
  this.routes.push(route)
66
77
 
67
78
  if (callback) {
79
+ const originalGet = route.get.bind(route)
80
+ const originalPost = route.post.bind(route)
81
+
82
+ route.get = (routeName, routeOptions = {}) => {
83
+ const subOptions = /** @type {{skipControllerConnections?: boolean}} */ (routeOptions)
84
+ return originalGet(routeName, {...routeOptions, skipControllerConnections: subOptions.skipControllerConnections ?? skipControllerConnections})
85
+ }
86
+
87
+ route.post = (routeName, routeOptions = {}) => {
88
+ const subOptions = /** @type {{skipControllerConnections?: boolean}} */ (routeOptions)
89
+ return originalPost(routeName, {...routeOptions, skipControllerConnections: subOptions.skipControllerConnections ?? skipControllerConnections})
90
+ }
91
+
68
92
  callback(route)
69
93
  }
70
94
  }
71
95
 
72
96
  /**
73
97
  * Runs post.
74
- * @param {string} name - Name.
98
+ * @param {string} name - Route name.
99
+ * @param {{on?: "member" | "collection"} | {skipControllerConnections?: boolean}} [options] - Route options; `skipControllerConnections: true` skips the request-scoped controller connection for this route.
75
100
  * @returns {void} - No return value.
76
101
  */
77
- post(name) {
102
+ post(name, options = {}) {
78
103
  const PostRoute = VelociousBasicRoute.PostRouteType
79
104
 
80
105
  if (!PostRoute) throw new Error("No PostRoute registered")
81
106
 
82
- const route = new PostRoute({name})
107
+ const route = new PostRoute({name, ...options})
83
108
 
84
109
  this.routes.push(route)
85
110
  }
@@ -28,16 +28,32 @@ function assignActionAndController(params, name) {
28
28
  }
29
29
 
30
30
  class VelociousRouteGetRoute extends BaseRoute {
31
+ /**
32
+ * When true the resolver skips the request-scoped controller connection for
33
+ * this route: the action runs without an ambient `ensureConnections` checkout,
34
+ * so a long-lived action (streaming response, long wait) does not pin a pool
35
+ * connection for its whole lifetime. Each explicit `ensureConnections` call
36
+ * inside the action still opens and returns its own short-lived connection.
37
+ * @type {boolean}
38
+ */
39
+ skipControllerConnections = false
40
+
31
41
  /**
32
42
  * Runs constructor.
33
43
  * @param {object} args - Options object.
34
44
  * @param {string} args.name - Name.
45
+ * @param {boolean} [args.skipControllerConnections] - Skip the request-scoped controller connection for this route.
35
46
  */
36
- constructor({name, ...restArgs}) {
47
+ constructor({name, skipControllerConnections = false, ...restArgs}) {
37
48
  super()
38
49
  restArgsError(restArgs)
39
50
  this.name = name
40
51
  this.regExp = new RegExp(`^(${escapeStringRegexp(name)})(.*)$`)
52
+ if (skipControllerConnections !== true && skipControllerConnections !== false) {
53
+ throw new Error(`Expected get route 'skipControllerConnections' to be a boolean, got: ${String(skipControllerConnections)}`)
54
+ }
55
+
56
+ this.skipControllerConnections = skipControllerConnections
41
57
  }
42
58
 
43
59
  getHumanPaths() {
@@ -27,16 +27,32 @@ function assignActionAndController(params, name) {
27
27
  }
28
28
 
29
29
  class VelociousRoutePostRoute extends BaseRoute {
30
+ /**
31
+ * When true the resolver skips the request-scoped controller connection for
32
+ * this route: the action runs without an ambient `ensureConnections` checkout,
33
+ * so a long-lived action (streaming response, long wait) does not pin a pool
34
+ * connection for its whole lifetime. Each explicit `ensureConnections` call
35
+ * inside the action still opens and returns its own short-lived connection.
36
+ * @type {boolean}
37
+ */
38
+ skipControllerConnections = false
39
+
30
40
  /**
31
41
  * Runs constructor.
32
42
  * @param {object} args - Options object.
33
43
  * @param {string} args.name - Name.
44
+ * @param {boolean} [args.skipControllerConnections] - Skip the request-scoped controller connection for this route.
34
45
  */
35
- constructor({name, ...restArgs}) {
46
+ constructor({name, skipControllerConnections = false, ...restArgs}) {
36
47
  super()
37
48
  restArgsError(restArgs)
38
49
  this.name = name
39
50
  this.regExp = new RegExp(`^(${escapeStringRegexp(name)})(.*)$`)
51
+ if (skipControllerConnections !== true && skipControllerConnections !== false) {
52
+ throw new Error(`Expected post route 'skipControllerConnections' to be a boolean, got: ${String(skipControllerConnections)}`)
53
+ }
54
+
55
+ this.skipControllerConnections = skipControllerConnections
40
56
  }
41
57
 
42
58
  getHumanPaths() {
@@ -92,10 +92,12 @@ export default class VelociousRoutesResolver {
92
92
  }
93
93
 
94
94
  const routeResolverHookMatch = await this.resolveRouteResolverHooks(currentPath, {hasMatchingCustomRoute})
95
- let skipControllerConnections = this.configuration.database === false || routeResolverHookMatch?.skipControllerConnections === true
95
+ const matchResult = routeResolverHookMatch || !currentRoute ? undefined : this.matchPathWithRoutes(currentRoute, currentPath)
96
+ let skipControllerConnections = this.configuration.database === false
97
+ || routeResolverHookMatch?.skipControllerConnections === true
98
+ || matchResult?.skipControllerConnections === true
96
99
  let skipAbilityResolution = routeResolverHookMatch?.skipAbilityResolution === true
97
100
  let skipTenantResolution = routeResolverHookMatch?.skipTenantResolution === true
98
- const matchResult = routeResolverHookMatch || !currentRoute ? undefined : this.matchPathWithRoutes(currentRoute, currentPath)
99
101
  const actionParam = this.params.action
100
102
  const controllerParam = this.params.controller
101
103
  const actionValue = typeof actionParam == "string" ? actionParam : (Array.isArray(actionParam) ? actionParam[0] : undefined)
@@ -272,7 +274,7 @@ export default class VelociousRoutesResolver {
272
274
  * Runs match path with routes.
273
275
  * @param {import("./base-route.js").default} route - Route.
274
276
  * @param {string} path - Path.
275
- * @returns {{restPath: string} | undefined} - REST path metadata for this route.
277
+ * @returns {{restPath: string, skipControllerConnections?: boolean} | undefined} - REST path metadata for this route, including the matched leaf route's options.
276
278
  */
277
279
  matchPathWithRoutes(route, path) {
278
280
  const pathWithoutSlash = path.replace(/^\//, "").split("?")[0]
@@ -0,0 +1,56 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @module sync-cursor
5
+ *
6
+ * Shared cursor helpers for the sync change feed: building a cursor from a
7
+ * serialized sync entry and ordering two cursors under the feed's stable order
8
+ * (server sequence when present, else updated-at then id). The server channel
9
+ * advances its per-subscription replay watermark with these across out-of-order
10
+ * live broadcasts, and the realtime bridge advances the persisted scope cursor
11
+ * monotonically on pushed changes.
12
+ */
13
+
14
+ /** @typedef {import("./sync-api-client-types.js").SyncCursor} SyncCursor */
15
+
16
+ /**
17
+ * Builds a cursor from a serialized sync entry, or null when the entry carries
18
+ * no cursor identity (no updated-at).
19
+ * @param {Record<string, ReturnType<typeof JSON.parse>>} entry - Serialized sync entry.
20
+ * @returns {SyncCursor} Cursor for the entry, or null.
21
+ */
22
+ export function cursorFromSyncEntry(entry) {
23
+ const updatedAt = entry.updatedAt
24
+
25
+ if (typeof updatedAt !== "string" || updatedAt === "") return null
26
+
27
+ return {
28
+ id: entry.id === null || entry.id === undefined ? null : String(entry.id),
29
+ serverSequence: typeof entry.serverSequence === "number" ? entry.serverSequence : null,
30
+ updatedAt
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Whether one cursor is strictly later than another under the change-feed order.
36
+ * A sequenced cursor is always later than an unsequenced one; both sequenced
37
+ * cursors order by server sequence; both unsequenced cursors order by updated-at
38
+ * then id.
39
+ * @param {SyncCursor} candidate - Candidate cursor.
40
+ * @param {SyncCursor} current - Current cursor.
41
+ * @returns {boolean} Whether the candidate is later.
42
+ */
43
+ export function isCursorAfter(candidate, current) {
44
+ if (!candidate) return false
45
+ if (!current) return true
46
+
47
+ if (candidate.serverSequence !== null && current.serverSequence !== null) {
48
+ return candidate.serverSequence > current.serverSequence
49
+ }
50
+ if (candidate.serverSequence !== null) return true
51
+ if (current.serverSequence !== null) return false
52
+
53
+ if (candidate.updatedAt !== current.updatedAt) return candidate.updatedAt > current.updatedAt
54
+
55
+ return (candidate.id ?? "") > (current.id ?? "")
56
+ }
@@ -5,10 +5,46 @@ import {mergeRemoteRequestContext} from "../remote-request-context.js"
5
5
 
6
6
  import SyncApiClient from "./sync-api-client.js"
7
7
  import {VELOCIOUS_SYNC_CHANNEL} from "./sync-channel-name.js"
8
+ import {cursorFromSyncEntry, isCursorAfter} from "./sync-cursor.js"
8
9
 
9
10
  /** @typedef {import("../configuration-types.js").VelociousSyncRealtimeChannelDescriptor} VelociousSyncRealtimeChannelDescriptor */
10
11
  /** @typedef {import("../configuration-types.js").VelociousSyncRealtimeWebsocketClient} VelociousSyncRealtimeWebsocketClient */
11
12
 
13
+ /**
14
+ * The persisted scope row the bridge carries so it can advance the scope's cursor
15
+ * on applied pushes. Mirrors the framework-owned scope store row shape.
16
+ * @typedef {object} SyncRealtimeScopeRow
17
+ * @property {Record<string, ReturnType<typeof JSON.parse>>} conditions - Scope attribute conditions.
18
+ * @property {string | null} cursorPayload - Persisted cursor JSON payload.
19
+ * @property {string} id - Scope row id.
20
+ * @property {string | null} resourceType - Scope resource type.
21
+ * @property {string} scopeDigest - Fixed-size scope digest.
22
+ * @property {string} state - Scope state.
23
+ * @property {string} storeIdentity - Physical store identity.
24
+ */
25
+
26
+ /**
27
+ * One derived channel subscription carrying its owning scope row (null for legacy
28
+ * app channels) and the in-memory cursor watermark advanced by applied pushes.
29
+ * @typedef {object} DerivedSyncChannel
30
+ * @property {string} channel - Channel name.
31
+ * @property {import("./sync-api-client-types.js").SyncCursor} cursor - In-memory cursor watermark.
32
+ * @property {string | null} resourceType - Default resource type for pushed changes.
33
+ * @property {SyncRealtimeScopeRow | null} scopeRow - Owning scope row, or null for legacy channels.
34
+ * @property {import("../configuration-types.js").VelociousSyncRealtimeSubscription} subscription - Subscription handle.
35
+ */
36
+
37
+ /**
38
+ * One derived channel to subscribe, carrying its owning scope row and cursor for
39
+ * framework sync channel subscriptions (null scope row for legacy app channels).
40
+ * @typedef {object} DerivedSyncChannelDescriptor
41
+ * @property {string} channel - Channel name.
42
+ * @property {import("./sync-api-client-types.js").SyncCursor} cursor - In-memory cursor watermark (null for legacy channels).
43
+ * @property {Record<string, ReturnType<typeof JSON.parse>>} params - Subscribe params (without the framework-injected auth token).
44
+ * @property {string | null} resourceType - Default resource type for pushed changes.
45
+ * @property {SyncRealtimeScopeRow | null} scopeRow - Owning scope row, or null for legacy channels.
46
+ */
47
+
12
48
  /**
13
49
  * Derived realtime push bridge for the sync client. Subscribes every declared
14
50
  * pull scope to the framework sync channel ({@link VELOCIOUS_SYNC_CHANNEL})
@@ -29,7 +65,7 @@ export default class SyncRealtimeBridge {
29
65
  */
30
66
  constructor({syncClient}) {
31
67
  this.syncClient = syncClient
32
- /** @type {Array<{channel: string, resourceType: string | null, subscription: import("../configuration-types.js").VelociousSyncRealtimeSubscription}>} */
68
+ /** @type {Array<DerivedSyncChannel>} */
33
69
  this._channels = []
34
70
  /** @type {VelociousSyncRealtimeWebsocketClient | null} */
35
71
  this._client = null
@@ -40,8 +76,6 @@ export default class SyncRealtimeBridge {
40
76
  /** @type {number} Subscription generation - bumped by unsubscribe so in-flight subscribes detect they became stale. */
41
77
  this._generation = 0
42
78
  /** @type {Promise<void> | null} */
43
- this._scheduledPull = null
44
- /** @type {Promise<void> | null} */
45
79
  this._subscribePromise = null
46
80
  /** @type {"subscribed" | "subscribing" | "unsubscribed"} */
47
81
  this._state = "unsubscribed"
@@ -83,7 +117,7 @@ export default class SyncRealtimeBridge {
83
117
 
84
118
  this._state = "subscribing"
85
119
 
86
- /** @type {Array<{channel: string, resourceType: string | null, subscription: import("../configuration-types.js").VelociousSyncRealtimeSubscription}>} */
120
+ /** @type {Array<DerivedSyncChannel>} */
87
121
  const channels = []
88
122
  /** @type {VelociousSyncRealtimeWebsocketClient | null} */
89
123
  let client = null
@@ -154,13 +188,19 @@ export default class SyncRealtimeBridge {
154
188
  label: "Sync client request context",
155
189
  params: {...channelDescriptor.params, authenticationToken}
156
190
  })
157
- const subscription = client.subscribeChannel(channelDescriptor.channel, {
158
- onMessage: (body) => this.enqueueApply({body, generation, resourceType}),
159
- onResume: () => this.schedulePull(generation),
160
- params
161
- })
191
+ /** @type {DerivedSyncChannel} */
192
+ const channel = {
193
+ channel: channelDescriptor.channel,
194
+ cursor: channelDescriptor.cursor,
195
+ resourceType,
196
+ scopeRow: channelDescriptor.scopeRow,
197
+ subscription: client.subscribeChannel(channelDescriptor.channel, {
198
+ onMessage: (body) => this.enqueueApply({body, channel, generation}),
199
+ params
200
+ })
201
+ }
162
202
 
163
- channels.push({channel: channelDescriptor.channel, resourceType, subscription})
203
+ channels.push(channel)
164
204
  }
165
205
 
166
206
  await Promise.all(channels.map(({subscription}) => subscription.waitForReady({signal})))
@@ -175,7 +215,6 @@ export default class SyncRealtimeBridge {
175
215
  this._client = client
176
216
  this._ownsClient = ownsClient
177
217
  this._state = "subscribed"
178
- this.schedulePull(generation)
179
218
  } catch (error) {
180
219
  await teardown()
181
220
 
@@ -225,12 +264,11 @@ export default class SyncRealtimeBridge {
225
264
  }
226
265
 
227
266
  /**
228
- * Awaits all enqueued message applies and any scheduled pull (tests, shutdown flows).
267
+ * Awaits all enqueued message applies (tests, shutdown flows).
229
268
  * @returns {Promise<void>}
230
269
  */
231
270
  async waitForApplied() {
232
271
  await this._applyPromise
233
- if (this._scheduledPull) await this._scheduledPull
234
272
  }
235
273
 
236
274
  /**
@@ -266,33 +304,44 @@ export default class SyncRealtimeBridge {
266
304
  * model-level static realtime declarations and the config channels callback.
267
305
  * Fails loudly when nothing is subscribable.
268
306
  * @param {ReturnType<typeof JSON.parse>} context - App context passed to the deprecated channels callback.
269
- * @returns {Promise<Array<VelociousSyncRealtimeChannelDescriptor>>} Channel descriptors.
307
+ * @returns {Promise<Array<DerivedSyncChannelDescriptor>>} Channel descriptors.
270
308
  */
271
309
  async channelDescriptors(context) {
272
310
  const realtime = this.realtimeConfiguration()
273
- /** @type {Array<VelociousSyncRealtimeChannelDescriptor>} */
311
+ /** @type {Array<DerivedSyncChannelDescriptor>} */
274
312
  const channelDescriptors = []
275
313
 
276
314
  for (const scopeRow of await this.syncClient.scopeStore().activeScopes()) {
315
+ const cursorPayload = await this.syncClient.scopeStore().loadCursor(scopeRow)
316
+ const cursor = SyncApiClient.syncCursorFromPayload(cursorPayload)
317
+
277
318
  channelDescriptors.push({
278
319
  channel: VELOCIOUS_SYNC_CHANNEL,
320
+ cursor,
279
321
  params: {
280
322
  conditions: this.attributeNamedConditions(scopeRow),
281
323
  resourceType: scopeRow.resourceType,
282
324
  // Only the all-types scope carries the type list; a type-declared scope needs none.
283
- ...(scopeRow.resourceType === null ? {resourceTypes: this.syncClient.userScopeResourceTypes()} : {})
284
- }
325
+ ...(scopeRow.resourceType === null ? {resourceTypes: this.syncClient.userScopeResourceTypes()} : {}),
326
+ // Reuse the changes-endpoint cursor fields so the server replays the
327
+ // catch-up backlog from the scope's persisted cursor on subscribe.
328
+ ...SyncApiClient.cursorPayload("after", cursor)
329
+ },
330
+ resourceType: scopeRow.resourceType,
331
+ scopeRow
285
332
  })
286
333
  }
287
334
 
288
335
  for (const [resourceType, resourceConfig] of Object.entries(this.syncClient.config.resources)) {
289
336
  if (!resourceConfig.realtime) continue
290
337
 
291
- channelDescriptors.push({channel: resourceConfig.realtime.channel, params: resourceConfig.realtime.params, resourceType})
338
+ channelDescriptors.push({channel: resourceConfig.realtime.channel, cursor: null, params: resourceConfig.realtime.params ?? {}, resourceType, scopeRow: null})
292
339
  }
293
340
 
294
341
  if (realtime?.channels) {
295
- channelDescriptors.push(...await realtime.channels(context))
342
+ const legacyChannels = await realtime.channels(context)
343
+
344
+ channelDescriptors.push(...legacyChannels.map((descriptor) => ({channel: descriptor.channel, cursor: null, params: descriptor.params ?? {}, resourceType: descriptor.resourceType ?? null, scopeRow: null})))
296
345
  }
297
346
 
298
347
  if (channelDescriptors.length === 0) {
@@ -341,15 +390,15 @@ export default class SyncRealtimeBridge {
341
390
  /**
342
391
  * Chains one pushed message onto the serialized apply queue so changes apply
343
392
  * in arrival order; failures go to the sync client's error reporting.
344
- * @param {{body: ReturnType<typeof JSON.parse>, generation?: number, resourceType: string | null}} args - Message args.
393
+ * @param {{body: ReturnType<typeof JSON.parse>, channel: DerivedSyncChannel, generation?: number}} args - Message args.
345
394
  * @returns {void}
346
395
  */
347
- enqueueApply({body, generation = this._generation, resourceType}) {
396
+ enqueueApply({body, channel, generation = this._generation}) {
348
397
  this._applyPromise = this._applyPromise.then(async () => {
349
398
  if (generation !== this._generation) return
350
399
 
351
400
  try {
352
- await this.applyMessage({body, resourceType})
401
+ await this.applyMessage({body, channel})
353
402
  } catch (error) {
354
403
  this.syncClient.reportError(/** @type {Error} */ (error))
355
404
  }
@@ -359,11 +408,12 @@ export default class SyncRealtimeBridge {
359
408
  /**
360
409
  * Applies one pushed message through the derived resource applier: drops
361
410
  * own-device messages by echo origin, defaults the channel's resourceType onto
362
- * envelopes without one, and fails loudly on unknown resource types.
363
- * @param {{body: ReturnType<typeof JSON.parse>, resourceType: string | null}} args - Message args.
411
+ * envelopes without one, fails loudly on unknown resource types, and advances
412
+ * the owning scope's cursor so a later replay resumes after the applied rows.
413
+ * @param {{body: ReturnType<typeof JSON.parse>, channel: DerivedSyncChannel}} args - Message args.
364
414
  * @returns {Promise<void>}
365
415
  */
366
- async applyMessage({body, resourceType}) {
416
+ async applyMessage({body, channel}) {
367
417
  if (!body || typeof body !== "object" || Array.isArray(body)) {
368
418
  throw new Error(`Realtime sync messages must be envelope objects, got: ${JSON.stringify(body)}`)
369
419
  }
@@ -382,44 +432,40 @@ export default class SyncRealtimeBridge {
382
432
  // Coalesce record-change events across the pushed batch so it triggers one live-query re-run.
383
433
  await recordChanges.batch(async () => {
384
434
  for (const syncPayload of syncPayloads) {
385
- const sync = SyncApiClient.syncEnvelopeFromPayload({resourceType, ...syncPayload})
435
+ const sync = SyncApiClient.syncEnvelopeFromPayload({resourceType: channel.resourceType, ...syncPayload})
386
436
 
387
437
  await applySync(sync)
388
438
  }
389
439
  })
440
+
441
+ await this.advanceScopeCursor(channel, syncPayloads)
390
442
  }
391
443
 
392
444
  /**
393
- * Schedules a coalesced background pull closing offline gaps after
394
- * (re)subscription readiness. Resumes arriving while a pull is already
395
- * scheduled or in flight coalesce into that pull instead of stacking.
396
- * @param {number} [generation] - Subscription generation owning the resume/readiness callback.
397
- * @returns {void}
445
+ * Advances the owning scope's persisted cursor to the newest applied row,
446
+ * monotonically (never backwards) so an out-of-order or duplicate push cannot
447
+ * regress the cursor and re-deliver already-applied rows on a later replay.
448
+ * Legacy channels with no owning scope are skipped.
449
+ * @param {DerivedSyncChannel} channel - Channel owning the scope.
450
+ * @param {Array<ReturnType<typeof JSON.parse>>} syncPayloads - Applied sync entries.
451
+ * @returns {Promise<void>}
398
452
  */
399
- schedulePull(generation = this._generation) {
400
- if (generation !== this._generation) return
401
- if (this.realtimeConfiguration()?.pullOnReconnect === false) return
453
+ async advanceScopeCursor(channel, syncPayloads) {
454
+ if (!channel.scopeRow) return
402
455
 
403
- const coordinatorRun = this.syncClient.requestCoordinatorSync("realtime")
456
+ let latestCursor = channel.cursor
404
457
 
405
- if (coordinatorRun) {
406
- this._scheduledPull ||= coordinatorRun.finally(() => {
407
- this._scheduledPull = null
408
- })
409
- return
458
+ for (const syncPayload of syncPayloads) {
459
+ const entry = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (syncPayload)
460
+ const cursor = cursorFromSyncEntry(entry)
461
+
462
+ if (cursor && isCursorAfter(cursor, latestCursor)) latestCursor = cursor
410
463
  }
411
464
 
412
- this._scheduledPull ||= (async () => {
413
- try {
414
- await this.syncClient.pull()
415
- } catch (error) {
416
- if (!this.syncClient.isLifecycleAbort(error)) {
417
- this.syncClient.reportError(/** @type {Error} */ (error))
418
- }
419
- } finally {
420
- this._scheduledPull = null
421
- }
422
- })()
465
+ if (latestCursor && latestCursor !== channel.cursor) {
466
+ channel.cursor = latestCursor
467
+ await this.syncClient.scopeStore().saveCursor(channel.scopeRow, latestCursor)
468
+ }
423
469
  }
424
470
  }
425
471