@syncular/core 0.0.6-96 → 0.1.1

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 (156) hide show
  1. package/dist/blobs.d.ts +14 -7
  2. package/dist/blobs.d.ts.map +1 -1
  3. package/dist/blobs.js.map +1 -1
  4. package/dist/column-codecs.d.ts +3 -0
  5. package/dist/column-codecs.d.ts.map +1 -1
  6. package/dist/column-codecs.js +32 -4
  7. package/dist/column-codecs.js.map +1 -1
  8. package/dist/conflict.js.map +1 -1
  9. package/dist/database.js.map +1 -1
  10. package/dist/error-responses.d.ts +393 -0
  11. package/dist/error-responses.d.ts.map +1 -0
  12. package/dist/error-responses.js +393 -0
  13. package/dist/error-responses.js.map +1 -0
  14. package/dist/http/api-client.d.ts +11 -0
  15. package/dist/http/api-client.d.ts.map +1 -0
  16. package/dist/http/api-client.js +30 -0
  17. package/dist/http/api-client.js.map +1 -0
  18. package/dist/http/blob.d.ts +6 -0
  19. package/dist/http/blob.d.ts.map +1 -0
  20. package/dist/http/blob.js +46 -0
  21. package/dist/http/blob.js.map +1 -0
  22. package/dist/http/generated/api.d.ts +5241 -0
  23. package/dist/http/generated/api.d.ts.map +1 -0
  24. package/dist/http/generated/api.js +6 -0
  25. package/dist/http/generated/api.js.map +1 -0
  26. package/dist/http/index.d.ts +18 -0
  27. package/dist/http/index.d.ts.map +1 -0
  28. package/dist/http/index.js +212 -0
  29. package/dist/http/index.js.map +1 -0
  30. package/dist/http/shared.d.ts +45 -0
  31. package/dist/http/shared.d.ts.map +1 -0
  32. package/dist/http/shared.js +122 -0
  33. package/dist/http/shared.js.map +1 -0
  34. package/dist/http/transport-client.d.ts +31 -0
  35. package/dist/http/transport-client.d.ts.map +1 -0
  36. package/dist/http/transport-client.js +193 -0
  37. package/dist/http/transport-client.js.map +1 -0
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/kysely-column-codecs.d.ts +17 -1
  43. package/dist/kysely-column-codecs.d.ts.map +1 -1
  44. package/dist/kysely-column-codecs.js +56 -70
  45. package/dist/kysely-column-codecs.js.map +1 -1
  46. package/dist/logger.js.map +1 -1
  47. package/dist/schemas/blobs.d.ts +7 -1
  48. package/dist/schemas/blobs.d.ts.map +1 -1
  49. package/dist/schemas/blobs.js +1 -0
  50. package/dist/schemas/blobs.js.map +1 -1
  51. package/dist/schemas/common.d.ts +73 -1
  52. package/dist/schemas/common.d.ts.map +1 -1
  53. package/dist/schemas/common.js +38 -0
  54. package/dist/schemas/common.js.map +1 -1
  55. package/dist/schemas/sync.d.ts +712 -38
  56. package/dist/schemas/sync.d.ts.map +1 -1
  57. package/dist/schemas/sync.js +209 -5
  58. package/dist/schemas/sync.js.map +1 -1
  59. package/dist/scopes/index.d.ts +5 -0
  60. package/dist/scopes/index.d.ts.map +1 -1
  61. package/dist/scopes/index.js +27 -0
  62. package/dist/scopes/index.js.map +1 -1
  63. package/dist/sentry.d.ts +31 -0
  64. package/dist/sentry.d.ts.map +1 -0
  65. package/dist/sentry.js +147 -0
  66. package/dist/sentry.js.map +1 -0
  67. package/dist/snapshot-chunks.d.ts +104 -20
  68. package/dist/snapshot-chunks.d.ts.map +1 -1
  69. package/dist/snapshot-chunks.js +665 -72
  70. package/dist/snapshot-chunks.js.map +1 -1
  71. package/dist/sync-packs.d.ts +33 -0
  72. package/dist/sync-packs.d.ts.map +1 -0
  73. package/dist/sync-packs.js +821 -0
  74. package/dist/sync-packs.js.map +1 -0
  75. package/dist/telemetry.d.ts.map +1 -1
  76. package/dist/telemetry.js +3 -2
  77. package/dist/telemetry.js.map +1 -1
  78. package/dist/transforms.js.map +1 -1
  79. package/dist/types.d.ts +48 -7
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/types.js.map +1 -1
  82. package/dist/utils/bytes.d.ts +4 -0
  83. package/dist/utils/bytes.d.ts.map +1 -0
  84. package/dist/utils/bytes.js +66 -0
  85. package/dist/utils/bytes.js.map +1 -0
  86. package/dist/utils/compression.d.ts +11 -3
  87. package/dist/utils/compression.d.ts.map +1 -1
  88. package/dist/utils/compression.js +102 -50
  89. package/dist/utils/compression.js.map +1 -1
  90. package/dist/utils/crypto.d.ts +7 -1
  91. package/dist/utils/crypto.d.ts.map +1 -1
  92. package/dist/utils/crypto.js +89 -7
  93. package/dist/utils/crypto.js.map +1 -1
  94. package/dist/utils/id.d.ts.map +1 -1
  95. package/dist/utils/id.js +4 -1
  96. package/dist/utils/id.js.map +1 -1
  97. package/dist/utils/index.d.ts +5 -0
  98. package/dist/utils/index.d.ts.map +1 -1
  99. package/dist/utils/index.js +5 -0
  100. package/dist/utils/index.js.map +1 -1
  101. package/dist/utils/internal-runtime.d.ts +16 -0
  102. package/dist/utils/internal-runtime.d.ts.map +1 -0
  103. package/dist/utils/internal-runtime.js +14 -0
  104. package/dist/utils/internal-runtime.js.map +1 -0
  105. package/dist/utils/object.js.map +1 -1
  106. package/dist/utils/realtime-change-index.d.ts +9 -0
  107. package/dist/utils/realtime-change-index.d.ts.map +1 -0
  108. package/dist/utils/realtime-change-index.js +36 -0
  109. package/dist/utils/realtime-change-index.js.map +1 -0
  110. package/dist/utils/realtime-connection-registry.d.ts +40 -0
  111. package/dist/utils/realtime-connection-registry.d.ts.map +1 -0
  112. package/dist/utils/realtime-connection-registry.js +237 -0
  113. package/dist/utils/realtime-connection-registry.js.map +1 -0
  114. package/dist/utils/table-registry.d.ts +10 -0
  115. package/dist/utils/table-registry.d.ts.map +1 -0
  116. package/dist/utils/table-registry.js +54 -0
  117. package/dist/utils/table-registry.js.map +1 -0
  118. package/dist/utils/url.d.ts +3 -0
  119. package/dist/utils/url.d.ts.map +1 -0
  120. package/dist/utils/url.js +20 -0
  121. package/dist/utils/url.js.map +1 -0
  122. package/package.json +51 -6
  123. package/src/blobs.ts +24 -7
  124. package/src/column-codecs.ts +36 -8
  125. package/src/error-responses.ts +423 -0
  126. package/src/http/api-client.ts +39 -0
  127. package/src/http/blob.ts +95 -0
  128. package/src/http/generated/api.ts +5241 -0
  129. package/src/http/index.ts +365 -0
  130. package/src/http/shared.ts +220 -0
  131. package/src/http/transport-client.ts +314 -0
  132. package/src/index.ts +4 -0
  133. package/src/kysely-column-codecs.ts +81 -86
  134. package/src/schemas/blobs.ts +10 -1
  135. package/src/schemas/common.ts +44 -0
  136. package/src/schemas/sync.ts +298 -7
  137. package/src/scopes/index.ts +42 -0
  138. package/src/sentry.ts +200 -0
  139. package/src/snapshot-chunks.ts +921 -69
  140. package/src/sync-packs.ts +1096 -0
  141. package/src/telemetry.ts +7 -2
  142. package/src/types.ts +63 -8
  143. package/src/utils/bytes.ts +75 -0
  144. package/src/utils/compression.ts +147 -51
  145. package/src/utils/crypto.ts +120 -7
  146. package/src/utils/id.ts +11 -1
  147. package/src/utils/index.ts +5 -0
  148. package/src/utils/internal-runtime.ts +39 -0
  149. package/src/utils/realtime-change-index.ts +44 -0
  150. package/src/utils/realtime-connection-registry.ts +286 -0
  151. package/src/utils/table-registry.ts +67 -0
  152. package/src/utils/url.ts +29 -0
  153. package/src/__tests__/compression.test.ts +0 -54
  154. package/src/__tests__/conflict.test.ts +0 -325
  155. package/src/__tests__/telemetry.test.ts +0 -170
  156. package/src/__tests__/utils.test.ts +0 -27
@@ -0,0 +1,314 @@
1
+ import type {
2
+ SyncAuthErrorContext,
3
+ SyncAuthLifecycle,
4
+ SyncCombinedRequest,
5
+ SyncCombinedResponse,
6
+ } from '@syncular/core';
7
+ import {
8
+ decodeBinarySyncPack,
9
+ isBinarySyncPackContentType,
10
+ } from '@syncular/core';
11
+ import type { SyncClient } from './api-client';
12
+ import {
13
+ type ApiResult,
14
+ type ClientOptions,
15
+ encodeSnapshotScopes,
16
+ type ResolveAuthRetry,
17
+ resolveRequestUrl,
18
+ SNAPSHOT_SCOPES_HEADER,
19
+ } from './shared';
20
+
21
+ export type { ClientOptions };
22
+
23
+ export const HTTP_TRANSPORT_SOURCE = Symbol.for('@syncular/core/http/source');
24
+
25
+ export type HttpTransportSource = SyncClient | ClientOptions;
26
+
27
+ function isApiClientLike(value: unknown): value is SyncClient {
28
+ return (
29
+ typeof value === 'object' &&
30
+ value !== null &&
31
+ typeof (value as SyncClient).GET === 'function' &&
32
+ typeof (value as SyncClient).POST === 'function'
33
+ );
34
+ }
35
+
36
+ interface TransportApiClient {
37
+ sync(
38
+ request: SyncCombinedRequest,
39
+ signal?: AbortSignal
40
+ ): Promise<ApiResult<SyncCombinedResponse>>;
41
+ initiateUpload(
42
+ args: { hash: string; size: number; mimeType: string },
43
+ signal?: AbortSignal
44
+ ): Promise<
45
+ ApiResult<{
46
+ exists: boolean;
47
+ uploadUrl?: string;
48
+ uploadMethod?: 'PUT' | 'POST';
49
+ uploadHeaders?: Record<string, string>;
50
+ }>
51
+ >;
52
+ completeUpload(
53
+ hash: string,
54
+ signal?: AbortSignal
55
+ ): Promise<ApiResult<{ ok: boolean; error?: string }>>;
56
+ getDownloadUrl(
57
+ hash: string,
58
+ signal?: AbortSignal
59
+ ): Promise<ApiResult<{ url: string; expiresAt: string }>>;
60
+ getSnapshotChunk(
61
+ chunkId: string,
62
+ scopeValues: Record<string, string | string[]> | undefined,
63
+ signal?: AbortSignal
64
+ ): Promise<ApiResult<Blob>>;
65
+ }
66
+
67
+ function isJsonContentType(contentType: string | null): boolean {
68
+ return contentType?.includes('application/json') === true;
69
+ }
70
+
71
+ async function parseErrorBody(response: Response): Promise<unknown> {
72
+ if (!isJsonContentType(response.headers.get('content-type'))) {
73
+ return undefined;
74
+ }
75
+ try {
76
+ return (await response.json()) as unknown;
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ async function parseJsonBody<T>(response: Response): Promise<T | undefined> {
83
+ if (!isJsonContentType(response.headers.get('content-type'))) {
84
+ return undefined;
85
+ }
86
+ try {
87
+ return (await response.json()) as T;
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ function createAuthRetryResolver(
94
+ defaultAuthLifecycle: SyncAuthLifecycle | undefined
95
+ ): ResolveAuthRetry {
96
+ let refreshInFlight: Promise<boolean> | null = null;
97
+
98
+ const runRefreshSingleFlight = async (
99
+ lifecycle: SyncAuthLifecycle,
100
+ context: SyncAuthErrorContext
101
+ ): Promise<boolean> => {
102
+ if (!lifecycle.refreshToken) return false;
103
+
104
+ if (!refreshInFlight) {
105
+ refreshInFlight = Promise.resolve(lifecycle.refreshToken(context))
106
+ .then((result) => Boolean(result))
107
+ .finally(() => {
108
+ refreshInFlight = null;
109
+ });
110
+ }
111
+
112
+ return refreshInFlight;
113
+ };
114
+
115
+ return async (context, options) => {
116
+ const lifecycle = options?.authLifecycle ?? defaultAuthLifecycle;
117
+ if (!lifecycle) return false;
118
+
119
+ await lifecycle.onAuthExpired?.(context);
120
+
121
+ const refreshResult = await runRefreshSingleFlight(lifecycle, context);
122
+ if (lifecycle.retryWithFreshToken) {
123
+ return Boolean(
124
+ await lifecycle.retryWithFreshToken({ ...context, refreshResult })
125
+ );
126
+ }
127
+ return refreshResult;
128
+ };
129
+ }
130
+
131
+ function createRequestHeaders(
132
+ baseOptions: ClientOptions,
133
+ extraHeaders?: Record<string, string>
134
+ ): Promise<Headers> {
135
+ return Promise.resolve(baseOptions.getHeaders?.()).then((dynamicHeaders) => {
136
+ const headers = new Headers(extraHeaders);
137
+ if (dynamicHeaders) {
138
+ for (const [key, value] of Object.entries(dynamicHeaders)) {
139
+ headers.set(key, value);
140
+ }
141
+ }
142
+ if (!headers.has('x-syncular-transport-path')) {
143
+ headers.set(
144
+ 'x-syncular-transport-path',
145
+ baseOptions.transportPath ?? 'direct'
146
+ );
147
+ }
148
+ return headers;
149
+ });
150
+ }
151
+
152
+ function createFetchApiClient(baseOptions: ClientOptions): TransportApiClient {
153
+ const fetchImpl = baseOptions.fetch ?? globalThis.fetch;
154
+
155
+ const request = async <T>(args: {
156
+ method: 'GET' | 'POST';
157
+ path: string;
158
+ body?: unknown;
159
+ headers?: Record<string, string>;
160
+ signal?: AbortSignal;
161
+ parseAs?: 'json' | 'blob';
162
+ }): Promise<ApiResult<T>> => {
163
+ const headers = await createRequestHeaders(baseOptions, args.headers);
164
+ let body: BodyInit | undefined;
165
+ if (args.body !== undefined) {
166
+ headers.set('content-type', 'application/json');
167
+ body = JSON.stringify(args.body);
168
+ }
169
+
170
+ const response = await fetchImpl(
171
+ resolveRequestUrl(baseOptions.baseUrl, args.path),
172
+ {
173
+ method: args.method,
174
+ headers,
175
+ body,
176
+ ...(args.signal ? { signal: args.signal } : {}),
177
+ }
178
+ );
179
+
180
+ if (!response.ok) {
181
+ return {
182
+ response,
183
+ error: await parseErrorBody(response),
184
+ };
185
+ }
186
+
187
+ if (args.parseAs === 'blob') {
188
+ return {
189
+ response,
190
+ data: (await response.blob()) as T,
191
+ };
192
+ }
193
+
194
+ if (isBinarySyncPackContentType(response.headers.get('content-type'))) {
195
+ return {
196
+ response,
197
+ data: decodeBinarySyncPack(
198
+ new Uint8Array(await response.arrayBuffer())
199
+ ) as T,
200
+ };
201
+ }
202
+
203
+ return {
204
+ response,
205
+ data: await parseJsonBody<T>(response),
206
+ };
207
+ };
208
+
209
+ return {
210
+ sync: (requestBody, signal) =>
211
+ request({
212
+ method: 'POST',
213
+ path: '/sync',
214
+ body: requestBody,
215
+ signal,
216
+ }),
217
+ initiateUpload: (args, signal) =>
218
+ request({
219
+ method: 'POST',
220
+ path: '/sync/blobs/upload',
221
+ body: args,
222
+ signal,
223
+ }),
224
+ completeUpload: (hash, signal) =>
225
+ request({
226
+ method: 'POST',
227
+ path: `/sync/blobs/${encodeURIComponent(hash)}/complete`,
228
+ signal,
229
+ }),
230
+ getDownloadUrl: (hash, signal) =>
231
+ request({
232
+ method: 'GET',
233
+ path: `/sync/blobs/${encodeURIComponent(hash)}/url`,
234
+ signal,
235
+ }),
236
+ getSnapshotChunk: (chunkId, scopeValues, signal) =>
237
+ request({
238
+ method: 'GET',
239
+ path: `/sync/snapshot-chunks/${encodeURIComponent(chunkId)}`,
240
+ headers: (() => {
241
+ const encodedScopes = encodeSnapshotScopes(scopeValues);
242
+ return encodedScopes
243
+ ? {
244
+ [SNAPSHOT_SCOPES_HEADER]: encodedScopes,
245
+ }
246
+ : undefined;
247
+ })(),
248
+ signal,
249
+ parseAs: 'blob',
250
+ }),
251
+ };
252
+ }
253
+
254
+ function createTypedTransportClient(client: SyncClient): TransportApiClient {
255
+ return {
256
+ sync: (request, signal) =>
257
+ client.POST('/sync', {
258
+ body: request,
259
+ ...(signal ? { signal } : {}),
260
+ }) as Promise<ApiResult<SyncCombinedResponse>>,
261
+ initiateUpload: (args, signal) =>
262
+ client.POST('/sync/blobs/upload', {
263
+ body: args,
264
+ ...(signal ? { signal } : {}),
265
+ }) as Promise<
266
+ ApiResult<{
267
+ exists: boolean;
268
+ uploadUrl?: string;
269
+ uploadMethod?: 'PUT' | 'POST';
270
+ uploadHeaders?: Record<string, string>;
271
+ }>
272
+ >,
273
+ completeUpload: (hash, signal) =>
274
+ client.POST('/sync/blobs/{hash}/complete', {
275
+ params: { path: { hash } },
276
+ ...(signal ? { signal } : {}),
277
+ }) as Promise<ApiResult<{ ok: boolean; error?: string }>>,
278
+ getDownloadUrl: (hash, signal) =>
279
+ client.GET('/sync/blobs/{hash}/url', {
280
+ params: { path: { hash } },
281
+ ...(signal ? { signal } : {}),
282
+ }) as Promise<ApiResult<{ url: string; expiresAt: string }>>,
283
+ getSnapshotChunk: (chunkId, scopeValues, signal) =>
284
+ client.GET('/sync/snapshot-chunks/{chunkId}', {
285
+ params: { path: { chunkId } },
286
+ parseAs: 'blob',
287
+ headers: (() => {
288
+ const encodedScopes = encodeSnapshotScopes(scopeValues);
289
+ return encodedScopes
290
+ ? {
291
+ [SNAPSHOT_SCOPES_HEADER]: encodedScopes,
292
+ }
293
+ : undefined;
294
+ })(),
295
+ ...(signal ? { signal } : {}),
296
+ }) as Promise<ApiResult<Blob>>,
297
+ };
298
+ }
299
+
300
+ export function createTransportApiClient(
301
+ source: HttpTransportSource
302
+ ): TransportApiClient {
303
+ return isApiClientLike(source)
304
+ ? createTypedTransportClient(source)
305
+ : createFetchApiClient(source);
306
+ }
307
+
308
+ export function createTransportAuthRetryResolver(
309
+ source: HttpTransportSource
310
+ ): ResolveAuthRetry {
311
+ return createAuthRetryResolver(
312
+ isApiClientLike(source) ? undefined : source.authLifecycle
313
+ );
314
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,8 @@ export * from './conflict';
20
20
  export * from './database';
21
21
  // Database dialect descriptors
22
22
  export * from './dialect';
23
+ // Public error response envelopes
24
+ export * from './error-responses';
23
25
  // Kysely plugin for applying column codecs in generic queries
24
26
  export * from './kysely-column-codecs';
25
27
  // Logging utilities
@@ -32,6 +34,8 @@ export * from './schemas';
32
34
  export * from './scopes';
33
35
  // Snapshot chunk encoding helpers
34
36
  export * from './snapshot-chunks';
37
+ // Sync response pack encoding helpers
38
+ export * from './sync-packs';
35
39
  // Telemetry abstraction
36
40
  export * from './telemetry';
37
41
  // Data transformation hooks
@@ -28,8 +28,8 @@ import {
28
28
  applyCodecToDbValue,
29
29
  type ColumnCodecDialect,
30
30
  type ColumnCodecSource,
31
+ createTableColumnCodecsResolver,
31
32
  type TableColumnCodecs,
32
- toTableColumnCodecs,
33
33
  } from './column-codecs';
34
34
 
35
35
  interface ColumnCodecPluginOptions {
@@ -43,7 +43,7 @@ interface ColumnReference {
43
43
  column: string;
44
44
  }
45
45
 
46
- interface QueryResultPlan {
46
+ export interface QueryResultPlan {
47
47
  explicit: ColumnReference[];
48
48
  selectAllTables: string[];
49
49
  }
@@ -335,7 +335,9 @@ function planForDelete(node: DeleteQueryNode): QueryResultPlan | null {
335
335
  return collected;
336
336
  }
337
337
 
338
- function buildResultPlan(node: RootOperationNode): QueryResultPlan | null {
338
+ export function buildQueryResultPlan(
339
+ node: RootOperationNode
340
+ ): QueryResultPlan | null {
339
341
  if (node.kind === 'SelectQueryNode') return planForSelect(node);
340
342
  if (node.kind === 'InsertQueryNode') return planForInsert(node);
341
343
  if (node.kind === 'UpdateQueryNode') return planForUpdate(node);
@@ -343,35 +345,73 @@ function buildResultPlan(node: RootOperationNode): QueryResultPlan | null {
343
345
  return null;
344
346
  }
345
347
 
346
- function cacheKey(table: string, columns: readonly string[]): string {
347
- const sorted = [...columns].sort().join('\u0000');
348
- return `${table}\u0001${sorted}`;
348
+ export function applyQueryResultPlan(args: {
349
+ row: UnknownRow;
350
+ plan: QueryResultPlan;
351
+ resolveTableCodecs: (
352
+ table: string,
353
+ row: Record<string, unknown>
354
+ ) => TableColumnCodecs;
355
+ dialect: ColumnCodecDialect;
356
+ }): UnknownRow {
357
+ const { row, plan, resolveTableCodecs, dialect } = args;
358
+ const source = row as Record<string, unknown>;
359
+ const target: Record<string, unknown> = { ...source };
360
+
361
+ if (plan.selectAllTables.length > 0) {
362
+ const codecCandidates = new Map<string, AnyColumnCodec>();
363
+ const ambiguousColumns = new Set<string>();
364
+
365
+ for (const table of plan.selectAllTables) {
366
+ const tableCodecs = resolveTableCodecs(table, source);
367
+ for (const [column, codec] of Object.entries(tableCodecs)) {
368
+ if (!(column in source)) continue;
369
+ const existing = codecCandidates.get(column);
370
+ if (existing && existing !== codec) {
371
+ ambiguousColumns.add(column);
372
+ continue;
373
+ }
374
+ codecCandidates.set(column, codec);
375
+ }
376
+ }
377
+
378
+ for (const [column, codec] of codecCandidates.entries()) {
379
+ if (ambiguousColumns.has(column)) continue;
380
+ target[column] = applyCodecFromDbValue(codec, target[column], dialect);
381
+ }
382
+ }
383
+
384
+ for (const ref of plan.explicit) {
385
+ if (!(ref.outputKey in source)) continue;
386
+ const tableCodecs = resolveTableCodecs(ref.table, {
387
+ [ref.column]: source[ref.outputKey],
388
+ });
389
+ const codec = tableCodecs[ref.column];
390
+ if (!codec) continue;
391
+ target[ref.outputKey] = applyCodecFromDbValue(
392
+ codec,
393
+ target[ref.outputKey],
394
+ dialect
395
+ );
396
+ }
397
+
398
+ return target;
349
399
  }
350
400
 
351
401
  class ColumnCodecsTransformer extends OperationNodeTransformer {
352
- readonly #codecs: ColumnCodecSource;
353
402
  readonly #dialect: ColumnCodecDialect;
354
- readonly #tableCodecsCache = new Map<string, TableColumnCodecs>();
403
+ readonly #resolveTableCodecs: (
404
+ table: string,
405
+ row: Record<string, unknown>
406
+ ) => TableColumnCodecs;
355
407
  #currentUpdateTable: string | null = null;
356
408
 
357
409
  constructor(options: ColumnCodecPluginOptions) {
358
410
  super();
359
- this.#codecs = options.codecs;
360
411
  this.#dialect = options.dialect ?? 'sqlite';
361
- }
362
-
363
- resolveTableCodecs(
364
- table: string,
365
- columns: readonly string[]
366
- ): TableColumnCodecs {
367
- const key = cacheKey(table, columns);
368
- const cached = this.#tableCodecsCache.get(key);
369
- if (cached) return cached;
370
- const resolved = toTableColumnCodecs(table, this.#codecs, columns, {
412
+ this.#resolveTableCodecs = createTableColumnCodecsResolver(options.codecs, {
371
413
  dialect: this.#dialect,
372
414
  });
373
- this.#tableCodecsCache.set(key, resolved);
374
- return resolved;
375
415
  }
376
416
 
377
417
  protected override transformInsertQuery(
@@ -393,7 +433,10 @@ class ColumnCodecsTransformer extends OperationNodeTransformer {
393
433
  .filter((columnName): columnName is string => Boolean(columnName));
394
434
  if (columns.length === 0) return transformed;
395
435
 
396
- const tableCodecs = this.resolveTableCodecs(tableName, columns);
436
+ const tableCodecs = this.#resolveTableCodecs(
437
+ tableName,
438
+ Object.fromEntries(columns.map((column) => [column, null]))
439
+ );
397
440
 
398
441
  let didChange = false;
399
442
  const nextValueRows = transformed.values.values.map((valueNode) => {
@@ -456,9 +499,9 @@ class ColumnCodecsTransformer extends OperationNodeTransformer {
456
499
  const columnName = getColumnName(transformed.column);
457
500
  if (!columnName) return transformed;
458
501
 
459
- const tableCodecs = this.resolveTableCodecs(this.#currentUpdateTable, [
460
- columnName,
461
- ]);
502
+ const tableCodecs = this.#resolveTableCodecs(this.#currentUpdateTable, {
503
+ [columnName]: null,
504
+ });
462
505
  const codec = tableCodecs[columnName];
463
506
  if (!codec) return transformed;
464
507
 
@@ -485,12 +528,16 @@ export class ColumnCodecsPlugin implements KyselyPlugin {
485
528
  readonly #dialect: ColumnCodecDialect;
486
529
  readonly #transformer: ColumnCodecsTransformer;
487
530
  readonly #resultPlans = new WeakMap<object, QueryResultPlan>();
488
- readonly #codecs: ColumnCodecSource;
489
- readonly #resultCodecCache = new Map<string, TableColumnCodecs>();
531
+ readonly #resolveTableCodecs: (
532
+ table: string,
533
+ row: Record<string, unknown>
534
+ ) => TableColumnCodecs;
490
535
 
491
536
  constructor(options: ColumnCodecPluginOptions) {
492
537
  this.#dialect = options.dialect ?? 'sqlite';
493
- this.#codecs = options.codecs;
538
+ this.#resolveTableCodecs = createTableColumnCodecsResolver(options.codecs, {
539
+ dialect: this.#dialect,
540
+ });
494
541
  this.#transformer = new ColumnCodecsTransformer(options);
495
542
  }
496
543
 
@@ -499,7 +546,7 @@ export class ColumnCodecsPlugin implements KyselyPlugin {
499
546
  queryId,
500
547
  }: PluginTransformQueryArgs): RootOperationNode {
501
548
  const transformed = this.#transformer.transformNode(node);
502
- const plan = buildResultPlan(transformed);
549
+ const plan = buildQueryResultPlan(transformed);
503
550
  if (plan) {
504
551
  this.#resultPlans.set(queryId, plan);
505
552
  }
@@ -521,65 +568,13 @@ export class ColumnCodecsPlugin implements KyselyPlugin {
521
568
  };
522
569
  }
523
570
 
524
- #resolveTableCodecs(
525
- table: string,
526
- columns: readonly string[]
527
- ): TableColumnCodecs {
528
- const key = cacheKey(table, columns);
529
- const cached = this.#resultCodecCache.get(key);
530
- if (cached) return cached;
531
- const resolved = toTableColumnCodecs(table, this.#codecs, columns, {
571
+ #transformRow(row: UnknownRow, plan: QueryResultPlan): UnknownRow {
572
+ return applyQueryResultPlan({
573
+ row,
574
+ plan,
575
+ resolveTableCodecs: this.#resolveTableCodecs,
532
576
  dialect: this.#dialect,
533
577
  });
534
- this.#resultCodecCache.set(key, resolved);
535
- return resolved;
536
- }
537
-
538
- #transformRow(row: UnknownRow, plan: QueryResultPlan): UnknownRow {
539
- const source = row as Record<string, unknown>;
540
- const target: Record<string, unknown> = { ...source };
541
- const rowColumns = Object.keys(source);
542
-
543
- if (plan.selectAllTables.length > 0) {
544
- const codecCandidates = new Map<string, AnyColumnCodec>();
545
- const ambiguousColumns = new Set<string>();
546
-
547
- for (const table of plan.selectAllTables) {
548
- const tableCodecs = this.#resolveTableCodecs(table, rowColumns);
549
- for (const [column, codec] of Object.entries(tableCodecs)) {
550
- if (!(column in source)) continue;
551
- const existing = codecCandidates.get(column);
552
- if (existing && existing !== codec) {
553
- ambiguousColumns.add(column);
554
- continue;
555
- }
556
- codecCandidates.set(column, codec);
557
- }
558
- }
559
-
560
- for (const [column, codec] of codecCandidates.entries()) {
561
- if (ambiguousColumns.has(column)) continue;
562
- target[column] = applyCodecFromDbValue(
563
- codec,
564
- target[column],
565
- this.#dialect
566
- );
567
- }
568
- }
569
-
570
- for (const ref of plan.explicit) {
571
- if (!(ref.outputKey in source)) continue;
572
- const tableCodecs = this.#resolveTableCodecs(ref.table, [ref.column]);
573
- const codec = tableCodecs[ref.column];
574
- if (!codec) continue;
575
- target[ref.outputKey] = applyCodecFromDbValue(
576
- codec,
577
- target[ref.outputKey],
578
- this.#dialect
579
- );
580
- }
581
-
582
- return target;
583
578
  }
584
579
  }
585
580
 
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { z } from 'zod';
8
+ import type { SyncularErrorCode } from '../error-responses';
8
9
 
9
10
  // ============================================================================
10
11
  // Blob Reference Schema
@@ -72,12 +73,20 @@ export const BlobUploadCompleteResponseSchema = z.object({
72
73
  ok: z.boolean(),
73
74
  metadata: BlobMetadataSchema.optional(),
74
75
  error: z.string().optional(),
76
+ code: z.string().optional(),
75
77
  });
76
78
 
77
- export type BlobUploadCompleteResponse = z.infer<
79
+ type BlobUploadCompleteResponseBase = z.infer<
78
80
  typeof BlobUploadCompleteResponseSchema
79
81
  >;
80
82
 
83
+ export type BlobUploadCompleteResponse = Omit<
84
+ BlobUploadCompleteResponseBase,
85
+ 'code'
86
+ > & {
87
+ code?: SyncularErrorCode;
88
+ };
89
+
81
90
  // ============================================================================
82
91
  // Download URL Request/Response Schemas
83
92
  // ============================================================================
@@ -8,13 +8,57 @@ import { z } from 'zod';
8
8
  // Error Response Schemas
9
9
  // ============================================================================
10
10
 
11
+ export const SyncularErrorCategorySchema = z.enum([
12
+ 'auth-required',
13
+ 'forbidden',
14
+ 'conflict',
15
+ 'scope-revoked',
16
+ 'offline',
17
+ 'invalid-request',
18
+ 'not-found',
19
+ 'schema-mismatch',
20
+ 'integrity-rejected',
21
+ 'rate-limited',
22
+ 'limit-exceeded',
23
+ 'transport',
24
+ 'storage',
25
+ 'blob',
26
+ 'server',
27
+ 'internal',
28
+ ]);
29
+
30
+ export const SyncularErrorRecommendedActionSchema = z.enum([
31
+ 'refreshAuth',
32
+ 'checkPermissions',
33
+ 'fixRequest',
34
+ 'resetClientId',
35
+ 'splitBatch',
36
+ 'reduceInput',
37
+ 'retryLater',
38
+ 'forceResync',
39
+ 'regenerateClient',
40
+ 'upgradeClient',
41
+ 'inspectStorage',
42
+ 'inspectServer',
43
+ 'resolveConflict',
44
+ 'recreateClient',
45
+ ]);
46
+
11
47
  export const ErrorResponseSchema = z.object({
12
48
  error: z.string(),
13
49
  message: z.string().optional(),
14
50
  code: z.string().optional(),
51
+ category: SyncularErrorCategorySchema.optional(),
52
+ retryable: z.boolean().optional(),
53
+ recommendedAction: SyncularErrorRecommendedActionSchema.optional(),
54
+ details: z.record(z.string(), z.unknown()).optional(),
15
55
  });
16
56
 
17
57
  export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
58
+ export type SyncularErrorCategory = z.infer<typeof SyncularErrorCategorySchema>;
59
+ export type SyncularErrorRecommendedAction = z.infer<
60
+ typeof SyncularErrorRecommendedActionSchema
61
+ >;
18
62
 
19
63
  // ============================================================================
20
64
  // Pagination Schemas