@qm-hub/sync-client-types 0.2.1

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/src/client.ts ADDED
@@ -0,0 +1,350 @@
1
+ /**
2
+ * QM Sync Client for Browser/Node.js
3
+ *
4
+ * TypeScript implementation of qm-sync-client using fetch for HTTP transport.
5
+ * Matches the API of the Rust SyncClient from qm-sync-client crate.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import type {
11
+ AuthHeaders,
12
+ AuthResponse,
13
+ Checkpoint,
14
+ DeltaRequest,
15
+ DeltaResponse,
16
+ HttpRequest,
17
+ HttpResponse,
18
+ PullRequest,
19
+ PullResponse,
20
+ PushRecord,
21
+ PushRequest,
22
+ PushResponse,
23
+ RefreshResponse,
24
+ SyncClientConfig,
25
+ SyncRecord,
26
+ } from "./index";
27
+
28
+ /**
29
+ * HTTP client function type.
30
+ * Implement this to provide custom HTTP transport.
31
+ */
32
+ export type HttpClientFn = (request: HttpRequest) => Promise<HttpResponse>;
33
+
34
+ /**
35
+ * Default fetch-based HTTP client implementation.
36
+ */
37
+ export async function fetchHttpClient(
38
+ request: HttpRequest
39
+ ): Promise<HttpResponse> {
40
+ const headers: HeadersInit = {
41
+ "Content-Type": request.headers.contentType,
42
+ "X-API-Key": request.headers.apiKey,
43
+ "X-App-Id": request.headers.appId,
44
+ };
45
+
46
+ if (request.headers.authorization) {
47
+ headers["Authorization"] = request.headers.authorization;
48
+ }
49
+
50
+ try {
51
+ const response = await fetch(request.url, {
52
+ method: request.method,
53
+ headers,
54
+ body: request.body,
55
+ });
56
+
57
+ return {
58
+ status: response.status,
59
+ body: await response.text(),
60
+ };
61
+ } catch (error) {
62
+ throw new Error(
63
+ `HTTP request failed: ${error instanceof Error ? error.message : String(error)}`
64
+ );
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Sync client for communicating with qm-hub-server.
70
+ * TypeScript implementation matching the Rust SyncClient API.
71
+ */
72
+ export class QmSyncClient {
73
+ private _accessToken: string | null = null;
74
+ private _refreshToken: string | null = null;
75
+ private _userId: string | null = null;
76
+
77
+ constructor(
78
+ public readonly config: SyncClientConfig,
79
+ private readonly http: HttpClientFn = fetchHttpClient
80
+ ) {}
81
+
82
+ // =========================================================================
83
+ // Authentication
84
+ // =========================================================================
85
+
86
+ /**
87
+ * Register a new user.
88
+ */
89
+ async register(
90
+ username: string,
91
+ email: string,
92
+ password: string
93
+ ): Promise<AuthResponse> {
94
+ const url = `${this.config.serverUrl}/api/v1/auth/register`;
95
+ const headers = this.buildHeaders();
96
+ const body = JSON.stringify({ username, email, password });
97
+
98
+ const response = await this.http({ method: "POST", url, headers, body });
99
+
100
+ if (!this.isSuccess(response)) {
101
+ throw new Error(
102
+ `Registration failed: ${response.status} - ${response.body}`
103
+ );
104
+ }
105
+
106
+ const auth: AuthResponse = JSON.parse(response.body);
107
+ this.storeTokens(auth);
108
+ console.log("Registered user:", auth.userId);
109
+ return auth;
110
+ }
111
+
112
+ /**
113
+ * Login with email and password.
114
+ */
115
+ async login(email: string, password: string): Promise<AuthResponse> {
116
+ const url = `${this.config.serverUrl}/api/v1/auth/login`;
117
+ const headers = this.buildHeaders();
118
+ const body = JSON.stringify({ email, password });
119
+
120
+ const response = await this.http({ method: "POST", url, headers, body });
121
+
122
+ if (!this.isSuccess(response)) {
123
+ throw new Error(`Login failed: ${response.status} - ${response.body}`);
124
+ }
125
+
126
+ const auth: AuthResponse = JSON.parse(response.body);
127
+ this.storeTokens(auth);
128
+ console.log("Logged in user:", auth.userId);
129
+ return auth;
130
+ }
131
+
132
+ /**
133
+ * Refresh the access token.
134
+ * Note: Named `refreshToken` to match Rust API (singular).
135
+ */
136
+ async refreshToken(): Promise<void> {
137
+ if (!this._refreshToken) {
138
+ throw new Error("Not authenticated - please login first");
139
+ }
140
+
141
+ const url = `${this.config.serverUrl}/api/v1/auth/refresh`;
142
+ const headers = this.buildHeaders();
143
+ const body = JSON.stringify({ refreshToken: this._refreshToken });
144
+
145
+ const response = await this.http({ method: "POST", url, headers, body });
146
+
147
+ if (!this.isSuccess(response)) {
148
+ throw new Error(
149
+ `Token refresh failed: ${response.status} - ${response.body}`
150
+ );
151
+ }
152
+
153
+ const refresh: RefreshResponse = JSON.parse(response.body);
154
+ this._accessToken = refresh.accessToken;
155
+ this._refreshToken = refresh.refreshToken;
156
+ console.log("Token refreshed successfully");
157
+ }
158
+
159
+ /**
160
+ * Logout and clear tokens.
161
+ */
162
+ logout(): void {
163
+ this._accessToken = null;
164
+ this._refreshToken = null;
165
+ this._userId = null;
166
+ console.log("Logged out");
167
+ }
168
+
169
+ /**
170
+ * Check if the client is authenticated.
171
+ */
172
+ isAuthenticated(): boolean {
173
+ return this._accessToken !== null;
174
+ }
175
+
176
+ /**
177
+ * Set tokens directly (for restoring from storage).
178
+ */
179
+ setTokens(accessToken: string, refreshToken: string, userId?: string): void {
180
+ this._accessToken = accessToken;
181
+ this._refreshToken = refreshToken;
182
+ this._userId = userId ?? null;
183
+ }
184
+
185
+ /**
186
+ * Get current tokens (for persisting to storage).
187
+ */
188
+ getTokens(): { accessToken: string | null; refreshToken: string | null } {
189
+ return { accessToken: this._accessToken, refreshToken: this._refreshToken };
190
+ }
191
+
192
+ /**
193
+ * Get current user ID.
194
+ */
195
+ getUserId(): string | null {
196
+ return this._userId;
197
+ }
198
+
199
+ // =========================================================================
200
+ // Sync Operations
201
+ // =========================================================================
202
+
203
+ /**
204
+ * Push local changes to the server.
205
+ */
206
+ async push(records: SyncRecord[]): Promise<PushResponse> {
207
+ const request: PushRequest = {
208
+ records: records.map(this.syncToPushRecord),
209
+ clientTimestamp: new Date().toISOString(),
210
+ };
211
+
212
+ const url = `${this.config.serverUrl}/api/v1/sync/${this.config.appId}/push`;
213
+ return this.authenticatedPost<PushRequest, PushResponse>(url, request);
214
+ }
215
+
216
+ /**
217
+ * Pull changes from the server.
218
+ */
219
+ async pull(
220
+ checkpoint?: Checkpoint,
221
+ batchSize?: number
222
+ ): Promise<PullResponse> {
223
+ const request: PullRequest = {
224
+ checkpoint,
225
+ batchSize: batchSize ?? this.config.defaultBatchSize,
226
+ };
227
+
228
+ const url = `${this.config.serverUrl}/api/v1/sync/${this.config.appId}/pull`;
229
+ return this.authenticatedPost<PullRequest, PullResponse>(url, request);
230
+ }
231
+
232
+ /**
233
+ * Perform a delta sync (push + pull in one request).
234
+ */
235
+ async delta(
236
+ records: SyncRecord[],
237
+ checkpoint?: Checkpoint
238
+ ): Promise<DeltaResponse> {
239
+ const request: DeltaRequest = {
240
+ push:
241
+ records.length > 0
242
+ ? {
243
+ records: records.map(this.syncToPushRecord),
244
+ clientTimestamp: new Date().toISOString(),
245
+ }
246
+ : undefined,
247
+ pull: {
248
+ checkpoint,
249
+ batchSize: this.config.defaultBatchSize,
250
+ },
251
+ };
252
+
253
+ const url = `${this.config.serverUrl}/api/v1/sync/${this.config.appId}/delta`;
254
+ return this.authenticatedPost<DeltaRequest, DeltaResponse>(url, request);
255
+ }
256
+
257
+ // =========================================================================
258
+ // Internal Helpers
259
+ // =========================================================================
260
+
261
+ private buildHeaders(accessToken?: string): AuthHeaders {
262
+ const headers: AuthHeaders = {
263
+ apiKey: this.config.apiKey,
264
+ appId: this.config.appId,
265
+ contentType: "application/json",
266
+ };
267
+
268
+ if (accessToken) {
269
+ headers.authorization = `Bearer ${accessToken}`;
270
+ }
271
+
272
+ return headers;
273
+ }
274
+
275
+ private storeTokens(auth: AuthResponse): void {
276
+ this._accessToken = auth.accessToken;
277
+ this._refreshToken = auth.refreshToken;
278
+ this._userId = auth.userId;
279
+ }
280
+
281
+ private isSuccess(response: HttpResponse): boolean {
282
+ return response.status >= 200 && response.status < 300;
283
+ }
284
+
285
+ private isUnauthorized(response: HttpResponse): boolean {
286
+ return response.status === 401;
287
+ }
288
+
289
+ private async authenticatedPost<T, R>(url: string, body: T): Promise<R> {
290
+ if (!this._accessToken) {
291
+ throw new Error("Not authenticated - please login first");
292
+ }
293
+
294
+ // First attempt
295
+ const headers = this.buildHeaders(this._accessToken);
296
+ const bodyJson = JSON.stringify(body);
297
+
298
+ let response = await this.http({
299
+ method: "POST",
300
+ url,
301
+ headers,
302
+ body: bodyJson,
303
+ });
304
+
305
+ // If unauthorized, try to refresh and retry
306
+ if (this.isUnauthorized(response)) {
307
+ console.warn("Access token expired, attempting refresh");
308
+ await this.refreshToken();
309
+
310
+ const newHeaders = this.buildHeaders(this._accessToken!);
311
+ response = await this.http({
312
+ method: "POST",
313
+ url,
314
+ headers: newHeaders,
315
+ body: bodyJson,
316
+ });
317
+
318
+ if (!this.isSuccess(response)) {
319
+ console.error(
320
+ "Request failed after token refresh:",
321
+ response.status,
322
+ response.body
323
+ );
324
+ throw new Error(
325
+ `Request failed: ${response.status} - ${response.body}`
326
+ );
327
+ }
328
+
329
+ return JSON.parse(response.body);
330
+ }
331
+
332
+ if (!this.isSuccess(response)) {
333
+ console.error("Request failed:", response.status, response.body);
334
+ throw new Error(`Request failed: ${response.status} - ${response.body}`);
335
+ }
336
+
337
+ return JSON.parse(response.body);
338
+ }
339
+
340
+ private syncToPushRecord(record: SyncRecord): PushRecord {
341
+ return {
342
+ tableName: record.tableName,
343
+ rowId: record.rowId,
344
+ data: record.data,
345
+ version: record.version,
346
+ deleted: record.deleted,
347
+ assumedServerVersion: undefined, // Match Rust: always None for now
348
+ };
349
+ }
350
+ }
package/src/index.ts ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * @qm-hub/sync-client-types
3
+ *
4
+ * TypeScript types for qm-sync-client - generated from Rust with Specta.
5
+ * These types match the Rust structs with #[derive(specta::Type)].
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ // =============================================================================
11
+ // HTTP Abstraction (from qm-sync-client/http.rs)
12
+ // =============================================================================
13
+
14
+ /** Structured auth headers for sync API requests */
15
+ export interface AuthHeaders {
16
+ /** Bearer token: "Authorization: Bearer {token}" */
17
+ authorization?: string;
18
+ /** API key header: "X-API-Key" */
19
+ apiKey: string;
20
+ /** App ID header: "X-App-Id" */
21
+ appId: string;
22
+ /** Content type (always "application/json") */
23
+ contentType: string;
24
+ }
25
+
26
+ /** HTTP request to be executed */
27
+ export interface HttpRequest {
28
+ method: string;
29
+ url: string;
30
+ headers: AuthHeaders;
31
+ body?: string;
32
+ }
33
+
34
+ /** HTTP response from the request */
35
+ export interface HttpResponse {
36
+ status: number;
37
+ body: string;
38
+ }
39
+
40
+ // =============================================================================
41
+ // Checkpoint (from qm-sync-engine/checkpoint.rs)
42
+ // =============================================================================
43
+
44
+ /** Composite checkpoint for deterministic ordering */
45
+ export interface Checkpoint {
46
+ /** ISO 8601 timestamp of the last synced document */
47
+ updatedAt: string;
48
+ /** ID of the last synced document (tie-breaker) */
49
+ id: string;
50
+ }
51
+
52
+ // =============================================================================
53
+ // Sync Records (from qm-sync-engine/document.rs)
54
+ // =============================================================================
55
+
56
+ /** A sync record representing a document to be synchronized */
57
+ export interface SyncRecord {
58
+ tableName: string;
59
+ rowId: string;
60
+ data: Record<string, unknown>;
61
+ version: number;
62
+ deleted: boolean;
63
+ }
64
+
65
+ /** A sync record with additional server metadata */
66
+ export interface SyncRecordWithMeta extends SyncRecord {
67
+ syncedAt: string;
68
+ userId: string;
69
+ }
70
+
71
+ /** Documents bundled with their checkpoint */
72
+ export interface DocumentsWithCheckpoint<T, C> {
73
+ documents: T[];
74
+ checkpoint: C;
75
+ }
76
+
77
+ // =============================================================================
78
+ // Push Sync (from qm-sync-engine/protocol.rs)
79
+ // =============================================================================
80
+
81
+ export interface PushRequest {
82
+ records: PushRecord[];
83
+ clientTimestamp?: string;
84
+ }
85
+
86
+ export interface PushRecord {
87
+ tableName: string;
88
+ rowId: string;
89
+ data: Record<string, unknown>;
90
+ version: number;
91
+ deleted: boolean;
92
+ assumedServerVersion?: number;
93
+ }
94
+
95
+ export interface PushResponse {
96
+ synced: number;
97
+ conflicts: ConflictInfo[];
98
+ serverTimestamp: string;
99
+ }
100
+
101
+ // =============================================================================
102
+ // Pull Sync
103
+ // =============================================================================
104
+
105
+ export interface PullRequest {
106
+ checkpoint?: Checkpoint;
107
+ batchSize: number;
108
+ tables?: string[];
109
+ }
110
+
111
+ export interface PullResponse {
112
+ records: PullRecord[];
113
+ checkpoint: Checkpoint;
114
+ serverTimestamp: string;
115
+ hasMore: boolean;
116
+ }
117
+
118
+ export interface PullRecord {
119
+ tableName: string;
120
+ rowId: string;
121
+ data: Record<string, unknown>;
122
+ version: number;
123
+ syncedAt: string;
124
+ deleted: boolean;
125
+ }
126
+
127
+ // =============================================================================
128
+ // Delta Sync (Push + Pull combined)
129
+ // =============================================================================
130
+
131
+ export interface DeltaRequest {
132
+ push?: PushRequest;
133
+ pull?: PullRequest;
134
+ }
135
+
136
+ export interface DeltaResponse {
137
+ push?: PushResponse;
138
+ pull?: PullResponse;
139
+ }
140
+
141
+ // =============================================================================
142
+ // Conflict Info (from qm-sync-engine/conflict.rs)
143
+ // =============================================================================
144
+
145
+ export interface ConflictInfo {
146
+ tableName: string;
147
+ rowId: string;
148
+ clientVersion: number;
149
+ serverVersion: number;
150
+ serverState?: Record<string, unknown>;
151
+ }
152
+
153
+ // =============================================================================
154
+ // Auth Types (from qm-sync-client/client.rs)
155
+ // =============================================================================
156
+
157
+ export interface AuthResponse {
158
+ userId: string;
159
+ accessToken: string;
160
+ refreshToken: string;
161
+ apps: string[];
162
+ isAdmin: boolean;
163
+ }
164
+
165
+ export interface RefreshResponse {
166
+ accessToken: string;
167
+ refreshToken: string;
168
+ }
169
+
170
+ // =============================================================================
171
+ // Client Config
172
+ // =============================================================================
173
+
174
+ export interface SyncClientConfig {
175
+ serverUrl: string;
176
+ appId: string;
177
+ apiKey: string;
178
+ defaultBatchSize: number;
179
+ timeoutMs: number;
180
+ }
181
+
182
+ // =============================================================================
183
+ // Sync Result (from qm-sync-engine/handler.rs)
184
+ // =============================================================================
185
+
186
+ export interface SyncResult {
187
+ pushed: number;
188
+ pulled: number;
189
+ conflicts: number;
190
+ success: boolean;
191
+ error?: string;
192
+ }
193
+
194
+ // =============================================================================
195
+ // Helper Functions
196
+ // =============================================================================
197
+
198
+ /** Create default AuthHeaders */
199
+ export function createAuthHeaders(apiKey: string, appId: string): AuthHeaders {
200
+ return {
201
+ apiKey,
202
+ appId,
203
+ contentType: 'application/json',
204
+ };
205
+ }
206
+
207
+ /** Create AuthHeaders with bearer token */
208
+ export function withBearer(headers: AuthHeaders, token: string): AuthHeaders {
209
+ return {
210
+ ...headers,
211
+ authorization: `Bearer ${token}`,
212
+ };
213
+ }
214
+
215
+ /** Create default SyncClientConfig */
216
+ export function createSyncClientConfig(
217
+ serverUrl: string,
218
+ appId: string,
219
+ apiKey: string,
220
+ options?: Partial<Pick<SyncClientConfig, 'defaultBatchSize' | 'timeoutMs'>>
221
+ ): SyncClientConfig {
222
+ return {
223
+ serverUrl,
224
+ appId,
225
+ apiKey,
226
+ defaultBatchSize: options?.defaultBatchSize ?? 100,
227
+ timeoutMs: options?.timeoutMs ?? 30000,
228
+ };
229
+ }
230
+
231
+ /** Create an initial checkpoint */
232
+ export function initialCheckpoint(): Checkpoint {
233
+ return {
234
+ updatedAt: '1970-01-01T00:00:00Z',
235
+ id: '',
236
+ };
237
+ }
238
+
239
+ // =============================================================================
240
+ // Client Implementation
241
+ // =============================================================================
242
+
243
+ export { QmSyncClient, fetchHttpClient, type HttpClientFn } from './client';