velocious 1.0.701 → 1.0.702

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 (56) hide show
  1. package/README.md +1 -0
  2. package/build/configuration-types.js +72 -2
  3. package/build/configuration.js +61 -3
  4. package/build/frontend-model-resource/base-resource.js +150 -2
  5. package/build/frontend-models/resource-definition.js +47 -0
  6. package/build/src/configuration-types.d.ts +166 -5
  7. package/build/src/configuration-types.d.ts.map +1 -1
  8. package/build/src/configuration-types.js +67 -3
  9. package/build/src/configuration.d.ts +11 -2
  10. package/build/src/configuration.d.ts.map +1 -1
  11. package/build/src/configuration.js +61 -4
  12. package/build/src/frontend-model-resource/base-resource.d.ts +76 -3
  13. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  14. package/build/src/frontend-model-resource/base-resource.js +127 -3
  15. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  16. package/build/src/frontend-models/resource-definition.js +43 -1
  17. package/build/src/sync/local-mutation-log.d.ts +6 -3
  18. package/build/src/sync/local-mutation-log.d.ts.map +1 -1
  19. package/build/src/sync/local-mutation-log.js +11 -4
  20. package/build/src/sync/peer-mutation-bundle.d.ts +26 -0
  21. package/build/src/sync/peer-mutation-bundle.d.ts.map +1 -1
  22. package/build/src/sync/peer-mutation-bundle.js +7 -1
  23. package/build/src/sync/sync-api-client.d.ts +3 -1
  24. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  25. package/build/src/sync/sync-api-client.js +46 -8
  26. package/build/src/sync/sync-client-types.d.ts +24 -12
  27. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  28. package/build/src/sync/sync-client-types.js +7 -5
  29. package/build/src/sync/sync-client.d.ts +101 -0
  30. package/build/src/sync/sync-client.d.ts.map +1 -1
  31. package/build/src/sync/sync-client.js +236 -24
  32. package/build/src/sync/sync-offline-session.d.ts +328 -0
  33. package/build/src/sync/sync-offline-session.d.ts.map +1 -0
  34. package/build/src/sync/sync-offline-session.js +617 -0
  35. package/build/src/sync/sync-resource-base.d.ts +26 -28
  36. package/build/src/sync/sync-resource-base.d.ts.map +1 -1
  37. package/build/src/sync/sync-resource-base.js +11 -45
  38. package/build/sync/local-mutation-log.js +10 -3
  39. package/build/sync/peer-mutation-bundle.js +6 -0
  40. package/build/sync/sync-api-client.js +53 -7
  41. package/build/sync/sync-client-types.js +6 -4
  42. package/build/sync/sync-client.js +264 -21
  43. package/build/sync/sync-offline-session.js +713 -0
  44. package/build/sync/sync-resource-base.js +10 -53
  45. package/package.json +1 -1
  46. package/src/configuration-types.js +72 -2
  47. package/src/configuration.js +61 -3
  48. package/src/frontend-model-resource/base-resource.js +150 -2
  49. package/src/frontend-models/resource-definition.js +47 -0
  50. package/src/sync/local-mutation-log.js +10 -3
  51. package/src/sync/peer-mutation-bundle.js +6 -0
  52. package/src/sync/sync-api-client.js +53 -7
  53. package/src/sync/sync-client-types.js +6 -4
  54. package/src/sync/sync-client.js +264 -21
  55. package/src/sync/sync-offline-session.js +713 -0
  56. package/src/sync/sync-resource-base.js +10 -53
@@ -29,13 +29,14 @@ export default class SyncApiClient {
29
29
  * @param {string | number | null} args.baseVersion - Authoritative version observed before the local mutation.
30
30
  * @param {import("./sync-client-types.js").SyncClientConflictTrackingConfig} args.conflictTracking - Durable tracking configuration.
31
31
  * @param {Record<string, unknown>} args.data - Backend-safe mutation attributes.
32
+ * @param {import("./sync-offline-session.js").default} [args.offlineSession] - Active long-offline session: the mutation identity (actor ids, grant id, policy hash) is supplied by the session and the appended record carries the device-signed envelope.
32
33
  * @param {"create" | "update" | "destroy"} args.operation - Local operation.
33
34
  * @param {ReturnType<typeof JSON.parse>} args.resource - Local resource.
34
35
  * @param {string} args.resourceType - Resource type.
35
36
  * @param {string} args.syncType - Wire operation.
36
37
  * @returns {Promise<import("./local-mutation-log.js").LocalMutationLogRecord>} Appended intent.
37
38
  */
38
- static async queueConflictTrackedSync({baseVersion, conflictTracking, data, operation, resource, resourceType, syncType}) {
39
+ static async queueConflictTrackedSync({baseVersion, conflictTracking, data, offlineSession, operation, resource, resourceType, syncType}) {
39
40
  const resourceId = String(scalarModelPrimaryKeyValue(resource.id(), `Conflict-tracked sync for ${resourceType}`))
40
41
  const records = await conflictTracking.mutationLog.records()
41
42
  const predecessor = records
@@ -46,20 +47,40 @@ export default class SyncApiClient {
46
47
  const predecessorTime = predecessor ? new Date(predecessor.mutation.occurredAt).getTime() : Number.NEGATIVE_INFINITY
47
48
  const occurredAt = new Date(Math.max(now.getTime(), predecessorTime + 1)).toISOString()
48
49
 
50
+ const dependencies = predecessor ? [{clientMutationId: predecessor.mutation.clientMutationId, model: resourceType}] : []
51
+ const attributes = /** @type {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} */ (data)
52
+
53
+ if (offlineSession) {
54
+ // Long-offline queueing: the session supplies the rotating identity
55
+ // (actor ids, live grant id, current policy hash) and signs the intent
56
+ // with the device key so it can replay without a live session.
57
+ const signedMutation = await offlineSession.signMutation({
58
+ attributes,
59
+ baseVersion,
60
+ clientMutationId,
61
+ model: resourceType,
62
+ occurredAt,
63
+ operation,
64
+ payload: {resourceId, syncType}
65
+ })
66
+
67
+ return await conflictTracking.mutationLog.append({dependencies, mutation: signedMutation.mutation, signedMutation})
68
+ }
69
+
49
70
  return await conflictTracking.mutationLog.append({
50
- dependencies: predecessor ? [{clientMutationId: predecessor.mutation.clientMutationId, model: resourceType}] : [],
71
+ dependencies,
51
72
  mutation: {
52
- actorDeviceId: conflictTracking.actorDeviceId,
53
- actorUserId: conflictTracking.actorUserId,
54
- attributes: /** @type {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} */ (data),
73
+ actorDeviceId: await resolveConflictTrackingIdentityValue({conflictTracking, key: "actorDeviceId", resourceType}),
74
+ actorUserId: await resolveConflictTrackingIdentityValue({conflictTracking, key: "actorUserId", resourceType}),
75
+ attributes,
55
76
  baseVersion,
56
77
  clientMutationId,
57
78
  model: resourceType,
58
79
  occurredAt,
59
- offlineGrantId: conflictTracking.offlineGrantId,
80
+ offlineGrantId: await resolveConflictTrackingIdentityValue({conflictTracking, key: "offlineGrantId", resourceType}),
60
81
  operation,
61
82
  payload: {resourceId, syncType},
62
- policyHash: conflictTracking.policyHash
83
+ policyHash: await resolveConflictTrackingIdentityValue({conflictTracking, key: "policyHash", resourceType})
63
84
  }
64
85
  })
65
86
  }
@@ -938,3 +959,28 @@ function throwIfSyncAborted(signal) {
938
959
 
939
960
  throw signal.reason instanceof Error ? signal.reason : new Error("Sync operation aborted")
940
961
  }
962
+
963
+ /**
964
+ * Resolves one conflict-tracking identity field at queue time: a static string
965
+ * or a function (sync or async) resolving the current value. Grants and policy
966
+ * hashes rotate at runtime, so declarations may resolve them per mutation.
967
+ * @param {{conflictTracking: import("./sync-client-types.js").SyncClientConflictTrackingConfig, key: "actorDeviceId" | "actorUserId" | "offlineGrantId" | "policyHash", resourceType: string}} args - Resolution args.
968
+ * @returns {Promise<string>} - Resolved non-empty identity value.
969
+ */
970
+ async function resolveConflictTrackingIdentityValue({conflictTracking, key, resourceType}) {
971
+ const value = conflictTracking[key]
972
+
973
+ if (typeof value === "function") {
974
+ const resolved = await value()
975
+
976
+ if (typeof resolved !== "string" || resolved.length === 0) {
977
+ throw new Error(`${resourceType} conflictTracking.${key} resolved to an empty value`)
978
+ }
979
+
980
+ return resolved
981
+ }
982
+
983
+ if (typeof value === "string" && value.length > 0) return value
984
+
985
+ throw new Error(`${resourceType} conflictTracking.${key} must be a non-empty string or a function resolving one (or start an offline session)`)
986
+ }
@@ -21,13 +21,13 @@
21
21
  * Durable optimistic-version tracking configuration. Identity/grant fields are
22
22
  * retained in the shared LocalMutationLog mutation shape used by offline sync.
23
23
  * @typedef {object} SyncClientConflictTrackingConfig
24
- * @property {string} actorDeviceId - Stable device identity.
25
- * @property {string} actorUserId - Stable user identity.
24
+ * @property {string | (() => string | Promise<string>)} [actorDeviceId] - Stable device identity, or a function resolving it at queue time. When a `sync.client.offline` session is configured, the session supplies it and the declaration may omit it.
25
+ * @property {string | (() => string | Promise<string>)} [actorUserId] - Stable user identity, or a function resolving it at queue time. When a `sync.client.offline` session is configured, the session supplies it and the declaration may omit it.
26
26
  * @property {() => string} clientMutationId - Durable idempotency-id generator.
27
27
  * @property {import("./local-mutation-log.js").default} mutationLog - Existing durable mutation log.
28
28
  * @property {() => Date} [now] - Mutation clock.
29
- * @property {string} offlineGrantId - Grant identifier retained with intent.
30
- * @property {string} policyHash - Policy hash retained with intent.
29
+ * @property {string | (() => string | Promise<string>)} [offlineGrantId] - Grant identifier retained with intent, or a function resolving it at queue time (grants rotate at runtime). When a `sync.client.offline` session is configured, the session supplies it and the declaration may omit it.
30
+ * @property {string | (() => string | Promise<string>)} [policyHash] - Policy hash retained with intent, or a function resolving it at queue time (policy hashes rotate with the server policy). When a `sync.client.offline` session is configured, the session supplies it and the declaration may omit it.
31
31
  * @property {string} [versionAttribute] - Authoritative version attribute; defaults to updatedAt when present.
32
32
  */
33
33
 
@@ -128,6 +128,8 @@
128
128
  * @property {(args: {scope: SerializedSyncScope}) => string | null | Promise<string | null>} [legacyCursor] - Seeds a newly declared scope's cursor (e.g. from a pre-scope cursor store) so devices don't re-pull everything.
129
129
  * @property {(error: Error) => void} [onError] - Reports background replay/pull failures. Defaults to rethrowing.
130
130
  * @property {(payload: import("./sync-api-client-types.js").SyncChangesRequest & {scope: SerializedSyncScope}, options?: {signal?: AbortSignal}) => Promise<import("./sync-api-client-types.js").SyncChangesResponse>} postChanges - Posts one changes request.
131
+ * @property {import("../configuration-types.js").NormalizedVelociousSyncClientOfflineConfiguration} [offline] - Normalized long-offline session configuration from `sync.client.offline`.
132
+ * @property {(path: string, payload: Record<string, ReturnType<typeof JSON.parse>>, options?: {signal?: AbortSignal}) => Promise<ReturnType<typeof JSON.parse>>} offlinePost - Path-selecting POSTer over the configured transport for the offline session's bootstrap/replay endpoints.
131
133
  * @property {(payload: {authenticationToken: string, syncs: Array<Record<string, ReturnType<typeof JSON.parse>>>}, options?: {signal?: AbortSignal}) => Promise<import("./sync-api-client-types.js").SyncReplayResponse>} postReplay - Posts one replay request.
132
134
  * @property {import("../configuration-types.js").VelociousSyncClientRealtimeConfiguration} [realtime] - Realtime push configuration consumed by `subscribeRealtime(...)`.
133
135
  * @property {import("../remote-request-context.js").RemoteRequestContext} requestContext - Immutable scalar context captured for this client.
@@ -10,6 +10,7 @@ import VelociousWebsocketClient from "../http-client/websocket-client.js"
10
10
 
11
11
  import {serializedScopeFromQuery} from "./query-scope.js"
12
12
  import SyncApiClient from "./sync-api-client.js"
13
+ import SyncOfflineSession from "./sync-offline-session.js"
13
14
  import SyncRealtimeBridge from "./sync-realtime-bridge.js"
14
15
  import SyncScopeStore from "./sync-scope-store.js"
15
16
  import {currentSyncClient, setCurrentSyncClient} from "./sync-client-registry.js"
@@ -121,7 +122,7 @@ export default class SyncClient {
121
122
  const metadataModelClass = tenantHandle
122
123
  ? tenantHandle.metadataModelClass({databaseIdentifier: /** @type {string} */ (databaseIdentifier), modelClass})
123
124
  : modelClass
124
- const resourceConfig = resourceConfigFromSyncDeclaration({declaration: modelClass.sync, metadataModelClass, modelClass, resourceType})
125
+ const resourceConfig = resourceConfigFromSyncDeclaration({declaration: modelClass.sync, metadataModelClass, modelClass, offlineConfigured: Boolean(clientConfiguration.offline), resourceType})
125
126
 
126
127
  if (databaseIdentity && resourceConfig.conflictTracking) {
127
128
  resourceConfig.conflictTracking = {
@@ -153,6 +154,8 @@ export default class SyncClient {
153
154
  isOnline: clientConfiguration.isOnline,
154
155
  legacyCursor,
155
156
  onError: clientConfiguration.onError,
157
+ offline: clientConfiguration.offline,
158
+ offlinePost: transportPathPoster({requestContext: capturedRequestContext, transport: clientConfiguration.transport}),
156
159
  postChanges: transportPoster({path: `${clientConfiguration.mountPath}/changes`, requestContext: capturedRequestContext, transport: clientConfiguration.transport}),
157
160
  postReplay: transportPoster({path: `${clientConfiguration.mountPath}/replay`, requestContext: capturedRequestContext, transport: clientConfiguration.transport}),
158
161
  realtime: clientConfiguration.realtime,
@@ -170,6 +173,8 @@ export default class SyncClient {
170
173
  : null
171
174
  /** @type {SyncRealtimeBridge | null} */
172
175
  this._realtimeBridge = null
176
+ /** @type {SyncOfflineSession | null} */
177
+ this._offlineSession = null
173
178
  /** @type {import("./sync-client-types.js").SyncClientSharedConnection | null | undefined} Shared app-lifetime websocket connection (undefined until first resolved, null when none is configured). */
174
179
  this._syncConnection = undefined
175
180
  /** @type {Promise<void> | null} */
@@ -1089,6 +1094,7 @@ export default class SyncClient {
1089
1094
  baseVersion: baseVersion === undefined ? this.baseVersionFor({operation, record: resource, resourceConfig}) : baseVersion,
1090
1095
  conflictTracking: resourceConfig.conflictTracking,
1091
1096
  data: queuedData,
1097
+ offlineSession: this.config.offline ? this.offlineSession() : undefined,
1092
1098
  operation,
1093
1099
  resource,
1094
1100
  resourceType: resource.constructor.getModelName(),
@@ -1137,19 +1143,28 @@ export default class SyncClient {
1137
1143
 
1138
1144
  await SyncApiClient.singleFlight(`velocious-sync-client-replay-${this._clientNumber}`, async () => await this.withTenantOperation(async (operation) => {
1139
1145
  this._throwIfLifecycleAborted(signal)
1140
- for (const [resourceType, resourceConfig] of Object.entries(this.config.resources)) {
1141
- if (!resourceConfig.conflictTracking) continue
1142
1146
 
1143
- await SyncApiClient.replayConflictTrackedSyncs({
1144
- applyConflict: async ({record, result}) => await this.applyConflictReplayResult({record, result, resourceType}),
1145
- authenticationToken: await this.config.authenticationToken(),
1146
- batchSize: this.config.batchSize,
1147
- conflictTracking: resourceConfig.conflictTracking,
1148
- postReplay: this.config.postReplay,
1149
- remoteGeneration: (identity) => this._remoteGenerations.get(identity) || 0,
1150
- resourceType,
1151
- signal
1152
- })
1147
+ if (this.config.offline) {
1148
+ // Long-offline session configured: the signed offline session owns the
1149
+ // conflict-tracked mutation logs, so the background drain replays them
1150
+ // through the signed replay endpoint instead of the legacy unsigned
1151
+ // one. One owner per log — never both.
1152
+ await this._replayOfflinePending({signal})
1153
+ } else {
1154
+ for (const [resourceType, resourceConfig] of Object.entries(this.config.resources)) {
1155
+ if (!resourceConfig.conflictTracking) continue
1156
+
1157
+ await SyncApiClient.replayConflictTrackedSyncs({
1158
+ applyConflict: async ({record, result}) => await this.applyConflictReplayResult({record, result, resourceType}),
1159
+ authenticationToken: await this.config.authenticationToken(),
1160
+ batchSize: this.config.batchSize,
1161
+ conflictTracking: resourceConfig.conflictTracking,
1162
+ postReplay: this.config.postReplay,
1163
+ remoteGeneration: (identity) => this._remoteGenerations.get(identity) || 0,
1164
+ resourceType,
1165
+ signal
1166
+ })
1167
+ }
1153
1168
  }
1154
1169
 
1155
1170
  this._throwIfLifecycleAborted(signal)
@@ -1256,6 +1271,203 @@ export default class SyncClient {
1256
1271
  })
1257
1272
  }
1258
1273
 
1274
+ /**
1275
+ * Returns the framework-owned long-offline session built from
1276
+ * `sync.client.offline`, creating it on first use. Fails when the offline
1277
+ * configuration block is missing.
1278
+ * @returns {SyncOfflineSession} - The client's offline session.
1279
+ */
1280
+ offlineSession() {
1281
+ if (!this.config.offline) {
1282
+ throw new Error("SyncClient has no sync.client.offline configuration - configure deviceIdentityStorage, enrollDevice, and grantScopes to use the long-offline session")
1283
+ }
1284
+
1285
+ if (!this._offlineSession) {
1286
+ this._offlineSession = new SyncOfflineSession({
1287
+ authenticationToken: this.config.authenticationToken,
1288
+ config: this.config.offline,
1289
+ mutationLogs: this.offlineMutationLogs(),
1290
+ post: this.config.offlinePost
1291
+ })
1292
+ }
1293
+
1294
+ return this._offlineSession
1295
+ }
1296
+
1297
+ /**
1298
+ * Collects the durable mutation logs the offline session signs, replays,
1299
+ * re-signs, and exports: the configured `sync.client.offline.mutationLog`
1300
+ * when given, plus every conflict-tracking log declared by the synced models.
1301
+ * @returns {Array<import("./local-mutation-log.js").default>} - Session mutation logs.
1302
+ */
1303
+ offlineMutationLogs() {
1304
+ /** @type {Array<import("./local-mutation-log.js").default>} */
1305
+ const logs = []
1306
+
1307
+ if (this.config.offline?.mutationLog) logs.push(this.config.offline.mutationLog)
1308
+
1309
+ for (const resourceConfig of Object.values(this.config.resources)) {
1310
+ const mutationLog = resourceConfig.conflictTracking?.mutationLog
1311
+
1312
+ if (!mutationLog) continue
1313
+ if (logs.some((log) => log === mutationLog || log.storageKey === mutationLog.storageKey)) continue
1314
+
1315
+ logs.push(mutationLog)
1316
+ }
1317
+
1318
+ if (logs.length === 0) throw new Error("SyncClient offline session found no durable mutation log - declare conflictTracking on a synced model or configure sync.client.offline.mutationLog")
1319
+
1320
+ return logs
1321
+ }
1322
+
1323
+ /**
1324
+ * Starts the long-offline session: resolves the device signing identity
1325
+ * (generating and enrolling a key pair when the app-owned storage has none)
1326
+ * and bootstraps a live offline grant through the built-in
1327
+ * `/frontend-models/sync/bootstrap` endpoint. Idempotent.
1328
+ * @returns {Promise<import("./sync-offline-session.js").SyncOfflineSessionSummary>} - Session summary (actor ids, live grant id, expiry).
1329
+ */
1330
+ async startOfflineSession() {
1331
+ return await this._runLifecycleWork(async (signal) => {
1332
+ this._throwIfLifecycleAborted(signal)
1333
+
1334
+ const summary = await this.offlineSession().start()
1335
+
1336
+ this._throwIfLifecycleAborted(signal)
1337
+
1338
+ return summary
1339
+ })
1340
+ }
1341
+
1342
+ /**
1343
+ * Stops the long-offline session. The device identity and retained grants
1344
+ * stay in the app-owned durable storage; pending mutations stay in the
1345
+ * durable mutation logs and replay with the grant they were signed under.
1346
+ * @returns {Promise<void>} - Resolves after the session is stopped.
1347
+ */
1348
+ async stopOfflineSession() {
1349
+ if (!this._offlineSession) return
1350
+
1351
+ await this._offlineSession.stop()
1352
+ }
1353
+
1354
+ /**
1355
+ * Bootstraps a fresh offline grant and re-signs this device's pending
1356
+ * mutations under it (the replay idempotency key is grant-id-free, so
1357
+ * re-signing never duplicates an intent).
1358
+ * @returns {Promise<import("./sync-offline-session.js").SyncOfflineSessionSummary>} - Refreshed session summary.
1359
+ */
1360
+ async refreshOfflineGrant() {
1361
+ return await this._runLifecycleWork(async (signal) => {
1362
+ this._throwIfLifecycleAborted(signal)
1363
+
1364
+ const summary = await this.offlineSession().refreshGrant()
1365
+
1366
+ this._throwIfLifecycleAborted(signal)
1367
+
1368
+ return summary
1369
+ })
1370
+ }
1371
+
1372
+ /**
1373
+ * Drains the durable mutation logs through the built-in signed replay
1374
+ * endpoint (`/frontend-models/sync/replay`): ensures a live grant (refreshing
1375
+ * an expired one), batches pending signed mutations with the signed grant
1376
+ * each was signed under, and maps per-mutation results back onto the log
1377
+ * records as synced/conflict/rejected. Structured conflicts stay reachable
1378
+ * through the existing conflict contract (`syncResult.conflict` on the record
1379
+ * and `resolveConflict(...)`).
1380
+ * @param {object} [options] - Replay options.
1381
+ * @param {number} [options.batchSize] - Max signed mutations per request.
1382
+ * @returns {Promise<import("./sync-offline-session.js").SyncOfflineReplayResult>} - Mapped replay result.
1383
+ */
1384
+ async replayOfflinePending(options = {}) {
1385
+ return await this._runLifecycleWork(async (signal) => await this._replayOfflinePending({...options, signal}))
1386
+ }
1387
+
1388
+ /**
1389
+ * Signed replay implementation bound to one lifecycle generation.
1390
+ * @param {object} [options] - Replay options.
1391
+ * @param {number} [options.batchSize] - Max signed mutations per request.
1392
+ * @param {AbortSignal} [options.signal] - Lifecycle cancellation signal.
1393
+ * @returns {Promise<import("./sync-offline-session.js").SyncOfflineReplayResult>} - Mapped replay result.
1394
+ */
1395
+ async _replayOfflinePending(options = {}) {
1396
+ const {signal, ...replayOptions} = options
1397
+ const lifecycleSignal = signal ?? this._lifecycleAbortController.signal
1398
+
1399
+ this._throwIfLifecycleAborted(lifecycleSignal)
1400
+
1401
+ const result = await this.offlineSession().replayPending({...replayOptions, signal: lifecycleSignal})
1402
+
1403
+ this._throwIfLifecycleAborted(lifecycleSignal)
1404
+
1405
+ for (const entry of result.results) {
1406
+ if (entry.status !== "conflict" || !entry.serverResult) continue
1407
+
1408
+ await this.applyOfflineConflictReplayResult(entry)
1409
+ }
1410
+
1411
+ return result
1412
+ }
1413
+
1414
+ /**
1415
+ * Applies the authoritative server version carried by a signed replay
1416
+ * conflict result through the same conflict contract the online replay uses.
1417
+ * @param {{mutation: import("./device-identity.js").SyncMutation, serverResult: Record<string, ReturnType<typeof JSON.parse>> | null, status: string}} entry - Mapped replay result.
1418
+ * @returns {Promise<void>} - Resolves after the conflict server version is applied.
1419
+ */
1420
+ async applyOfflineConflictReplayResult(entry) {
1421
+ if (!entry.serverResult) return
1422
+
1423
+ const response = entry.serverResult.response
1424
+
1425
+ if (!response || typeof response !== "object" || Array.isArray(response)) return
1426
+
1427
+ const conflict = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (response).conflict
1428
+
1429
+ if (!conflict || !Object.hasOwn(conflict, "serverModel")) return
1430
+
1431
+ await this.applyConflictReplayResult({
1432
+ record: /** @type {import("./local-mutation-log.js").LocalMutationLogRecord} */ ({mutation: {payload: entry.mutation.payload}}),
1433
+ resourceType: entry.mutation.model,
1434
+ result: /** @type {import("./sync-api-client-types.js").SyncReplayItem} */ ({conflict})
1435
+ })
1436
+ }
1437
+
1438
+ /**
1439
+ * Exports the durable non-terminal mutations of the offline session's logs
1440
+ * as one signed peer bundle for device-to-device transfer.
1441
+ * @param {object} [options] - Export options.
1442
+ * @param {import("./local-mutation-log.js").LocalMutationStatus[]} [options.statuses] - Statuses to export.
1443
+ * @param {() => Date} [options.now] - Export clock.
1444
+ * @returns {Promise<import("./peer-mutation-bundle.js").PeerMutationBundle>} - Signed peer bundle.
1445
+ */
1446
+ async exportPeerBundle(options = {}) {
1447
+ return await this._runLifecycleWork(async (signal) => {
1448
+ this._throwIfLifecycleAborted(signal)
1449
+
1450
+ return await this.offlineSession().exportBundle(options)
1451
+ })
1452
+ }
1453
+
1454
+ /**
1455
+ * Imports a peer bundle into the local mutation log: every entry is verified
1456
+ * against the configured backend public key before it is appended, and
1457
+ * entries already present by idempotency key are skipped as duplicates.
1458
+ * @param {object} args - Import args.
1459
+ * @param {import("./peer-mutation-bundle.js").PeerMutationBundle} args.bundle - Peer bundle to import.
1460
+ * @param {import("./local-mutation-log.js").default} [args.mutationLog] - Target log. Defaults to the session's first log.
1461
+ * @returns {Promise<import("./peer-mutation-bundle.js").PeerMutationBundleImportResult>} - Import result (imported/rejected/skipped).
1462
+ */
1463
+ async importPeerBundle({bundle, mutationLog}) {
1464
+ return await this._runLifecycleWork(async (signal) => {
1465
+ this._throwIfLifecycleAborted(signal)
1466
+
1467
+ return await this.offlineSession().importBundle({bundle, mutationLog})
1468
+ })
1469
+ }
1470
+
1259
1471
  /**
1260
1472
  * Records an authoritative remote observation so an in-flight acknowledgement
1261
1473
  * cannot rebase a successor across that observation.
@@ -1605,10 +1817,10 @@ function safeConflictVersion(value) {
1605
1817
  /**
1606
1818
  * Builds one resource config from a model's `static sync` declaration plus its
1607
1819
  * derived column metadata.
1608
- * @param {{declaration: import("./sync-client-types.js").ModelSyncDeclaration, metadataModelClass: ReturnType<typeof JSON.parse>, modelClass: ReturnType<typeof JSON.parse>, resourceType: string}} args - Declaration args.
1820
+ * @param {{declaration: import("./sync-client-types.js").ModelSyncDeclaration, metadataModelClass: ReturnType<typeof JSON.parse>, modelClass: ReturnType<typeof JSON.parse>, offlineConfigured?: boolean, resourceType: string}} args - Declaration args.
1609
1821
  * @returns {import("./sync-client-types.js").SyncClientResourceConfig} Derived resource config.
1610
1822
  */
1611
- function resourceConfigFromSyncDeclaration({declaration, metadataModelClass, modelClass, resourceType}) {
1823
+ function resourceConfigFromSyncDeclaration({declaration, metadataModelClass, modelClass, offlineConfigured = false, resourceType}) {
1612
1824
  const normalizedDeclaration = declaration === true ? {} : declaration
1613
1825
 
1614
1826
  if (!normalizedDeclaration || typeof normalizedDeclaration !== "object" || Array.isArray(normalizedDeclaration)) {
@@ -1632,7 +1844,7 @@ function resourceConfigFromSyncDeclaration({declaration, metadataModelClass, mod
1632
1844
 
1633
1845
  const derived = derivedSyncAttributes({modelClass: metadataModelClass, resourceType})
1634
1846
 
1635
- if (conflictTracking) validateConflictTracking({conflictTracking, derived, resourceType})
1847
+ if (conflictTracking) validateConflictTracking({conflictTracking, derived, offlineConfigured, resourceType})
1636
1848
 
1637
1849
  return {
1638
1850
  afterApply,
@@ -1656,19 +1868,26 @@ function resourceConfigFromSyncDeclaration({declaration, metadataModelClass, mod
1656
1868
 
1657
1869
  /**
1658
1870
  * Validates one resource's durable conflict-tracking declaration.
1659
- * @param {{conflictTracking: import("./sync-client-types.js").SyncClientConflictTrackingConfig, derived: {booleanAttributes: string[], localOnlyAttributes: string[]}, resourceType: string}} args - Validation args.
1871
+ * @param {{conflictTracking: import("./sync-client-types.js").SyncClientConflictTrackingConfig, derived: {booleanAttributes: string[], localOnlyAttributes: string[]}, offlineConfigured: boolean, resourceType: string}} args - Validation args.
1660
1872
  * @returns {void}
1661
1873
  */
1662
- function validateConflictTracking({conflictTracking, derived, resourceType}) {
1663
- const requiredStrings = {
1874
+ function validateConflictTracking({conflictTracking, derived, offlineConfigured, resourceType}) {
1875
+ const identityFields = {
1664
1876
  actorDeviceId: conflictTracking.actorDeviceId,
1665
1877
  actorUserId: conflictTracking.actorUserId,
1666
1878
  offlineGrantId: conflictTracking.offlineGrantId,
1667
1879
  policyHash: conflictTracking.policyHash
1668
1880
  }
1669
1881
 
1670
- for (const [key, value] of Object.entries(requiredStrings)) {
1671
- if (typeof value !== "string" || value.length === 0) throw new Error(`${resourceType} conflictTracking.${key} must be a non-empty string`)
1882
+ for (const [key, value] of Object.entries(identityFields)) {
1883
+ // Grants and policy hashes rotate at runtime: a declaration may provide a
1884
+ // static string, a function resolving the value at queue time, or omit the
1885
+ // key entirely when a `sync.client.offline` session supplies it.
1886
+ if (value === undefined && offlineConfigured) continue
1887
+ if (typeof value === "function") continue
1888
+ if (typeof value !== "string" || value.length === 0) {
1889
+ throw new Error(`${resourceType} conflictTracking.${key} must be a non-empty string, a function resolving one, or omitted when sync.client.offline supplies it`)
1890
+ }
1672
1891
  }
1673
1892
  if (!conflictTracking.mutationLog || typeof conflictTracking.mutationLog.append !== "function") throw new Error(`${resourceType} conflictTracking.mutationLog must be a LocalMutationLog`)
1674
1893
  if (typeof conflictTracking.clientMutationId !== "function") throw new Error(`${resourceType} conflictTracking.clientMutationId must be a function`)
@@ -1744,6 +1963,30 @@ function normalizedTrack(track) {
1744
1963
  return track
1745
1964
  }
1746
1965
 
1966
+ /**
1967
+ * Builds a framework-owned POSTer over the configured transport for a path
1968
+ * chosen at call time (used by the long-offline session's bootstrap/replay
1969
+ * endpoints, which live outside the sync.api mount path).
1970
+ * @param {{requestContext: import("../remote-request-context.js").RemoteRequestContext, transport: import("../configuration-types.js").VelociousSyncClientTransport}} args - Poster args.
1971
+ * @returns {(path: string, payload: Record<string, ReturnType<typeof JSON.parse>>, options?: {signal?: AbortSignal}) => Promise<ReturnType<typeof JSON.parse>>} Path-selecting sync endpoint POSTer.
1972
+ */
1973
+ function transportPathPoster({requestContext, transport}) {
1974
+ return async (path, payload, options = {}) => {
1975
+ const requestPayload = mergeRemoteRequestContext({
1976
+ context: requestContext,
1977
+ label: "Sync client request context",
1978
+ params: payload
1979
+ })
1980
+ const response = await transport.post(path, requestPayload, {signal: options.signal})
1981
+
1982
+ if (!response || typeof response.json !== "function") {
1983
+ throw new Error(`sync.client transport.post must resolve to a response with a json() method for ${path} (like the frontend-model websocket client)`)
1984
+ }
1985
+
1986
+ return await response.json()
1987
+ }
1988
+ }
1989
+
1747
1990
  /**
1748
1991
  * Builds a framework-owned sync endpoint POSTer over the configured transport.
1749
1992
  * @param {{path: string, requestContext: import("../remote-request-context.js").RemoteRequestContext, transport: import("../configuration-types.js").VelociousSyncClientTransport}} args - Poster args.