@syncular/testkit 0.0.6-95 → 0.1.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 (71) hide show
  1. package/dist/audit.d.ts +22 -0
  2. package/dist/audit.d.ts.map +1 -0
  3. package/dist/audit.js +40 -0
  4. package/dist/audit.js.map +1 -0
  5. package/dist/client-bridge.d.ts +77 -0
  6. package/dist/client-bridge.d.ts.map +1 -0
  7. package/dist/client-bridge.js +540 -0
  8. package/dist/client-bridge.js.map +1 -0
  9. package/dist/deterministic.js.map +1 -1
  10. package/dist/disposable.js.map +1 -1
  11. package/dist/faults.d.ts +14 -2
  12. package/dist/faults.d.ts.map +1 -1
  13. package/dist/faults.js +104 -42
  14. package/dist/faults.js.map +1 -1
  15. package/dist/hono-node-server.js +2 -2
  16. package/dist/hono-node-server.js.map +1 -1
  17. package/dist/http-fixtures.d.ts +1 -30
  18. package/dist/http-fixtures.d.ts.map +1 -1
  19. package/dist/http-fixtures.js +6 -66
  20. package/dist/http-fixtures.js.map +1 -1
  21. package/dist/index.d.ts +2 -4
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +14 -16
  24. package/dist/index.js.map +1 -1
  25. package/dist/project-scoped-tasks.d.ts.map +1 -1
  26. package/dist/project-scoped-tasks.js +6 -4
  27. package/dist/project-scoped-tasks.js.map +1 -1
  28. package/dist/realtime-ws.js +1 -1
  29. package/dist/realtime-ws.js.map +1 -1
  30. package/dist/runtime-process.js +1 -1
  31. package/dist/runtime-process.js.map +1 -1
  32. package/dist/sync-builders.d.ts +5 -3
  33. package/dist/sync-builders.d.ts.map +1 -1
  34. package/dist/sync-builders.js +9 -3
  35. package/dist/sync-builders.js.map +1 -1
  36. package/dist/sync-http.js +1 -1
  37. package/dist/sync-http.js.map +1 -1
  38. package/dist/sync-parse.js.map +1 -1
  39. package/dist/sync-response.d.ts +3 -4
  40. package/dist/sync-response.d.ts.map +1 -1
  41. package/dist/sync-response.js.map +1 -1
  42. package/package.json +15 -16
  43. package/src/audit.ts +74 -0
  44. package/src/client-bridge.ts +841 -0
  45. package/src/faults.ts +178 -55
  46. package/src/hono-node-server.ts +1 -1
  47. package/src/http-fixtures.ts +6 -127
  48. package/src/index.ts +2 -4
  49. package/src/project-scoped-tasks.ts +6 -4
  50. package/src/sync-builders.ts +14 -6
  51. package/src/sync-response.ts +3 -5
  52. package/dist/assertions.d.ts +0 -61
  53. package/dist/assertions.d.ts.map +0 -1
  54. package/dist/assertions.js +0 -239
  55. package/dist/assertions.js.map +0 -1
  56. package/dist/fixtures.d.ts +0 -104
  57. package/dist/fixtures.d.ts.map +0 -1
  58. package/dist/fixtures.js +0 -558
  59. package/dist/fixtures.js.map +0 -1
  60. package/dist/resources.d.ts +0 -25
  61. package/dist/resources.d.ts.map +0 -1
  62. package/dist/resources.js +0 -64
  63. package/dist/resources.js.map +0 -1
  64. package/dist/scenario-flow.d.ts +0 -39
  65. package/dist/scenario-flow.d.ts.map +0 -1
  66. package/dist/scenario-flow.js +0 -41
  67. package/dist/scenario-flow.js.map +0 -1
  68. package/src/assertions.ts +0 -432
  69. package/src/fixtures.ts +0 -867
  70. package/src/resources.ts +0 -186
  71. package/src/scenario-flow.ts +0 -143
@@ -0,0 +1,841 @@
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
+ }