velocious 1.0.474 → 1.0.476
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 +10 -2
- package/build/configuration-types.js +39 -1
- package/build/configuration.js +46 -1
- package/build/database/drivers/base.js +67 -3
- package/build/database/drivers/mssql/index.js +13 -0
- package/build/database/drivers/mssql/sql/remove-index.js +6 -0
- package/build/database/drivers/mysql/index.js +87 -7
- package/build/database/drivers/mysql/sql/remove-index.js +6 -0
- package/build/database/drivers/pgsql/index.js +13 -0
- package/build/database/drivers/pgsql/sql/remove-index.js +6 -0
- package/build/database/drivers/sqlite/base.js +13 -0
- package/build/database/drivers/sqlite/connection-sql-js.js +16 -5
- package/build/database/drivers/sqlite/index.web.js +29 -15
- package/build/database/drivers/sqlite/sql/remove-index.js +6 -0
- package/build/database/drivers/sqlite/web-persistence.js +521 -0
- package/build/database/migration/index.js +44 -0
- package/build/database/pool/base-methods-forward.js +1 -0
- package/build/database/query/remove-index-base.js +39 -0
- package/build/database/record/index.js +25 -9
- package/build/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
- package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +63 -0
- package/build/frontend-model-controller.js +401 -4
- package/build/frontend-model-resource/base-resource.js +5 -1
- package/build/frontend-models/base.js +146 -2
- package/build/frontend-models/query.js +3 -3
- package/build/frontend-models/resource-definition.js +304 -2
- package/build/routes/hooks/frontend-model-command-route-hook.js +16 -0
- package/build/src/configuration-types.d.ts +118 -2
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +36 -2
- package/build/src/configuration.d.ts +18 -1
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +42 -2
- package/build/src/database/drivers/base.d.ts +50 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +62 -4
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +12 -1
- package/build/src/database/drivers/mssql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/mssql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/mssql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/mysql/index.d.ts +32 -3
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +76 -7
- package/build/src/database/drivers/mysql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/mysql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/mysql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/index.js +12 -1
- package/build/src/database/drivers/pgsql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/pgsql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/pgsql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/base.js +12 -1
- package/build/src/database/drivers/sqlite/connection-sql-js.d.ts +8 -1
- package/build/src/database/drivers/sqlite/connection-sql-js.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/connection-sql-js.js +15 -6
- package/build/src/database/drivers/sqlite/index.web.d.ts +5 -5
- package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/index.web.js +27 -13
- package/build/src/database/drivers/sqlite/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/sqlite/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/sqlite/sql/remove-index.js +5 -0
- package/build/src/database/drivers/sqlite/web-persistence.d.ts +77 -0
- package/build/src/database/drivers/sqlite/web-persistence.d.ts.map +1 -0
- package/build/src/database/drivers/sqlite/web-persistence.js +464 -0
- package/build/src/database/migration/index.d.ts +25 -0
- package/build/src/database/migration/index.d.ts.map +1 -1
- package/build/src/database/migration/index.js +39 -1
- package/build/src/database/pool/base-methods-forward.d.ts.map +1 -1
- package/build/src/database/pool/base-methods-forward.js +2 -1
- package/build/src/database/query/remove-index-base.d.ts +35 -0
- package/build/src/database/query/remove-index-base.d.ts.map +1 -0
- package/build/src/database/query/remove-index-base.js +34 -0
- package/build/src/database/record/index.d.ts +11 -0
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +26 -11
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts +15 -0
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts.map +1 -1
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +33 -1
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +30 -0
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +53 -1
- package/build/src/frontend-model-controller.d.ts +96 -0
- package/build/src/frontend-model-controller.d.ts.map +1 -1
- package/build/src/frontend-model-controller.js +359 -5
- package/build/src/frontend-model-resource/base-resource.d.ts +4 -2
- package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
- package/build/src/frontend-model-resource/base-resource.js +7 -2
- package/build/src/frontend-models/base.d.ts +29 -0
- package/build/src/frontend-models/base.d.ts.map +1 -1
- package/build/src/frontend-models/base.js +131 -3
- package/build/src/frontend-models/query.js +4 -4
- package/build/src/frontend-models/resource-definition.d.ts +6 -0
- package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
- package/build/src/frontend-models/resource-definition.js +272 -3
- package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
- package/build/src/routes/hooks/frontend-model-command-route-hook.js +15 -1
- package/build/src/sync/device-identity.d.ts +240 -0
- package/build/src/sync/device-identity.d.ts.map +1 -0
- package/build/src/sync/device-identity.js +454 -0
- package/build/src/sync/local-mutation-log.d.ts +212 -0
- package/build/src/sync/local-mutation-log.d.ts.map +1 -0
- package/build/src/sync/local-mutation-log.js +404 -0
- package/build/src/sync/offline-grant.d.ts +157 -0
- package/build/src/sync/offline-grant.d.ts.map +1 -0
- package/build/src/sync/offline-grant.js +292 -0
- package/build/sync/device-identity.js +503 -0
- package/build/sync/local-mutation-log.js +448 -0
- package/build/sync/offline-grant.js +327 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/configuration-types.js +39 -1
- package/src/configuration.js +46 -1
- package/src/database/drivers/base.js +67 -3
- package/src/database/drivers/mssql/index.js +13 -0
- package/src/database/drivers/mssql/sql/remove-index.js +6 -0
- package/src/database/drivers/mysql/index.js +87 -7
- package/src/database/drivers/mysql/sql/remove-index.js +6 -0
- package/src/database/drivers/pgsql/index.js +13 -0
- package/src/database/drivers/pgsql/sql/remove-index.js +6 -0
- package/src/database/drivers/sqlite/base.js +13 -0
- package/src/database/drivers/sqlite/connection-sql-js.js +16 -5
- package/src/database/drivers/sqlite/index.web.js +29 -15
- package/src/database/drivers/sqlite/sql/remove-index.js +6 -0
- package/src/database/drivers/sqlite/web-persistence.js +521 -0
- package/src/database/migration/index.js +44 -0
- package/src/database/pool/base-methods-forward.js +1 -0
- package/src/database/query/remove-index-base.js +39 -0
- package/src/database/record/index.js +25 -9
- package/src/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
- package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +63 -0
- package/src/frontend-model-controller.js +401 -4
- package/src/frontend-model-resource/base-resource.js +5 -1
- package/src/frontend-models/base.js +146 -2
- package/src/frontend-models/query.js +3 -3
- package/src/frontend-models/resource-definition.js +304 -2
- package/src/routes/hooks/frontend-model-command-route-hook.js +16 -0
- package/src/sync/device-identity.js +503 -0
- package/src/sync/local-mutation-log.js +448 -0
- package/src/sync/offline-grant.js +327 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const DEFAULT_STORAGE_KEY = "velocious.sync.localMutationLog"
|
|
4
|
+
const PENDING_STATUS_VALUES = /** @type {LocalMutationStatus[]} */ (["pending", "applied-locally", "peer-applied"])
|
|
5
|
+
const PENDING_STATUSES = new Set(PENDING_STATUS_VALUES)
|
|
6
|
+
const MUTATION_STATUSES = new Set([...PENDING_STATUSES, "conflict", "rejected", "synced"])
|
|
7
|
+
const TERMINAL_STATUSES = new Set(["rejected", "synced"])
|
|
8
|
+
/** @type {Map<string, Promise<unknown>>} */
|
|
9
|
+
const STORAGE_KEY_LOCKS = new Map()
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Local mutation log record query options.
|
|
13
|
+
* @typedef {object} LocalMutationLogRecordsOptions
|
|
14
|
+
* @property {LocalMutationStatus[]} [statuses] - Optional status filter.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Local mutation log row-oriented storage adapter.
|
|
19
|
+
*
|
|
20
|
+
* Implementations should store each mutation log record as its own row/entry.
|
|
21
|
+
* Native apps should back this with SQLite and indexes on storage key, status,
|
|
22
|
+
* and sequence. Avoid storing the whole log as one JSON blob.
|
|
23
|
+
*
|
|
24
|
+
* @typedef {object} LocalMutationLogStorage
|
|
25
|
+
* @property {(storageKey: string, record: LocalMutationLogRecord) => Promise<void> | void} appendRecord - Appends one log record.
|
|
26
|
+
* @property {(storageKey: string, ids: string[]) => Promise<void> | void} deleteRecords - Deletes log records by id.
|
|
27
|
+
* @property {(storageKey: string) => Promise<number> | number} nextSequence - Returns the next local sequence number.
|
|
28
|
+
* @property {(storageKey: string, id: string) => Promise<LocalMutationLogRecord | null | undefined> | LocalMutationLogRecord | null | undefined} record - Reads one log record by id.
|
|
29
|
+
* @property {(storageKey: string, options?: LocalMutationLogRecordsOptions) => Promise<LocalMutationLogRecord[]> | LocalMutationLogRecord[]} records - Reads log records.
|
|
30
|
+
* @property {(storageKey: string, record: LocalMutationLogRecord) => Promise<void> | void} updateRecord - Replaces one log record.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Local sync mutation dependency metadata.
|
|
35
|
+
* @typedef {object} LocalMutationDependency
|
|
36
|
+
* @property {string} clientMutationId - Client mutation id this mutation depends on.
|
|
37
|
+
* @property {string} model - Dependent model/resource name.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Local mutation log status.
|
|
42
|
+
* @typedef {"pending" | "applied-locally" | "peer-applied" | "conflict" | "rejected" | "synced"} LocalMutationStatus
|
|
43
|
+
* */
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Local mutation log record.
|
|
47
|
+
* @typedef {object} LocalMutationLogRecord
|
|
48
|
+
* @property {string} createdAt - ISO timestamp when the record was created locally.
|
|
49
|
+
* @property {LocalMutationDependency[]} dependencies - Other local mutations that must replay first.
|
|
50
|
+
* @property {string} id - Local log record id.
|
|
51
|
+
* @property {import("./device-identity.js").SyncMutation} mutation - Device mutation payload.
|
|
52
|
+
* @property {number} sequence - Monotonic local sequence.
|
|
53
|
+
* @property {LocalMutationStatus} status - Local replay/apply status.
|
|
54
|
+
* @property {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} [syncResult] - Backend replay/result metadata.
|
|
55
|
+
* @property {string} updatedAt - ISO timestamp when the record was last changed.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/** Client-side append-only sync mutation log with pluggable persistent storage. */
|
|
59
|
+
export default class LocalMutationLog {
|
|
60
|
+
/**
|
|
61
|
+
* Creates a local mutation log.
|
|
62
|
+
* @param {object} args - Arguments.
|
|
63
|
+
* @param {() => string} [args.idGenerator] - Record id generator.
|
|
64
|
+
* @param {() => Date} [args.now] - Clock callback.
|
|
65
|
+
* @param {LocalMutationLogStorage} args.storage - Persistent storage adapter.
|
|
66
|
+
* @param {string} [args.storageKey] - Storage key.
|
|
67
|
+
*/
|
|
68
|
+
constructor({idGenerator = randomRecordId, now = () => new Date(), storage, storageKey = DEFAULT_STORAGE_KEY}) {
|
|
69
|
+
if (!validStorage(storage)) {
|
|
70
|
+
throw new Error("LocalMutationLog requires row storage with appendRecord/deleteRecords/nextSequence/record/records/updateRecord")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.idGenerator = idGenerator
|
|
74
|
+
this.now = now
|
|
75
|
+
this.storage = storage
|
|
76
|
+
this.storageKey = storageKey
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Appends a pending mutation record.
|
|
81
|
+
* @param {object} args - Arguments.
|
|
82
|
+
* @param {LocalMutationDependency[]} [args.dependencies] - Mutation dependencies.
|
|
83
|
+
* @param {import("./device-identity.js").SyncMutation} args.mutation - Mutation payload.
|
|
84
|
+
* @returns {Promise<LocalMutationLogRecord>} - Created log record.
|
|
85
|
+
*/
|
|
86
|
+
async append({dependencies = [], mutation}) {
|
|
87
|
+
return await withStorageKeyLock(this.storageKey, async () => {
|
|
88
|
+
const timestamp = this.currentTimestamp()
|
|
89
|
+
const record = normalizeRecord({
|
|
90
|
+
createdAt: timestamp,
|
|
91
|
+
dependencies,
|
|
92
|
+
id: this.idGenerator(),
|
|
93
|
+
mutation,
|
|
94
|
+
sequence: await this.storage.nextSequence(this.storageKey),
|
|
95
|
+
status: "pending",
|
|
96
|
+
updatedAt: timestamp
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
await this.storage.appendRecord(this.storageKey, cloneRecord(record))
|
|
100
|
+
|
|
101
|
+
return cloneRecord(record)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Returns all records ordered by local sequence.
|
|
107
|
+
* @returns {Promise<LocalMutationLogRecord[]>} - Log records.
|
|
108
|
+
*/
|
|
109
|
+
async records() {
|
|
110
|
+
return normalizeRecordList(await this.storage.records(this.storageKey))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns records that still need local/server reconciliation.
|
|
115
|
+
* @returns {Promise<LocalMutationLogRecord[]>} - Pending records.
|
|
116
|
+
*/
|
|
117
|
+
async pendingRecords() {
|
|
118
|
+
return normalizeRecordList(await this.storage.records(this.storageKey, {statuses: PENDING_STATUS_VALUES}))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Updates a record status.
|
|
123
|
+
* @param {object} args - Arguments.
|
|
124
|
+
* @param {string} args.id - Record id.
|
|
125
|
+
* @param {LocalMutationStatus} args.status - New status.
|
|
126
|
+
* @param {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} [args.syncResult] - Result metadata.
|
|
127
|
+
* @returns {Promise<LocalMutationLogRecord>} - Updated record.
|
|
128
|
+
*/
|
|
129
|
+
async updateStatus({id, status, syncResult}) {
|
|
130
|
+
if (!MUTATION_STATUSES.has(status)) throw new Error(`Unknown local mutation status '${status}'`)
|
|
131
|
+
|
|
132
|
+
return await withStorageKeyLock(this.storageKey, async () => {
|
|
133
|
+
const rawRecord = await this.storage.record(this.storageKey, id)
|
|
134
|
+
|
|
135
|
+
if (!rawRecord) throw new Error(`No local mutation log record '${id}'`)
|
|
136
|
+
|
|
137
|
+
const record = normalizeRecord(rawRecord)
|
|
138
|
+
|
|
139
|
+
record.status = /** @type {LocalMutationStatus} */ (status)
|
|
140
|
+
if (syncResult !== undefined) record.syncResult = cloneJsonObject(syncResult, "syncResult")
|
|
141
|
+
record.updatedAt = this.currentTimestamp()
|
|
142
|
+
await this.storage.updateRecord(this.storageKey, cloneRecord(record))
|
|
143
|
+
|
|
144
|
+
return cloneRecord(record)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Prunes terminal records that are no longer needed for replay dependencies.
|
|
150
|
+
* @param {object} [args] - Compaction options.
|
|
151
|
+
* @param {number} [args.maxTerminalRecords] - Maximum terminal records to retain.
|
|
152
|
+
* @param {number} [args.terminalRetentionMs] - Minimum age before pruning terminal records.
|
|
153
|
+
* @returns {Promise<{deletedRecordIds: string[]}>} - Compaction result.
|
|
154
|
+
*/
|
|
155
|
+
async compact({maxTerminalRecords, terminalRetentionMs} = {}) {
|
|
156
|
+
return await withStorageKeyLock(this.storageKey, async () => {
|
|
157
|
+
const records = await this.records()
|
|
158
|
+
const protectedClientMutationIds = new Set(
|
|
159
|
+
records
|
|
160
|
+
.filter((record) => PENDING_STATUSES.has(record.status) || record.status === "conflict")
|
|
161
|
+
.flatMap((record) => record.dependencies.map((dependency) => dependency.clientMutationId))
|
|
162
|
+
)
|
|
163
|
+
const terminalRecords = records
|
|
164
|
+
.filter((record) => TERMINAL_STATUSES.has(record.status))
|
|
165
|
+
.filter((record) => !protectedClientMutationIds.has(record.mutation.clientMutationId))
|
|
166
|
+
.sort(compareRecordsNewestFirst)
|
|
167
|
+
const deleteIds = new Set()
|
|
168
|
+
|
|
169
|
+
if (typeof maxTerminalRecords === "number" && maxTerminalRecords >= 0) {
|
|
170
|
+
for (const record of terminalRecords.slice(maxTerminalRecords)) deleteIds.add(record.id)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (typeof terminalRetentionMs === "number" && terminalRetentionMs >= 0) {
|
|
174
|
+
const cutoff = this.now().getTime() - terminalRetentionMs
|
|
175
|
+
|
|
176
|
+
for (const record of terminalRecords) {
|
|
177
|
+
if (new Date(record.updatedAt).getTime() < cutoff) deleteIds.add(record.id)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const deletedRecordIds = Array.from(deleteIds)
|
|
182
|
+
|
|
183
|
+
if (deletedRecordIds.length > 0) await this.storage.deleteRecords(this.storageKey, deletedRecordIds)
|
|
184
|
+
|
|
185
|
+
return {deletedRecordIds}
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Returns the current log timestamp.
|
|
191
|
+
* @returns {string} - Current ISO timestamp.
|
|
192
|
+
*/
|
|
193
|
+
currentTimestamp() {
|
|
194
|
+
const date = this.now()
|
|
195
|
+
|
|
196
|
+
if (!(date instanceof Date) || Number.isNaN(date.getTime())) throw new Error("LocalMutationLog now() must return a valid Date")
|
|
197
|
+
|
|
198
|
+
return date.toISOString()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Checks whether a storage adapter has all required row-store methods.
|
|
204
|
+
* @param {unknown} storage - Storage adapter candidate.
|
|
205
|
+
* @returns {storage is LocalMutationLogStorage} - Whether storage is valid.
|
|
206
|
+
*/
|
|
207
|
+
function validStorage(storage) {
|
|
208
|
+
if (!storage || typeof storage !== "object") return false
|
|
209
|
+
|
|
210
|
+
const storageObject = /** @type {Record<string, unknown>} */ (storage)
|
|
211
|
+
|
|
212
|
+
return typeof storageObject.appendRecord === "function"
|
|
213
|
+
&& typeof storageObject.deleteRecords === "function"
|
|
214
|
+
&& typeof storageObject.nextSequence === "function"
|
|
215
|
+
&& typeof storageObject.record === "function"
|
|
216
|
+
&& typeof storageObject.records === "function"
|
|
217
|
+
&& typeof storageObject.updateRecord === "function"
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Sorts records by newest update/sequence first.
|
|
222
|
+
* @param {LocalMutationLogRecord} left - Left record.
|
|
223
|
+
* @param {LocalMutationLogRecord} right - Right record.
|
|
224
|
+
* @returns {number} - Sort result.
|
|
225
|
+
*/
|
|
226
|
+
function compareRecordsNewestFirst(left, right) {
|
|
227
|
+
const updatedAtComparison = right.updatedAt.localeCompare(left.updatedAt)
|
|
228
|
+
|
|
229
|
+
if (updatedAtComparison !== 0) return updatedAtComparison
|
|
230
|
+
|
|
231
|
+
return right.sequence - left.sequence
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Normalizes and sorts a list of records.
|
|
236
|
+
* @param {unknown} records - Raw records.
|
|
237
|
+
* @returns {LocalMutationLogRecord[]} - Normalized records.
|
|
238
|
+
*/
|
|
239
|
+
function normalizeRecordList(records) {
|
|
240
|
+
if (!Array.isArray(records)) throw new Error("Expected local mutation log storage records array")
|
|
241
|
+
|
|
242
|
+
return records
|
|
243
|
+
.map(normalizeRecord)
|
|
244
|
+
.sort((left, right) => left.sequence - right.sequence)
|
|
245
|
+
.map((record) => cloneRecord(record))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Runs a callback after earlier writes for the same storage key have completed.
|
|
250
|
+
* @template T
|
|
251
|
+
* @param {string} storageKey - Storage key to serialize.
|
|
252
|
+
* @param {() => Promise<T>} callback - Callback to run under the storage-key lock.
|
|
253
|
+
* @returns {Promise<T>} - Callback result.
|
|
254
|
+
*/
|
|
255
|
+
async function withStorageKeyLock(storageKey, callback) {
|
|
256
|
+
const previous = STORAGE_KEY_LOCKS.get(storageKey) || Promise.resolve()
|
|
257
|
+
let release = () => {}
|
|
258
|
+
const current = new Promise((resolve) => { release = () => resolve(undefined) })
|
|
259
|
+
const chained = previous.catch((_error) => {}).then(() => current)
|
|
260
|
+
|
|
261
|
+
STORAGE_KEY_LOCKS.set(storageKey, chained)
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
await previous.catch((_error) => {})
|
|
265
|
+
|
|
266
|
+
return await callback()
|
|
267
|
+
} finally {
|
|
268
|
+
release()
|
|
269
|
+
if (STORAGE_KEY_LOCKS.get(storageKey) === chained) STORAGE_KEY_LOCKS.delete(storageKey)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Normalizes a persisted log record.
|
|
275
|
+
* @param {unknown} value - Raw record.
|
|
276
|
+
* @returns {LocalMutationLogRecord} - Normalized record.
|
|
277
|
+
*/
|
|
278
|
+
function normalizeRecord(value) {
|
|
279
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected local mutation log record object")
|
|
280
|
+
|
|
281
|
+
const record = /** @type {Record<string, unknown>} */ (value)
|
|
282
|
+
const status = requiredString(record.status, "status")
|
|
283
|
+
|
|
284
|
+
if (!MUTATION_STATUSES.has(status)) throw new Error(`Unknown local mutation status '${status}'`)
|
|
285
|
+
|
|
286
|
+
const baseRecord = {
|
|
287
|
+
createdAt: requiredIsoTimestamp(record.createdAt, "createdAt"),
|
|
288
|
+
dependencies: normalizeDependencies(record.dependencies),
|
|
289
|
+
id: requiredString(record.id, "id"),
|
|
290
|
+
mutation: normalizeMutation(record.mutation),
|
|
291
|
+
sequence: requiredPositiveInteger(record.sequence, "sequence"),
|
|
292
|
+
status: /** @type {LocalMutationStatus} */ (status),
|
|
293
|
+
updatedAt: requiredIsoTimestamp(record.updatedAt, "updatedAt")
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (record.syncResult === undefined) return baseRecord
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
...baseRecord,
|
|
300
|
+
syncResult: cloneJsonObject(record.syncResult, "syncResult")
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Normalizes dependency metadata entries.
|
|
306
|
+
* @param {unknown} value - Raw dependencies.
|
|
307
|
+
* @returns {LocalMutationDependency[]} - Normalized dependencies.
|
|
308
|
+
*/
|
|
309
|
+
function normalizeDependencies(value) {
|
|
310
|
+
if (value === undefined) return []
|
|
311
|
+
if (!Array.isArray(value)) throw new Error("Expected local mutation dependencies array")
|
|
312
|
+
|
|
313
|
+
return value.map((dependency) => {
|
|
314
|
+
if (!dependency || typeof dependency !== "object" || Array.isArray(dependency)) {
|
|
315
|
+
throw new Error("Expected local mutation dependency object")
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const dependencyObject = /** @type {Record<string, unknown>} */ (dependency)
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
clientMutationId: requiredString(dependencyObject.clientMutationId, "dependency clientMutationId"),
|
|
322
|
+
model: requiredString(dependencyObject.model, "dependency model")
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Normalizes a sync mutation payload.
|
|
329
|
+
* @param {unknown} value - Raw mutation.
|
|
330
|
+
* @returns {import("./device-identity.js").SyncMutation} - Normalized mutation.
|
|
331
|
+
*/
|
|
332
|
+
function normalizeMutation(value) {
|
|
333
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected local sync mutation object")
|
|
334
|
+
|
|
335
|
+
const mutation = /** @type {Record<string, unknown>} */ (value)
|
|
336
|
+
const normalized = /** @type {import("./device-identity.js").SyncMutation} */ ({
|
|
337
|
+
actorDeviceId: requiredString(mutation.actorDeviceId, "actorDeviceId"),
|
|
338
|
+
actorUserId: requiredString(mutation.actorUserId, "actorUserId"),
|
|
339
|
+
clientMutationId: requiredString(mutation.clientMutationId, "clientMutationId"),
|
|
340
|
+
model: requiredString(mutation.model, "model"),
|
|
341
|
+
occurredAt: requiredIsoTimestamp(mutation.occurredAt, "occurredAt"),
|
|
342
|
+
offlineGrantId: requiredString(mutation.offlineGrantId, "offlineGrantId"),
|
|
343
|
+
operation: requiredString(mutation.operation, "operation"),
|
|
344
|
+
policyHash: requiredString(mutation.policyHash, "policyHash")
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
if (mutation.attributes !== undefined) normalized.attributes = cloneJsonObject(mutation.attributes, "attributes")
|
|
348
|
+
if (mutation.baseVersion !== undefined) normalized.baseVersion = cloneBaseVersion(mutation.baseVersion)
|
|
349
|
+
if (mutation.command !== undefined) normalized.command = requiredString(mutation.command, "command")
|
|
350
|
+
if (mutation.payload !== undefined) normalized.payload = cloneJsonObject(mutation.payload, "payload")
|
|
351
|
+
|
|
352
|
+
return normalized
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Requires a non-empty string value.
|
|
357
|
+
* @param {unknown} value - Raw value.
|
|
358
|
+
* @param {string} label - Field label.
|
|
359
|
+
* @returns {string} - Required string.
|
|
360
|
+
*/
|
|
361
|
+
function requiredString(value, label) {
|
|
362
|
+
if (typeof value !== "string" || value.length < 1) throw new Error(`Expected local mutation ${label}`)
|
|
363
|
+
|
|
364
|
+
return value
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Requires an ISO timestamp string.
|
|
369
|
+
* @param {unknown} value - Raw value.
|
|
370
|
+
* @param {string} label - Field label.
|
|
371
|
+
* @returns {string} - ISO timestamp.
|
|
372
|
+
*/
|
|
373
|
+
function requiredIsoTimestamp(value, label) {
|
|
374
|
+
const stringValue = requiredString(value, label)
|
|
375
|
+
const date = new Date(stringValue)
|
|
376
|
+
|
|
377
|
+
if (Number.isNaN(date.getTime()) || date.toISOString() !== stringValue) throw new Error(`Expected local mutation ${label} ISO timestamp`)
|
|
378
|
+
|
|
379
|
+
return stringValue
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Requires a positive integer value.
|
|
384
|
+
* @param {unknown} value - Raw value.
|
|
385
|
+
* @param {string} label - Field label.
|
|
386
|
+
* @returns {number} - Positive integer.
|
|
387
|
+
*/
|
|
388
|
+
function requiredPositiveInteger(value, label) {
|
|
389
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) throw new Error(`Expected local mutation ${label} positive integer`)
|
|
390
|
+
|
|
391
|
+
return value
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Clones a supported base-version value.
|
|
396
|
+
* @param {unknown} value - Raw base version.
|
|
397
|
+
* @returns {string | number | null} - Normalized base version.
|
|
398
|
+
*/
|
|
399
|
+
function cloneBaseVersion(value) {
|
|
400
|
+
if (value === null || typeof value === "string" || typeof value === "number") return value
|
|
401
|
+
|
|
402
|
+
throw new Error("Expected local mutation baseVersion string, number, or null")
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Clones a JSON-compatible value.
|
|
407
|
+
* @param {unknown} value - Raw JSON value.
|
|
408
|
+
* @param {string} label - Field label.
|
|
409
|
+
* @returns {import("../configuration-types.js").FrontendModelSyncJsonValue} - Cloned JSON value.
|
|
410
|
+
*/
|
|
411
|
+
function cloneJsonValue(value, label) {
|
|
412
|
+
if (value === undefined || typeof value === "function") throw new Error(`Expected JSON-compatible local mutation ${label}`)
|
|
413
|
+
|
|
414
|
+
return /** @type {import("../configuration-types.js").FrontendModelSyncJsonValue} */ (JSON.parse(JSON.stringify(value)))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Clones a JSON-compatible object.
|
|
419
|
+
* @param {unknown} value - Raw JSON object.
|
|
420
|
+
* @param {string} label - Field label.
|
|
421
|
+
* @returns {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} - Cloned JSON object.
|
|
422
|
+
*/
|
|
423
|
+
function cloneJsonObject(value, label) {
|
|
424
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Expected local mutation ${label} object`)
|
|
425
|
+
|
|
426
|
+
return /** @type {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} */ (cloneJsonValue(value, label))
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Clones a local mutation log record.
|
|
431
|
+
* @param {LocalMutationLogRecord} record - Record to clone.
|
|
432
|
+
* @returns {LocalMutationLogRecord} - Cloned record.
|
|
433
|
+
*/
|
|
434
|
+
function cloneRecord(record) {
|
|
435
|
+
return /** @type {LocalMutationLogRecord} */ (JSON.parse(JSON.stringify(record)))
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Generates a random local mutation record id.
|
|
440
|
+
* @returns {string} - Random record id.
|
|
441
|
+
*/
|
|
442
|
+
function randomRecordId() {
|
|
443
|
+
const cryptoProvider = globalThis.crypto
|
|
444
|
+
|
|
445
|
+
if (cryptoProvider && typeof cryptoProvider.randomUUID === "function") return cryptoProvider.randomUUID()
|
|
446
|
+
|
|
447
|
+
return `local-mutation-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
448
|
+
}
|