@syncular/client-react 0.0.1 → 0.0.2-127

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.
@@ -21,7 +21,7 @@ import React from 'react';
21
21
  import { createSyncularReact } from '../index';
22
22
  import {
23
23
  createMockDb,
24
- createMockShapeRegistry,
24
+ createMockHandlerRegistry,
25
25
  createMockTransport,
26
26
  } from './test-utils';
27
27
 
@@ -66,14 +66,14 @@ describe('SyncProvider (StrictMode)', () => {
66
66
 
67
67
  function renderWithProvider(node: React.ReactNode) {
68
68
  const transport = createMockTransport();
69
- const shapes = createMockShapeRegistry();
69
+ const handlers = createMockHandlerRegistry();
70
70
 
71
71
  return render(
72
72
  <React.StrictMode>
73
73
  <SyncProvider
74
74
  db={db}
75
75
  transport={transport}
76
- shapes={shapes}
76
+ handlers={handlers}
77
77
  actorId="test-actor"
78
78
  clientId="test-client"
79
79
  subscriptions={[]}
@@ -12,7 +12,7 @@ import {
12
12
  import type { Kysely } from 'kysely';
13
13
  import {
14
14
  createMockDb,
15
- createMockShapeRegistry,
15
+ createMockHandlerRegistry,
16
16
  createMockTransport,
17
17
  } from './test-utils';
18
18
 
@@ -32,7 +32,7 @@ describe('fingerprint utilities', () => {
32
32
  const config: SyncEngineConfig = {
33
33
  db,
34
34
  transport: createMockTransport(),
35
- shapes: createMockShapeRegistry(),
35
+ handlers: createMockHandlerRegistry(),
36
36
  actorId: 'test-actor',
37
37
  clientId: 'test-client',
38
38
  subscriptions: [],
@@ -108,11 +108,11 @@ describe('fingerprint utilities', () => {
108
108
  await engine.start();
109
109
 
110
110
  // Simulate a mutation by calling applyLocalMutation
111
- // This requires the shape handler to exist, so we'll test getMutationTimestamp directly
111
+ // This requires the table handler to exist, so we'll test getMutationTimestamp directly
112
112
  const beforeMutation = engine.getMutationTimestamp('tasks', 'abc');
113
113
  expect(beforeMutation).toBe(0);
114
114
 
115
- // We can't easily test the full mutation flow without proper shape setup,
115
+ // We can't easily test the full mutation flow without proper handler setup,
116
116
  // but we can verify the fingerprint changes when timestamps change
117
117
  });
118
118
 
@@ -20,7 +20,7 @@ import type { ReactNode } from 'react';
20
20
  import { createSyncularReact } from '../../index';
21
21
  import {
22
22
  createMockDb,
23
- createMockShapeRegistry,
23
+ createMockHandlerRegistry,
24
24
  createMockTransport,
25
25
  } from '../test-utils';
26
26
 
@@ -59,13 +59,13 @@ describe('useMutation', () => {
59
59
 
60
60
  function createWrapper() {
61
61
  const transport = createMockTransport();
62
- const shapes = createMockShapeRegistry<TestDb>();
62
+ const handlers = createMockHandlerRegistry<TestDb>();
63
63
 
64
64
  const Wrapper = ({ children }: { children: ReactNode }) => (
65
65
  <SyncProvider
66
66
  db={db}
67
67
  transport={transport}
68
- shapes={shapes}
68
+ handlers={handlers}
69
69
  actorId="test-actor"
70
70
  clientId="test-client"
71
71
  subscriptions={[]}
@@ -13,14 +13,16 @@ import type { ReactNode } from 'react';
13
13
  import { createSyncularReact } from '../index';
14
14
  import {
15
15
  createMockDb,
16
- createMockShapeRegistry,
16
+ createMockHandlerRegistry,
17
17
  createMockTransport,
18
18
  } from './test-utils';
19
19
 
20
20
  const {
21
21
  SyncProvider,
22
22
  useConflicts,
23
+ useEngine,
23
24
  useOutbox,
25
+ usePresenceWithJoin,
24
26
  useResolveConflict,
25
27
  useSyncConnection,
26
28
  useSyncEngine,
@@ -36,13 +38,13 @@ describe('React Hooks', () => {
36
38
 
37
39
  function createWrapper(options?: { autoStart?: boolean }) {
38
40
  const transport = createMockTransport();
39
- const shapes = createMockShapeRegistry();
41
+ const handlers = createMockHandlerRegistry();
40
42
 
41
43
  const Wrapper = ({ children }: { children: ReactNode }) => (
42
44
  <SyncProvider
43
45
  db={db}
44
46
  transport={transport}
45
- shapes={shapes}
47
+ handlers={handlers}
46
48
  actorId="test-actor"
47
49
  clientId="test-client"
48
50
  subscriptions={[]}
@@ -151,6 +153,99 @@ describe('React Hooks', () => {
151
153
  // These hook tests verify the React binding works
152
154
  });
153
155
 
156
+ describe('usePresenceWithJoin', () => {
157
+ it('does not re-join when metadata object identity changes with equal values', async () => {
158
+ let joinCalls = 0;
159
+ let leaveCalls = 0;
160
+
161
+ const { result, rerender } = renderHook(
162
+ ({ metadata }: { metadata: { displayName: string } }) => {
163
+ const engine = useEngine();
164
+ const presence = usePresenceWithJoin('room:1', {
165
+ metadata,
166
+ autoJoin: true,
167
+ });
168
+ return { engine, presence };
169
+ },
170
+ {
171
+ wrapper: createWrapper(),
172
+ initialProps: { metadata: { displayName: 'Alice' } },
173
+ }
174
+ );
175
+
176
+ await waitFor(() => {
177
+ expect(result.current.presence.isJoined).toBe(true);
178
+ });
179
+
180
+ const originalJoin = result.current.engine.joinPresence.bind(
181
+ result.current.engine
182
+ );
183
+ const originalLeave = result.current.engine.leavePresence.bind(
184
+ result.current.engine
185
+ );
186
+
187
+ result.current.engine.joinPresence = (scopeKey, metadata) => {
188
+ joinCalls += 1;
189
+ originalJoin(scopeKey, metadata);
190
+ };
191
+ result.current.engine.leavePresence = (scopeKey) => {
192
+ leaveCalls += 1;
193
+ originalLeave(scopeKey);
194
+ };
195
+
196
+ rerender({ metadata: { displayName: 'Alice' } });
197
+ await act(async () => {
198
+ await Promise.resolve();
199
+ });
200
+
201
+ expect(joinCalls).toBe(0);
202
+ expect(leaveCalls).toBe(0);
203
+ });
204
+
205
+ it('updates metadata when auto-join metadata changes', async () => {
206
+ let updateCalls = 0;
207
+
208
+ const { result, rerender } = renderHook(
209
+ ({ metadata }: { metadata: { displayName: string } }) => {
210
+ const engine = useEngine();
211
+ const presence = usePresenceWithJoin('room:2', {
212
+ metadata,
213
+ autoJoin: true,
214
+ });
215
+ return { engine, presence };
216
+ },
217
+ {
218
+ wrapper: createWrapper(),
219
+ initialProps: { metadata: { displayName: 'Alice' } },
220
+ }
221
+ );
222
+
223
+ await waitFor(() => {
224
+ expect(result.current.presence.isJoined).toBe(true);
225
+ });
226
+
227
+ const originalUpdate = result.current.engine.updatePresenceMetadata.bind(
228
+ result.current.engine
229
+ );
230
+ result.current.engine.updatePresenceMetadata = (scopeKey, metadata) => {
231
+ updateCalls += 1;
232
+ originalUpdate(scopeKey, metadata);
233
+ };
234
+
235
+ rerender({ metadata: { displayName: 'Bob' } });
236
+
237
+ await waitFor(() => {
238
+ expect(updateCalls).toBe(1);
239
+ });
240
+
241
+ await waitFor(() => {
242
+ expect(result.current.presence.presence[0]?.metadata).toMatchObject({
243
+ displayName: 'Bob',
244
+ });
245
+ });
246
+ });
247
+ });
248
+
154
249
  describe('useOutbox', () => {
155
250
  it('should return outbox stats', async () => {
156
251
  const { result } = renderHook(() => useOutbox(), {
@@ -46,12 +46,12 @@ function createMockClientTableRegistry(): ClientTableRegistry<ClientDb> {
46
46
  describe('SyncProvider Reconfiguration', () => {
47
47
  let server: TestServer;
48
48
  let db: Kysely<ClientDb>;
49
- let mockShapes: ClientTableRegistry<ClientDb>;
49
+ let mockHandlers: ClientTableRegistry<ClientDb>;
50
50
 
51
51
  beforeEach(async () => {
52
52
  server = await createTestServer();
53
53
  db = createBunSqliteDb<ClientDb>({ path: ':memory:' });
54
- mockShapes = createMockClientTableRegistry();
54
+ mockHandlers = createMockClientTableRegistry();
55
55
 
56
56
  await ensureClientSyncSchema(db);
57
57
 
@@ -77,14 +77,8 @@ describe('SyncProvider Reconfiguration', () => {
77
77
  it('SyncEngine recreates when actorId changes', async () => {
78
78
  // Create a simple transport for testing
79
79
  const transport = {
80
- async pull() {
81
- return {
82
- ok: true as const,
83
- subscriptions: [],
84
- };
85
- },
86
- async push() {
87
- return { ok: true as const, status: 'applied' as const, results: [] };
80
+ async sync() {
81
+ return { ok: true as const };
88
82
  },
89
83
  async fetchSnapshotChunk() {
90
84
  return new Uint8Array();
@@ -95,7 +89,7 @@ describe('SyncProvider Reconfiguration', () => {
95
89
  const engine1 = new SyncEngine({
96
90
  db,
97
91
  transport,
98
- shapes: mockShapes,
92
+ handlers: mockHandlers,
99
93
  actorId: 'user-1',
100
94
  clientId: 'client-1',
101
95
  subscriptions: [],
@@ -110,7 +104,7 @@ describe('SyncProvider Reconfiguration', () => {
110
104
  const engine2 = new SyncEngine({
111
105
  db,
112
106
  transport,
113
- shapes: mockShapes,
107
+ handlers: mockHandlers,
114
108
  actorId: 'user-2',
115
109
  clientId: 'client-1',
116
110
  subscriptions: [],
@@ -163,11 +157,8 @@ describe('SyncProvider Reconfiguration', () => {
163
157
 
164
158
  it('engine config is immutable after creation', () => {
165
159
  const transport = {
166
- async pull() {
167
- return { ok: true as const, subscriptions: [] };
168
- },
169
- async push() {
170
- return { ok: true as const, status: 'applied' as const, results: [] };
160
+ async sync() {
161
+ return { ok: true as const };
171
162
  },
172
163
  async fetchSnapshotChunk() {
173
164
  return new Uint8Array();
@@ -177,7 +168,7 @@ describe('SyncProvider Reconfiguration', () => {
177
168
  const engine = new SyncEngine({
178
169
  db,
179
170
  transport,
180
- shapes: mockShapes,
171
+ handlers: mockHandlers,
181
172
  actorId: 'user-1',
182
173
  clientId: 'client-1',
183
174
  subscriptions: [],
@@ -197,13 +188,10 @@ describe('SyncProvider Reconfiguration', () => {
197
188
 
198
189
  describe('SyncProvider React render tests', () => {
199
190
  let db: Kysely<ClientDb>;
200
- let mockShapes: ClientTableRegistry<ClientDb>;
191
+ let mockHandlers: ClientTableRegistry<ClientDb>;
201
192
  const mockTransport = {
202
- async pull() {
203
- return { ok: true as const, subscriptions: [] };
204
- },
205
- async push() {
206
- return { ok: true as const, status: 'applied' as const, results: [] };
193
+ async sync() {
194
+ return { ok: true as const };
207
195
  },
208
196
  async fetchSnapshotChunk() {
209
197
  return new Uint8Array();
@@ -212,7 +200,7 @@ describe('SyncProvider React render tests', () => {
212
200
 
213
201
  beforeEach(async () => {
214
202
  db = createBunSqliteDb<ClientDb>({ path: ':memory:' });
215
- mockShapes = new ClientTableRegistry<ClientDb>();
203
+ mockHandlers = new ClientTableRegistry<ClientDb>();
216
204
  await ensureClientSyncSchema(db);
217
205
  });
218
206
 
@@ -229,7 +217,7 @@ describe('SyncProvider React render tests', () => {
229
217
  createElement(SyncProvider, {
230
218
  db,
231
219
  transport: mockTransport,
232
- shapes: mockShapes,
220
+ handlers: mockHandlers,
233
221
  actorId: 'user-1',
234
222
  clientId: 'client-1',
235
223
  autoStart: false, // Disable auto-start for faster test
@@ -244,7 +232,7 @@ describe('SyncProvider React render tests', () => {
244
232
  createElement(SyncProvider, {
245
233
  db,
246
234
  transport: mockTransport,
247
- shapes: mockShapes,
235
+ handlers: mockHandlers,
248
236
  actorId: 'user-2', // Changed!
249
237
  clientId: 'client-1',
250
238
  autoStart: false,
@@ -261,7 +249,7 @@ describe('SyncProvider React render tests', () => {
261
249
  createElement(SyncProvider, {
262
250
  db,
263
251
  transport: mockTransport,
264
- shapes: mockShapes,
252
+ handlers: mockHandlers,
265
253
  actorId: 'user-1',
266
254
  clientId: 'client-1',
267
255
  autoStart: false,
@@ -277,7 +265,7 @@ describe('SyncProvider React render tests', () => {
277
265
  createElement(SyncProvider, {
278
266
  db,
279
267
  transport: mockTransport,
280
- shapes: mockShapes,
268
+ handlers: mockHandlers,
281
269
  actorId: 'user-1',
282
270
  clientId: 'client-1',
283
271
  autoStart: false,
@@ -9,7 +9,7 @@ import {
9
9
  enqueueOutboxCommit,
10
10
  getNextSendableOutboxCommit,
11
11
  } from '@syncular/client';
12
- import type { SyncPushRequest, SyncPushResponse } from '@syncular/core';
12
+
13
13
  import {
14
14
  createTestClient,
15
15
  createTestServer,
@@ -109,8 +109,8 @@ describe('Push Flow', () => {
109
109
 
110
110
  it('sync updates local row with server version after push', async () => {
111
111
  // First, apply the mutation locally (this is what useMutation does)
112
- const shapes = client.shapes;
113
- const handler = shapes.get('tasks');
112
+ const handlers = client.handlers;
113
+ const handler = handlers.get('tasks');
114
114
 
115
115
  await client.db.transaction().execute(async (trx) => {
116
116
  await handler!.applyChange(
@@ -243,31 +243,32 @@ describe('Push Flow', () => {
243
243
  clientId: 'retriable-client',
244
244
  });
245
245
 
246
- // Override the transport to return retriable errors
247
- const originalPush = retriableClient.transport.push.bind(
246
+ // Override the transport to return retriable errors on push
247
+ const originalSync = retriableClient.transport.sync.bind(
248
248
  retriableClient.transport
249
249
  );
250
250
  let retriableErrorCount = 0;
251
- retriableClient.transport.push = async (
252
- request: SyncPushRequest
253
- ): Promise<SyncPushResponse> => {
254
- if (retriableErrorCount < 2) {
255
- // Return retriable error for first two attempts
251
+ retriableClient.transport.sync = async (request) => {
252
+ if (request.push && retriableErrorCount < 2) {
253
+ // Return retriable error for first two push attempts
256
254
  retriableErrorCount++;
257
255
  return {
258
256
  ok: true as const,
259
- status: 'rejected' as const,
260
- results: request.operations.map((_, i) => ({
261
- opIndex: i,
262
- status: 'error' as const,
263
- error: 'TEMPORARY_FAILURE',
264
- code: 'TEMPORARY',
265
- retriable: true,
266
- })),
257
+ push: {
258
+ ok: true as const,
259
+ status: 'rejected' as const,
260
+ results: request.push.operations.map((_, i) => ({
261
+ opIndex: i,
262
+ status: 'error' as const,
263
+ error: 'TEMPORARY_FAILURE',
264
+ code: 'TEMPORARY',
265
+ retriable: true,
266
+ })),
267
+ },
267
268
  };
268
269
  }
269
270
  // After that, use the real transport
270
- return originalPush(request);
271
+ return originalSync(request);
271
272
  };
272
273
 
273
274
  await retriableClient.engine.start();
@@ -8,8 +8,6 @@
8
8
  import type {
9
9
  SyncClientDb,
10
10
  SyncClientPlugin,
11
- SyncPullRequest,
12
- SyncPushRequest,
13
11
  SyncTransport,
14
12
  } from '@syncular/client';
15
13
  import {
@@ -27,6 +25,7 @@ import {
27
25
  ensureSyncSchema,
28
26
  pull,
29
27
  pushCommit,
28
+ readSnapshotChunk,
30
29
  recordClientCursor,
31
30
  type ServerSyncDialect,
32
31
  type ServerTableHandler,
@@ -73,7 +72,7 @@ export interface TestServer {
73
72
  /** Full database instance with app tables (also includes sync tables) */
74
73
  db: Kysely<ServerDb>;
75
74
  dialect: ServerSyncDialect;
76
- shapes: TableRegistry<ServerDb>;
75
+ handlers: TableRegistry<ServerDb>;
77
76
  }
78
77
 
79
78
  /**
@@ -84,14 +83,14 @@ export interface TestClient {
84
83
  db: Kysely<ClientDb>;
85
84
  engine: SyncEngine<ClientDb>;
86
85
  transport: SyncTransport;
87
- /** Client shapes registry */
88
- shapes: ClientTableRegistry<ClientDb>;
86
+ /** Client handler registry */
87
+ handlers: ClientTableRegistry<ClientDb>;
89
88
  }
90
89
 
91
90
  /**
92
- * Server-side tasks shape handler for tests
91
+ * Server-side tasks table handler for tests
93
92
  */
94
- const tasksServerShape: ServerTableHandler<ServerDb> = {
93
+ const tasksServerHandler: ServerTableHandler<ServerDb> = {
95
94
  table: 'tasks',
96
95
  scopePatterns: ['user:{user_id}'],
97
96
 
@@ -346,14 +345,14 @@ export async function createTestServer(): Promise<TestServer> {
346
345
  .addColumn('server_version', 'integer', (col) => col.notNull().defaultTo(1))
347
346
  .execute();
348
347
 
349
- // Register shapes
350
- const shapes = new TableRegistry<ServerDb>();
351
- shapes.register(tasksServerShape);
348
+ // Register handlers
349
+ const handlers = new TableRegistry<ServerDb>();
350
+ handlers.register(tasksServerHandler);
352
351
 
353
352
  return {
354
353
  db,
355
354
  dialect,
356
- shapes,
355
+ handlers,
357
356
  };
358
357
  }
359
358
 
@@ -366,39 +365,89 @@ function createInProcessTransport(
366
365
  ): SyncTransport {
367
366
  const syncDb = server.db;
368
367
 
368
+ async function streamToBytes(
369
+ stream: ReadableStream<Uint8Array>
370
+ ): Promise<Uint8Array> {
371
+ const reader = stream.getReader();
372
+ try {
373
+ const chunks: Uint8Array[] = [];
374
+ let total = 0;
375
+ while (true) {
376
+ const { done, value } = await reader.read();
377
+ if (done) break;
378
+ if (!value) continue;
379
+ chunks.push(value);
380
+ total += value.length;
381
+ }
382
+
383
+ const merged = new Uint8Array(total);
384
+ let offset = 0;
385
+ for (const chunk of chunks) {
386
+ merged.set(chunk, offset);
387
+ offset += chunk.length;
388
+ }
389
+ return merged;
390
+ } finally {
391
+ reader.releaseLock();
392
+ }
393
+ }
394
+
369
395
  return {
370
- async pull(request: SyncPullRequest) {
371
- const pulled = await pull({
372
- db: syncDb,
373
- dialect: server.dialect,
374
- shapes: server.shapes,
375
- actorId,
376
- request,
377
- });
378
-
379
- await recordClientCursor(syncDb, server.dialect, {
380
- clientId: request.clientId,
381
- actorId,
382
- cursor: pulled.clientCursor,
383
- effectiveScopes: pulled.effectiveScopes,
384
- });
385
-
386
- return pulled.response;
387
- },
396
+ async sync(request) {
397
+ const result: { ok: true; push?: any; pull?: any } = { ok: true };
398
+
399
+ if (request.push) {
400
+ const pushed = await pushCommit({
401
+ db: syncDb,
402
+ dialect: server.dialect,
403
+ handlers: server.handlers,
404
+ actorId,
405
+ request: {
406
+ clientId: request.clientId,
407
+ clientCommitId: request.push.clientCommitId,
408
+ operations: request.push.operations,
409
+ schemaVersion: request.push.schemaVersion,
410
+ },
411
+ });
412
+ result.push = pushed.response;
413
+ }
388
414
 
389
- async push(request: SyncPushRequest) {
390
- const pushed = await pushCommit({
391
- db: syncDb,
392
- dialect: server.dialect,
393
- shapes: server.shapes,
394
- actorId,
395
- request,
396
- });
397
- return pushed.response;
415
+ if (request.pull) {
416
+ const pulled = await pull({
417
+ db: syncDb,
418
+ dialect: server.dialect,
419
+ handlers: server.handlers,
420
+ actorId,
421
+ request: {
422
+ clientId: request.clientId,
423
+ ...request.pull,
424
+ },
425
+ });
426
+
427
+ recordClientCursor(syncDb, server.dialect, {
428
+ clientId: request.clientId,
429
+ actorId,
430
+ cursor: pulled.clientCursor,
431
+ effectiveScopes: pulled.effectiveScopes,
432
+ }).catch(() => {});
433
+
434
+ result.pull = pulled.response;
435
+ }
436
+
437
+ return result;
398
438
  },
399
439
 
400
- async fetchSnapshotChunk() {
401
- return new Uint8Array();
440
+ async fetchSnapshotChunk(request) {
441
+ const chunk = await readSnapshotChunk(syncDb, request.chunkId);
442
+ if (!chunk) {
443
+ throw new Error(`Snapshot chunk not found: ${request.chunkId}`);
444
+ }
445
+
446
+ if (chunk.body instanceof Uint8Array) {
447
+ return new Uint8Array(chunk.body);
448
+ }
449
+
450
+ return streamToBytes(chunk.body);
402
451
  },
403
452
  };
404
453
  }
@@ -429,9 +478,9 @@ export async function createTestClient(
429
478
  .addColumn('server_version', 'integer', (col) => col.notNull().defaultTo(0))
430
479
  .execute();
431
480
 
432
- // Create client shapes registry
433
- const shapes = new ClientTableRegistry<ClientDb>();
434
- shapes.register({
481
+ // Create client handler registry
482
+ const handlers = new ClientTableRegistry<ClientDb>();
483
+ handlers.register({
435
484
  table: 'tasks',
436
485
 
437
486
  async applySnapshot(ctx, snapshot) {
@@ -509,11 +558,11 @@ export async function createTestClient(
509
558
  const config: SyncEngineConfig<ClientDb> = {
510
559
  db,
511
560
  transport,
512
- shapes,
561
+ handlers,
513
562
  actorId: options.actorId,
514
563
  clientId: options.clientId,
515
564
  subscriptions: [
516
- { id: 'my-tasks', shape: 'tasks', scopes: { user_id: options.actorId } },
565
+ { id: 'my-tasks', table: 'tasks', scopes: { user_id: options.actorId } },
517
566
  ],
518
567
  pollIntervalMs: 999999, // Disable polling for tests
519
568
  realtimeEnabled: false,
@@ -522,7 +571,7 @@ export async function createTestClient(
522
571
 
523
572
  const engine = new SyncEngine<ClientDb>(config);
524
573
 
525
- return { db, engine, transport, shapes };
574
+ return { db, engine, transport, handlers };
526
575
  }
527
576
 
528
577
  /**