@syncular/client 0.0.1-100

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 (178) hide show
  1. package/dist/blobs/index.d.ts +7 -0
  2. package/dist/blobs/index.d.ts.map +1 -0
  3. package/dist/blobs/index.js +7 -0
  4. package/dist/blobs/index.js.map +1 -0
  5. package/dist/blobs/manager.d.ts +345 -0
  6. package/dist/blobs/manager.d.ts.map +1 -0
  7. package/dist/blobs/manager.js +749 -0
  8. package/dist/blobs/manager.js.map +1 -0
  9. package/dist/blobs/migrate.d.ts +14 -0
  10. package/dist/blobs/migrate.d.ts.map +1 -0
  11. package/dist/blobs/migrate.js +59 -0
  12. package/dist/blobs/migrate.js.map +1 -0
  13. package/dist/blobs/types.d.ts +62 -0
  14. package/dist/blobs/types.d.ts.map +1 -0
  15. package/dist/blobs/types.js +5 -0
  16. package/dist/blobs/types.js.map +1 -0
  17. package/dist/client.d.ts +339 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +881 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/conflicts.d.ts +31 -0
  22. package/dist/conflicts.d.ts.map +1 -0
  23. package/dist/conflicts.js +112 -0
  24. package/dist/conflicts.js.map +1 -0
  25. package/dist/create-client.d.ts +115 -0
  26. package/dist/create-client.d.ts.map +1 -0
  27. package/dist/create-client.js +162 -0
  28. package/dist/create-client.js.map +1 -0
  29. package/dist/engine/SyncEngine.d.ts +216 -0
  30. package/dist/engine/SyncEngine.d.ts.map +1 -0
  31. package/dist/engine/SyncEngine.js +1141 -0
  32. package/dist/engine/SyncEngine.js.map +1 -0
  33. package/dist/engine/index.d.ts +6 -0
  34. package/dist/engine/index.d.ts.map +1 -0
  35. package/dist/engine/index.js +6 -0
  36. package/dist/engine/index.js.map +1 -0
  37. package/dist/engine/types.d.ts +230 -0
  38. package/dist/engine/types.d.ts.map +1 -0
  39. package/dist/engine/types.js +7 -0
  40. package/dist/engine/types.js.map +1 -0
  41. package/dist/handlers/create-handler.d.ts +110 -0
  42. package/dist/handlers/create-handler.d.ts.map +1 -0
  43. package/dist/handlers/create-handler.js +142 -0
  44. package/dist/handlers/create-handler.js.map +1 -0
  45. package/dist/handlers/registry.d.ts +15 -0
  46. package/dist/handlers/registry.d.ts.map +1 -0
  47. package/dist/handlers/registry.js +29 -0
  48. package/dist/handlers/registry.js.map +1 -0
  49. package/dist/handlers/types.d.ts +83 -0
  50. package/dist/handlers/types.d.ts.map +1 -0
  51. package/dist/handlers/types.js +5 -0
  52. package/dist/handlers/types.js.map +1 -0
  53. package/dist/index.d.ts +24 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +24 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/migrate.d.ts +19 -0
  58. package/dist/migrate.d.ts.map +1 -0
  59. package/dist/migrate.js +106 -0
  60. package/dist/migrate.js.map +1 -0
  61. package/dist/mutations.d.ts +138 -0
  62. package/dist/mutations.d.ts.map +1 -0
  63. package/dist/mutations.js +601 -0
  64. package/dist/mutations.js.map +1 -0
  65. package/dist/outbox.d.ts +112 -0
  66. package/dist/outbox.d.ts.map +1 -0
  67. package/dist/outbox.js +294 -0
  68. package/dist/outbox.js.map +1 -0
  69. package/dist/plugins/incrementing-version.d.ts +34 -0
  70. package/dist/plugins/incrementing-version.d.ts.map +1 -0
  71. package/dist/plugins/incrementing-version.js +83 -0
  72. package/dist/plugins/incrementing-version.js.map +1 -0
  73. package/dist/plugins/index.d.ts +3 -0
  74. package/dist/plugins/index.d.ts.map +1 -0
  75. package/dist/plugins/index.js +3 -0
  76. package/dist/plugins/index.js.map +1 -0
  77. package/dist/plugins/types.d.ts +49 -0
  78. package/dist/plugins/types.d.ts.map +1 -0
  79. package/dist/plugins/types.js +15 -0
  80. package/dist/plugins/types.js.map +1 -0
  81. package/dist/proxy/connection.d.ts +33 -0
  82. package/dist/proxy/connection.d.ts.map +1 -0
  83. package/dist/proxy/connection.js +153 -0
  84. package/dist/proxy/connection.js.map +1 -0
  85. package/dist/proxy/dialect.d.ts +46 -0
  86. package/dist/proxy/dialect.d.ts.map +1 -0
  87. package/dist/proxy/dialect.js +58 -0
  88. package/dist/proxy/dialect.js.map +1 -0
  89. package/dist/proxy/driver.d.ts +42 -0
  90. package/dist/proxy/driver.d.ts.map +1 -0
  91. package/dist/proxy/driver.js +78 -0
  92. package/dist/proxy/driver.js.map +1 -0
  93. package/dist/proxy/index.d.ts +10 -0
  94. package/dist/proxy/index.d.ts.map +1 -0
  95. package/dist/proxy/index.js +10 -0
  96. package/dist/proxy/index.js.map +1 -0
  97. package/dist/proxy/mutations.d.ts +9 -0
  98. package/dist/proxy/mutations.d.ts.map +1 -0
  99. package/dist/proxy/mutations.js +11 -0
  100. package/dist/proxy/mutations.js.map +1 -0
  101. package/dist/pull-engine.d.ts +45 -0
  102. package/dist/pull-engine.d.ts.map +1 -0
  103. package/dist/pull-engine.js +381 -0
  104. package/dist/pull-engine.js.map +1 -0
  105. package/dist/push-engine.d.ts +18 -0
  106. package/dist/push-engine.d.ts.map +1 -0
  107. package/dist/push-engine.js +155 -0
  108. package/dist/push-engine.js.map +1 -0
  109. package/dist/query/FingerprintCollector.d.ts +18 -0
  110. package/dist/query/FingerprintCollector.d.ts.map +1 -0
  111. package/dist/query/FingerprintCollector.js +28 -0
  112. package/dist/query/FingerprintCollector.js.map +1 -0
  113. package/dist/query/QueryContext.d.ts +33 -0
  114. package/dist/query/QueryContext.d.ts.map +1 -0
  115. package/dist/query/QueryContext.js +16 -0
  116. package/dist/query/QueryContext.js.map +1 -0
  117. package/dist/query/fingerprint.d.ts +61 -0
  118. package/dist/query/fingerprint.d.ts.map +1 -0
  119. package/dist/query/fingerprint.js +91 -0
  120. package/dist/query/fingerprint.js.map +1 -0
  121. package/dist/query/index.d.ts +7 -0
  122. package/dist/query/index.d.ts.map +1 -0
  123. package/dist/query/index.js +7 -0
  124. package/dist/query/index.js.map +1 -0
  125. package/dist/query/tracked-select.d.ts +18 -0
  126. package/dist/query/tracked-select.d.ts.map +1 -0
  127. package/dist/query/tracked-select.js +90 -0
  128. package/dist/query/tracked-select.js.map +1 -0
  129. package/dist/schema.d.ts +83 -0
  130. package/dist/schema.d.ts.map +1 -0
  131. package/dist/schema.js +7 -0
  132. package/dist/schema.js.map +1 -0
  133. package/dist/sync-loop.d.ts +32 -0
  134. package/dist/sync-loop.d.ts.map +1 -0
  135. package/dist/sync-loop.js +249 -0
  136. package/dist/sync-loop.js.map +1 -0
  137. package/dist/utils/id.d.ts +8 -0
  138. package/dist/utils/id.d.ts.map +1 -0
  139. package/dist/utils/id.js +19 -0
  140. package/dist/utils/id.js.map +1 -0
  141. package/package.json +59 -0
  142. package/src/blobs/index.ts +7 -0
  143. package/src/blobs/manager.ts +1027 -0
  144. package/src/blobs/migrate.ts +67 -0
  145. package/src/blobs/types.ts +84 -0
  146. package/src/client.test.ts +369 -0
  147. package/src/client.ts +1288 -0
  148. package/src/conflicts.ts +171 -0
  149. package/src/create-client.ts +297 -0
  150. package/src/engine/SyncEngine.test.ts +157 -0
  151. package/src/engine/SyncEngine.ts +1464 -0
  152. package/src/engine/index.ts +6 -0
  153. package/src/engine/types.ts +268 -0
  154. package/src/handlers/create-handler.ts +298 -0
  155. package/src/handlers/registry.ts +36 -0
  156. package/src/handlers/types.ts +102 -0
  157. package/src/index.ts +25 -0
  158. package/src/migrate.ts +122 -0
  159. package/src/mutations.ts +912 -0
  160. package/src/outbox.ts +383 -0
  161. package/src/plugins/incrementing-version.ts +133 -0
  162. package/src/plugins/index.ts +2 -0
  163. package/src/plugins/types.ts +63 -0
  164. package/src/proxy/connection.ts +191 -0
  165. package/src/proxy/dialect.ts +76 -0
  166. package/src/proxy/driver.ts +126 -0
  167. package/src/proxy/index.ts +10 -0
  168. package/src/proxy/mutations.ts +18 -0
  169. package/src/pull-engine.ts +508 -0
  170. package/src/push-engine.ts +201 -0
  171. package/src/query/FingerprintCollector.ts +29 -0
  172. package/src/query/QueryContext.ts +54 -0
  173. package/src/query/fingerprint.ts +109 -0
  174. package/src/query/index.ts +10 -0
  175. package/src/query/tracked-select.ts +139 -0
  176. package/src/schema.ts +94 -0
  177. package/src/sync-loop.ts +368 -0
  178. package/src/utils/id.ts +20 -0
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @syncular/client - Blob storage migrations
3
+ */
4
+
5
+ import type { Kysely } from 'kysely';
6
+
7
+ /**
8
+ * Ensures the client blob schema exists in the database.
9
+ * Safe to call multiple times (idempotent).
10
+ */
11
+ export async function ensureClientBlobSchema<DB>(
12
+ db: Kysely<DB>
13
+ ): Promise<void> {
14
+ // Blob cache table
15
+ await db.schema
16
+ .createTable('sync_blob_cache')
17
+ .ifNotExists()
18
+ .addColumn('hash', 'text', (col) => col.primaryKey())
19
+ .addColumn('size', 'integer', (col) => col.notNull())
20
+ .addColumn('mime_type', 'text', (col) => col.notNull())
21
+ .addColumn('body', 'blob', (col) => col.notNull())
22
+ .addColumn('encrypted', 'integer', (col) => col.notNull().defaultTo(0))
23
+ .addColumn('key_id', 'text')
24
+ .addColumn('cached_at', 'bigint', (col) => col.notNull())
25
+ .addColumn('last_accessed_at', 'bigint', (col) => col.notNull())
26
+ .execute();
27
+
28
+ await db.schema
29
+ .createIndex('idx_sync_blob_cache_last_accessed')
30
+ .ifNotExists()
31
+ .on('sync_blob_cache')
32
+ .columns(['last_accessed_at'])
33
+ .execute();
34
+
35
+ // Blob upload outbox table
36
+ await db.schema
37
+ .createTable('sync_blob_outbox')
38
+ .ifNotExists()
39
+ .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
40
+ .addColumn('hash', 'text', (col) => col.notNull().unique())
41
+ .addColumn('size', 'integer', (col) => col.notNull())
42
+ .addColumn('mime_type', 'text', (col) => col.notNull())
43
+ .addColumn('body', 'blob', (col) => col.notNull())
44
+ .addColumn('encrypted', 'integer', (col) => col.notNull().defaultTo(0))
45
+ .addColumn('key_id', 'text')
46
+ .addColumn('status', 'text', (col) => col.notNull())
47
+ .addColumn('attempt_count', 'integer', (col) => col.notNull().defaultTo(0))
48
+ .addColumn('error', 'text')
49
+ .addColumn('created_at', 'bigint', (col) => col.notNull())
50
+ .addColumn('updated_at', 'bigint', (col) => col.notNull())
51
+ .execute();
52
+
53
+ await db.schema
54
+ .createIndex('idx_sync_blob_outbox_status')
55
+ .ifNotExists()
56
+ .on('sync_blob_outbox')
57
+ .columns(['status', 'created_at'])
58
+ .execute();
59
+ }
60
+
61
+ /**
62
+ * Drops the client blob schema from the database.
63
+ */
64
+ export async function dropClientBlobSchema<DB>(db: Kysely<DB>): Promise<void> {
65
+ await db.schema.dropTable('sync_blob_outbox').ifExists().execute();
66
+ await db.schema.dropTable('sync_blob_cache').ifExists().execute();
67
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @syncular/client - Blob storage types
3
+ */
4
+
5
+ import type { Generated } from 'kysely';
6
+
7
+ // ============================================================================
8
+ // Blob Cache Table
9
+ // ============================================================================
10
+
11
+ /**
12
+ * Local blob cache table.
13
+ * Stores downloaded blobs for offline access.
14
+ */
15
+ export interface SyncBlobCacheTable {
16
+ /** SHA-256 hash with prefix: "sha256:<hex>" */
17
+ hash: string;
18
+ /** Size in bytes */
19
+ size: number;
20
+ /** MIME type */
21
+ mime_type: string;
22
+ /** Blob content */
23
+ body: Uint8Array;
24
+ /** Whether the blob is encrypted */
25
+ encrypted: number; // SQLite boolean
26
+ /** Encryption key ID (if encrypted) */
27
+ key_id: string | null;
28
+ /** When the blob was cached */
29
+ cached_at: number;
30
+ /** Last accessed timestamp (for LRU eviction) */
31
+ last_accessed_at: number;
32
+ }
33
+
34
+ // ============================================================================
35
+ // Blob Upload Outbox Table
36
+ // ============================================================================
37
+
38
+ export type BlobUploadStatus =
39
+ | 'pending'
40
+ | 'uploading'
41
+ | 'uploaded'
42
+ | 'confirming'
43
+ | 'complete'
44
+ | 'failed';
45
+
46
+ /**
47
+ * Blob upload outbox table.
48
+ * Tracks pending blob uploads for offline support.
49
+ */
50
+ export interface SyncBlobOutboxTable {
51
+ /** Local row id */
52
+ id: Generated<number>;
53
+ /** SHA-256 hash with prefix: "sha256:<hex>" */
54
+ hash: string;
55
+ /** Size in bytes */
56
+ size: number;
57
+ /** MIME type */
58
+ mime_type: string;
59
+ /** Blob content (stored locally until upload completes) */
60
+ body: Uint8Array;
61
+ /** Whether the blob is encrypted */
62
+ encrypted: number; // SQLite boolean
63
+ /** Encryption key ID (if encrypted) */
64
+ key_id: string | null;
65
+ /** Upload status */
66
+ status: BlobUploadStatus;
67
+ /** Upload attempt count */
68
+ attempt_count: number;
69
+ /** Last error message */
70
+ error: string | null;
71
+ /** When the upload was queued */
72
+ created_at: number;
73
+ /** When the upload was last updated */
74
+ updated_at: number;
75
+ }
76
+
77
+ // ============================================================================
78
+ // Database Interface
79
+ // ============================================================================
80
+
81
+ export interface SyncBlobClientDb {
82
+ sync_blob_cache: SyncBlobCacheTable;
83
+ sync_blob_outbox: SyncBlobOutboxTable;
84
+ }
@@ -0,0 +1,369 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import type { SyncTransport } from '@syncular/core';
3
+ import type { Kysely } from 'kysely';
4
+ import { sql } from 'kysely';
5
+ import { createBunSqliteDb } from '../../dialect-bun-sqlite/src';
6
+ import { ensureClientBlobSchema } from './blobs/migrate';
7
+ import { Client, type ClientBlobStorage } from './client';
8
+ import { SyncEngine } from './engine/SyncEngine';
9
+ import { ClientTableRegistry } from './handlers/registry';
10
+ import { ensureClientSyncSchema } from './migrate';
11
+ import type { SyncClientDb } from './schema';
12
+
13
+ interface TasksTable {
14
+ id: string;
15
+ title: string;
16
+ server_version: number;
17
+ }
18
+
19
+ interface TestDb extends SyncClientDb {
20
+ tasks: TasksTable;
21
+ }
22
+
23
+ const noopTransport: SyncTransport = {
24
+ async sync() {
25
+ return {};
26
+ },
27
+ async fetchSnapshotChunk() {
28
+ return new Uint8Array();
29
+ },
30
+ };
31
+
32
+ function createMemoryBlobStorage(): ClientBlobStorage {
33
+ const memory = new Map<string, Uint8Array>();
34
+ return {
35
+ async write(hash, data) {
36
+ if (data instanceof ReadableStream) {
37
+ const reader = data.getReader();
38
+ const chunks: Uint8Array[] = [];
39
+ let total = 0;
40
+ while (true) {
41
+ const chunk = await reader.read();
42
+ if (chunk.done) break;
43
+ chunks.push(chunk.value);
44
+ total += chunk.value.length;
45
+ }
46
+ const combined = new Uint8Array(total);
47
+ let offset = 0;
48
+ for (const chunk of chunks) {
49
+ combined.set(chunk, offset);
50
+ offset += chunk.length;
51
+ }
52
+ memory.set(hash, combined);
53
+ return;
54
+ }
55
+ memory.set(hash, new Uint8Array(data));
56
+ },
57
+ async read(hash) {
58
+ const data = memory.get(hash);
59
+ return data ? new Uint8Array(data) : null;
60
+ },
61
+ async delete(hash) {
62
+ memory.delete(hash);
63
+ },
64
+ async exists(hash) {
65
+ return memory.has(hash);
66
+ },
67
+ };
68
+ }
69
+
70
+ describe('Client conflict events', () => {
71
+ let db: Kysely<TestDb>;
72
+ let client: Client<TestDb>;
73
+ let engine: SyncEngine<TestDb>;
74
+
75
+ async function seedConflict(id: string): Promise<void> {
76
+ const now = Date.now();
77
+ await sql`
78
+ insert into ${sql.table('sync_outbox_commits')} (
79
+ ${sql.ref('id')},
80
+ ${sql.ref('client_commit_id')},
81
+ ${sql.ref('status')},
82
+ ${sql.ref('operations_json')},
83
+ ${sql.ref('last_response_json')},
84
+ ${sql.ref('error')},
85
+ ${sql.ref('created_at')},
86
+ ${sql.ref('updated_at')},
87
+ ${sql.ref('attempt_count')},
88
+ ${sql.ref('acked_commit_seq')},
89
+ ${sql.ref('schema_version')}
90
+ ) values (
91
+ ${'outbox-1'},
92
+ ${'commit-1'},
93
+ ${'failed'},
94
+ ${JSON.stringify([
95
+ {
96
+ table: 'tasks',
97
+ row_id: 't1',
98
+ op: 'upsert',
99
+ payload: { id: 't1', title: 'local', server_version: 1 },
100
+ },
101
+ ])},
102
+ ${null},
103
+ ${'conflict'},
104
+ ${now},
105
+ ${now},
106
+ ${1},
107
+ ${null},
108
+ ${1}
109
+ )
110
+ `.execute(db);
111
+
112
+ await sql`
113
+ insert into ${sql.table('sync_conflicts')} (
114
+ ${sql.ref('id')},
115
+ ${sql.ref('outbox_commit_id')},
116
+ ${sql.ref('client_commit_id')},
117
+ ${sql.ref('op_index')},
118
+ ${sql.ref('result_status')},
119
+ ${sql.ref('message')},
120
+ ${sql.ref('code')},
121
+ ${sql.ref('server_version')},
122
+ ${sql.ref('server_row_json')},
123
+ ${sql.ref('created_at')},
124
+ ${sql.ref('resolved_at')},
125
+ ${sql.ref('resolution')}
126
+ ) values (
127
+ ${id},
128
+ ${'outbox-1'},
129
+ ${'commit-1'},
130
+ ${0},
131
+ ${'conflict'},
132
+ ${'server conflict'},
133
+ ${'CONFLICT'},
134
+ ${2},
135
+ ${JSON.stringify({ id: 't1', title: 'server', server_version: 2 })},
136
+ ${now},
137
+ ${null},
138
+ ${null}
139
+ )
140
+ `.execute(db);
141
+ }
142
+
143
+ async function runConflictCheck(
144
+ clientInstance: Client<TestDb>
145
+ ): Promise<void> {
146
+ const checker = Reflect.get(clientInstance, 'checkForNewConflicts');
147
+ if (typeof checker !== 'function') {
148
+ throw new Error('Expected checkForNewConflicts to be callable');
149
+ }
150
+ await checker.call(clientInstance);
151
+ }
152
+
153
+ beforeEach(async () => {
154
+ db = createBunSqliteDb<TestDb>({ path: ':memory:' });
155
+ await ensureClientSyncSchema(db);
156
+ await db.schema
157
+ .createTable('tasks')
158
+ .addColumn('id', 'text', (col) => col.primaryKey())
159
+ .addColumn('title', 'text', (col) => col.notNull())
160
+ .addColumn('server_version', 'integer', (col) =>
161
+ col.notNull().defaultTo(0)
162
+ )
163
+ .execute();
164
+
165
+ const handlers = new ClientTableRegistry<TestDb>();
166
+ client = new Client<TestDb>({
167
+ db,
168
+ transport: noopTransport,
169
+ tableHandlers: handlers,
170
+ clientId: 'client-1',
171
+ actorId: 'u1',
172
+ subscriptions: [],
173
+ });
174
+
175
+ engine = new SyncEngine<TestDb>({
176
+ db,
177
+ transport: noopTransport,
178
+ shapes: handlers,
179
+ actorId: 'u1',
180
+ clientId: 'client-1',
181
+ subscriptions: [],
182
+ });
183
+ Reflect.set(client, 'engine', engine);
184
+ });
185
+
186
+ afterEach(async () => {
187
+ client.destroy();
188
+ await db.destroy();
189
+ });
190
+
191
+ it('emits conflict:resolved with the resolved conflict payload', async () => {
192
+ await seedConflict('conflict-1');
193
+
194
+ const resolvedEvents: Array<{ id: string }> = [];
195
+ client.on('conflict:resolved', (conflict) => {
196
+ resolvedEvents.push({ id: conflict.id });
197
+ });
198
+
199
+ await client.resolveConflict('conflict-1', { strategy: 'keep-local' });
200
+
201
+ expect(resolvedEvents).toEqual([{ id: 'conflict-1' }]);
202
+
203
+ const resolvedRow = await sql<{ resolved_at: number | null }>`
204
+ select ${sql.ref('resolved_at')}
205
+ from ${sql.table('sync_conflicts')}
206
+ where ${sql.ref('id')} = ${'conflict-1'}
207
+ limit 1
208
+ `.execute(db);
209
+ expect(resolvedRow.rows[0]?.resolved_at).not.toBeNull();
210
+ });
211
+
212
+ it('emits conflict:new only once per unresolved conflict id', async () => {
213
+ await seedConflict('conflict-1');
214
+
215
+ const newEvents: string[] = [];
216
+ client.on('conflict:new', (conflict) => {
217
+ newEvents.push(conflict.id);
218
+ });
219
+
220
+ await runConflictCheck(client);
221
+ await runConflictCheck(client);
222
+
223
+ expect(newEvents).toEqual(['conflict-1']);
224
+ });
225
+ });
226
+
227
+ describe('Client blob upload queue recovery', () => {
228
+ let db: Kysely<TestDb>;
229
+ let client: Client<TestDb>;
230
+ let initiateCalls = 0;
231
+
232
+ async function insertBlobOutboxRow(input: {
233
+ hash: string;
234
+ status: string;
235
+ attemptCount: number;
236
+ updatedAt: number;
237
+ }): Promise<void> {
238
+ const now = Date.now();
239
+ await sql`
240
+ insert into ${sql.table('sync_blob_outbox')} (
241
+ ${sql.join([
242
+ sql.ref('hash'),
243
+ sql.ref('size'),
244
+ sql.ref('mime_type'),
245
+ sql.ref('body'),
246
+ sql.ref('encrypted'),
247
+ sql.ref('key_id'),
248
+ sql.ref('status'),
249
+ sql.ref('attempt_count'),
250
+ sql.ref('error'),
251
+ sql.ref('created_at'),
252
+ sql.ref('updated_at'),
253
+ ])}
254
+ ) values (
255
+ ${sql.join([
256
+ sql.val(input.hash),
257
+ sql.val(3),
258
+ sql.val('application/octet-stream'),
259
+ sql.val(new Uint8Array([1, 2, 3])),
260
+ sql.val(0),
261
+ sql.val(null),
262
+ sql.val(input.status),
263
+ sql.val(input.attemptCount),
264
+ sql.val(null),
265
+ sql.val(now),
266
+ sql.val(input.updatedAt),
267
+ ])}
268
+ )
269
+ `.execute(db);
270
+ }
271
+
272
+ beforeEach(async () => {
273
+ db = createBunSqliteDb<TestDb>({ path: ':memory:' });
274
+ await ensureClientSyncSchema(db);
275
+ await ensureClientBlobSchema(db);
276
+ initiateCalls = 0;
277
+
278
+ const handlers = new ClientTableRegistry<TestDb>();
279
+ const transport: SyncTransport = {
280
+ ...noopTransport,
281
+ blobs: {
282
+ async initiateUpload() {
283
+ initiateCalls++;
284
+ return { exists: true };
285
+ },
286
+ async completeUpload() {
287
+ return { ok: true };
288
+ },
289
+ async getDownloadUrl() {
290
+ return {
291
+ url: 'https://example.invalid/blob',
292
+ expiresAt: new Date(0).toISOString(),
293
+ };
294
+ },
295
+ },
296
+ };
297
+
298
+ client = new Client<TestDb>({
299
+ db,
300
+ transport,
301
+ tableHandlers: handlers,
302
+ blobStorage: createMemoryBlobStorage(),
303
+ clientId: 'client-1',
304
+ actorId: 'u1',
305
+ subscriptions: [],
306
+ });
307
+ });
308
+
309
+ afterEach(async () => {
310
+ client.destroy();
311
+ await db.destroy();
312
+ });
313
+
314
+ it('requeues stale uploading rows and uploads them on the next queue run', async () => {
315
+ await insertBlobOutboxRow({
316
+ hash: 'sha256:stale-upload',
317
+ status: 'uploading',
318
+ attemptCount: 0,
319
+ updatedAt: Date.now() - 31_000,
320
+ });
321
+
322
+ const result = await client.blobs!.processUploadQueue();
323
+
324
+ expect(result.uploaded).toBe(1);
325
+ expect(result.failed).toBe(0);
326
+ expect(initiateCalls).toBe(1);
327
+
328
+ const remaining = await sql<{ count: number | bigint }>`
329
+ select count(${sql.ref('hash')}) as count
330
+ from ${sql.table('sync_blob_outbox')}
331
+ where ${sql.ref('hash')} = ${'sha256:stale-upload'}
332
+ `.execute(db);
333
+ expect(Number(remaining.rows[0]?.count ?? 0)).toBe(0);
334
+ });
335
+
336
+ it('marks stale uploading rows as failed after max retries', async () => {
337
+ await insertBlobOutboxRow({
338
+ hash: 'sha256:stale-failed',
339
+ status: 'uploading',
340
+ attemptCount: 2,
341
+ updatedAt: Date.now() - 31_000,
342
+ });
343
+
344
+ await client.blobs!.processUploadQueue();
345
+
346
+ expect(initiateCalls).toBe(0);
347
+
348
+ const rowResult = await sql<{
349
+ status: string;
350
+ attempt_count: number;
351
+ error: string | null;
352
+ }>`
353
+ select
354
+ ${sql.ref('status')} as status,
355
+ ${sql.ref('attempt_count')} as attempt_count,
356
+ ${sql.ref('error')} as error
357
+ from ${sql.table('sync_blob_outbox')}
358
+ where ${sql.ref('hash')} = ${'sha256:stale-failed'}
359
+ limit 1
360
+ `.execute(db);
361
+ const row = rowResult.rows[0];
362
+ if (!row) {
363
+ throw new Error('Expected stale failed row to remain in outbox');
364
+ }
365
+ expect(row.status).toBe('failed');
366
+ expect(row.attempt_count).toBe(3);
367
+ expect(row.error).toContain('Upload timed out while in uploading state');
368
+ });
369
+ });