@syncular/client-react 0.0.1-60

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.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Tests for useMutations hook
3
+ */
4
+
5
+ import { beforeEach, describe, expect, it } from 'bun:test';
6
+ import type { SyncClientDb } from '@syncular/client';
7
+ import { act, renderHook } from '@testing-library/react';
8
+ import type { Kysely } from 'kysely';
9
+ import type { ReactNode } from 'react';
10
+ import { createSyncularReact } from '../index';
11
+ import {
12
+ createMockDb,
13
+ createMockShapeRegistry,
14
+ createMockTransport,
15
+ } from './test-utils';
16
+
17
+ // DB schema for tests
18
+ // server_version has a DB default so it's optional for inserts but present on selects
19
+ interface TestDbTasks {
20
+ id: string;
21
+ title: string;
22
+ completed: number;
23
+ user_id: string;
24
+ server_version?: number;
25
+ }
26
+
27
+ // TestDb includes app tables + sync tables (created by createMockDb)
28
+ interface TestDb extends SyncClientDb {
29
+ tasks: TestDbTasks;
30
+ }
31
+
32
+ const { SyncProvider, useEngine, useMutations } = createSyncularReact<TestDb>();
33
+
34
+ describe('useMutations', () => {
35
+ let db: Kysely<TestDb>;
36
+
37
+ beforeEach(async () => {
38
+ db = await createMockDb<TestDb>();
39
+
40
+ // App table used in tests
41
+ await db.schema
42
+ .createTable('tasks')
43
+ .ifNotExists()
44
+ .addColumn('id', 'text', (col) => col.primaryKey())
45
+ .addColumn('title', 'text', (col) => col.notNull())
46
+ .addColumn('completed', 'integer', (col) => col.notNull().defaultTo(0))
47
+ .addColumn('user_id', 'text', (col) => col.notNull())
48
+ .addColumn('server_version', 'integer', (col) =>
49
+ col.notNull().defaultTo(0)
50
+ )
51
+ .execute();
52
+ });
53
+
54
+ function createWrapper() {
55
+ const transport = createMockTransport();
56
+ const shapes = createMockShapeRegistry<TestDb>();
57
+
58
+ const Wrapper = ({ children }: { children: ReactNode }) => (
59
+ <SyncProvider
60
+ db={db}
61
+ transport={transport}
62
+ shapes={shapes}
63
+ actorId="test-actor"
64
+ clientId="test-client"
65
+ subscriptions={[]}
66
+ pollIntervalMs={999999}
67
+ autoStart={false}
68
+ >
69
+ {children}
70
+ </SyncProvider>
71
+ );
72
+
73
+ return Wrapper;
74
+ }
75
+
76
+ it('insert() generates id, writes local row, and enqueues one outbox commit', async () => {
77
+ const { result } = renderHook(
78
+ () => ({
79
+ api: useMutations({ sync: false }),
80
+ engine: useEngine(),
81
+ }),
82
+ { wrapper: createWrapper() }
83
+ );
84
+
85
+ let insertedId = '';
86
+ await act(async () => {
87
+ const res = await result.current.api.tasks.insert({
88
+ title: 'Hello',
89
+ completed: 0,
90
+ user_id: 'test-actor',
91
+ });
92
+ insertedId = res.id;
93
+ expect(res.commitId).toBeTruthy();
94
+ expect(res.clientCommitId).toBeTruthy();
95
+ });
96
+
97
+ const row = await db
98
+ .selectFrom('tasks')
99
+ .selectAll()
100
+ .where('id', '=', insertedId)
101
+ .executeTakeFirstOrThrow();
102
+ expect(row.title).toBe('Hello');
103
+
104
+ const outbox = await db
105
+ .selectFrom('sync_outbox_commits')
106
+ .select(['id', 'operations_json'])
107
+ .execute();
108
+ expect(outbox.length).toBe(1);
109
+
110
+ const ops = JSON.parse(outbox[0]!.operations_json);
111
+ expect(ops.length).toBe(1);
112
+ expect(ops[0].op).toBe('upsert');
113
+ expect(ops[0].table).toBe('tasks');
114
+ expect(ops[0].row_id).toBe(insertedId);
115
+ expect(ops[0].payload.id).toBeUndefined();
116
+ expect(ops[0].payload.server_version).toBeUndefined();
117
+
118
+ // Fingerprinting: local mutation timestamps updated
119
+ expect(
120
+ result.current.engine.getMutationTimestamp('tasks', insertedId)
121
+ ).toBeGreaterThan(0);
122
+ });
123
+
124
+ it('$commit() batches multiple ops into a single outbox commit', async () => {
125
+ const { result } = renderHook(() => useMutations({ sync: false }), {
126
+ wrapper: createWrapper(),
127
+ });
128
+
129
+ let ids: string[] = [];
130
+ await act(async () => {
131
+ const res = await result.current.$commit(async (tx) => {
132
+ const a = await tx.tasks.insert({
133
+ title: 'A',
134
+ completed: 0,
135
+ user_id: 'test-actor',
136
+ });
137
+ const b = await tx.tasks.insert({
138
+ title: 'B',
139
+ completed: 0,
140
+ user_id: 'test-actor',
141
+ });
142
+ return [a, b];
143
+ });
144
+ ids = res.result;
145
+ expect(res.commit.commitId).toBeTruthy();
146
+ });
147
+
148
+ const outbox = await db
149
+ .selectFrom('sync_outbox_commits')
150
+ .select(['operations_json'])
151
+ .execute();
152
+ expect(outbox.length).toBe(1);
153
+
154
+ const ops: { row_id: string }[] = JSON.parse(outbox[0]!.operations_json);
155
+ expect(ops.length).toBe(2);
156
+ expect(new Set(ops.map((o) => o.row_id))).toEqual(new Set(ids));
157
+ });
158
+
159
+ it('update() patches only provided columns and auto-reads base_version from server_version', async () => {
160
+ const { result } = renderHook(() => useMutations({ sync: false }), {
161
+ wrapper: createWrapper(),
162
+ });
163
+
164
+ // Seed a row with server_version=7
165
+ await db
166
+ .insertInto('tasks')
167
+ .values({
168
+ id: 't1',
169
+ title: 'Keep',
170
+ completed: 0,
171
+ user_id: 'test-actor',
172
+ server_version: 7,
173
+ })
174
+ .execute();
175
+
176
+ await act(async () => {
177
+ await result.current.tasks.update('t1', { completed: 1 });
178
+ });
179
+
180
+ const row = await db
181
+ .selectFrom('tasks')
182
+ .selectAll()
183
+ .where('id', '=', 't1')
184
+ .executeTakeFirstOrThrow();
185
+ expect(row.title).toBe('Keep');
186
+ expect(row.completed).toBe(1);
187
+ expect(row.server_version).toBe(7);
188
+
189
+ const outbox = await db
190
+ .selectFrom('sync_outbox_commits')
191
+ .select(['operations_json'])
192
+ .executeTakeFirstOrThrow();
193
+
194
+ const ops = JSON.parse(outbox.operations_json);
195
+ expect(ops.length).toBe(1);
196
+ expect(ops[0].base_version).toBe(7);
197
+ });
198
+ });