@syncular/client 0.0.1-60
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/dist/blobs/index.d.ts +7 -0
- package/dist/blobs/index.d.ts.map +1 -0
- package/dist/blobs/index.js +7 -0
- package/dist/blobs/index.js.map +1 -0
- package/dist/blobs/manager.d.ts +345 -0
- package/dist/blobs/manager.d.ts.map +1 -0
- package/dist/blobs/manager.js +749 -0
- package/dist/blobs/manager.js.map +1 -0
- package/dist/blobs/migrate.d.ts +14 -0
- package/dist/blobs/migrate.d.ts.map +1 -0
- package/dist/blobs/migrate.js +59 -0
- package/dist/blobs/migrate.js.map +1 -0
- package/dist/blobs/types.d.ts +62 -0
- package/dist/blobs/types.d.ts.map +1 -0
- package/dist/blobs/types.js +5 -0
- package/dist/blobs/types.js.map +1 -0
- package/dist/client.d.ts +338 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +834 -0
- package/dist/client.js.map +1 -0
- package/dist/conflicts.d.ts +31 -0
- package/dist/conflicts.d.ts.map +1 -0
- package/dist/conflicts.js +118 -0
- package/dist/conflicts.js.map +1 -0
- package/dist/create-client.d.ts +115 -0
- package/dist/create-client.d.ts.map +1 -0
- package/dist/create-client.js +162 -0
- package/dist/create-client.js.map +1 -0
- package/dist/engine/SyncEngine.d.ts +215 -0
- package/dist/engine/SyncEngine.d.ts.map +1 -0
- package/dist/engine/SyncEngine.js +1066 -0
- package/dist/engine/SyncEngine.js.map +1 -0
- package/dist/engine/index.d.ts +6 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +6 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/engine/types.d.ts +230 -0
- package/dist/engine/types.d.ts.map +1 -0
- package/dist/engine/types.js +7 -0
- package/dist/engine/types.js.map +1 -0
- package/dist/handlers/create-handler.d.ts +110 -0
- package/dist/handlers/create-handler.d.ts.map +1 -0
- package/dist/handlers/create-handler.js +140 -0
- package/dist/handlers/create-handler.js.map +1 -0
- package/dist/handlers/registry.d.ts +15 -0
- package/dist/handlers/registry.d.ts.map +1 -0
- package/dist/handlers/registry.js +29 -0
- package/dist/handlers/registry.js.map +1 -0
- package/dist/handlers/types.d.ts +83 -0
- package/dist/handlers/types.d.ts.map +1 -0
- package/dist/handlers/types.js +5 -0
- package/dist/handlers/types.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/migrate.d.ts +19 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +106 -0
- package/dist/migrate.js.map +1 -0
- package/dist/mutations.d.ts +138 -0
- package/dist/mutations.d.ts.map +1 -0
- package/dist/mutations.js +611 -0
- package/dist/mutations.js.map +1 -0
- package/dist/outbox.d.ts +112 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +304 -0
- package/dist/outbox.js.map +1 -0
- package/dist/plugins/incrementing-version.d.ts +34 -0
- package/dist/plugins/incrementing-version.d.ts.map +1 -0
- package/dist/plugins/incrementing-version.js +83 -0
- package/dist/plugins/incrementing-version.js.map +1 -0
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +3 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/types.d.ts +49 -0
- package/dist/plugins/types.d.ts.map +1 -0
- package/dist/plugins/types.js +15 -0
- package/dist/plugins/types.js.map +1 -0
- package/dist/proxy/connection.d.ts +33 -0
- package/dist/proxy/connection.d.ts.map +1 -0
- package/dist/proxy/connection.js +153 -0
- package/dist/proxy/connection.js.map +1 -0
- package/dist/proxy/dialect.d.ts +46 -0
- package/dist/proxy/dialect.d.ts.map +1 -0
- package/dist/proxy/dialect.js +58 -0
- package/dist/proxy/dialect.js.map +1 -0
- package/dist/proxy/driver.d.ts +42 -0
- package/dist/proxy/driver.d.ts.map +1 -0
- package/dist/proxy/driver.js +78 -0
- package/dist/proxy/driver.js.map +1 -0
- package/dist/proxy/index.d.ts +10 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +10 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/mutations.d.ts +9 -0
- package/dist/proxy/mutations.d.ts.map +1 -0
- package/dist/proxy/mutations.js +11 -0
- package/dist/proxy/mutations.js.map +1 -0
- package/dist/pull-engine.d.ts +45 -0
- package/dist/pull-engine.d.ts.map +1 -0
- package/dist/pull-engine.js +391 -0
- package/dist/pull-engine.js.map +1 -0
- package/dist/push-engine.d.ts +18 -0
- package/dist/push-engine.d.ts.map +1 -0
- package/dist/push-engine.js +155 -0
- package/dist/push-engine.js.map +1 -0
- package/dist/query/FingerprintCollector.d.ts +18 -0
- package/dist/query/FingerprintCollector.d.ts.map +1 -0
- package/dist/query/FingerprintCollector.js +28 -0
- package/dist/query/FingerprintCollector.js.map +1 -0
- package/dist/query/QueryContext.d.ts +33 -0
- package/dist/query/QueryContext.d.ts.map +1 -0
- package/dist/query/QueryContext.js +16 -0
- package/dist/query/QueryContext.js.map +1 -0
- package/dist/query/fingerprint.d.ts +61 -0
- package/dist/query/fingerprint.d.ts.map +1 -0
- package/dist/query/fingerprint.js +91 -0
- package/dist/query/fingerprint.js.map +1 -0
- package/dist/query/index.d.ts +7 -0
- package/dist/query/index.d.ts.map +1 -0
- package/dist/query/index.js +7 -0
- package/dist/query/index.js.map +1 -0
- package/dist/query/tracked-select.d.ts +18 -0
- package/dist/query/tracked-select.d.ts.map +1 -0
- package/dist/query/tracked-select.js +90 -0
- package/dist/query/tracked-select.js.map +1 -0
- package/dist/schema.d.ts +83 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +7 -0
- package/dist/schema.js.map +1 -0
- package/dist/sync-loop.d.ts +32 -0
- package/dist/sync-loop.d.ts.map +1 -0
- package/dist/sync-loop.js +249 -0
- package/dist/sync-loop.js.map +1 -0
- package/dist/utils/id.d.ts +8 -0
- package/dist/utils/id.d.ts.map +1 -0
- package/dist/utils/id.js +19 -0
- package/dist/utils/id.js.map +1 -0
- package/package.json +58 -0
- package/src/blobs/index.ts +7 -0
- package/src/blobs/manager.ts +1027 -0
- package/src/blobs/migrate.ts +67 -0
- package/src/blobs/types.ts +84 -0
- package/src/client.ts +1222 -0
- package/src/conflicts.ts +180 -0
- package/src/create-client.ts +297 -0
- package/src/engine/SyncEngine.ts +1337 -0
- package/src/engine/index.ts +6 -0
- package/src/engine/types.ts +268 -0
- package/src/handlers/create-handler.ts +287 -0
- package/src/handlers/registry.ts +36 -0
- package/src/handlers/types.ts +102 -0
- package/src/index.ts +25 -0
- package/src/migrate.ts +122 -0
- package/src/mutations.ts +926 -0
- package/src/outbox.ts +397 -0
- package/src/plugins/incrementing-version.ts +133 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/types.ts +63 -0
- package/src/proxy/connection.ts +191 -0
- package/src/proxy/dialect.ts +76 -0
- package/src/proxy/driver.ts +126 -0
- package/src/proxy/index.ts +10 -0
- package/src/proxy/mutations.ts +18 -0
- package/src/pull-engine.ts +518 -0
- package/src/push-engine.ts +201 -0
- package/src/query/FingerprintCollector.ts +29 -0
- package/src/query/QueryContext.ts +54 -0
- package/src/query/fingerprint.ts +109 -0
- package/src/query/index.ts +10 -0
- package/src/query/tracked-select.ts +139 -0
- package/src/schema.ts +94 -0
- package/src/sync-loop.ts +368 -0
- package/src/utils/id.ts +20 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/client - Unified Client class
|
|
3
|
+
*
|
|
4
|
+
* Single entry point for offline-first sync with:
|
|
5
|
+
* - Built-in mutations API
|
|
6
|
+
* - Optional blob support
|
|
7
|
+
* - Automatic migrations
|
|
8
|
+
* - Event-driven state management
|
|
9
|
+
* - Conflict handling with events
|
|
10
|
+
*/
|
|
11
|
+
import { sql } from 'kysely';
|
|
12
|
+
import { ensureClientBlobSchema } from './blobs/migrate';
|
|
13
|
+
import { SyncEngine } from './engine/SyncEngine';
|
|
14
|
+
import { ensureClientSyncSchema } from './migrate';
|
|
15
|
+
import { createMutationsApi, createOutboxCommit, } from './mutations';
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Client Class
|
|
18
|
+
// ============================================================================
|
|
19
|
+
/**
|
|
20
|
+
* Unified sync client.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* import { Client } from '@syncular/client';
|
|
25
|
+
* import { createHttpTransport } from '@syncular/transport-http';
|
|
26
|
+
*
|
|
27
|
+
* const client = new Client({
|
|
28
|
+
* db,
|
|
29
|
+
* transport: createHttpTransport({ baseUrl: '/api/sync', getHeaders }),
|
|
30
|
+
* tableHandlers,
|
|
31
|
+
* clientId: 'device-123',
|
|
32
|
+
* actorId: 'user-456',
|
|
33
|
+
* subscriptions: [{ id: 'tasks', shape: 'tasks', scopes: { user_id: 'user-456' } }],
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* await client.start();
|
|
37
|
+
*
|
|
38
|
+
* // Mutations
|
|
39
|
+
* await client.mutations.tasks.insert({ title: 'New task' });
|
|
40
|
+
*
|
|
41
|
+
* // Events
|
|
42
|
+
* client.on('sync:complete', () => console.log('synced'));
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export class Client {
|
|
46
|
+
options;
|
|
47
|
+
engine = null;
|
|
48
|
+
started = false;
|
|
49
|
+
destroyed = false;
|
|
50
|
+
eventListeners = new Map();
|
|
51
|
+
outboxStats = {
|
|
52
|
+
pending: 0,
|
|
53
|
+
sending: 0,
|
|
54
|
+
failed: 0,
|
|
55
|
+
acked: 0,
|
|
56
|
+
total: 0,
|
|
57
|
+
};
|
|
58
|
+
/** Mutations API (always available) */
|
|
59
|
+
mutations;
|
|
60
|
+
/** Blob client (only available if blobStorage configured) */
|
|
61
|
+
blobs;
|
|
62
|
+
constructor(options) {
|
|
63
|
+
this.options = options;
|
|
64
|
+
// Create mutations API
|
|
65
|
+
const commitFn = createOutboxCommit({
|
|
66
|
+
db: options.db,
|
|
67
|
+
idColumn: options.idColumn ?? 'id',
|
|
68
|
+
versionColumn: options.versionColumn ?? 'server_version',
|
|
69
|
+
omitColumns: options.omitColumns ?? [],
|
|
70
|
+
});
|
|
71
|
+
this.mutations = createMutationsApi(commitFn);
|
|
72
|
+
// Create blob client if storage provided
|
|
73
|
+
if (options.blobStorage && options.transport.blobs) {
|
|
74
|
+
this.blobs = this.createBlobClient(options.blobStorage, options.transport);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// ===========================================================================
|
|
78
|
+
// Identity Getters
|
|
79
|
+
// ===========================================================================
|
|
80
|
+
/** Client ID */
|
|
81
|
+
get clientId() {
|
|
82
|
+
return this.options.clientId;
|
|
83
|
+
}
|
|
84
|
+
/** Actor ID */
|
|
85
|
+
get actorId() {
|
|
86
|
+
return this.options.actorId;
|
|
87
|
+
}
|
|
88
|
+
/** Database instance */
|
|
89
|
+
get db() {
|
|
90
|
+
return this.options.db;
|
|
91
|
+
}
|
|
92
|
+
// ===========================================================================
|
|
93
|
+
// Lifecycle
|
|
94
|
+
// ===========================================================================
|
|
95
|
+
/**
|
|
96
|
+
* Start the client.
|
|
97
|
+
* Runs migrations and starts sync engine.
|
|
98
|
+
*/
|
|
99
|
+
async start() {
|
|
100
|
+
if (this.destroyed) {
|
|
101
|
+
throw new Error('Client has been destroyed');
|
|
102
|
+
}
|
|
103
|
+
if (this.started) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Run migrations
|
|
107
|
+
await ensureClientSyncSchema(this.options.db);
|
|
108
|
+
if (this.options.blobStorage) {
|
|
109
|
+
await ensureClientBlobSchema(this.options.db);
|
|
110
|
+
}
|
|
111
|
+
// Create and start engine
|
|
112
|
+
this.engine = new SyncEngine({
|
|
113
|
+
db: this.options.db,
|
|
114
|
+
transport: this.options.transport,
|
|
115
|
+
shapes: this.options.tableHandlers,
|
|
116
|
+
clientId: this.options.clientId,
|
|
117
|
+
actorId: this.options.actorId,
|
|
118
|
+
subscriptions: this.options.subscriptions.map((s) => ({
|
|
119
|
+
id: s.id,
|
|
120
|
+
shape: s.shape,
|
|
121
|
+
scopes: s.scopes ?? {},
|
|
122
|
+
params: s.params ?? {},
|
|
123
|
+
})),
|
|
124
|
+
plugins: this.options.plugins,
|
|
125
|
+
realtimeEnabled: this.options.realtimeEnabled,
|
|
126
|
+
pollIntervalMs: this.options.pollIntervalMs,
|
|
127
|
+
stateId: this.options.stateId,
|
|
128
|
+
migrate: undefined, // We already ran migrations
|
|
129
|
+
});
|
|
130
|
+
// Wire up engine events to client events
|
|
131
|
+
this.wireEngineEvents();
|
|
132
|
+
await this.engine.start();
|
|
133
|
+
this.started = true;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Stop the client (can be restarted).
|
|
137
|
+
*/
|
|
138
|
+
stop() {
|
|
139
|
+
this.engine?.stop();
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Destroy the client (cannot be restarted).
|
|
143
|
+
*/
|
|
144
|
+
destroy() {
|
|
145
|
+
this.engine?.destroy();
|
|
146
|
+
this.eventListeners.clear();
|
|
147
|
+
this.destroyed = true;
|
|
148
|
+
}
|
|
149
|
+
// ===========================================================================
|
|
150
|
+
// Sync
|
|
151
|
+
// ===========================================================================
|
|
152
|
+
/**
|
|
153
|
+
* Trigger a manual sync.
|
|
154
|
+
*/
|
|
155
|
+
async sync() {
|
|
156
|
+
if (!this.engine) {
|
|
157
|
+
throw new Error('Client not started');
|
|
158
|
+
}
|
|
159
|
+
return this.engine.sync();
|
|
160
|
+
}
|
|
161
|
+
// ===========================================================================
|
|
162
|
+
// Subscriptions
|
|
163
|
+
// ===========================================================================
|
|
164
|
+
/**
|
|
165
|
+
* Update subscriptions.
|
|
166
|
+
*/
|
|
167
|
+
updateSubscriptions(subscriptions) {
|
|
168
|
+
this.options.subscriptions = subscriptions;
|
|
169
|
+
if (this.engine) {
|
|
170
|
+
this.engine.updateSubscriptions(subscriptions.map((s) => ({
|
|
171
|
+
id: s.id,
|
|
172
|
+
shape: s.shape,
|
|
173
|
+
scopes: s.scopes ?? {},
|
|
174
|
+
params: s.params ?? {},
|
|
175
|
+
})));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Get current subscriptions.
|
|
180
|
+
*/
|
|
181
|
+
getSubscriptions() {
|
|
182
|
+
return this.options.subscriptions.map((s) => ({
|
|
183
|
+
id: s.id,
|
|
184
|
+
shape: s.shape,
|
|
185
|
+
scopes: s.scopes ?? {},
|
|
186
|
+
params: s.params ?? {},
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
// ===========================================================================
|
|
190
|
+
// State
|
|
191
|
+
// ===========================================================================
|
|
192
|
+
/**
|
|
193
|
+
* Get current client state.
|
|
194
|
+
*/
|
|
195
|
+
getState() {
|
|
196
|
+
const engineState = this.engine?.getState() ?? this.createInitialEngineState();
|
|
197
|
+
return {
|
|
198
|
+
clientId: this.options.clientId,
|
|
199
|
+
actorId: this.options.actorId,
|
|
200
|
+
enabled: engineState.enabled,
|
|
201
|
+
isSyncing: engineState.isSyncing,
|
|
202
|
+
connectionState: engineState.connectionState,
|
|
203
|
+
lastSyncAt: engineState.lastSyncAt,
|
|
204
|
+
error: engineState.error
|
|
205
|
+
? { code: engineState.error.code, message: engineState.error.message }
|
|
206
|
+
: null,
|
|
207
|
+
outbox: this.outboxStats,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Subscribe to state changes (for useSyncExternalStore).
|
|
212
|
+
*/
|
|
213
|
+
subscribe(callback) {
|
|
214
|
+
if (!this.engine) {
|
|
215
|
+
// Return no-op unsubscribe before engine is started
|
|
216
|
+
return () => { };
|
|
217
|
+
}
|
|
218
|
+
return this.engine.subscribe(callback);
|
|
219
|
+
}
|
|
220
|
+
// ===========================================================================
|
|
221
|
+
// Events
|
|
222
|
+
// ===========================================================================
|
|
223
|
+
/**
|
|
224
|
+
* Subscribe to client events.
|
|
225
|
+
*/
|
|
226
|
+
on(event, handler) {
|
|
227
|
+
if (!this.eventListeners.has(event)) {
|
|
228
|
+
this.eventListeners.set(event, new Set());
|
|
229
|
+
}
|
|
230
|
+
this.eventListeners.get(event).add(handler);
|
|
231
|
+
return () => {
|
|
232
|
+
this.eventListeners.get(event)?.delete(handler);
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
emit(event, payload) {
|
|
236
|
+
const listeners = this.eventListeners.get(event);
|
|
237
|
+
if (listeners) {
|
|
238
|
+
for (const listener of listeners) {
|
|
239
|
+
try {
|
|
240
|
+
listener(payload);
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
console.error(`[Client] Error in ${event} listener:`, err);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// ===========================================================================
|
|
249
|
+
// Conflicts
|
|
250
|
+
// ===========================================================================
|
|
251
|
+
/**
|
|
252
|
+
* Get pending conflicts.
|
|
253
|
+
*/
|
|
254
|
+
async getConflicts() {
|
|
255
|
+
if (!this.engine) {
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
const conflicts = await this.engine.getConflicts();
|
|
259
|
+
return conflicts.map((c) => this.mapConflictInfo(c));
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Resolve a conflict.
|
|
263
|
+
*/
|
|
264
|
+
async resolveConflict(id, resolution) {
|
|
265
|
+
const { resolveConflict } = await import('./conflicts');
|
|
266
|
+
// For 'keep-local' and 'keep-server', we just mark it resolved
|
|
267
|
+
// For 'custom', we would need to apply the payload - but that requires
|
|
268
|
+
// creating a new mutation, which the user should do separately
|
|
269
|
+
const resolutionStr = resolution.strategy === 'custom'
|
|
270
|
+
? `custom:${JSON.stringify(resolution.payload)}`
|
|
271
|
+
: resolution.strategy;
|
|
272
|
+
await resolveConflict(this.options.db, { id, resolution: resolutionStr });
|
|
273
|
+
// Get the conflict for the event
|
|
274
|
+
const conflicts = await this.getConflicts();
|
|
275
|
+
const resolved = conflicts.find((c) => c.id === id);
|
|
276
|
+
if (resolved) {
|
|
277
|
+
this.emit('conflict:resolved', resolved);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// ===========================================================================
|
|
281
|
+
// Outbox
|
|
282
|
+
// ===========================================================================
|
|
283
|
+
/**
|
|
284
|
+
* Get outbox statistics.
|
|
285
|
+
*/
|
|
286
|
+
async getOutboxStats() {
|
|
287
|
+
if (!this.engine) {
|
|
288
|
+
return this.outboxStats;
|
|
289
|
+
}
|
|
290
|
+
this.outboxStats = await this.engine.refreshOutboxStats({ emit: false });
|
|
291
|
+
return this.outboxStats;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Clear failed commits from outbox.
|
|
295
|
+
*/
|
|
296
|
+
async clearFailedCommits() {
|
|
297
|
+
if (!this.engine) {
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
return this.engine.clearFailedCommits();
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Retry failed commits.
|
|
304
|
+
*/
|
|
305
|
+
async retryFailedCommits() {
|
|
306
|
+
// Mark failed commits as pending and trigger sync
|
|
307
|
+
const result = await sql `
|
|
308
|
+
update ${sql.table('sync_outbox_commits')}
|
|
309
|
+
set
|
|
310
|
+
${sql.ref('status')} = ${sql.val('pending')},
|
|
311
|
+
${sql.ref('attempt_count')} = ${sql.val(0)},
|
|
312
|
+
${sql.ref('error')} = ${sql.val(null)}
|
|
313
|
+
where ${sql.ref('status')} = ${sql.val('failed')}
|
|
314
|
+
`.execute(this.options.db);
|
|
315
|
+
const count = Number(result.numAffectedRows ?? 0n);
|
|
316
|
+
if (count > 0 && this.engine) {
|
|
317
|
+
await this.engine.refreshOutboxStats();
|
|
318
|
+
await this.engine.sync();
|
|
319
|
+
}
|
|
320
|
+
return count;
|
|
321
|
+
}
|
|
322
|
+
// ===========================================================================
|
|
323
|
+
// Presence
|
|
324
|
+
// ===========================================================================
|
|
325
|
+
/**
|
|
326
|
+
* Get presence for a scope.
|
|
327
|
+
*/
|
|
328
|
+
getPresence(scopeKey) {
|
|
329
|
+
if (!this.engine) {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
return this.engine.getPresence(scopeKey);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Join presence for a scope key.
|
|
336
|
+
*/
|
|
337
|
+
joinPresence(scopeKey, metadata) {
|
|
338
|
+
this.engine?.joinPresence(scopeKey, metadata);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Leave presence for a scope key.
|
|
342
|
+
*/
|
|
343
|
+
leavePresence(scopeKey) {
|
|
344
|
+
this.engine?.leavePresence(scopeKey);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Update presence metadata for a scope key.
|
|
348
|
+
*/
|
|
349
|
+
updatePresenceMetadata(scopeKey, metadata) {
|
|
350
|
+
this.engine?.updatePresenceMetadata(scopeKey, metadata);
|
|
351
|
+
}
|
|
352
|
+
// ===========================================================================
|
|
353
|
+
// Migration Info
|
|
354
|
+
// ===========================================================================
|
|
355
|
+
/**
|
|
356
|
+
* Get migration info.
|
|
357
|
+
*/
|
|
358
|
+
async getMigrationInfo() {
|
|
359
|
+
// Check if sync tables exist
|
|
360
|
+
let syncMigrated = false;
|
|
361
|
+
try {
|
|
362
|
+
await this.options.db
|
|
363
|
+
.selectFrom('sync_outbox_commits')
|
|
364
|
+
.selectAll()
|
|
365
|
+
.limit(1)
|
|
366
|
+
.execute();
|
|
367
|
+
syncMigrated = true;
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
syncMigrated = false;
|
|
371
|
+
}
|
|
372
|
+
// Check if blob tables exist
|
|
373
|
+
let blobsMigrated = false;
|
|
374
|
+
try {
|
|
375
|
+
await this.options.db
|
|
376
|
+
.selectFrom('sync_blob_cache')
|
|
377
|
+
.selectAll()
|
|
378
|
+
.limit(1)
|
|
379
|
+
.execute();
|
|
380
|
+
blobsMigrated = true;
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
blobsMigrated = false;
|
|
384
|
+
}
|
|
385
|
+
return { syncMigrated, blobsMigrated };
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Static: Check if migrations are needed.
|
|
389
|
+
*/
|
|
390
|
+
static async checkMigrations(db) {
|
|
391
|
+
let syncMigrated = false;
|
|
392
|
+
let blobsMigrated = false;
|
|
393
|
+
try {
|
|
394
|
+
await db.selectFrom('sync_outbox_commits').selectAll().limit(1).execute();
|
|
395
|
+
syncMigrated = true;
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
syncMigrated = false;
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
await db.selectFrom('sync_blob_cache').selectAll().limit(1).execute();
|
|
402
|
+
blobsMigrated = true;
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
blobsMigrated = false;
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
needsMigration: !syncMigrated,
|
|
409
|
+
syncMigrated,
|
|
410
|
+
blobsMigrated,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Static: Run migrations.
|
|
415
|
+
*/
|
|
416
|
+
static async migrate(db, options) {
|
|
417
|
+
await ensureClientSyncSchema(db);
|
|
418
|
+
if (options?.blobs) {
|
|
419
|
+
await ensureClientBlobSchema(db);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// ===========================================================================
|
|
423
|
+
// Private Helpers
|
|
424
|
+
// ===========================================================================
|
|
425
|
+
createInitialEngineState() {
|
|
426
|
+
return {
|
|
427
|
+
enabled: false,
|
|
428
|
+
isSyncing: false,
|
|
429
|
+
connectionState: 'disconnected',
|
|
430
|
+
transportMode: 'polling',
|
|
431
|
+
lastSyncAt: null,
|
|
432
|
+
error: null,
|
|
433
|
+
pendingCount: 0,
|
|
434
|
+
retryCount: 0,
|
|
435
|
+
isRetrying: false,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
wireEngineEvents() {
|
|
439
|
+
if (!this.engine)
|
|
440
|
+
return;
|
|
441
|
+
this.engine.on('sync:start', (payload) => {
|
|
442
|
+
this.emit('sync:start', payload);
|
|
443
|
+
});
|
|
444
|
+
this.engine.on('sync:complete', (payload) => {
|
|
445
|
+
this.emit('sync:complete', {
|
|
446
|
+
success: true,
|
|
447
|
+
pushedCommits: payload.pushedCommits,
|
|
448
|
+
pullRounds: payload.pullRounds,
|
|
449
|
+
pullResponse: payload.pullResponse,
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
this.engine.on('sync:error', (error) => {
|
|
453
|
+
this.emit('sync:error', { code: error.code, message: error.message });
|
|
454
|
+
// Check for new conflicts after sync error
|
|
455
|
+
this.checkForNewConflicts();
|
|
456
|
+
});
|
|
457
|
+
this.engine.on('connection:change', (payload) => {
|
|
458
|
+
this.emit('connection:change', payload);
|
|
459
|
+
});
|
|
460
|
+
this.engine.on('data:change', (payload) => {
|
|
461
|
+
this.emit('data:change', payload);
|
|
462
|
+
});
|
|
463
|
+
this.engine.on('outbox:change', (payload) => {
|
|
464
|
+
this.outboxStats = {
|
|
465
|
+
pending: payload.pendingCount,
|
|
466
|
+
sending: payload.sendingCount,
|
|
467
|
+
failed: payload.failedCount,
|
|
468
|
+
acked: payload.ackedCount ?? 0,
|
|
469
|
+
total: payload.pendingCount +
|
|
470
|
+
payload.sendingCount +
|
|
471
|
+
payload.failedCount +
|
|
472
|
+
(payload.ackedCount ?? 0),
|
|
473
|
+
};
|
|
474
|
+
this.emit('outbox:change', this.outboxStats);
|
|
475
|
+
});
|
|
476
|
+
this.engine.on('presence:change', (payload) => {
|
|
477
|
+
this.emit('presence:change', payload);
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
async checkForNewConflicts() {
|
|
481
|
+
const conflicts = await this.getConflicts();
|
|
482
|
+
for (const conflict of conflicts) {
|
|
483
|
+
this.emit('conflict:new', conflict);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
mapConflictInfo(info) {
|
|
487
|
+
let serverPayload = null;
|
|
488
|
+
if (info.serverRowJson) {
|
|
489
|
+
try {
|
|
490
|
+
serverPayload = JSON.parse(info.serverRowJson);
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
serverPayload = null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
id: info.id,
|
|
498
|
+
table: info.table,
|
|
499
|
+
rowId: info.rowId,
|
|
500
|
+
opIndex: info.opIndex,
|
|
501
|
+
localPayload: info.localPayload,
|
|
502
|
+
serverPayload,
|
|
503
|
+
serverVersion: info.serverVersion,
|
|
504
|
+
message: info.message,
|
|
505
|
+
code: info.code,
|
|
506
|
+
createdAt: info.createdAt,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
createBlobClient(storage, transport) {
|
|
510
|
+
const db = this.options.db;
|
|
511
|
+
const blobs = transport.blobs;
|
|
512
|
+
return {
|
|
513
|
+
async store(data, options) {
|
|
514
|
+
const bytes = await toUint8Array(data);
|
|
515
|
+
const mimeType = data instanceof Blob
|
|
516
|
+
? data.type
|
|
517
|
+
: (options?.mimeType ?? 'application/octet-stream');
|
|
518
|
+
// Compute hash
|
|
519
|
+
const hashHex = await computeSha256Hex(bytes);
|
|
520
|
+
const hash = `sha256:${hashHex}`;
|
|
521
|
+
// Store locally
|
|
522
|
+
await storage.write(hash, bytes);
|
|
523
|
+
// Store metadata
|
|
524
|
+
const now = Date.now();
|
|
525
|
+
await sql `
|
|
526
|
+
insert into ${sql.table('sync_blob_cache')} (
|
|
527
|
+
${sql.join([
|
|
528
|
+
sql.ref('hash'),
|
|
529
|
+
sql.ref('size'),
|
|
530
|
+
sql.ref('mime_type'),
|
|
531
|
+
sql.ref('cached_at'),
|
|
532
|
+
sql.ref('last_accessed_at'),
|
|
533
|
+
sql.ref('encrypted'),
|
|
534
|
+
sql.ref('key_id'),
|
|
535
|
+
sql.ref('body'),
|
|
536
|
+
])}
|
|
537
|
+
) values (
|
|
538
|
+
${sql.join([
|
|
539
|
+
sql.val(hash),
|
|
540
|
+
sql.val(bytes.length),
|
|
541
|
+
sql.val(mimeType),
|
|
542
|
+
sql.val(now),
|
|
543
|
+
sql.val(now),
|
|
544
|
+
sql.val(0),
|
|
545
|
+
sql.val(null),
|
|
546
|
+
sql.val(bytes),
|
|
547
|
+
])}
|
|
548
|
+
)
|
|
549
|
+
on conflict (${sql.ref('hash')}) do nothing
|
|
550
|
+
`.execute(db);
|
|
551
|
+
// Queue for upload or upload immediately
|
|
552
|
+
if (options?.immediate) {
|
|
553
|
+
// Initiate upload
|
|
554
|
+
const initResult = await blobs.initiateUpload({
|
|
555
|
+
hash,
|
|
556
|
+
size: bytes.length,
|
|
557
|
+
mimeType,
|
|
558
|
+
});
|
|
559
|
+
if (!initResult.exists && initResult.uploadUrl) {
|
|
560
|
+
// Upload to presigned URL
|
|
561
|
+
const uploadResponse = await fetch(initResult.uploadUrl, {
|
|
562
|
+
method: initResult.uploadMethod ?? 'PUT',
|
|
563
|
+
body: bytes.buffer,
|
|
564
|
+
headers: initResult.uploadHeaders,
|
|
565
|
+
});
|
|
566
|
+
if (!uploadResponse.ok) {
|
|
567
|
+
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
|
|
568
|
+
}
|
|
569
|
+
// Complete upload
|
|
570
|
+
await blobs.completeUpload(hash);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
// Queue for later upload
|
|
575
|
+
await sql `
|
|
576
|
+
insert into ${sql.table('sync_blob_outbox')} (
|
|
577
|
+
${sql.join([
|
|
578
|
+
sql.ref('hash'),
|
|
579
|
+
sql.ref('size'),
|
|
580
|
+
sql.ref('mime_type'),
|
|
581
|
+
sql.ref('status'),
|
|
582
|
+
sql.ref('created_at'),
|
|
583
|
+
sql.ref('updated_at'),
|
|
584
|
+
sql.ref('attempt_count'),
|
|
585
|
+
sql.ref('error'),
|
|
586
|
+
sql.ref('encrypted'),
|
|
587
|
+
sql.ref('key_id'),
|
|
588
|
+
sql.ref('body'),
|
|
589
|
+
])}
|
|
590
|
+
) values (
|
|
591
|
+
${sql.join([
|
|
592
|
+
sql.val(hash),
|
|
593
|
+
sql.val(bytes.length),
|
|
594
|
+
sql.val(mimeType),
|
|
595
|
+
sql.val('pending'),
|
|
596
|
+
sql.val(now),
|
|
597
|
+
sql.val(now),
|
|
598
|
+
sql.val(0),
|
|
599
|
+
sql.val(null),
|
|
600
|
+
sql.val(0),
|
|
601
|
+
sql.val(null),
|
|
602
|
+
sql.val(bytes),
|
|
603
|
+
])}
|
|
604
|
+
)
|
|
605
|
+
on conflict (${sql.ref('hash')}) do nothing
|
|
606
|
+
`.execute(db);
|
|
607
|
+
}
|
|
608
|
+
return {
|
|
609
|
+
hash,
|
|
610
|
+
size: bytes.length,
|
|
611
|
+
mimeType,
|
|
612
|
+
};
|
|
613
|
+
},
|
|
614
|
+
async retrieve(ref) {
|
|
615
|
+
// Check local storage first
|
|
616
|
+
const local = await storage.read(ref.hash);
|
|
617
|
+
if (local) {
|
|
618
|
+
// Update access time
|
|
619
|
+
await sql `
|
|
620
|
+
update ${sql.table('sync_blob_cache')}
|
|
621
|
+
set ${sql.ref('last_accessed_at')} = ${sql.val(Date.now())}
|
|
622
|
+
where ${sql.ref('hash')} = ${sql.val(ref.hash)}
|
|
623
|
+
`.execute(db);
|
|
624
|
+
return local;
|
|
625
|
+
}
|
|
626
|
+
// Fetch from server
|
|
627
|
+
const { url } = await blobs.getDownloadUrl(ref.hash);
|
|
628
|
+
const response = await fetch(url);
|
|
629
|
+
if (!response.ok) {
|
|
630
|
+
throw new Error(`Download failed: ${response.statusText}`);
|
|
631
|
+
}
|
|
632
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
633
|
+
// Cache locally
|
|
634
|
+
await storage.write(ref.hash, bytes);
|
|
635
|
+
const now = Date.now();
|
|
636
|
+
await sql `
|
|
637
|
+
insert into ${sql.table('sync_blob_cache')} (
|
|
638
|
+
${sql.join([
|
|
639
|
+
sql.ref('hash'),
|
|
640
|
+
sql.ref('size'),
|
|
641
|
+
sql.ref('mime_type'),
|
|
642
|
+
sql.ref('cached_at'),
|
|
643
|
+
sql.ref('last_accessed_at'),
|
|
644
|
+
sql.ref('encrypted'),
|
|
645
|
+
sql.ref('key_id'),
|
|
646
|
+
sql.ref('body'),
|
|
647
|
+
])}
|
|
648
|
+
) values (
|
|
649
|
+
${sql.join([
|
|
650
|
+
sql.val(ref.hash),
|
|
651
|
+
sql.val(bytes.length),
|
|
652
|
+
sql.val(ref.mimeType),
|
|
653
|
+
sql.val(now),
|
|
654
|
+
sql.val(now),
|
|
655
|
+
sql.val(0),
|
|
656
|
+
sql.val(null),
|
|
657
|
+
sql.val(bytes),
|
|
658
|
+
])}
|
|
659
|
+
)
|
|
660
|
+
on conflict (${sql.ref('hash')}) do nothing
|
|
661
|
+
`.execute(db);
|
|
662
|
+
return bytes;
|
|
663
|
+
},
|
|
664
|
+
async isLocal(hash) {
|
|
665
|
+
return storage.exists(hash);
|
|
666
|
+
},
|
|
667
|
+
async preload(refs) {
|
|
668
|
+
await Promise.all(refs.map((ref) => this.retrieve(ref)));
|
|
669
|
+
},
|
|
670
|
+
async processUploadQueue() {
|
|
671
|
+
let uploaded = 0;
|
|
672
|
+
let failed = 0;
|
|
673
|
+
const pendingResult = await sql `
|
|
674
|
+
select
|
|
675
|
+
${sql.ref('hash')},
|
|
676
|
+
${sql.ref('size')},
|
|
677
|
+
${sql.ref('mime_type')},
|
|
678
|
+
${sql.ref('body')}
|
|
679
|
+
from ${sql.table('sync_blob_outbox')}
|
|
680
|
+
where ${sql.ref('status')} = ${sql.val('pending')}
|
|
681
|
+
limit ${sql.val(10)}
|
|
682
|
+
`.execute(db);
|
|
683
|
+
const pending = pendingResult.rows;
|
|
684
|
+
for (const item of pending) {
|
|
685
|
+
try {
|
|
686
|
+
// Mark as uploading
|
|
687
|
+
await sql `
|
|
688
|
+
update ${sql.table('sync_blob_outbox')}
|
|
689
|
+
set
|
|
690
|
+
${sql.ref('status')} = ${sql.val('uploading')},
|
|
691
|
+
${sql.ref('updated_at')} = ${sql.val(Date.now())}
|
|
692
|
+
where ${sql.ref('hash')} = ${sql.val(item.hash)}
|
|
693
|
+
`.execute(db);
|
|
694
|
+
// Initiate upload
|
|
695
|
+
const initResult = await blobs.initiateUpload({
|
|
696
|
+
hash: item.hash,
|
|
697
|
+
size: item.size,
|
|
698
|
+
mimeType: item.mime_type,
|
|
699
|
+
});
|
|
700
|
+
if (!initResult.exists && initResult.uploadUrl && item.body) {
|
|
701
|
+
const uploadBody = new ArrayBuffer(item.body.byteLength);
|
|
702
|
+
new Uint8Array(uploadBody).set(item.body);
|
|
703
|
+
// Upload
|
|
704
|
+
const uploadResponse = await fetch(initResult.uploadUrl, {
|
|
705
|
+
method: initResult.uploadMethod ?? 'PUT',
|
|
706
|
+
body: uploadBody,
|
|
707
|
+
headers: initResult.uploadHeaders,
|
|
708
|
+
});
|
|
709
|
+
if (!uploadResponse.ok) {
|
|
710
|
+
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
|
|
711
|
+
}
|
|
712
|
+
// Complete
|
|
713
|
+
await blobs.completeUpload(item.hash);
|
|
714
|
+
}
|
|
715
|
+
// Mark as complete
|
|
716
|
+
await sql `
|
|
717
|
+
delete from ${sql.table('sync_blob_outbox')}
|
|
718
|
+
where ${sql.ref('hash')} = ${sql.val(item.hash)}
|
|
719
|
+
`.execute(db);
|
|
720
|
+
uploaded++;
|
|
721
|
+
}
|
|
722
|
+
catch (err) {
|
|
723
|
+
// Mark as failed
|
|
724
|
+
await sql `
|
|
725
|
+
update ${sql.table('sync_blob_outbox')}
|
|
726
|
+
set
|
|
727
|
+
${sql.ref('status')} = ${sql.val('failed')},
|
|
728
|
+
${sql.ref('error')} = ${sql.val(err instanceof Error ? err.message : 'Unknown error')},
|
|
729
|
+
${sql.ref('attempt_count')} = ${sql.ref('attempt_count')} + ${sql.val(1)},
|
|
730
|
+
${sql.ref('updated_at')} = ${sql.val(Date.now())}
|
|
731
|
+
where ${sql.ref('hash')} = ${sql.val(item.hash)}
|
|
732
|
+
`.execute(db);
|
|
733
|
+
failed++;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return { uploaded, failed };
|
|
737
|
+
},
|
|
738
|
+
async getUploadQueueStats() {
|
|
739
|
+
const rowsResult = await sql `
|
|
740
|
+
select
|
|
741
|
+
${sql.ref('status')} as status,
|
|
742
|
+
count(${sql.ref('hash')}) as count
|
|
743
|
+
from ${sql.table('sync_blob_outbox')}
|
|
744
|
+
group by ${sql.ref('status')}
|
|
745
|
+
`.execute(db);
|
|
746
|
+
const stats = { pending: 0, uploading: 0, failed: 0 };
|
|
747
|
+
for (const row of rowsResult.rows) {
|
|
748
|
+
if (row.status === 'pending')
|
|
749
|
+
stats.pending = Number(row.count);
|
|
750
|
+
if (row.status === 'uploading')
|
|
751
|
+
stats.uploading = Number(row.count);
|
|
752
|
+
if (row.status === 'failed')
|
|
753
|
+
stats.failed = Number(row.count);
|
|
754
|
+
}
|
|
755
|
+
return stats;
|
|
756
|
+
},
|
|
757
|
+
async getCacheStats() {
|
|
758
|
+
const result = await sql `
|
|
759
|
+
select
|
|
760
|
+
count(${sql.ref('hash')}) as count,
|
|
761
|
+
sum(${sql.ref('size')}) as totalBytes
|
|
762
|
+
from ${sql.table('sync_blob_cache')}
|
|
763
|
+
`.execute(db);
|
|
764
|
+
const row = result.rows[0];
|
|
765
|
+
return {
|
|
766
|
+
count: Number(row?.count ?? 0),
|
|
767
|
+
totalBytes: Number(row?.totalBytes ?? 0),
|
|
768
|
+
};
|
|
769
|
+
},
|
|
770
|
+
async pruneCache(maxBytes) {
|
|
771
|
+
if (!maxBytes)
|
|
772
|
+
return 0;
|
|
773
|
+
// Get current size
|
|
774
|
+
const stats = await this.getCacheStats();
|
|
775
|
+
if (stats.totalBytes <= maxBytes)
|
|
776
|
+
return 0;
|
|
777
|
+
// Get oldest entries to delete
|
|
778
|
+
const toFree = stats.totalBytes - maxBytes;
|
|
779
|
+
let freed = 0;
|
|
780
|
+
const oldEntriesResult = await sql `
|
|
781
|
+
select ${sql.ref('hash')}, ${sql.ref('size')}
|
|
782
|
+
from ${sql.table('sync_blob_cache')}
|
|
783
|
+
order by ${sql.ref('last_accessed_at')} asc
|
|
784
|
+
`.execute(db);
|
|
785
|
+
const oldEntries = oldEntriesResult.rows;
|
|
786
|
+
for (const entry of oldEntries) {
|
|
787
|
+
if (freed >= toFree)
|
|
788
|
+
break;
|
|
789
|
+
await storage.delete(entry.hash);
|
|
790
|
+
await sql `
|
|
791
|
+
delete from ${sql.table('sync_blob_cache')}
|
|
792
|
+
where ${sql.ref('hash')} = ${sql.val(entry.hash)}
|
|
793
|
+
`.execute(db);
|
|
794
|
+
freed += entry.size;
|
|
795
|
+
}
|
|
796
|
+
return freed;
|
|
797
|
+
},
|
|
798
|
+
async clearCache() {
|
|
799
|
+
if (storage.clear) {
|
|
800
|
+
await storage.clear();
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
// Delete each entry individually
|
|
804
|
+
const entriesResult = await sql `
|
|
805
|
+
select ${sql.ref('hash')}
|
|
806
|
+
from ${sql.table('sync_blob_cache')}
|
|
807
|
+
`.execute(db);
|
|
808
|
+
for (const entry of entriesResult.rows) {
|
|
809
|
+
await storage.delete(entry.hash);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
await sql `delete from ${sql.table('sync_blob_cache')}`.execute(db);
|
|
813
|
+
},
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
// ============================================================================
|
|
818
|
+
// Helpers
|
|
819
|
+
// ============================================================================
|
|
820
|
+
async function toUint8Array(data) {
|
|
821
|
+
if (data instanceof Uint8Array) {
|
|
822
|
+
return data;
|
|
823
|
+
}
|
|
824
|
+
const buffer = await data.arrayBuffer();
|
|
825
|
+
return new Uint8Array(buffer);
|
|
826
|
+
}
|
|
827
|
+
async function computeSha256Hex(data) {
|
|
828
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data.buffer);
|
|
829
|
+
const hashArray = new Uint8Array(hashBuffer);
|
|
830
|
+
return Array.from(hashArray)
|
|
831
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
832
|
+
.join('');
|
|
833
|
+
}
|
|
834
|
+
//# sourceMappingURL=client.js.map
|