@syncular/testkit 0.1.2 → 0.2.0

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.
Files changed (92) hide show
  1. package/README.md +210 -0
  2. package/dist/client.d.ts +68 -0
  3. package/dist/client.js +145 -0
  4. package/dist/clock.d.ts +24 -0
  5. package/dist/clock.js +32 -0
  6. package/dist/create-test-sync.d.ts +67 -0
  7. package/dist/create-test-sync.js +90 -0
  8. package/dist/faults.d.ts +52 -50
  9. package/dist/faults.js +74 -192
  10. package/dist/index.d.ts +17 -15
  11. package/dist/index.js +18 -15
  12. package/dist/react.d.ts +10 -0
  13. package/dist/react.js +41 -0
  14. package/dist/server.d.ts +44 -0
  15. package/dist/server.js +68 -0
  16. package/package.json +52 -29
  17. package/src/client.ts +218 -0
  18. package/src/clock.ts +45 -0
  19. package/src/create-test-sync.ts +158 -0
  20. package/src/faults.ts +77 -338
  21. package/src/index.ts +44 -14
  22. package/src/react.ts +46 -0
  23. package/src/server.ts +102 -0
  24. package/dist/audit.d.ts +0 -22
  25. package/dist/audit.d.ts.map +0 -1
  26. package/dist/audit.js +0 -40
  27. package/dist/audit.js.map +0 -1
  28. package/dist/client-bridge.d.ts +0 -77
  29. package/dist/client-bridge.d.ts.map +0 -1
  30. package/dist/client-bridge.js +0 -540
  31. package/dist/client-bridge.js.map +0 -1
  32. package/dist/deterministic.d.ts +0 -31
  33. package/dist/deterministic.d.ts.map +0 -1
  34. package/dist/deterministic.js +0 -69
  35. package/dist/deterministic.js.map +0 -1
  36. package/dist/disposable.d.ts +0 -10
  37. package/dist/disposable.d.ts.map +0 -1
  38. package/dist/disposable.js +0 -28
  39. package/dist/disposable.js.map +0 -1
  40. package/dist/faults.d.ts.map +0 -1
  41. package/dist/faults.js.map +0 -1
  42. package/dist/hono-node-server.d.ts +0 -20
  43. package/dist/hono-node-server.d.ts.map +0 -1
  44. package/dist/hono-node-server.js +0 -90
  45. package/dist/hono-node-server.js.map +0 -1
  46. package/dist/http-fixtures.d.ts +0 -32
  47. package/dist/http-fixtures.d.ts.map +0 -1
  48. package/dist/http-fixtures.js +0 -117
  49. package/dist/http-fixtures.js.map +0 -1
  50. package/dist/index.d.ts.map +0 -1
  51. package/dist/index.js.map +0 -1
  52. package/dist/project-scoped-tasks.d.ts +0 -40
  53. package/dist/project-scoped-tasks.d.ts.map +0 -1
  54. package/dist/project-scoped-tasks.js +0 -247
  55. package/dist/project-scoped-tasks.js.map +0 -1
  56. package/dist/realtime-ws.d.ts +0 -48
  57. package/dist/realtime-ws.d.ts.map +0 -1
  58. package/dist/realtime-ws.js +0 -203
  59. package/dist/realtime-ws.js.map +0 -1
  60. package/dist/runtime-process.d.ts +0 -14
  61. package/dist/runtime-process.d.ts.map +0 -1
  62. package/dist/runtime-process.js +0 -99
  63. package/dist/runtime-process.js.map +0 -1
  64. package/dist/sync-builders.d.ts +0 -48
  65. package/dist/sync-builders.d.ts.map +0 -1
  66. package/dist/sync-builders.js +0 -66
  67. package/dist/sync-builders.js.map +0 -1
  68. package/dist/sync-http.d.ts +0 -48
  69. package/dist/sync-http.d.ts.map +0 -1
  70. package/dist/sync-http.js +0 -43
  71. package/dist/sync-http.js.map +0 -1
  72. package/dist/sync-parse.d.ts +0 -8
  73. package/dist/sync-parse.d.ts.map +0 -1
  74. package/dist/sync-parse.js +0 -38
  75. package/dist/sync-parse.js.map +0 -1
  76. package/dist/sync-response.d.ts +0 -6
  77. package/dist/sync-response.d.ts.map +0 -1
  78. package/dist/sync-response.js +0 -19
  79. package/dist/sync-response.js.map +0 -1
  80. package/src/audit.ts +0 -74
  81. package/src/client-bridge.ts +0 -841
  82. package/src/deterministic.ts +0 -121
  83. package/src/disposable.ts +0 -50
  84. package/src/hono-node-server.ts +0 -144
  85. package/src/http-fixtures.ts +0 -187
  86. package/src/project-scoped-tasks.ts +0 -351
  87. package/src/realtime-ws.ts +0 -354
  88. package/src/runtime-process.ts +0 -159
  89. package/src/sync-builders.ts +0 -136
  90. package/src/sync-http.ts +0 -117
  91. package/src/sync-parse.ts +0 -86
  92. package/src/sync-response.ts +0 -43
@@ -1,841 +0,0 @@
1
- import { Database } from 'bun:sqlite';
2
- import type {
3
- SyncularAuthLeaseRecord,
4
- SyncularBridge,
5
- SyncularBridgeMutationBatch,
6
- SyncularBridgeQueryRequest,
7
- SyncularBridgeQueryResult,
8
- SyncularBridgeStatus,
9
- SyncularClientEventMap,
10
- SyncularClientEventSink,
11
- SyncularClientEventType,
12
- SyncularConflictResolution,
13
- SyncularConflictStats,
14
- SyncularConflictSummary,
15
- SyncularConnectionState,
16
- SyncularDiagnosticSnapshot,
17
- SyncularLifecycleState,
18
- SyncularPresenceChangeEvent,
19
- SyncularPresenceEntry,
20
- SyncularPresenceSink,
21
- SyncularSubscriptionSpec,
22
- SyncularSyncResult,
23
- } from '@syncular/client';
24
- import type { SyncAuthLeaseIssueRequest, SyncOperation } from '@syncular/core';
25
- import type { AsyncDisposableResource } from './disposable';
26
- import { createAsyncDisposableResource } from './disposable';
27
-
28
- export type ClientBridgeSeed = Record<
29
- string,
30
- readonly Record<string, unknown>[]
31
- >;
32
-
33
- export type ClientBridgeTauriInvoke = <TResult>(
34
- command: string,
35
- args?: Record<string, unknown>
36
- ) => Promise<TResult>;
37
-
38
- export type ClientBridgeTauriListen = <TPayload>(
39
- event: string,
40
- handler: (event: { payload: TPayload }) => void
41
- ) => Promise<() => void>;
42
-
43
- export type ClientBridgeNativeEventSubscription =
44
- | (() => void)
45
- | { remove(): void };
46
-
47
- export interface ClientBridgeNativeModule {
48
- executeSql<Row extends Record<string, unknown> = Record<string, unknown>>(
49
- request: SyncularBridgeQueryRequest
50
- ): Promise<SyncularBridgeQueryResult<Row>> | SyncularBridgeQueryResult<Row>;
51
- applyMutationsCommit(
52
- batch: SyncularBridgeMutationBatch
53
- ): Promise<string> | string;
54
- applyLeasedMutationsCommit?(
55
- batch: SyncularBridgeMutationBatch
56
- ): Promise<string> | string;
57
- sync?(): Promise<SyncularSyncResult>;
58
- resumeFromBackground?(): Promise<SyncularSyncResult>;
59
- start?(): Promise<void>;
60
- stop?(): Promise<void>;
61
- setSubscriptions?(
62
- subscriptions: readonly SyncularSubscriptionSpec[]
63
- ): Promise<void>;
64
- getStatus?(): SyncularBridgeStatus;
65
- issueAuthLease?(
66
- request: SyncAuthLeaseIssueRequest
67
- ): Promise<SyncularAuthLeaseRecord>;
68
- upsertAuthLease?(lease: SyncularAuthLeaseRecord): Promise<void>;
69
- authLease?(leaseId: string): Promise<SyncularAuthLeaseRecord | null>;
70
- activeAuthLeases?(
71
- actorId?: string | null,
72
- nowMs?: number
73
- ): Promise<SyncularAuthLeaseRecord[]>;
74
- diagnosticSnapshot?(): Promise<SyncularDiagnosticSnapshot>;
75
- addListener?<T extends SyncularClientEventType>(
76
- event: T,
77
- listener: SyncularClientEventSink<T>
78
- ): ClientBridgeNativeEventSubscription;
79
- getPresence?<TMetadata = Record<string, unknown>>(
80
- scopeKey: string
81
- ): SyncularPresenceEntry<TMetadata>[];
82
- joinPresence?(scopeKey: string, metadata?: Record<string, unknown>): void;
83
- leavePresence?(scopeKey: string): void;
84
- updatePresenceMetadata?(
85
- scopeKey: string,
86
- metadata: Record<string, unknown>
87
- ): void;
88
- conflictSummaries?(): Promise<SyncularConflictSummary[]>;
89
- retryConflictKeepLocal?(id: string): Promise<string>;
90
- resolveConflict?(
91
- id: string,
92
- resolution: SyncularConflictResolution
93
- ): Promise<void>;
94
- }
95
-
96
- export interface CreateClientBridgeHarnessOptions {
97
- seed?: ClientBridgeSeed;
98
- idColumn?: string;
99
- actorId?: string;
100
- clientId?: string;
101
- }
102
-
103
- export interface ClientBridgeInvocation {
104
- command: string;
105
- args: Record<string, unknown> | undefined;
106
- }
107
-
108
- export interface ClientBridgeSqlQuery extends SyncularBridgeQueryRequest {}
109
-
110
- export interface ClientBridgeHarness {
111
- bridge: SyncularBridge;
112
- tauri: {
113
- invoke: ClientBridgeTauriInvoke;
114
- listen: ClientBridgeTauriListen;
115
- invocations(): ClientBridgeInvocation[];
116
- };
117
- reactNative: {
118
- module: ClientBridgeNativeModule;
119
- };
120
- queries(): ClientBridgeSqlQuery[];
121
- batches(): SyncularBridgeMutationBatch[];
122
- leasedBatches(): SyncularBridgeMutationBatch[];
123
- operations(): SyncOperation[];
124
- syncCount(): number;
125
- listenerCount(event: SyncularClientEventType): number;
126
- rows(table: string): Record<string, unknown>[];
127
- setRows(table: string, rows: readonly Record<string, unknown>[]): void;
128
- setStatus(status: SyncularBridgeStatus): void;
129
- setConflicts(conflicts: readonly SyncularConflictSummary[]): void;
130
- authLease(leaseId: string): SyncularAuthLeaseRecord | null;
131
- authLeases(): SyncularAuthLeaseRecord[];
132
- diagnosticSnapshot(): SyncularDiagnosticSnapshot;
133
- emit<T extends SyncularClientEventType>(
134
- event: T,
135
- payload: SyncularClientEventMap[T]
136
- ): void;
137
- emitRowsChanged(event: SyncularClientEventMap['rowsChanged']): void;
138
- presence<TMetadata = Record<string, unknown>>(
139
- scopeKey: string
140
- ): SyncularPresenceEntry<TMetadata>[];
141
- close(): void;
142
- }
143
-
144
- export async function createClientBridgeHarness(
145
- options: CreateClientBridgeHarnessOptions = {}
146
- ): Promise<AsyncDisposableResource<ClientBridgeHarness>> {
147
- const harness = new InProcessClientBridgeHarness(options);
148
- return createAsyncDisposableResource(harness, () => harness.close());
149
- }
150
-
151
- class InProcessClientBridgeHarness implements ClientBridgeHarness {
152
- readonly #db = new Database(':memory:');
153
- readonly #idColumn: string;
154
- readonly #actorId: string;
155
- readonly #clientId: string;
156
- readonly #queries: ClientBridgeSqlQuery[] = [];
157
- readonly #batches: SyncularBridgeMutationBatch[] = [];
158
- readonly #leasedBatches: SyncularBridgeMutationBatch[] = [];
159
- readonly #invocations: ClientBridgeInvocation[] = [];
160
- readonly #listeners = new Map<
161
- SyncularClientEventType,
162
- Set<SyncularClientEventSink<SyncularClientEventType>>
163
- >();
164
- readonly #tauriListeners = new Map<
165
- string,
166
- Set<(event: { payload: unknown }) => void>
167
- >();
168
- readonly #presenceListeners = new Set<SyncularPresenceSink>();
169
- readonly #presence = new Map<string, SyncularPresenceEntry[]>();
170
- #conflicts: SyncularConflictSummary[] = [];
171
- #status: SyncularBridgeStatus = {};
172
- #authLeases = new Map<string, SyncularAuthLeaseRecord>();
173
- #syncCount = 0;
174
- #commitIndex = 1;
175
- #leaseIndex = 1;
176
-
177
- constructor(options: CreateClientBridgeHarnessOptions) {
178
- this.#idColumn = options.idColumn ?? 'id';
179
- this.#actorId = options.actorId ?? 'actor-test';
180
- this.#clientId = options.clientId ?? 'client-test';
181
- for (const [table, rows] of Object.entries(options.seed ?? {})) {
182
- this.setRows(table, rows);
183
- }
184
- }
185
-
186
- readonly bridge: SyncularBridge = {
187
- executeSql: (request) => this.executeSql(request),
188
- applyMutationsCommit: (batch) => this.applyMutationsCommit(batch),
189
- applyLeasedMutationsCommit: (batch) =>
190
- this.applyMutationsCommit(batch, { leased: true }),
191
- sync: () => this.sync(),
192
- resumeFromBackground: () => this.resumeFromBackground(),
193
- start: async () => {
194
- this.setConnection('connected');
195
- },
196
- stop: async () => {
197
- this.setConnection('disconnected');
198
- },
199
- setSubscriptions: async () => undefined,
200
- getStatus: () => this.#status,
201
- issueAuthLease: (request) => this.issueAuthLease(request),
202
- upsertAuthLease: async (lease) => {
203
- this.#authLeases.set(lease.leaseId, { ...lease });
204
- },
205
- authLease: async (leaseId) => this.authLease(leaseId),
206
- activeAuthLeases: async (actorId, nowMs) =>
207
- this.activeAuthLeases(actorId, nowMs),
208
- diagnosticSnapshot: async () => this.diagnosticSnapshot(),
209
- on: (event, listener) => this.addEventListener(event, listener),
210
- presence: {
211
- get: (scopeKey) => this.presence(scopeKey),
212
- join: (scopeKey, metadata) => this.joinPresence(scopeKey, metadata),
213
- leave: (scopeKey) => this.leavePresence(scopeKey),
214
- updateMetadata: (scopeKey, metadata) =>
215
- this.updatePresenceMetadata(scopeKey, metadata),
216
- onChange: (listener) => {
217
- this.#presenceListeners.add(listener as SyncularPresenceSink);
218
- return () =>
219
- this.#presenceListeners.delete(listener as SyncularPresenceSink);
220
- },
221
- },
222
- conflicts: {
223
- list: async () => this.#conflicts,
224
- retryKeepLocal: async (id) => `retry-${id}`,
225
- resolve: async (id, resolution) => {
226
- this.#conflicts = this.#conflicts.map((conflict) =>
227
- conflict.id === id
228
- ? { ...conflict, resolvedAt: Date.now(), resolution }
229
- : conflict
230
- );
231
- this.emit('conflictsChanged', conflictStats(this.#conflicts));
232
- },
233
- },
234
- };
235
-
236
- readonly tauri = {
237
- invoke: (async <TResult>(
238
- command: string,
239
- args?: Record<string, unknown>
240
- ) => {
241
- this.#invocations.push({ command, args });
242
- return this.handleTauriCommand(command, args) as TResult;
243
- }) satisfies ClientBridgeTauriInvoke,
244
- listen: (async <TPayload>(
245
- event: string,
246
- handler: (event: { payload: TPayload }) => void
247
- ) => {
248
- const listeners = this.#tauriListeners.get(event) ?? new Set();
249
- const wrapped = handler as (event: { payload: unknown }) => void;
250
- listeners.add(wrapped);
251
- this.#tauriListeners.set(event, listeners);
252
- return () => listeners.delete(wrapped);
253
- }) satisfies ClientBridgeTauriListen,
254
- invocations: () => [...this.#invocations],
255
- };
256
-
257
- readonly reactNative = {
258
- module: {
259
- executeSql: (request) => this.executeSql(request),
260
- applyMutationsCommit: (batch) =>
261
- this.applyMutationsCommit(batch).commitId,
262
- applyLeasedMutationsCommit: (batch) =>
263
- this.applyMutationsCommit(batch, { leased: true }).commitId,
264
- sync: () => this.sync(),
265
- resumeFromBackground: () => this.resumeFromBackground(),
266
- start: async () => {
267
- this.setConnection('connected');
268
- },
269
- stop: async () => {
270
- this.setConnection('disconnected');
271
- },
272
- setSubscriptions: async (_subscriptions) => undefined,
273
- getStatus: () => this.#status,
274
- issueAuthLease: (request) => this.issueAuthLease(request),
275
- upsertAuthLease: async (lease) => {
276
- this.#authLeases.set(lease.leaseId, { ...lease });
277
- },
278
- authLease: async (leaseId) => this.authLease(leaseId),
279
- activeAuthLeases: async (actorId, nowMs) =>
280
- this.activeAuthLeases(actorId, nowMs),
281
- diagnosticSnapshot: async () => this.diagnosticSnapshot(),
282
- addListener: (event, listener) =>
283
- this.reactNativeSubscription(this.addEventListener(event, listener)),
284
- getPresence: (scopeKey) => this.presence(scopeKey),
285
- joinPresence: (scopeKey, metadata) =>
286
- this.joinPresence(scopeKey, metadata),
287
- leavePresence: (scopeKey) => this.leavePresence(scopeKey),
288
- updatePresenceMetadata: (scopeKey, metadata) =>
289
- this.updatePresenceMetadata(scopeKey, metadata),
290
- conflictSummaries: async () => this.#conflicts,
291
- retryConflictKeepLocal: async (id) => `retry-${id}`,
292
- resolveConflict: async (id, resolution) => {
293
- await this.bridge.conflicts?.resolve(id, resolution);
294
- },
295
- } satisfies ClientBridgeNativeModule,
296
- };
297
-
298
- queries(): ClientBridgeSqlQuery[] {
299
- return [...this.#queries];
300
- }
301
-
302
- batches(): SyncularBridgeMutationBatch[] {
303
- return [...this.#batches];
304
- }
305
-
306
- leasedBatches(): SyncularBridgeMutationBatch[] {
307
- return [...this.#leasedBatches];
308
- }
309
-
310
- operations(): SyncOperation[] {
311
- return [...this.#batches, ...this.#leasedBatches].flatMap(
312
- (batch) => batch.operations
313
- );
314
- }
315
-
316
- syncCount(): number {
317
- return this.#syncCount;
318
- }
319
-
320
- listenerCount(event: SyncularClientEventType): number {
321
- return this.#listeners.get(event)?.size ?? 0;
322
- }
323
-
324
- rows(table: string): Record<string, unknown>[] {
325
- this.ensureTable(table);
326
- return this.#db.query(`select * from ${quoteIdent(table)}`).all() as Record<
327
- string,
328
- unknown
329
- >[];
330
- }
331
-
332
- setRows(table: string, rows: readonly Record<string, unknown>[]): void {
333
- this.ensureTable(table, rows);
334
- this.#db.run(`delete from ${quoteIdent(table)}`);
335
- for (const row of rows) {
336
- this.upsertRow(table, row);
337
- }
338
- }
339
-
340
- setStatus(status: SyncularBridgeStatus): void {
341
- this.#status = status;
342
- }
343
-
344
- setConflicts(conflicts: readonly SyncularConflictSummary[]): void {
345
- this.#conflicts = [...conflicts];
346
- this.emit('conflictsChanged', conflictStats(this.#conflicts));
347
- }
348
-
349
- authLease(leaseId: string): SyncularAuthLeaseRecord | null {
350
- const lease = this.#authLeases.get(leaseId);
351
- return lease ? { ...lease } : null;
352
- }
353
-
354
- authLeases(): SyncularAuthLeaseRecord[] {
355
- return [...this.#authLeases.values()].map((lease) => ({ ...lease }));
356
- }
357
-
358
- emit<T extends SyncularClientEventType>(
359
- event: T,
360
- payload: SyncularClientEventMap[T]
361
- ): void {
362
- for (const listener of this.#listeners.get(event) ?? []) {
363
- listener(payload as never);
364
- }
365
- this.emitTauri(`syncular:${event}`, payload);
366
- }
367
-
368
- emitRowsChanged(event: SyncularClientEventMap['rowsChanged']): void {
369
- this.emit('rowsChanged', event);
370
- }
371
-
372
- presence<TMetadata = Record<string, unknown>>(
373
- scopeKey: string
374
- ): SyncularPresenceEntry<TMetadata>[] {
375
- return [
376
- ...(this.#presence.get(scopeKey) ?? []),
377
- ] as SyncularPresenceEntry<TMetadata>[];
378
- }
379
-
380
- close(): void {
381
- this.#db.close();
382
- this.#listeners.clear();
383
- this.#tauriListeners.clear();
384
- this.#presenceListeners.clear();
385
- }
386
-
387
- private executeSql<Row extends Record<string, unknown>>(
388
- request: SyncularBridgeQueryRequest
389
- ): SyncularBridgeQueryResult<Row> {
390
- this.#queries.push(request);
391
- const statement = this.#db.query(request.sql);
392
- const rows = statement.all(...(request.parameters as never[])) as Row[];
393
- return { rows };
394
- }
395
-
396
- private applyMutationsCommit(
397
- batch: SyncularBridgeMutationBatch,
398
- options: { leased?: boolean } = {}
399
- ): {
400
- commitId: string;
401
- clientCommitId: string;
402
- } {
403
- const target = options.leased ? this.#leasedBatches : this.#batches;
404
- target.push({
405
- operations: batch.operations.map((operation) => ({ ...operation })),
406
- });
407
- const commitId = `bridge-commit-${this.#commitIndex++}`;
408
- for (const operation of batch.operations) {
409
- this.applyOperation(operation);
410
- }
411
- return { commitId, clientCommitId: commitId };
412
- }
413
-
414
- private async sync(): Promise<SyncularSyncResult> {
415
- this.#syncCount += 1;
416
- return zeroSyncResult();
417
- }
418
-
419
- private async resumeFromBackground(): Promise<SyncularSyncResult> {
420
- this.setConnection('connected');
421
- return this.sync();
422
- }
423
-
424
- private async issueAuthLease(
425
- request: SyncAuthLeaseIssueRequest
426
- ): Promise<SyncularAuthLeaseRecord> {
427
- const nowMs = Date.now();
428
- const leaseId = `bridge-lease-${this.#leaseIndex++}`;
429
- const expiresAtMs = nowMs + (request.ttlMs ?? 60_000);
430
- const payload = {
431
- version: 1,
432
- leaseId,
433
- issuer: 'syncular-testkit',
434
- audience: 'syncular-testkit',
435
- actorId: this.#actorId,
436
- subject: {},
437
- schemaVersion: request.schemaVersion,
438
- protocolVersion: 1,
439
- issuedAtMs: nowMs,
440
- notBeforeMs: nowMs,
441
- expiresAtMs,
442
- maxClockSkewMs: 0,
443
- scopes: request.scopes,
444
- capabilities: {
445
- allowBlobs: true,
446
- allowCrdt: true,
447
- allowEncryptedFields: true,
448
- },
449
- };
450
- const lease: SyncularAuthLeaseRecord = {
451
- leaseId,
452
- kid: 'syncular-testkit',
453
- actorId: this.#actorId,
454
- issuedAtMs: nowMs,
455
- notBeforeMs: nowMs,
456
- expiresAtMs,
457
- schemaVersion: request.schemaVersion,
458
- payloadJson: JSON.stringify(payload),
459
- token: `testkit.${leaseId}`,
460
- status: 'active',
461
- lastValidationError: null,
462
- createdAtMs: nowMs,
463
- updatedAtMs: nowMs,
464
- };
465
- this.#authLeases.set(leaseId, lease);
466
- return { ...lease };
467
- }
468
-
469
- private handleTauriCommand(
470
- command: string,
471
- args: Record<string, unknown> | undefined
472
- ): unknown {
473
- switch (command) {
474
- case 'syncular_execute_sql':
475
- return this.executeSql(args?.request as SyncularBridgeQueryRequest);
476
- case 'syncular_apply_mutations_commit':
477
- return this.applyMutationsCommit(
478
- args?.batch as SyncularBridgeMutationBatch
479
- ).commitId;
480
- case 'syncular_apply_leased_mutations_commit':
481
- return this.applyMutationsCommit(
482
- args?.batch as SyncularBridgeMutationBatch,
483
- { leased: true }
484
- ).commitId;
485
- case 'syncular_sync':
486
- return this.sync();
487
- case 'syncular_resume_from_background':
488
- return this.resumeFromBackground();
489
- case 'syncular_start':
490
- return this.bridge.start?.();
491
- case 'syncular_stop':
492
- return this.bridge.stop?.();
493
- case 'syncular_set_subscriptions':
494
- return undefined;
495
- case 'syncular_issue_auth_lease':
496
- return this.issueAuthLease(args?.request as SyncAuthLeaseIssueRequest);
497
- case 'syncular_upsert_auth_lease':
498
- this.#authLeases.set((args?.lease as SyncularAuthLeaseRecord).leaseId, {
499
- ...(args?.lease as SyncularAuthLeaseRecord),
500
- });
501
- return undefined;
502
- case 'syncular_auth_lease':
503
- return this.authLease(String(args?.leaseId));
504
- case 'syncular_active_auth_leases':
505
- return this.activeAuthLeases(
506
- args?.actorId == null ? null : String(args.actorId),
507
- typeof args?.nowMs === 'number' ? args.nowMs : undefined
508
- );
509
- case 'syncular_diagnostic_snapshot':
510
- return this.diagnosticSnapshot();
511
- case 'syncular_join_presence':
512
- return this.joinPresence(
513
- String(args?.scopeKey),
514
- args?.metadata as Record<string, unknown> | undefined
515
- );
516
- case 'syncular_leave_presence':
517
- return this.leavePresence(String(args?.scopeKey));
518
- case 'syncular_update_presence_metadata':
519
- return this.updatePresenceMetadata(
520
- String(args?.scopeKey),
521
- args?.metadata as Record<string, unknown>
522
- );
523
- case 'syncular_conflict_summaries':
524
- return this.#conflicts;
525
- case 'syncular_retry_conflict_keep_local':
526
- return `retry-${String(args?.id)}`;
527
- case 'syncular_resolve_conflict':
528
- return this.bridge.conflicts?.resolve(
529
- String(args?.id),
530
- args?.resolution as SyncularConflictResolution
531
- );
532
- default:
533
- throw new Error(`Unknown Syncular test bridge command: ${command}`);
534
- }
535
- }
536
-
537
- private addEventListener<T extends SyncularClientEventType>(
538
- event: T,
539
- listener: SyncularClientEventSink<T>
540
- ): () => void {
541
- const listeners = this.#listeners.get(event) ?? new Set();
542
- listeners.add(listener as SyncularClientEventSink<SyncularClientEventType>);
543
- this.#listeners.set(event, listeners);
544
- return () =>
545
- listeners.delete(
546
- listener as SyncularClientEventSink<SyncularClientEventType>
547
- );
548
- }
549
-
550
- private emitTauri(event: string, payload: unknown): void {
551
- for (const listener of this.#tauriListeners.get(event) ?? []) {
552
- listener({ payload });
553
- }
554
- }
555
-
556
- private reactNativeSubscription(
557
- unsubscribe: () => void
558
- ): ClientBridgeNativeEventSubscription {
559
- return { remove: unsubscribe };
560
- }
561
-
562
- private setConnection(realtime: SyncularConnectionState['realtime']): void {
563
- const connection: SyncularConnectionState = {
564
- closed: false,
565
- pendingRequests: 0,
566
- realtime,
567
- };
568
- const lifecycle: SyncularLifecycleState = {
569
- phase: realtime === 'connected' ? 'complete' : 'offline',
570
- realtime,
571
- online: realtime === 'connected',
572
- requiresAction: false,
573
- pendingRequests: 0,
574
- };
575
- this.#status = { ...this.#status, connection, lifecycle };
576
- this.emit('lifecycleChanged', lifecycle);
577
- }
578
-
579
- private joinPresence(
580
- scopeKey: string,
581
- metadata?: Record<string, unknown>
582
- ): void {
583
- this.#presence.set(scopeKey, [
584
- {
585
- clientId: this.#clientId,
586
- actorId: this.#actorId,
587
- joinedAt: Date.now(),
588
- metadata,
589
- },
590
- ]);
591
- this.emitPresence(scopeKey);
592
- }
593
-
594
- private leavePresence(scopeKey: string): void {
595
- this.#presence.set(scopeKey, []);
596
- this.emitPresence(scopeKey);
597
- }
598
-
599
- private updatePresenceMetadata(
600
- scopeKey: string,
601
- metadata: Record<string, unknown>
602
- ): void {
603
- this.#presence.set(
604
- scopeKey,
605
- this.presence(scopeKey).map((entry) => ({ ...entry, metadata }))
606
- );
607
- this.emitPresence(scopeKey);
608
- }
609
-
610
- private emitPresence(scopeKey: string): void {
611
- const event: SyncularPresenceChangeEvent = {
612
- scopeKey,
613
- presence: this.presence(scopeKey),
614
- };
615
- for (const listener of this.#presenceListeners) listener(event);
616
- this.emit('presenceChanged', event);
617
- }
618
-
619
- private activeAuthLeases(
620
- actorId?: string | null,
621
- nowMs = Date.now()
622
- ): SyncularAuthLeaseRecord[] {
623
- return this.authLeases().filter(
624
- (lease) =>
625
- lease.status === 'active' &&
626
- lease.notBeforeMs <= nowMs &&
627
- lease.expiresAtMs > nowMs &&
628
- (actorId == null || lease.actorId === actorId)
629
- );
630
- }
631
-
632
- diagnosticSnapshot(): SyncularDiagnosticSnapshot {
633
- const status = this.#status;
634
- return {
635
- generatedAt: Date.now(),
636
- runtime: {
637
- packageName: '@syncular/testkit',
638
- packageVersion: '0.0.0',
639
- workerProtocolVersion: 0,
640
- wasmGlueUrl: '',
641
- wasmUrl: '',
642
- },
643
- connection: status.connection ?? {
644
- closed: false,
645
- pendingRequests: 0,
646
- realtime: 'disconnected',
647
- },
648
- subscriptions: [],
649
- recentDiagnostics: [],
650
- recentSyncTimings: [],
651
- ...(status.lifecycle
652
- ? {
653
- bootstrap: {
654
- channelPhase: 'idle',
655
- progressPercent: 100,
656
- isBootstrapping: false,
657
- criticalReady: true,
658
- interactiveReady: true,
659
- complete: true,
660
- activePhase: null,
661
- expectedSubscriptionIds: [],
662
- readySubscriptionIds: [],
663
- pendingSubscriptionIds: [],
664
- subscriptions: [],
665
- phases: [],
666
- },
667
- }
668
- : {}),
669
- ...(status.outbox ? { outboxStats: status.outbox } : {}),
670
- ...(status.conflicts ? { conflictStats: status.conflicts } : {}),
671
- };
672
- }
673
-
674
- private applyOperation(operation: SyncOperation): void {
675
- if (operation.op === 'delete') {
676
- this.ensureTable(operation.table);
677
- this.#db
678
- .query(
679
- `delete from ${quoteIdent(operation.table)} where ${quoteIdent(
680
- this.#idColumn
681
- )} = ?`
682
- )
683
- .run(operation.row_id);
684
- this.emitOperationChange(operation);
685
- return;
686
- }
687
-
688
- this.upsertRow(operation.table, {
689
- [this.#idColumn]: operation.row_id,
690
- ...(operation.payload ?? {}),
691
- });
692
- this.emitOperationChange(operation);
693
- }
694
-
695
- private emitOperationChange(operation: SyncOperation): void {
696
- this.emitRowsChanged({
697
- source: 'localWrite',
698
- changedTables: [operation.table],
699
- changedRows: [
700
- {
701
- table: operation.table,
702
- rowId: operation.row_id,
703
- operation: operation.op === 'delete' ? 'delete' : 'update',
704
- changedFields: Object.keys(operation.payload ?? {}),
705
- crdtFields: [],
706
- },
707
- ],
708
- });
709
- }
710
-
711
- private upsertRow(table: string, row: Record<string, unknown>): void {
712
- this.ensureTable(table, [row]);
713
- const columns = Object.keys(row);
714
- const assignments = columns
715
- .filter((column) => column !== this.#idColumn)
716
- .map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`)
717
- .join(', ');
718
- const sql = `insert into ${quoteIdent(table)} (${columns
719
- .map(quoteIdent)
720
- .join(', ')}) values (${columns
721
- .map(() => '?')
722
- .join(', ')}) on conflict(${quoteIdent(this.#idColumn)}) do update set ${
723
- assignments ||
724
- `${quoteIdent(this.#idColumn)} = excluded.${quoteIdent(this.#idColumn)}`
725
- }`;
726
- const statement = this.#db.query(sql) as unknown as {
727
- run: (...bindings: unknown[]) => unknown;
728
- };
729
- statement.run(...columns.map((column) => encodeSqlValue(row[column])));
730
- }
731
-
732
- private ensureTable(
733
- table: string,
734
- rows: readonly Record<string, unknown>[] = []
735
- ): void {
736
- this.#db.run(
737
- `create table if not exists ${quoteIdent(table)} (${quoteIdent(
738
- this.#idColumn
739
- )} text primary key)`
740
- );
741
- const columns = new Set<string>([this.#idColumn]);
742
- for (const row of rows) {
743
- for (const column of Object.keys(row)) columns.add(column);
744
- }
745
- const existing = new Set(
746
- (
747
- this.#db
748
- .query(`pragma table_info(${quoteIdent(table)})`)
749
- .all() as Array<{
750
- name: string;
751
- }>
752
- ).map((column) => column.name)
753
- );
754
- for (const column of columns) {
755
- if (!existing.has(column)) {
756
- this.#db.run(
757
- `alter table ${quoteIdent(table)} add column ${quoteIdent(
758
- column
759
- )} ${column === this.#idColumn ? 'text' : 'any'}`
760
- );
761
- }
762
- }
763
- }
764
- }
765
-
766
- function quoteIdent(identifier: string): string {
767
- return `"${identifier.replaceAll('"', '""')}"`;
768
- }
769
-
770
- function encodeSqlValue(value: unknown): unknown {
771
- if (value == null) return null;
772
- if (
773
- typeof value === 'string' ||
774
- typeof value === 'number' ||
775
- typeof value === 'bigint' ||
776
- typeof value === 'boolean' ||
777
- value instanceof Uint8Array
778
- ) {
779
- return value;
780
- }
781
- return JSON.stringify(value);
782
- }
783
-
784
- function conflictStats(
785
- conflicts: readonly SyncularConflictSummary[]
786
- ): SyncularConflictStats {
787
- const resolved = conflicts.filter((conflict) => conflict.resolvedAt != null);
788
- return {
789
- unresolved: conflicts.length - resolved.length,
790
- resolved: resolved.length,
791
- total: conflicts.length,
792
- };
793
- }
794
-
795
- function zeroSyncResult(): SyncularSyncResult {
796
- return {
797
- changedTables: [],
798
- changedRows: [],
799
- changedRowsTruncated: false,
800
- subscriptions: [],
801
- bootstrap: {
802
- channelPhase: 'idle',
803
- progressPercent: 100,
804
- isBootstrapping: false,
805
- criticalReady: true,
806
- interactiveReady: true,
807
- complete: true,
808
- activePhase: null,
809
- expectedSubscriptionIds: [],
810
- readySubscriptionIds: [],
811
- pendingSubscriptionIds: [],
812
- subscriptions: [],
813
- phases: [],
814
- },
815
- pushedCommits: 0,
816
- timings: {
817
- totalMs: 0,
818
- pushMs: 0,
819
- pullMs: 0,
820
- pullRequestMs: 0,
821
- syncPackDecodeMs: 0,
822
- pullTransformMs: 0,
823
- integrityVerifyMs: 0,
824
- snapshotFetchMs: 0,
825
- pullApplyMs: 0,
826
- scopeClearMs: 0,
827
- snapshotRowApplyMs: 0,
828
- snapshotArtifactApplyMs: 0,
829
- snapshotArtifactCheckpointMs: 0,
830
- snapshotArtifactCheckpointCount: 0,
831
- snapshotChunkApplyMs: 0,
832
- snapshotChunkMaterializeMs: 0,
833
- snapshotChunkResetMs: 0,
834
- snapshotChunkBindMs: 0,
835
- snapshotChunkStepMs: 0,
836
- commitApplyMs: 0,
837
- subscriptionStateMs: 0,
838
- notifyMs: 0,
839
- },
840
- };
841
- }