@syncular/client 0.0.3-3 → 0.0.3-7

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.
@@ -15,6 +15,7 @@ import type { Kysely } from 'kysely';
15
15
  import type { ClientTableRegistry } from '../handlers/registry';
16
16
  import type { SyncClientPlugin } from '../plugins/types';
17
17
  import type { SyncClientDb } from '../schema';
18
+ import type { SubscriptionState } from '../subscription-state';
18
19
 
19
20
  /**
20
21
  * Connection state for the sync engine
@@ -30,6 +31,8 @@ export type SyncConnectionState =
30
31
  */
31
32
  export type SyncTransportMode = 'polling' | 'realtime';
32
33
 
34
+ export type TransportFallbackReason = 'network' | 'auth' | 'server' | 'manual';
35
+
33
36
  /**
34
37
  * Sync engine state
35
38
  */
@@ -54,18 +57,78 @@ export interface SyncEngineState {
54
57
  isRetrying: boolean;
55
58
  }
56
59
 
60
+ export interface TransportHealth {
61
+ mode: 'realtime' | 'polling' | 'disconnected';
62
+ connected: boolean;
63
+ lastSuccessfulPollAt: number | null;
64
+ lastRealtimeMessageAt: number | null;
65
+ fallbackReason: TransportFallbackReason | null;
66
+ }
67
+
68
+ export type SubscriptionProgressPhase =
69
+ | 'idle'
70
+ | 'bootstrapping'
71
+ | 'catching_up'
72
+ | 'live'
73
+ | 'error';
74
+
75
+ export type SyncChannelPhase =
76
+ | 'idle'
77
+ | 'starting'
78
+ | 'bootstrapping'
79
+ | 'catching_up'
80
+ | 'live'
81
+ | 'error';
82
+
83
+ export interface SubscriptionProgress {
84
+ stateId: string;
85
+ id: string;
86
+ table?: string;
87
+ phase: SubscriptionProgressPhase;
88
+ progressPercent: number;
89
+ rowsProcessed?: number;
90
+ rowsTotal?: number;
91
+ tablesProcessed?: number;
92
+ tablesTotal?: number;
93
+ startedAt?: number;
94
+ completedAt?: number;
95
+ lastErrorCode?: string;
96
+ lastErrorMessage?: string;
97
+ }
98
+
99
+ export interface SyncProgress {
100
+ channelPhase: SyncChannelPhase;
101
+ progressPercent: number;
102
+ subscriptions: SubscriptionProgress[];
103
+ }
104
+
57
105
  /**
58
106
  * Sync error with context
59
107
  */
60
108
  export interface SyncError {
61
109
  /** Error code */
62
- code: 'NETWORK_ERROR' | 'SYNC_ERROR' | 'CONFLICT' | 'UNKNOWN';
110
+ code:
111
+ | 'NETWORK_ERROR'
112
+ | 'AUTH_FAILED'
113
+ | 'SNAPSHOT_CHUNK_NOT_FOUND'
114
+ | 'MIGRATION_FAILED'
115
+ | 'CONFLICT'
116
+ | 'SYNC_ERROR'
117
+ | 'UNKNOWN';
63
118
  /** Error message */
64
119
  message: string;
65
120
  /** Original error if available */
66
121
  cause?: Error;
67
122
  /** Timestamp when error occurred */
68
123
  timestamp: number;
124
+ /** Whether retrying this error is expected to succeed */
125
+ retryable: boolean;
126
+ /** HTTP status code when available */
127
+ httpStatus?: number;
128
+ /** Related subscription id when available */
129
+ subscriptionId?: string;
130
+ /** Related state id when available */
131
+ stateId?: string;
69
132
  }
70
133
 
71
134
  /**
@@ -75,7 +138,11 @@ export type SyncEventType =
75
138
  | 'state:change'
76
139
  | 'sync:start'
77
140
  | 'sync:complete'
141
+ | 'sync:live'
78
142
  | 'sync:error'
143
+ | 'bootstrap:start'
144
+ | 'bootstrap:progress'
145
+ | 'bootstrap:complete'
79
146
  | 'connection:change'
80
147
  | 'outbox:change'
81
148
  | 'data:change'
@@ -103,7 +170,25 @@ export interface SyncEventPayloads {
103
170
  pullRounds: number;
104
171
  pullResponse: SyncPullResponse;
105
172
  };
173
+ 'sync:live': { timestamp: number };
106
174
  'sync:error': SyncError;
175
+ 'bootstrap:start': {
176
+ timestamp: number;
177
+ stateId: string;
178
+ subscriptionId: string;
179
+ };
180
+ 'bootstrap:progress': {
181
+ timestamp: number;
182
+ stateId: string;
183
+ subscriptionId: string;
184
+ progress: SubscriptionProgress;
185
+ };
186
+ 'bootstrap:complete': {
187
+ timestamp: number;
188
+ stateId: string;
189
+ subscriptionId: string;
190
+ durationMs: number;
191
+ };
107
192
  'connection:change': {
108
193
  previous: SyncConnectionState;
109
194
  current: SyncConnectionState;
@@ -268,3 +353,67 @@ export interface OutboxStats {
268
353
  acked: number;
269
354
  total: number;
270
355
  }
356
+
357
+ export type SyncResetScope = 'state' | 'subscription' | 'all';
358
+
359
+ export interface SyncResetOptions {
360
+ scope: SyncResetScope;
361
+ stateId?: string;
362
+ subscriptionIds?: string[];
363
+ clearOutbox?: boolean;
364
+ clearConflicts?: boolean;
365
+ clearSyncedTables?: boolean;
366
+ }
367
+
368
+ export interface SyncResetResult {
369
+ deletedSubscriptionStates: number;
370
+ deletedOutboxCommits: number;
371
+ deletedConflicts: number;
372
+ clearedTables: string[];
373
+ }
374
+
375
+ export interface SyncRepairOptions {
376
+ mode: 'rebootstrap-missing-chunks';
377
+ stateId?: string;
378
+ subscriptionIds?: string[];
379
+ clearOutbox?: boolean;
380
+ clearConflicts?: boolean;
381
+ }
382
+
383
+ export interface SyncAwaitPhaseOptions {
384
+ timeoutMs?: number;
385
+ }
386
+
387
+ export interface SyncAwaitBootstrapOptions {
388
+ timeoutMs?: number;
389
+ stateId?: string;
390
+ subscriptionId?: string;
391
+ }
392
+
393
+ export interface SyncDiagnostics {
394
+ timestamp: number;
395
+ state: SyncEngineState;
396
+ transport: TransportHealth;
397
+ progress: SyncProgress;
398
+ outbox: OutboxStats;
399
+ conflictCount: number;
400
+ subscriptions: SubscriptionState[];
401
+ }
402
+
403
+ export interface SyncInspectorEvent {
404
+ id: number;
405
+ event: SyncEventType;
406
+ timestamp: number;
407
+ payload: Record<string, unknown>;
408
+ }
409
+
410
+ export interface SyncInspectorOptions {
411
+ eventLimit?: number;
412
+ }
413
+
414
+ export interface SyncInspectorSnapshot {
415
+ version: 1;
416
+ generatedAt: number;
417
+ diagnostics: Record<string, unknown>;
418
+ recentEvents: SyncInspectorEvent[];
419
+ }
package/src/index.ts CHANGED
@@ -21,5 +21,6 @@ export * from './pull-engine';
21
21
  export * from './push-engine';
22
22
  export * from './query';
23
23
  export * from './schema';
24
+ export * from './subscription-state';
24
25
  export * from './sync-loop';
25
26
  export * from './utils/id';
@@ -0,0 +1,259 @@
1
+ /**
2
+ * @syncular/client - Subscription state helpers
3
+ *
4
+ * Stable accessors for sync subscription metadata.
5
+ */
6
+
7
+ import type { ScopeValues, SyncBootstrapState } from '@syncular/core';
8
+ import { isRecord } from '@syncular/core';
9
+ import type { Kysely } from 'kysely';
10
+ import { sql } from 'kysely';
11
+ import type {
12
+ SubscriptionStatus,
13
+ SyncClientDb,
14
+ SyncSubscriptionStateTable,
15
+ } from './schema';
16
+
17
+ export const DEFAULT_SYNC_STATE_ID = 'default';
18
+
19
+ export interface SubscriptionState {
20
+ stateId: string;
21
+ subscriptionId: string;
22
+ table: string;
23
+ scopes: ScopeValues;
24
+ params: Record<string, unknown>;
25
+ cursor: number;
26
+ bootstrapState: SyncBootstrapState | null;
27
+ status: SubscriptionStatus;
28
+ createdAt: number;
29
+ updatedAt: number;
30
+ }
31
+
32
+ export interface ListSubscriptionStatesOptions {
33
+ stateId?: string;
34
+ table?: string;
35
+ status?: SubscriptionStatus;
36
+ }
37
+
38
+ export interface GetSubscriptionStateOptions {
39
+ stateId?: string;
40
+ subscriptionId: string;
41
+ }
42
+
43
+ export interface UpsertSubscriptionStateInput {
44
+ stateId?: string;
45
+ subscriptionId: string;
46
+ table: string;
47
+ scopes: ScopeValues;
48
+ params?: Record<string, unknown>;
49
+ cursor: number;
50
+ bootstrapState?: SyncBootstrapState | null;
51
+ status?: SubscriptionStatus;
52
+ nowMs?: number;
53
+ }
54
+
55
+ function isScopeValues(value: unknown): value is ScopeValues {
56
+ if (!isRecord(value)) return false;
57
+
58
+ for (const entry of Object.values(value)) {
59
+ if (typeof entry === 'string') continue;
60
+ if (Array.isArray(entry) && entry.every((v) => typeof v === 'string')) {
61
+ continue;
62
+ }
63
+ return false;
64
+ }
65
+
66
+ return true;
67
+ }
68
+
69
+ export function parseBootstrapState(
70
+ value: string | object | null | undefined
71
+ ): SyncBootstrapState | null {
72
+ if (!value) return null;
73
+
74
+ try {
75
+ const parsed: unknown =
76
+ typeof value === 'string' ? JSON.parse(value) : value;
77
+
78
+ if (!isRecord(parsed)) return null;
79
+ if (typeof parsed.asOfCommitSeq !== 'number') return null;
80
+ if (!Array.isArray(parsed.tables)) return null;
81
+ if (!parsed.tables.every((table) => typeof table === 'string')) return null;
82
+ if (typeof parsed.tableIndex !== 'number') return null;
83
+ if (parsed.rowCursor !== null && typeof parsed.rowCursor !== 'string') {
84
+ return null;
85
+ }
86
+
87
+ return {
88
+ asOfCommitSeq: parsed.asOfCommitSeq,
89
+ tables: parsed.tables,
90
+ tableIndex: parsed.tableIndex,
91
+ rowCursor: parsed.rowCursor,
92
+ };
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function parseScopes(value: string): ScopeValues {
99
+ try {
100
+ const parsed: unknown = JSON.parse(value);
101
+ return isScopeValues(parsed) ? parsed : {};
102
+ } catch {
103
+ return {};
104
+ }
105
+ }
106
+
107
+ function parseParams(value: string): Record<string, unknown> {
108
+ try {
109
+ const parsed: unknown = JSON.parse(value);
110
+ return isRecord(parsed) ? parsed : {};
111
+ } catch {
112
+ return {};
113
+ }
114
+ }
115
+
116
+ function mapSubscriptionState(
117
+ row: SyncSubscriptionStateTable
118
+ ): SubscriptionState {
119
+ return {
120
+ stateId: row.state_id,
121
+ subscriptionId: row.subscription_id,
122
+ table: row.table,
123
+ scopes: parseScopes(row.scopes_json),
124
+ params: parseParams(row.params_json),
125
+ cursor: row.cursor,
126
+ bootstrapState: parseBootstrapState(row.bootstrap_state_json),
127
+ status: row.status,
128
+ createdAt: row.created_at,
129
+ updatedAt: row.updated_at,
130
+ };
131
+ }
132
+
133
+ export async function listSubscriptionStates<DB extends SyncClientDb>(
134
+ db: Kysely<DB>,
135
+ options: ListSubscriptionStatesOptions = {}
136
+ ): Promise<SubscriptionState[]> {
137
+ const filters: Array<ReturnType<typeof sql>> = [];
138
+ if (options.stateId) {
139
+ filters.push(sql`${sql.ref('state_id')} = ${sql.val(options.stateId)}`);
140
+ }
141
+ if (options.table) {
142
+ filters.push(sql`${sql.ref('table')} = ${sql.val(options.table)}`);
143
+ }
144
+ if (options.status) {
145
+ filters.push(sql`${sql.ref('status')} = ${sql.val(options.status)}`);
146
+ }
147
+
148
+ const whereClause =
149
+ filters.length > 0 ? sql`where ${sql.join(filters, sql` and `)}` : sql``;
150
+
151
+ const rows = await sql<SyncSubscriptionStateTable>`
152
+ select
153
+ ${sql.ref('state_id')},
154
+ ${sql.ref('subscription_id')},
155
+ ${sql.ref('table')},
156
+ ${sql.ref('scopes_json')},
157
+ ${sql.ref('params_json')},
158
+ ${sql.ref('cursor')},
159
+ ${sql.ref('bootstrap_state_json')},
160
+ ${sql.ref('status')},
161
+ ${sql.ref('created_at')},
162
+ ${sql.ref('updated_at')}
163
+ from ${sql.table('sync_subscription_state')}
164
+ ${whereClause}
165
+ order by ${sql.ref('state_id')} asc, ${sql.ref('subscription_id')} asc
166
+ `.execute(db);
167
+
168
+ return rows.rows.map((row) => mapSubscriptionState(row));
169
+ }
170
+
171
+ export async function getSubscriptionState<DB extends SyncClientDb>(
172
+ db: Kysely<DB>,
173
+ options: GetSubscriptionStateOptions
174
+ ): Promise<SubscriptionState | null> {
175
+ const stateId = options.stateId ?? DEFAULT_SYNC_STATE_ID;
176
+
177
+ const rows = await sql<SyncSubscriptionStateTable>`
178
+ select
179
+ ${sql.ref('state_id')},
180
+ ${sql.ref('subscription_id')},
181
+ ${sql.ref('table')},
182
+ ${sql.ref('scopes_json')},
183
+ ${sql.ref('params_json')},
184
+ ${sql.ref('cursor')},
185
+ ${sql.ref('bootstrap_state_json')},
186
+ ${sql.ref('status')},
187
+ ${sql.ref('created_at')},
188
+ ${sql.ref('updated_at')}
189
+ from ${sql.table('sync_subscription_state')}
190
+ where
191
+ ${sql.ref('state_id')} = ${sql.val(stateId)}
192
+ and ${sql.ref('subscription_id')} = ${sql.val(options.subscriptionId)}
193
+ limit 1
194
+ `.execute(db);
195
+
196
+ const row = rows.rows[0];
197
+ return row ? mapSubscriptionState(row) : null;
198
+ }
199
+
200
+ export async function upsertSubscriptionState<DB extends SyncClientDb>(
201
+ db: Kysely<DB>,
202
+ input: UpsertSubscriptionStateInput
203
+ ): Promise<SubscriptionState> {
204
+ const now = input.nowMs ?? Date.now();
205
+ const stateId = input.stateId ?? DEFAULT_SYNC_STATE_ID;
206
+
207
+ const bootstrapStateJson =
208
+ input.bootstrapState === null || input.bootstrapState === undefined
209
+ ? null
210
+ : JSON.stringify(input.bootstrapState);
211
+
212
+ await sql`
213
+ insert into ${sql.table('sync_subscription_state')} (
214
+ ${sql.ref('state_id')},
215
+ ${sql.ref('subscription_id')},
216
+ ${sql.ref('table')},
217
+ ${sql.ref('scopes_json')},
218
+ ${sql.ref('params_json')},
219
+ ${sql.ref('cursor')},
220
+ ${sql.ref('bootstrap_state_json')},
221
+ ${sql.ref('status')},
222
+ ${sql.ref('created_at')},
223
+ ${sql.ref('updated_at')}
224
+ ) values (
225
+ ${sql.val(stateId)},
226
+ ${sql.val(input.subscriptionId)},
227
+ ${sql.val(input.table)},
228
+ ${sql.val(JSON.stringify(input.scopes ?? {}))},
229
+ ${sql.val(JSON.stringify(input.params ?? {}))},
230
+ ${sql.val(input.cursor)},
231
+ ${sql.val(bootstrapStateJson)},
232
+ ${sql.val(input.status ?? 'active')},
233
+ ${sql.val(now)},
234
+ ${sql.val(now)}
235
+ )
236
+ on conflict (${sql.join([sql.ref('state_id'), sql.ref('subscription_id')])})
237
+ do update set
238
+ ${sql.ref('table')} = ${sql.val(input.table)},
239
+ ${sql.ref('scopes_json')} = ${sql.val(JSON.stringify(input.scopes ?? {}))},
240
+ ${sql.ref('params_json')} = ${sql.val(JSON.stringify(input.params ?? {}))},
241
+ ${sql.ref('cursor')} = ${sql.val(input.cursor)},
242
+ ${sql.ref('bootstrap_state_json')} = ${sql.val(bootstrapStateJson)},
243
+ ${sql.ref('status')} = ${sql.val(input.status ?? 'active')},
244
+ ${sql.ref('updated_at')} = ${sql.val(now)}
245
+ `.execute(db);
246
+
247
+ const next = await getSubscriptionState(db, {
248
+ stateId,
249
+ subscriptionId: input.subscriptionId,
250
+ });
251
+
252
+ if (!next) {
253
+ throw new Error(
254
+ `[subscription-state] Failed to load upserted state for "${input.subscriptionId}"`
255
+ );
256
+ }
257
+
258
+ return next;
259
+ }
package/src/utils/id.ts CHANGED
@@ -18,3 +18,38 @@ export function randomUUID(): string {
18
18
  return v.toString(16);
19
19
  });
20
20
  }
21
+
22
+ /**
23
+ * Build a stable state id from meaningful segments.
24
+ * Empty/undefined segments are ignored.
25
+ */
26
+ export function buildStateId(
27
+ ...segments: Array<string | null | undefined>
28
+ ): string {
29
+ const normalized = segments
30
+ .map((segment) => segment?.trim())
31
+ .filter((segment): segment is string => !!segment && segment.length > 0);
32
+
33
+ if (normalized.length === 0) return 'default';
34
+ return normalized.join(':');
35
+ }
36
+
37
+ /**
38
+ * Create a deterministic fingerprint string for scope values.
39
+ */
40
+ export function createScopeFingerprint(
41
+ scopes: Record<string, string | string[]>
42
+ ): string {
43
+ const entries = Object.entries(scopes)
44
+ .map(([key, value]) => {
45
+ const encodedValues = (Array.isArray(value) ? [...value] : [value])
46
+ .map((item) => item.trim())
47
+ .filter((item) => item.length > 0)
48
+ .sort();
49
+
50
+ return `${key}:${encodedValues.join('|')}`;
51
+ })
52
+ .sort();
53
+
54
+ return entries.join(';');
55
+ }