velocious 1.0.700 → 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.
- package/README.md +1 -0
- package/build/configuration-types.js +73 -3
- package/build/configuration.js +113 -20
- package/build/frontend-model-resource/base-resource.js +150 -2
- package/build/frontend-models/resource-definition.js +47 -0
- package/build/src/configuration-types.d.ts +168 -7
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +68 -4
- package/build/src/configuration.d.ts +22 -2
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +110 -20
- package/build/src/frontend-model-resource/base-resource.d.ts +76 -3
- package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
- package/build/src/frontend-model-resource/base-resource.js +127 -3
- package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
- package/build/src/frontend-models/resource-definition.js +43 -1
- package/build/src/sync/local-mutation-log.d.ts +6 -3
- package/build/src/sync/local-mutation-log.d.ts.map +1 -1
- package/build/src/sync/local-mutation-log.js +11 -4
- package/build/src/sync/peer-mutation-bundle.d.ts +26 -0
- package/build/src/sync/peer-mutation-bundle.d.ts.map +1 -1
- package/build/src/sync/peer-mutation-bundle.js +7 -1
- package/build/src/sync/sync-api-client.d.ts +3 -1
- package/build/src/sync/sync-api-client.d.ts.map +1 -1
- package/build/src/sync/sync-api-client.js +46 -8
- package/build/src/sync/sync-client-types.d.ts +24 -12
- package/build/src/sync/sync-client-types.d.ts.map +1 -1
- package/build/src/sync/sync-client-types.js +7 -5
- package/build/src/sync/sync-client.d.ts +101 -0
- package/build/src/sync/sync-client.d.ts.map +1 -1
- package/build/src/sync/sync-client.js +236 -24
- package/build/src/sync/sync-offline-session.d.ts +328 -0
- package/build/src/sync/sync-offline-session.d.ts.map +1 -0
- package/build/src/sync/sync-offline-session.js +617 -0
- package/build/src/sync/sync-resource-base.d.ts +26 -28
- package/build/src/sync/sync-resource-base.d.ts.map +1 -1
- package/build/src/sync/sync-resource-base.js +11 -45
- package/build/sync/local-mutation-log.js +10 -3
- package/build/sync/peer-mutation-bundle.js +6 -0
- package/build/sync/sync-api-client.js +53 -7
- package/build/sync/sync-client-types.js +6 -4
- package/build/sync/sync-client.js +264 -21
- package/build/sync/sync-offline-session.js +713 -0
- package/build/sync/sync-resource-base.js +10 -53
- package/package.json +1 -1
- package/src/configuration-types.js +73 -3
- package/src/configuration.js +113 -20
- package/src/frontend-model-resource/base-resource.js +150 -2
- package/src/frontend-models/resource-definition.js +47 -0
- package/src/sync/local-mutation-log.js +10 -3
- package/src/sync/peer-mutation-bundle.js +6 -0
- package/src/sync/sync-api-client.js +53 -7
- package/src/sync/sync-client-types.js +6 -4
- package/src/sync/sync-client.js +264 -21
- package/src/sync/sync-offline-session.js +713 -0
- package/src/sync/sync-resource-base.js +10 -53
|
@@ -27,6 +27,7 @@ const RESOURCE_STATIC_CONFIG_KEYS = new Set([
|
|
|
27
27
|
"ModelClass",
|
|
28
28
|
"primaryKey",
|
|
29
29
|
"quickSearchColumns",
|
|
30
|
+
"quickSearchNumericColumns",
|
|
30
31
|
"relationships",
|
|
31
32
|
"ReplayServiceClass",
|
|
32
33
|
"server",
|
|
@@ -168,6 +169,8 @@ function normalizeFrontendModelResourceConfiguration(resourceConfiguration) {
|
|
|
168
169
|
"memberCommands",
|
|
169
170
|
"modelName",
|
|
170
171
|
"primaryKey",
|
|
172
|
+
"quickSearchColumns",
|
|
173
|
+
"quickSearchNumericColumns",
|
|
171
174
|
"relationships",
|
|
172
175
|
"server",
|
|
173
176
|
"sync"
|
|
@@ -180,6 +183,8 @@ function normalizeFrontendModelResourceConfiguration(resourceConfiguration) {
|
|
|
180
183
|
|
|
181
184
|
const normalizedCommands = normalizeFrontendModelResourceCommands(resourceConfiguration)
|
|
182
185
|
const sync = normalizeFrontendModelResourceSync(resourceConfiguration)
|
|
186
|
+
const quickSearchColumns = normalizeFrontendModelQuickSearchColumns(resourceConfiguration.quickSearchColumns, "quickSearchColumns")
|
|
187
|
+
const quickSearchNumericColumns = normalizeFrontendModelQuickSearchColumns(resourceConfiguration.quickSearchNumericColumns, "quickSearchNumericColumns")
|
|
183
188
|
|
|
184
189
|
return {
|
|
185
190
|
...resourceConfiguration,
|
|
@@ -192,6 +197,8 @@ function normalizeFrontendModelResourceConfiguration(resourceConfiguration) {
|
|
|
192
197
|
// generator uses it to type each custom command method.
|
|
193
198
|
commandMetadata: normalizedCommands.commandMetadata,
|
|
194
199
|
memberCommands: normalizedCommands.memberCommands,
|
|
200
|
+
quickSearchColumns,
|
|
201
|
+
quickSearchNumericColumns,
|
|
195
202
|
sync
|
|
196
203
|
}
|
|
197
204
|
}
|
|
@@ -518,6 +525,46 @@ function normalizeSyncOperations(operations) {
|
|
|
518
525
|
return [...new Set(normalized)].sort()
|
|
519
526
|
}
|
|
520
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Normalizes declarative quick-search column entries into a stable list.
|
|
530
|
+
* Each entry is a plain column-name string or a `{path, column}` object whose
|
|
531
|
+
* path is a relationship path from the root model.
|
|
532
|
+
* @param {unknown} columns - Raw quick-search column declarations.
|
|
533
|
+
* @param {string} label - Config key label for error messages.
|
|
534
|
+
* @returns {Array<import("../configuration-types.js").FrontendModelQuickSearchColumn> | null} - Normalized columns, or null when undeclared.
|
|
535
|
+
*/
|
|
536
|
+
function normalizeFrontendModelQuickSearchColumns(columns, label) {
|
|
537
|
+
if (columns === undefined || columns === null) return null
|
|
538
|
+
|
|
539
|
+
if (!Array.isArray(columns)) throw new Error(`Resource ${label} must be an array of column names or {path, column} objects.`)
|
|
540
|
+
|
|
541
|
+
return columns.map((entry) => {
|
|
542
|
+
if (typeof entry === "string") {
|
|
543
|
+
if (entry.length < 1) throw new Error(`Resource ${label} entries must be non-empty column names.`)
|
|
544
|
+
|
|
545
|
+
return {column: entry}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
549
|
+
throw new Error(`Resource ${label} entries must be column names or {path, column} objects, got: ${JSON.stringify(entry)}`)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const entryRecord = /** @type {Record<string, unknown>} */ (entry)
|
|
553
|
+
const column = entryRecord.column
|
|
554
|
+
const path = entryRecord.path
|
|
555
|
+
|
|
556
|
+
if (typeof column !== "string" || column.length < 1) throw new Error(`Resource ${label} entries require a non-empty column name.`)
|
|
557
|
+
|
|
558
|
+
if (path === undefined || path === null) return {column}
|
|
559
|
+
|
|
560
|
+
if (!Array.isArray(path) || path.some((pathSegment) => typeof pathSegment !== "string" || pathSegment.length < 1)) {
|
|
561
|
+
throw new Error(`Resource ${label} entry paths must be arrays of non-empty relationship names.`)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return {column, path: [...path]}
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
|
|
521
568
|
/**
|
|
522
569
|
* Builds a deterministic policy hash.
|
|
523
570
|
* @param {unknown} value - Hash input.
|
|
@@ -173,11 +173,17 @@ export default class LocalMutationLog {
|
|
|
173
173
|
|
|
174
174
|
/**
|
|
175
175
|
* Replaces the mutation payload of a still-pending record. Used when an
|
|
176
|
-
* acknowledged predecessor supplies the authoritative base for its successor
|
|
177
|
-
*
|
|
176
|
+
* acknowledged predecessor supplies the authoritative base for its successor
|
|
177
|
+
* and when an offline session re-signs a pending record under a refreshed
|
|
178
|
+
* grant (pass the new `signedMutation` envelope together with the mutation).
|
|
179
|
+
* @param {{id: string, mutation: import("./device-identity.js").SyncMutation, signedMutation?: import("./device-identity.js").SignedSyncMutation}} args - Record and replacement mutation.
|
|
178
180
|
* @returns {Promise<LocalMutationLogRecord>} Updated record.
|
|
179
181
|
*/
|
|
180
|
-
async updateMutation({id, mutation}) {
|
|
182
|
+
async updateMutation({id, mutation, signedMutation}) {
|
|
183
|
+
if (signedMutation !== undefined && stableJsonStringify(signedMutation.mutation) !== stableJsonStringify(mutation)) {
|
|
184
|
+
throw new Error("signedMutation must match the mutation payload it signs")
|
|
185
|
+
}
|
|
186
|
+
|
|
181
187
|
return await withStorageKeyLock(this.storageKey, async () => {
|
|
182
188
|
const rawRecord = await this.storage.record(this.storageKey, id)
|
|
183
189
|
|
|
@@ -188,6 +194,7 @@ export default class LocalMutationLog {
|
|
|
188
194
|
if (!PENDING_STATUSES.has(record.status)) throw new Error(`Cannot update mutation for ${record.status} local mutation '${id}'`)
|
|
189
195
|
|
|
190
196
|
record.mutation = normalizeMutation(mutation)
|
|
197
|
+
if (signedMutation !== undefined) record.signedMutation = /** @type {import("./device-identity.js").SignedSyncMutation} */ (JSON.parse(JSON.stringify(signedMutation)))
|
|
191
198
|
record.updatedAt = this.currentTimestamp()
|
|
192
199
|
await this.storage.updateRecord(this.storageKey, cloneRecord(record))
|
|
193
200
|
|
|
@@ -11,6 +11,12 @@ import {createSignedMutation, mutationIdempotencyKey, verifySignedMutation} from
|
|
|
11
11
|
*/
|
|
12
12
|
/**
|
|
13
13
|
* One peer mutation bundle entry.
|
|
14
|
+
* @typedef {object} PeerMutationBundleImportResult
|
|
15
|
+
* @property {Array<{clientMutationId: string, idempotencyKey: string, localRecordId: string}>} imported - Entries appended to the local log.
|
|
16
|
+
* @property {Array<{errorMessage: string, index: number}>} rejected - Entries that failed verification.
|
|
17
|
+
* @property {Array<{clientMutationId: string, idempotencyKey: string, localRecordId: string, reason: "duplicate"}>} skipped - Entries already present by idempotency key.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
14
20
|
* @typedef {object} PeerMutationBundleEntry
|
|
15
21
|
* @property {string} [localRecordId] - Exporting device's local mutation record id.
|
|
16
22
|
* @property {number} [localSequence] - Exporting device's local mutation sequence.
|
|
@@ -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
|
|
71
|
+
dependencies,
|
|
51
72
|
mutation: {
|
|
52
|
-
actorDeviceId: conflictTracking
|
|
53
|
-
actorUserId: conflictTracking
|
|
54
|
-
attributes
|
|
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
|
|
80
|
+
offlineGrantId: await resolveConflictTrackingIdentityValue({conflictTracking, key: "offlineGrantId", resourceType}),
|
|
60
81
|
operation,
|
|
61
82
|
payload: {resourceId, syncType},
|
|
62
|
-
policyHash: conflictTracking
|
|
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.
|
package/src/sync/sync-client.js
CHANGED
|
@@ -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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
|
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(
|
|
1671
|
-
|
|
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.
|