@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/src/client.ts
ADDED
|
@@ -0,0 +1,1222 @@
|
|
|
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
|
+
|
|
12
|
+
import type { BlobRef, SyncTransport } from '@syncular/core';
|
|
13
|
+
import type { Kysely } from 'kysely';
|
|
14
|
+
import { sql } from 'kysely';
|
|
15
|
+
import { ensureClientBlobSchema } from './blobs/migrate';
|
|
16
|
+
import { SyncEngine } from './engine/SyncEngine';
|
|
17
|
+
import type {
|
|
18
|
+
ConflictInfo,
|
|
19
|
+
OutboxStats,
|
|
20
|
+
PresenceEntry,
|
|
21
|
+
SyncEngineState,
|
|
22
|
+
SyncResult,
|
|
23
|
+
} from './engine/types';
|
|
24
|
+
import type { ClientTableRegistry } from './handlers/registry';
|
|
25
|
+
import { ensureClientSyncSchema } from './migrate';
|
|
26
|
+
import {
|
|
27
|
+
createMutationsApi,
|
|
28
|
+
createOutboxCommit,
|
|
29
|
+
type MutationsApi,
|
|
30
|
+
} from './mutations';
|
|
31
|
+
import type { SyncClientPlugin } from './plugins/types';
|
|
32
|
+
import type { SyncClientDb } from './schema';
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Types
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pluggable client-side blob storage adapter.
|
|
40
|
+
* Implementations handle platform-specific binary storage (OPFS, Expo FileSystem, etc.)
|
|
41
|
+
* Metadata is stored separately in the main SQLite db.
|
|
42
|
+
*/
|
|
43
|
+
export interface ClientBlobStorage {
|
|
44
|
+
/** Write blob data from bytes or stream */
|
|
45
|
+
write(
|
|
46
|
+
hash: string,
|
|
47
|
+
data: Uint8Array | ReadableStream<Uint8Array>
|
|
48
|
+
): Promise<void>;
|
|
49
|
+
|
|
50
|
+
/** Read blob data, null if not found */
|
|
51
|
+
read(hash: string): Promise<Uint8Array | null>;
|
|
52
|
+
|
|
53
|
+
/** Read blob data as stream, null if not found */
|
|
54
|
+
readStream?(hash: string): Promise<ReadableStream<Uint8Array> | null>;
|
|
55
|
+
|
|
56
|
+
/** Delete blob data */
|
|
57
|
+
delete(hash: string): Promise<void>;
|
|
58
|
+
|
|
59
|
+
/** Check if blob exists in storage */
|
|
60
|
+
exists(hash: string): Promise<boolean>;
|
|
61
|
+
|
|
62
|
+
/** Get total storage usage in bytes (for cache management) */
|
|
63
|
+
getUsage?(): Promise<number>;
|
|
64
|
+
|
|
65
|
+
/** Clear all blobs (for cache reset) */
|
|
66
|
+
clear?(): Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ClientOptions<DB extends SyncClientDb> {
|
|
70
|
+
/** Kysely database instance */
|
|
71
|
+
db: Kysely<DB>;
|
|
72
|
+
|
|
73
|
+
/** Transport for server communication (includes sync and blob operations) */
|
|
74
|
+
transport: SyncTransport;
|
|
75
|
+
|
|
76
|
+
/** Table handlers for applying snapshots and changes */
|
|
77
|
+
tableHandlers: ClientTableRegistry<DB>;
|
|
78
|
+
|
|
79
|
+
/** Unique client identifier (e.g., device ID) */
|
|
80
|
+
clientId: string;
|
|
81
|
+
|
|
82
|
+
/** Current actor/user identifier */
|
|
83
|
+
actorId: string;
|
|
84
|
+
|
|
85
|
+
/** Subscriptions to sync */
|
|
86
|
+
subscriptions: Array<{
|
|
87
|
+
id: string;
|
|
88
|
+
shape: string;
|
|
89
|
+
scopes?: Record<string, string | string[]>;
|
|
90
|
+
params?: Record<string, unknown>;
|
|
91
|
+
}>;
|
|
92
|
+
|
|
93
|
+
/** Optional: Local blob storage adapter (enables blob support) */
|
|
94
|
+
blobStorage?: ClientBlobStorage;
|
|
95
|
+
|
|
96
|
+
/** Optional: Sync plugins */
|
|
97
|
+
plugins?: SyncClientPlugin[];
|
|
98
|
+
|
|
99
|
+
/** Optional: Enable realtime transport mode */
|
|
100
|
+
realtimeEnabled?: boolean;
|
|
101
|
+
|
|
102
|
+
/** Optional: Polling interval in milliseconds (default: 10000) */
|
|
103
|
+
pollIntervalMs?: number;
|
|
104
|
+
|
|
105
|
+
/** Optional: State ID for multi-tenant scenarios */
|
|
106
|
+
stateId?: string;
|
|
107
|
+
|
|
108
|
+
/** Optional: ID column name (default: 'id') */
|
|
109
|
+
idColumn?: string;
|
|
110
|
+
|
|
111
|
+
/** Optional: Version column name (default: 'server_version') */
|
|
112
|
+
versionColumn?: string;
|
|
113
|
+
|
|
114
|
+
/** Optional: Columns to omit from sync */
|
|
115
|
+
omitColumns?: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ClientState {
|
|
119
|
+
/** Client ID */
|
|
120
|
+
clientId: string;
|
|
121
|
+
/** Actor ID */
|
|
122
|
+
actorId: string;
|
|
123
|
+
/** Whether sync is enabled (actorId and clientId are set) */
|
|
124
|
+
enabled: boolean;
|
|
125
|
+
/** Whether a sync is currently in progress */
|
|
126
|
+
isSyncing: boolean;
|
|
127
|
+
/** Connection state */
|
|
128
|
+
connectionState: 'connected' | 'connecting' | 'disconnected' | 'reconnecting';
|
|
129
|
+
/** Last successful sync timestamp */
|
|
130
|
+
lastSyncAt: number | null;
|
|
131
|
+
/** Current error if any */
|
|
132
|
+
error: { code: string; message: string } | null;
|
|
133
|
+
/** Outbox statistics */
|
|
134
|
+
outbox: OutboxStats;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface Conflict {
|
|
138
|
+
id: string;
|
|
139
|
+
table: string;
|
|
140
|
+
rowId: string;
|
|
141
|
+
opIndex: number;
|
|
142
|
+
localPayload: Record<string, unknown> | null;
|
|
143
|
+
serverPayload: Record<string, unknown> | null;
|
|
144
|
+
serverVersion: number | null;
|
|
145
|
+
message: string;
|
|
146
|
+
code: string | null;
|
|
147
|
+
createdAt: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ConflictResolution {
|
|
151
|
+
strategy: 'keep-local' | 'keep-server' | 'custom';
|
|
152
|
+
payload?: Record<string, unknown>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface BlobStoreOptions {
|
|
156
|
+
/** MIME type of the blob */
|
|
157
|
+
mimeType?: string;
|
|
158
|
+
/** Upload immediately vs queue for later */
|
|
159
|
+
immediate?: boolean;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface BlobClient {
|
|
163
|
+
/** Store a blob locally and queue for upload */
|
|
164
|
+
store(
|
|
165
|
+
data: Blob | File | Uint8Array,
|
|
166
|
+
options?: BlobStoreOptions
|
|
167
|
+
): Promise<BlobRef>;
|
|
168
|
+
|
|
169
|
+
/** Retrieve a blob (from local storage or fetch from server) */
|
|
170
|
+
retrieve(ref: BlobRef): Promise<Uint8Array>;
|
|
171
|
+
|
|
172
|
+
/** Check if blob is available locally */
|
|
173
|
+
isLocal(hash: string): Promise<boolean>;
|
|
174
|
+
|
|
175
|
+
/** Preload blobs for offline use */
|
|
176
|
+
preload(refs: BlobRef[]): Promise<void>;
|
|
177
|
+
|
|
178
|
+
/** Process pending uploads */
|
|
179
|
+
processUploadQueue(): Promise<{ uploaded: number; failed: number }>;
|
|
180
|
+
|
|
181
|
+
/** Get upload queue statistics */
|
|
182
|
+
getUploadQueueStats(): Promise<{
|
|
183
|
+
pending: number;
|
|
184
|
+
uploading: number;
|
|
185
|
+
failed: number;
|
|
186
|
+
}>;
|
|
187
|
+
|
|
188
|
+
/** Get cache statistics */
|
|
189
|
+
getCacheStats(): Promise<{ count: number; totalBytes: number }>;
|
|
190
|
+
|
|
191
|
+
/** Prune cache to free space */
|
|
192
|
+
pruneCache(maxBytes?: number): Promise<number>;
|
|
193
|
+
|
|
194
|
+
/** Clear all cached blobs */
|
|
195
|
+
clearCache(): Promise<void>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface MigrationInfo {
|
|
199
|
+
/** Whether sync schema is migrated */
|
|
200
|
+
syncMigrated: boolean;
|
|
201
|
+
/** Whether blob schema is migrated */
|
|
202
|
+
blobsMigrated: boolean;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
type ClientEventType =
|
|
206
|
+
| 'sync:start'
|
|
207
|
+
| 'sync:complete'
|
|
208
|
+
| 'sync:error'
|
|
209
|
+
| 'connection:change'
|
|
210
|
+
| 'data:change'
|
|
211
|
+
| 'outbox:change'
|
|
212
|
+
| 'conflict:new'
|
|
213
|
+
| 'conflict:resolved'
|
|
214
|
+
| 'blob:upload:complete'
|
|
215
|
+
| 'blob:upload:error'
|
|
216
|
+
| 'presence:change';
|
|
217
|
+
|
|
218
|
+
type ClientEventPayloads = {
|
|
219
|
+
'sync:start': { timestamp: number };
|
|
220
|
+
'sync:complete': SyncResult;
|
|
221
|
+
'sync:error': { code: string; message: string };
|
|
222
|
+
'connection:change': { previous: string; current: string };
|
|
223
|
+
'data:change': { scopes: string[]; timestamp: number };
|
|
224
|
+
'outbox:change': OutboxStats;
|
|
225
|
+
'conflict:new': Conflict;
|
|
226
|
+
'conflict:resolved': Conflict;
|
|
227
|
+
'blob:upload:complete': BlobRef;
|
|
228
|
+
'blob:upload:error': { hash: string; error: string };
|
|
229
|
+
'presence:change': { scopeKey: string; presence: PresenceEntry[] };
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
type ClientEventHandler<E extends ClientEventType> = (
|
|
233
|
+
payload: ClientEventPayloads[E]
|
|
234
|
+
) => void;
|
|
235
|
+
|
|
236
|
+
// ============================================================================
|
|
237
|
+
// Client Class
|
|
238
|
+
// ============================================================================
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Unified sync client.
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* ```typescript
|
|
245
|
+
* import { Client } from '@syncular/client';
|
|
246
|
+
* import { createHttpTransport } from '@syncular/transport-http';
|
|
247
|
+
*
|
|
248
|
+
* const client = new Client({
|
|
249
|
+
* db,
|
|
250
|
+
* transport: createHttpTransport({ baseUrl: '/api/sync', getHeaders }),
|
|
251
|
+
* tableHandlers,
|
|
252
|
+
* clientId: 'device-123',
|
|
253
|
+
* actorId: 'user-456',
|
|
254
|
+
* subscriptions: [{ id: 'tasks', shape: 'tasks', scopes: { user_id: 'user-456' } }],
|
|
255
|
+
* });
|
|
256
|
+
*
|
|
257
|
+
* await client.start();
|
|
258
|
+
*
|
|
259
|
+
* // Mutations
|
|
260
|
+
* await client.mutations.tasks.insert({ title: 'New task' });
|
|
261
|
+
*
|
|
262
|
+
* // Events
|
|
263
|
+
* client.on('sync:complete', () => console.log('synced'));
|
|
264
|
+
* ```
|
|
265
|
+
*/
|
|
266
|
+
export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
267
|
+
private readonly options: ClientOptions<DB>;
|
|
268
|
+
private engine: SyncEngine<DB> | null = null;
|
|
269
|
+
private started = false;
|
|
270
|
+
private destroyed = false;
|
|
271
|
+
private eventListeners = new Map<
|
|
272
|
+
ClientEventType,
|
|
273
|
+
Set<ClientEventHandler<any>>
|
|
274
|
+
>();
|
|
275
|
+
private outboxStats: OutboxStats = {
|
|
276
|
+
pending: 0,
|
|
277
|
+
sending: 0,
|
|
278
|
+
failed: 0,
|
|
279
|
+
acked: 0,
|
|
280
|
+
total: 0,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** Mutations API (always available) */
|
|
284
|
+
public readonly mutations: MutationsApi<DB>;
|
|
285
|
+
|
|
286
|
+
/** Blob client (only available if blobStorage configured) */
|
|
287
|
+
public readonly blobs: BlobClient | undefined;
|
|
288
|
+
|
|
289
|
+
constructor(options: ClientOptions<DB>) {
|
|
290
|
+
this.options = options;
|
|
291
|
+
|
|
292
|
+
// Create mutations API
|
|
293
|
+
const commitFn = createOutboxCommit({
|
|
294
|
+
db: options.db,
|
|
295
|
+
idColumn: options.idColumn ?? 'id',
|
|
296
|
+
versionColumn: options.versionColumn ?? 'server_version',
|
|
297
|
+
omitColumns: options.omitColumns ?? [],
|
|
298
|
+
});
|
|
299
|
+
this.mutations = createMutationsApi(commitFn) as MutationsApi<DB>;
|
|
300
|
+
|
|
301
|
+
// Create blob client if storage provided
|
|
302
|
+
if (options.blobStorage && options.transport.blobs) {
|
|
303
|
+
this.blobs = this.createBlobClient(
|
|
304
|
+
options.blobStorage,
|
|
305
|
+
options.transport
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ===========================================================================
|
|
311
|
+
// Identity Getters
|
|
312
|
+
// ===========================================================================
|
|
313
|
+
|
|
314
|
+
/** Client ID */
|
|
315
|
+
get clientId(): string {
|
|
316
|
+
return this.options.clientId;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Actor ID */
|
|
320
|
+
get actorId(): string {
|
|
321
|
+
return this.options.actorId;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Database instance */
|
|
325
|
+
get db(): Kysely<DB> {
|
|
326
|
+
return this.options.db;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ===========================================================================
|
|
330
|
+
// Lifecycle
|
|
331
|
+
// ===========================================================================
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Start the client.
|
|
335
|
+
* Runs migrations and starts sync engine.
|
|
336
|
+
*/
|
|
337
|
+
async start(): Promise<void> {
|
|
338
|
+
if (this.destroyed) {
|
|
339
|
+
throw new Error('Client has been destroyed');
|
|
340
|
+
}
|
|
341
|
+
if (this.started) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Run migrations
|
|
346
|
+
await ensureClientSyncSchema(this.options.db);
|
|
347
|
+
if (this.options.blobStorage) {
|
|
348
|
+
await ensureClientBlobSchema(this.options.db);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Create and start engine
|
|
352
|
+
this.engine = new SyncEngine({
|
|
353
|
+
db: this.options.db,
|
|
354
|
+
transport: this.options.transport,
|
|
355
|
+
shapes: this.options.tableHandlers,
|
|
356
|
+
clientId: this.options.clientId,
|
|
357
|
+
actorId: this.options.actorId,
|
|
358
|
+
subscriptions: this.options.subscriptions.map((s) => ({
|
|
359
|
+
id: s.id,
|
|
360
|
+
shape: s.shape,
|
|
361
|
+
scopes: s.scopes ?? {},
|
|
362
|
+
params: s.params ?? {},
|
|
363
|
+
})),
|
|
364
|
+
plugins: this.options.plugins,
|
|
365
|
+
realtimeEnabled: this.options.realtimeEnabled,
|
|
366
|
+
pollIntervalMs: this.options.pollIntervalMs,
|
|
367
|
+
stateId: this.options.stateId,
|
|
368
|
+
migrate: undefined, // We already ran migrations
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Wire up engine events to client events
|
|
372
|
+
this.wireEngineEvents();
|
|
373
|
+
|
|
374
|
+
await this.engine.start();
|
|
375
|
+
this.started = true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Stop the client (can be restarted).
|
|
380
|
+
*/
|
|
381
|
+
stop(): void {
|
|
382
|
+
this.engine?.stop();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Destroy the client (cannot be restarted).
|
|
387
|
+
*/
|
|
388
|
+
destroy(): void {
|
|
389
|
+
this.engine?.destroy();
|
|
390
|
+
this.eventListeners.clear();
|
|
391
|
+
this.destroyed = true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ===========================================================================
|
|
395
|
+
// Sync
|
|
396
|
+
// ===========================================================================
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Trigger a manual sync.
|
|
400
|
+
*/
|
|
401
|
+
async sync(): Promise<SyncResult> {
|
|
402
|
+
if (!this.engine) {
|
|
403
|
+
throw new Error('Client not started');
|
|
404
|
+
}
|
|
405
|
+
return this.engine.sync();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ===========================================================================
|
|
409
|
+
// Subscriptions
|
|
410
|
+
// ===========================================================================
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Update subscriptions.
|
|
414
|
+
*/
|
|
415
|
+
updateSubscriptions(
|
|
416
|
+
subscriptions: Array<{
|
|
417
|
+
id: string;
|
|
418
|
+
shape: string;
|
|
419
|
+
scopes?: Record<string, string | string[]>;
|
|
420
|
+
params?: Record<string, unknown>;
|
|
421
|
+
}>
|
|
422
|
+
): void {
|
|
423
|
+
this.options.subscriptions = subscriptions;
|
|
424
|
+
if (this.engine) {
|
|
425
|
+
this.engine.updateSubscriptions(
|
|
426
|
+
subscriptions.map((s) => ({
|
|
427
|
+
id: s.id,
|
|
428
|
+
shape: s.shape,
|
|
429
|
+
scopes: s.scopes ?? {},
|
|
430
|
+
params: s.params ?? {},
|
|
431
|
+
}))
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Get current subscriptions.
|
|
438
|
+
*/
|
|
439
|
+
getSubscriptions(): Array<{
|
|
440
|
+
id: string;
|
|
441
|
+
shape: string;
|
|
442
|
+
scopes: Record<string, string | string[]>;
|
|
443
|
+
params: Record<string, unknown>;
|
|
444
|
+
}> {
|
|
445
|
+
return this.options.subscriptions.map((s) => ({
|
|
446
|
+
id: s.id,
|
|
447
|
+
shape: s.shape,
|
|
448
|
+
scopes: s.scopes ?? {},
|
|
449
|
+
params: s.params ?? {},
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ===========================================================================
|
|
454
|
+
// State
|
|
455
|
+
// ===========================================================================
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Get current client state.
|
|
459
|
+
*/
|
|
460
|
+
getState(): ClientState {
|
|
461
|
+
const engineState =
|
|
462
|
+
this.engine?.getState() ?? this.createInitialEngineState();
|
|
463
|
+
return {
|
|
464
|
+
clientId: this.options.clientId,
|
|
465
|
+
actorId: this.options.actorId,
|
|
466
|
+
enabled: engineState.enabled,
|
|
467
|
+
isSyncing: engineState.isSyncing,
|
|
468
|
+
connectionState: engineState.connectionState,
|
|
469
|
+
lastSyncAt: engineState.lastSyncAt,
|
|
470
|
+
error: engineState.error
|
|
471
|
+
? { code: engineState.error.code, message: engineState.error.message }
|
|
472
|
+
: null,
|
|
473
|
+
outbox: this.outboxStats,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Subscribe to state changes (for useSyncExternalStore).
|
|
479
|
+
*/
|
|
480
|
+
subscribe(callback: () => void): () => void {
|
|
481
|
+
if (!this.engine) {
|
|
482
|
+
// Return no-op unsubscribe before engine is started
|
|
483
|
+
return () => {};
|
|
484
|
+
}
|
|
485
|
+
return this.engine.subscribe(callback);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ===========================================================================
|
|
489
|
+
// Events
|
|
490
|
+
// ===========================================================================
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Subscribe to client events.
|
|
494
|
+
*/
|
|
495
|
+
on<E extends ClientEventType>(
|
|
496
|
+
event: E,
|
|
497
|
+
handler: ClientEventHandler<E>
|
|
498
|
+
): () => void {
|
|
499
|
+
if (!this.eventListeners.has(event)) {
|
|
500
|
+
this.eventListeners.set(event, new Set());
|
|
501
|
+
}
|
|
502
|
+
this.eventListeners.get(event)!.add(handler);
|
|
503
|
+
|
|
504
|
+
return () => {
|
|
505
|
+
this.eventListeners.get(event)?.delete(handler);
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private emit<E extends ClientEventType>(
|
|
510
|
+
event: E,
|
|
511
|
+
payload: ClientEventPayloads[E]
|
|
512
|
+
): void {
|
|
513
|
+
const listeners = this.eventListeners.get(event);
|
|
514
|
+
if (listeners) {
|
|
515
|
+
for (const listener of listeners) {
|
|
516
|
+
try {
|
|
517
|
+
listener(payload);
|
|
518
|
+
} catch (err) {
|
|
519
|
+
console.error(`[Client] Error in ${event} listener:`, err);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ===========================================================================
|
|
526
|
+
// Conflicts
|
|
527
|
+
// ===========================================================================
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Get pending conflicts.
|
|
531
|
+
*/
|
|
532
|
+
async getConflicts(): Promise<Conflict[]> {
|
|
533
|
+
if (!this.engine) {
|
|
534
|
+
return [];
|
|
535
|
+
}
|
|
536
|
+
const conflicts = await this.engine.getConflicts();
|
|
537
|
+
return conflicts.map((c) => this.mapConflictInfo(c));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Resolve a conflict.
|
|
542
|
+
*/
|
|
543
|
+
async resolveConflict(
|
|
544
|
+
id: string,
|
|
545
|
+
resolution: ConflictResolution
|
|
546
|
+
): Promise<void> {
|
|
547
|
+
const { resolveConflict } = await import('./conflicts');
|
|
548
|
+
|
|
549
|
+
// For 'keep-local' and 'keep-server', we just mark it resolved
|
|
550
|
+
// For 'custom', we would need to apply the payload - but that requires
|
|
551
|
+
// creating a new mutation, which the user should do separately
|
|
552
|
+
const resolutionStr =
|
|
553
|
+
resolution.strategy === 'custom'
|
|
554
|
+
? `custom:${JSON.stringify(resolution.payload)}`
|
|
555
|
+
: resolution.strategy;
|
|
556
|
+
|
|
557
|
+
await resolveConflict(this.options.db, { id, resolution: resolutionStr });
|
|
558
|
+
|
|
559
|
+
// Get the conflict for the event
|
|
560
|
+
const conflicts = await this.getConflicts();
|
|
561
|
+
const resolved = conflicts.find((c) => c.id === id);
|
|
562
|
+
if (resolved) {
|
|
563
|
+
this.emit('conflict:resolved', resolved);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ===========================================================================
|
|
568
|
+
// Outbox
|
|
569
|
+
// ===========================================================================
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Get outbox statistics.
|
|
573
|
+
*/
|
|
574
|
+
async getOutboxStats(): Promise<OutboxStats> {
|
|
575
|
+
if (!this.engine) {
|
|
576
|
+
return this.outboxStats;
|
|
577
|
+
}
|
|
578
|
+
this.outboxStats = await this.engine.refreshOutboxStats({ emit: false });
|
|
579
|
+
return this.outboxStats;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Clear failed commits from outbox.
|
|
584
|
+
*/
|
|
585
|
+
async clearFailedCommits(): Promise<number> {
|
|
586
|
+
if (!this.engine) {
|
|
587
|
+
return 0;
|
|
588
|
+
}
|
|
589
|
+
return this.engine.clearFailedCommits();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Retry failed commits.
|
|
594
|
+
*/
|
|
595
|
+
async retryFailedCommits(): Promise<number> {
|
|
596
|
+
// Mark failed commits as pending and trigger sync
|
|
597
|
+
const result = await sql`
|
|
598
|
+
update ${sql.table('sync_outbox_commits')}
|
|
599
|
+
set
|
|
600
|
+
${sql.ref('status')} = ${sql.val('pending')},
|
|
601
|
+
${sql.ref('attempt_count')} = ${sql.val(0)},
|
|
602
|
+
${sql.ref('error')} = ${sql.val(null)}
|
|
603
|
+
where ${sql.ref('status')} = ${sql.val('failed')}
|
|
604
|
+
`.execute(this.options.db);
|
|
605
|
+
|
|
606
|
+
const count = Number(result.numAffectedRows ?? 0n);
|
|
607
|
+
if (count > 0 && this.engine) {
|
|
608
|
+
await this.engine.refreshOutboxStats();
|
|
609
|
+
await this.engine.sync();
|
|
610
|
+
}
|
|
611
|
+
return count;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ===========================================================================
|
|
615
|
+
// Presence
|
|
616
|
+
// ===========================================================================
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Get presence for a scope.
|
|
620
|
+
*/
|
|
621
|
+
getPresence<TMetadata = Record<string, unknown>>(
|
|
622
|
+
scopeKey: string
|
|
623
|
+
): PresenceEntry<TMetadata>[] {
|
|
624
|
+
if (!this.engine) {
|
|
625
|
+
return [];
|
|
626
|
+
}
|
|
627
|
+
return this.engine.getPresence<TMetadata>(scopeKey);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Join presence for a scope key.
|
|
632
|
+
*/
|
|
633
|
+
joinPresence(scopeKey: string, metadata?: Record<string, unknown>): void {
|
|
634
|
+
this.engine?.joinPresence(scopeKey, metadata);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Leave presence for a scope key.
|
|
639
|
+
*/
|
|
640
|
+
leavePresence(scopeKey: string): void {
|
|
641
|
+
this.engine?.leavePresence(scopeKey);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Update presence metadata for a scope key.
|
|
646
|
+
*/
|
|
647
|
+
updatePresenceMetadata(
|
|
648
|
+
scopeKey: string,
|
|
649
|
+
metadata: Record<string, unknown>
|
|
650
|
+
): void {
|
|
651
|
+
this.engine?.updatePresenceMetadata(scopeKey, metadata);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ===========================================================================
|
|
655
|
+
// Migration Info
|
|
656
|
+
// ===========================================================================
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Get migration info.
|
|
660
|
+
*/
|
|
661
|
+
async getMigrationInfo(): Promise<MigrationInfo> {
|
|
662
|
+
// Check if sync tables exist
|
|
663
|
+
let syncMigrated = false;
|
|
664
|
+
try {
|
|
665
|
+
await this.options.db
|
|
666
|
+
.selectFrom('sync_outbox_commits')
|
|
667
|
+
.selectAll()
|
|
668
|
+
.limit(1)
|
|
669
|
+
.execute();
|
|
670
|
+
syncMigrated = true;
|
|
671
|
+
} catch {
|
|
672
|
+
syncMigrated = false;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Check if blob tables exist
|
|
676
|
+
let blobsMigrated = false;
|
|
677
|
+
try {
|
|
678
|
+
await this.options.db
|
|
679
|
+
.selectFrom('sync_blob_cache')
|
|
680
|
+
.selectAll()
|
|
681
|
+
.limit(1)
|
|
682
|
+
.execute();
|
|
683
|
+
blobsMigrated = true;
|
|
684
|
+
} catch {
|
|
685
|
+
blobsMigrated = false;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return { syncMigrated, blobsMigrated };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Static: Check if migrations are needed.
|
|
693
|
+
*/
|
|
694
|
+
static async checkMigrations<DB extends SyncClientDb>(
|
|
695
|
+
db: Kysely<DB>
|
|
696
|
+
): Promise<{
|
|
697
|
+
needsMigration: boolean;
|
|
698
|
+
syncMigrated: boolean;
|
|
699
|
+
blobsMigrated: boolean;
|
|
700
|
+
}> {
|
|
701
|
+
let syncMigrated = false;
|
|
702
|
+
let blobsMigrated = false;
|
|
703
|
+
|
|
704
|
+
try {
|
|
705
|
+
await db.selectFrom('sync_outbox_commits').selectAll().limit(1).execute();
|
|
706
|
+
syncMigrated = true;
|
|
707
|
+
} catch {
|
|
708
|
+
syncMigrated = false;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
try {
|
|
712
|
+
await db.selectFrom('sync_blob_cache').selectAll().limit(1).execute();
|
|
713
|
+
blobsMigrated = true;
|
|
714
|
+
} catch {
|
|
715
|
+
blobsMigrated = false;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return {
|
|
719
|
+
needsMigration: !syncMigrated,
|
|
720
|
+
syncMigrated,
|
|
721
|
+
blobsMigrated,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Static: Run migrations.
|
|
727
|
+
*/
|
|
728
|
+
static async migrate<DB extends SyncClientDb>(
|
|
729
|
+
db: Kysely<DB>,
|
|
730
|
+
options?: { blobs?: boolean }
|
|
731
|
+
): Promise<void> {
|
|
732
|
+
await ensureClientSyncSchema(db);
|
|
733
|
+
if (options?.blobs) {
|
|
734
|
+
await ensureClientBlobSchema(db);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ===========================================================================
|
|
739
|
+
// Private Helpers
|
|
740
|
+
// ===========================================================================
|
|
741
|
+
|
|
742
|
+
private createInitialEngineState(): SyncEngineState {
|
|
743
|
+
return {
|
|
744
|
+
enabled: false,
|
|
745
|
+
isSyncing: false,
|
|
746
|
+
connectionState: 'disconnected',
|
|
747
|
+
transportMode: 'polling',
|
|
748
|
+
lastSyncAt: null,
|
|
749
|
+
error: null,
|
|
750
|
+
pendingCount: 0,
|
|
751
|
+
retryCount: 0,
|
|
752
|
+
isRetrying: false,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private wireEngineEvents(): void {
|
|
757
|
+
if (!this.engine) return;
|
|
758
|
+
|
|
759
|
+
this.engine.on('sync:start', (payload) => {
|
|
760
|
+
this.emit('sync:start', payload);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
this.engine.on('sync:complete', (payload) => {
|
|
764
|
+
this.emit('sync:complete', {
|
|
765
|
+
success: true,
|
|
766
|
+
pushedCommits: payload.pushedCommits,
|
|
767
|
+
pullRounds: payload.pullRounds,
|
|
768
|
+
pullResponse: payload.pullResponse,
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
this.engine.on('sync:error', (error) => {
|
|
773
|
+
this.emit('sync:error', { code: error.code, message: error.message });
|
|
774
|
+
|
|
775
|
+
// Check for new conflicts after sync error
|
|
776
|
+
this.checkForNewConflicts();
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
this.engine.on('connection:change', (payload) => {
|
|
780
|
+
this.emit('connection:change', payload);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
this.engine.on('data:change', (payload) => {
|
|
784
|
+
this.emit('data:change', payload);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
this.engine.on('outbox:change', (payload) => {
|
|
788
|
+
this.outboxStats = {
|
|
789
|
+
pending: payload.pendingCount,
|
|
790
|
+
sending: payload.sendingCount,
|
|
791
|
+
failed: payload.failedCount,
|
|
792
|
+
acked: payload.ackedCount ?? 0,
|
|
793
|
+
total:
|
|
794
|
+
payload.pendingCount +
|
|
795
|
+
payload.sendingCount +
|
|
796
|
+
payload.failedCount +
|
|
797
|
+
(payload.ackedCount ?? 0),
|
|
798
|
+
};
|
|
799
|
+
this.emit('outbox:change', this.outboxStats);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
this.engine.on('presence:change', (payload) => {
|
|
803
|
+
this.emit('presence:change', payload);
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
private async checkForNewConflicts(): Promise<void> {
|
|
808
|
+
const conflicts = await this.getConflicts();
|
|
809
|
+
for (const conflict of conflicts) {
|
|
810
|
+
this.emit('conflict:new', conflict);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private mapConflictInfo(info: ConflictInfo): Conflict {
|
|
815
|
+
let serverPayload: Record<string, unknown> | null = null;
|
|
816
|
+
if (info.serverRowJson) {
|
|
817
|
+
try {
|
|
818
|
+
serverPayload = JSON.parse(info.serverRowJson);
|
|
819
|
+
} catch {
|
|
820
|
+
serverPayload = null;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return {
|
|
825
|
+
id: info.id,
|
|
826
|
+
table: info.table,
|
|
827
|
+
rowId: info.rowId,
|
|
828
|
+
opIndex: info.opIndex,
|
|
829
|
+
localPayload: info.localPayload,
|
|
830
|
+
serverPayload,
|
|
831
|
+
serverVersion: info.serverVersion,
|
|
832
|
+
message: info.message,
|
|
833
|
+
code: info.code,
|
|
834
|
+
createdAt: info.createdAt,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
private createBlobClient(
|
|
839
|
+
storage: ClientBlobStorage,
|
|
840
|
+
transport: SyncTransport
|
|
841
|
+
): BlobClient {
|
|
842
|
+
const db = this.options.db;
|
|
843
|
+
const blobs = transport.blobs!;
|
|
844
|
+
|
|
845
|
+
return {
|
|
846
|
+
async store(data, options) {
|
|
847
|
+
const bytes = await toUint8Array(data);
|
|
848
|
+
const mimeType =
|
|
849
|
+
data instanceof Blob
|
|
850
|
+
? data.type
|
|
851
|
+
: (options?.mimeType ?? 'application/octet-stream');
|
|
852
|
+
|
|
853
|
+
// Compute hash
|
|
854
|
+
const hashHex = await computeSha256Hex(bytes);
|
|
855
|
+
const hash = `sha256:${hashHex}`;
|
|
856
|
+
|
|
857
|
+
// Store locally
|
|
858
|
+
await storage.write(hash, bytes);
|
|
859
|
+
|
|
860
|
+
// Store metadata
|
|
861
|
+
const now = Date.now();
|
|
862
|
+
await sql`
|
|
863
|
+
insert into ${sql.table('sync_blob_cache')} (
|
|
864
|
+
${sql.join([
|
|
865
|
+
sql.ref('hash'),
|
|
866
|
+
sql.ref('size'),
|
|
867
|
+
sql.ref('mime_type'),
|
|
868
|
+
sql.ref('cached_at'),
|
|
869
|
+
sql.ref('last_accessed_at'),
|
|
870
|
+
sql.ref('encrypted'),
|
|
871
|
+
sql.ref('key_id'),
|
|
872
|
+
sql.ref('body'),
|
|
873
|
+
])}
|
|
874
|
+
) values (
|
|
875
|
+
${sql.join([
|
|
876
|
+
sql.val(hash),
|
|
877
|
+
sql.val(bytes.length),
|
|
878
|
+
sql.val(mimeType),
|
|
879
|
+
sql.val(now),
|
|
880
|
+
sql.val(now),
|
|
881
|
+
sql.val(0),
|
|
882
|
+
sql.val(null),
|
|
883
|
+
sql.val(bytes),
|
|
884
|
+
])}
|
|
885
|
+
)
|
|
886
|
+
on conflict (${sql.ref('hash')}) do nothing
|
|
887
|
+
`.execute(db);
|
|
888
|
+
|
|
889
|
+
// Queue for upload or upload immediately
|
|
890
|
+
if (options?.immediate) {
|
|
891
|
+
// Initiate upload
|
|
892
|
+
const initResult = await blobs.initiateUpload({
|
|
893
|
+
hash,
|
|
894
|
+
size: bytes.length,
|
|
895
|
+
mimeType,
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
if (!initResult.exists && initResult.uploadUrl) {
|
|
899
|
+
// Upload to presigned URL
|
|
900
|
+
const uploadResponse = await fetch(initResult.uploadUrl, {
|
|
901
|
+
method: initResult.uploadMethod ?? 'PUT',
|
|
902
|
+
body: bytes.buffer as ArrayBuffer,
|
|
903
|
+
headers: initResult.uploadHeaders,
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
if (!uploadResponse.ok) {
|
|
907
|
+
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Complete upload
|
|
911
|
+
await blobs.completeUpload(hash);
|
|
912
|
+
}
|
|
913
|
+
} else {
|
|
914
|
+
// Queue for later upload
|
|
915
|
+
await sql`
|
|
916
|
+
insert into ${sql.table('sync_blob_outbox')} (
|
|
917
|
+
${sql.join([
|
|
918
|
+
sql.ref('hash'),
|
|
919
|
+
sql.ref('size'),
|
|
920
|
+
sql.ref('mime_type'),
|
|
921
|
+
sql.ref('status'),
|
|
922
|
+
sql.ref('created_at'),
|
|
923
|
+
sql.ref('updated_at'),
|
|
924
|
+
sql.ref('attempt_count'),
|
|
925
|
+
sql.ref('error'),
|
|
926
|
+
sql.ref('encrypted'),
|
|
927
|
+
sql.ref('key_id'),
|
|
928
|
+
sql.ref('body'),
|
|
929
|
+
])}
|
|
930
|
+
) values (
|
|
931
|
+
${sql.join([
|
|
932
|
+
sql.val(hash),
|
|
933
|
+
sql.val(bytes.length),
|
|
934
|
+
sql.val(mimeType),
|
|
935
|
+
sql.val('pending'),
|
|
936
|
+
sql.val(now),
|
|
937
|
+
sql.val(now),
|
|
938
|
+
sql.val(0),
|
|
939
|
+
sql.val(null),
|
|
940
|
+
sql.val(0),
|
|
941
|
+
sql.val(null),
|
|
942
|
+
sql.val(bytes),
|
|
943
|
+
])}
|
|
944
|
+
)
|
|
945
|
+
on conflict (${sql.ref('hash')}) do nothing
|
|
946
|
+
`.execute(db);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return {
|
|
950
|
+
hash,
|
|
951
|
+
size: bytes.length,
|
|
952
|
+
mimeType,
|
|
953
|
+
};
|
|
954
|
+
},
|
|
955
|
+
|
|
956
|
+
async retrieve(ref) {
|
|
957
|
+
// Check local storage first
|
|
958
|
+
const local = await storage.read(ref.hash);
|
|
959
|
+
if (local) {
|
|
960
|
+
// Update access time
|
|
961
|
+
await sql`
|
|
962
|
+
update ${sql.table('sync_blob_cache')}
|
|
963
|
+
set ${sql.ref('last_accessed_at')} = ${sql.val(Date.now())}
|
|
964
|
+
where ${sql.ref('hash')} = ${sql.val(ref.hash)}
|
|
965
|
+
`.execute(db);
|
|
966
|
+
return local;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Fetch from server
|
|
970
|
+
const { url } = await blobs.getDownloadUrl(ref.hash);
|
|
971
|
+
const response = await fetch(url);
|
|
972
|
+
if (!response.ok) {
|
|
973
|
+
throw new Error(`Download failed: ${response.statusText}`);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
977
|
+
|
|
978
|
+
// Cache locally
|
|
979
|
+
await storage.write(ref.hash, bytes);
|
|
980
|
+
const now = Date.now();
|
|
981
|
+
await sql`
|
|
982
|
+
insert into ${sql.table('sync_blob_cache')} (
|
|
983
|
+
${sql.join([
|
|
984
|
+
sql.ref('hash'),
|
|
985
|
+
sql.ref('size'),
|
|
986
|
+
sql.ref('mime_type'),
|
|
987
|
+
sql.ref('cached_at'),
|
|
988
|
+
sql.ref('last_accessed_at'),
|
|
989
|
+
sql.ref('encrypted'),
|
|
990
|
+
sql.ref('key_id'),
|
|
991
|
+
sql.ref('body'),
|
|
992
|
+
])}
|
|
993
|
+
) values (
|
|
994
|
+
${sql.join([
|
|
995
|
+
sql.val(ref.hash),
|
|
996
|
+
sql.val(bytes.length),
|
|
997
|
+
sql.val(ref.mimeType),
|
|
998
|
+
sql.val(now),
|
|
999
|
+
sql.val(now),
|
|
1000
|
+
sql.val(0),
|
|
1001
|
+
sql.val(null),
|
|
1002
|
+
sql.val(bytes),
|
|
1003
|
+
])}
|
|
1004
|
+
)
|
|
1005
|
+
on conflict (${sql.ref('hash')}) do nothing
|
|
1006
|
+
`.execute(db);
|
|
1007
|
+
|
|
1008
|
+
return bytes;
|
|
1009
|
+
},
|
|
1010
|
+
|
|
1011
|
+
async isLocal(hash) {
|
|
1012
|
+
return storage.exists(hash);
|
|
1013
|
+
},
|
|
1014
|
+
|
|
1015
|
+
async preload(refs) {
|
|
1016
|
+
await Promise.all(refs.map((ref) => this.retrieve(ref)));
|
|
1017
|
+
},
|
|
1018
|
+
|
|
1019
|
+
async processUploadQueue() {
|
|
1020
|
+
let uploaded = 0;
|
|
1021
|
+
let failed = 0;
|
|
1022
|
+
|
|
1023
|
+
const pendingResult = await sql<{
|
|
1024
|
+
hash: string;
|
|
1025
|
+
size: number;
|
|
1026
|
+
mime_type: string;
|
|
1027
|
+
body: Uint8Array | null;
|
|
1028
|
+
}>`
|
|
1029
|
+
select
|
|
1030
|
+
${sql.ref('hash')},
|
|
1031
|
+
${sql.ref('size')},
|
|
1032
|
+
${sql.ref('mime_type')},
|
|
1033
|
+
${sql.ref('body')}
|
|
1034
|
+
from ${sql.table('sync_blob_outbox')}
|
|
1035
|
+
where ${sql.ref('status')} = ${sql.val('pending')}
|
|
1036
|
+
limit ${sql.val(10)}
|
|
1037
|
+
`.execute(db);
|
|
1038
|
+
const pending = pendingResult.rows;
|
|
1039
|
+
|
|
1040
|
+
for (const item of pending) {
|
|
1041
|
+
try {
|
|
1042
|
+
// Mark as uploading
|
|
1043
|
+
await sql`
|
|
1044
|
+
update ${sql.table('sync_blob_outbox')}
|
|
1045
|
+
set
|
|
1046
|
+
${sql.ref('status')} = ${sql.val('uploading')},
|
|
1047
|
+
${sql.ref('updated_at')} = ${sql.val(Date.now())}
|
|
1048
|
+
where ${sql.ref('hash')} = ${sql.val(item.hash)}
|
|
1049
|
+
`.execute(db);
|
|
1050
|
+
|
|
1051
|
+
// Initiate upload
|
|
1052
|
+
const initResult = await blobs.initiateUpload({
|
|
1053
|
+
hash: item.hash,
|
|
1054
|
+
size: item.size,
|
|
1055
|
+
mimeType: item.mime_type,
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
if (!initResult.exists && initResult.uploadUrl && item.body) {
|
|
1059
|
+
const uploadBody = new ArrayBuffer(item.body.byteLength);
|
|
1060
|
+
new Uint8Array(uploadBody).set(item.body);
|
|
1061
|
+
|
|
1062
|
+
// Upload
|
|
1063
|
+
const uploadResponse = await fetch(initResult.uploadUrl, {
|
|
1064
|
+
method: initResult.uploadMethod ?? 'PUT',
|
|
1065
|
+
body: uploadBody,
|
|
1066
|
+
headers: initResult.uploadHeaders,
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
if (!uploadResponse.ok) {
|
|
1070
|
+
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Complete
|
|
1074
|
+
await blobs.completeUpload(item.hash);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Mark as complete
|
|
1078
|
+
await sql`
|
|
1079
|
+
delete from ${sql.table('sync_blob_outbox')}
|
|
1080
|
+
where ${sql.ref('hash')} = ${sql.val(item.hash)}
|
|
1081
|
+
`.execute(db);
|
|
1082
|
+
|
|
1083
|
+
uploaded++;
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
// Mark as failed
|
|
1086
|
+
await sql`
|
|
1087
|
+
update ${sql.table('sync_blob_outbox')}
|
|
1088
|
+
set
|
|
1089
|
+
${sql.ref('status')} = ${sql.val('failed')},
|
|
1090
|
+
${sql.ref('error')} = ${sql.val(
|
|
1091
|
+
err instanceof Error ? err.message : 'Unknown error'
|
|
1092
|
+
)},
|
|
1093
|
+
${sql.ref('attempt_count')} = ${sql.ref('attempt_count')} + ${sql.val(
|
|
1094
|
+
1
|
|
1095
|
+
)},
|
|
1096
|
+
${sql.ref('updated_at')} = ${sql.val(Date.now())}
|
|
1097
|
+
where ${sql.ref('hash')} = ${sql.val(item.hash)}
|
|
1098
|
+
`.execute(db);
|
|
1099
|
+
|
|
1100
|
+
failed++;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return { uploaded, failed };
|
|
1105
|
+
},
|
|
1106
|
+
|
|
1107
|
+
async getUploadQueueStats() {
|
|
1108
|
+
const rowsResult = await sql<{
|
|
1109
|
+
status: string;
|
|
1110
|
+
count: number | bigint;
|
|
1111
|
+
}>`
|
|
1112
|
+
select
|
|
1113
|
+
${sql.ref('status')} as status,
|
|
1114
|
+
count(${sql.ref('hash')}) as count
|
|
1115
|
+
from ${sql.table('sync_blob_outbox')}
|
|
1116
|
+
group by ${sql.ref('status')}
|
|
1117
|
+
`.execute(db);
|
|
1118
|
+
|
|
1119
|
+
const stats = { pending: 0, uploading: 0, failed: 0 };
|
|
1120
|
+
for (const row of rowsResult.rows) {
|
|
1121
|
+
if (row.status === 'pending') stats.pending = Number(row.count);
|
|
1122
|
+
if (row.status === 'uploading') stats.uploading = Number(row.count);
|
|
1123
|
+
if (row.status === 'failed') stats.failed = Number(row.count);
|
|
1124
|
+
}
|
|
1125
|
+
return stats;
|
|
1126
|
+
},
|
|
1127
|
+
|
|
1128
|
+
async getCacheStats() {
|
|
1129
|
+
const result = await sql<{
|
|
1130
|
+
count: number | bigint;
|
|
1131
|
+
totalBytes: number | bigint | null;
|
|
1132
|
+
}>`
|
|
1133
|
+
select
|
|
1134
|
+
count(${sql.ref('hash')}) as count,
|
|
1135
|
+
sum(${sql.ref('size')}) as totalBytes
|
|
1136
|
+
from ${sql.table('sync_blob_cache')}
|
|
1137
|
+
`.execute(db);
|
|
1138
|
+
const row = result.rows[0];
|
|
1139
|
+
|
|
1140
|
+
return {
|
|
1141
|
+
count: Number(row?.count ?? 0),
|
|
1142
|
+
totalBytes: Number(row?.totalBytes ?? 0),
|
|
1143
|
+
};
|
|
1144
|
+
},
|
|
1145
|
+
|
|
1146
|
+
async pruneCache(maxBytes) {
|
|
1147
|
+
if (!maxBytes) return 0;
|
|
1148
|
+
|
|
1149
|
+
// Get current size
|
|
1150
|
+
const stats = await this.getCacheStats();
|
|
1151
|
+
if (stats.totalBytes <= maxBytes) return 0;
|
|
1152
|
+
|
|
1153
|
+
// Get oldest entries to delete
|
|
1154
|
+
const toFree = stats.totalBytes - maxBytes;
|
|
1155
|
+
let freed = 0;
|
|
1156
|
+
|
|
1157
|
+
const oldEntriesResult = await sql<{ hash: string; size: number }>`
|
|
1158
|
+
select ${sql.ref('hash')}, ${sql.ref('size')}
|
|
1159
|
+
from ${sql.table('sync_blob_cache')}
|
|
1160
|
+
order by ${sql.ref('last_accessed_at')} asc
|
|
1161
|
+
`.execute(db);
|
|
1162
|
+
const oldEntries = oldEntriesResult.rows;
|
|
1163
|
+
|
|
1164
|
+
for (const entry of oldEntries) {
|
|
1165
|
+
if (freed >= toFree) break;
|
|
1166
|
+
|
|
1167
|
+
await storage.delete(entry.hash);
|
|
1168
|
+
await sql`
|
|
1169
|
+
delete from ${sql.table('sync_blob_cache')}
|
|
1170
|
+
where ${sql.ref('hash')} = ${sql.val(entry.hash)}
|
|
1171
|
+
`.execute(db);
|
|
1172
|
+
freed += entry.size;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
return freed;
|
|
1176
|
+
},
|
|
1177
|
+
|
|
1178
|
+
async clearCache() {
|
|
1179
|
+
if (storage.clear) {
|
|
1180
|
+
await storage.clear();
|
|
1181
|
+
} else {
|
|
1182
|
+
// Delete each entry individually
|
|
1183
|
+
const entriesResult = await sql<{ hash: string }>`
|
|
1184
|
+
select ${sql.ref('hash')}
|
|
1185
|
+
from ${sql.table('sync_blob_cache')}
|
|
1186
|
+
`.execute(db);
|
|
1187
|
+
|
|
1188
|
+
for (const entry of entriesResult.rows) {
|
|
1189
|
+
await storage.delete(entry.hash);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
await sql`delete from ${sql.table('sync_blob_cache')}`.execute(db);
|
|
1194
|
+
},
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// ============================================================================
|
|
1200
|
+
// Helpers
|
|
1201
|
+
// ============================================================================
|
|
1202
|
+
|
|
1203
|
+
async function toUint8Array(
|
|
1204
|
+
data: Blob | File | Uint8Array
|
|
1205
|
+
): Promise<Uint8Array> {
|
|
1206
|
+
if (data instanceof Uint8Array) {
|
|
1207
|
+
return data;
|
|
1208
|
+
}
|
|
1209
|
+
const buffer = await data.arrayBuffer();
|
|
1210
|
+
return new Uint8Array(buffer);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
async function computeSha256Hex(data: Uint8Array): Promise<string> {
|
|
1214
|
+
const hashBuffer = await crypto.subtle.digest(
|
|
1215
|
+
'SHA-256',
|
|
1216
|
+
data.buffer as ArrayBuffer
|
|
1217
|
+
);
|
|
1218
|
+
const hashArray = new Uint8Array(hashBuffer);
|
|
1219
|
+
return Array.from(hashArray)
|
|
1220
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
1221
|
+
.join('');
|
|
1222
|
+
}
|