@syncular/core 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.
Files changed (72) hide show
  1. package/dist/blobs.d.ts +137 -0
  2. package/dist/blobs.d.ts.map +1 -0
  3. package/dist/blobs.js +47 -0
  4. package/dist/blobs.js.map +1 -0
  5. package/dist/conflict.d.ts +22 -0
  6. package/dist/conflict.d.ts.map +1 -0
  7. package/dist/conflict.js +81 -0
  8. package/dist/conflict.js.map +1 -0
  9. package/dist/index.d.ts +21 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +30 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/kysely-serialize.d.ts +22 -0
  14. package/dist/kysely-serialize.d.ts.map +1 -0
  15. package/dist/kysely-serialize.js +147 -0
  16. package/dist/kysely-serialize.js.map +1 -0
  17. package/dist/logger.d.ts +46 -0
  18. package/dist/logger.d.ts.map +1 -0
  19. package/dist/logger.js +48 -0
  20. package/dist/logger.js.map +1 -0
  21. package/dist/proxy/index.d.ts +5 -0
  22. package/dist/proxy/index.d.ts.map +1 -0
  23. package/dist/proxy/index.js +5 -0
  24. package/dist/proxy/index.js.map +1 -0
  25. package/dist/proxy/types.d.ts +54 -0
  26. package/dist/proxy/types.d.ts.map +1 -0
  27. package/dist/proxy/types.js +7 -0
  28. package/dist/proxy/types.js.map +1 -0
  29. package/dist/schemas/blobs.d.ts +76 -0
  30. package/dist/schemas/blobs.d.ts.map +1 -0
  31. package/dist/schemas/blobs.js +63 -0
  32. package/dist/schemas/blobs.js.map +1 -0
  33. package/dist/schemas/common.d.ts +28 -0
  34. package/dist/schemas/common.d.ts.map +1 -0
  35. package/dist/schemas/common.js +26 -0
  36. package/dist/schemas/common.js.map +1 -0
  37. package/dist/schemas/index.d.ts +7 -0
  38. package/dist/schemas/index.d.ts.map +1 -0
  39. package/dist/schemas/index.js +7 -0
  40. package/dist/schemas/index.js.map +1 -0
  41. package/dist/schemas/sync.d.ts +391 -0
  42. package/dist/schemas/sync.d.ts.map +1 -0
  43. package/dist/schemas/sync.js +156 -0
  44. package/dist/schemas/sync.js.map +1 -0
  45. package/dist/scopes/index.d.ts +65 -0
  46. package/dist/scopes/index.d.ts.map +1 -0
  47. package/dist/scopes/index.js +67 -0
  48. package/dist/scopes/index.js.map +1 -0
  49. package/dist/transforms.d.ts +146 -0
  50. package/dist/transforms.d.ts.map +1 -0
  51. package/dist/transforms.js +155 -0
  52. package/dist/transforms.js.map +1 -0
  53. package/dist/types.d.ts +129 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +20 -0
  56. package/dist/types.js.map +1 -0
  57. package/package.json +56 -0
  58. package/src/__tests__/conflict.test.ts +325 -0
  59. package/src/blobs.ts +187 -0
  60. package/src/conflict.ts +92 -0
  61. package/src/index.ts +30 -0
  62. package/src/kysely-serialize.ts +214 -0
  63. package/src/logger.ts +80 -0
  64. package/src/proxy/index.ts +10 -0
  65. package/src/proxy/types.ts +57 -0
  66. package/src/schemas/blobs.ts +101 -0
  67. package/src/schemas/common.ts +45 -0
  68. package/src/schemas/index.ts +7 -0
  69. package/src/schemas/sync.ts +222 -0
  70. package/src/scopes/index.ts +122 -0
  71. package/src/transforms.ts +256 -0
  72. package/src/types.ts +158 -0
@@ -0,0 +1,214 @@
1
+ import {
2
+ type ColumnUpdateNode,
3
+ type KyselyPlugin,
4
+ OperationNodeTransformer,
5
+ type PluginTransformQueryArgs,
6
+ type PluginTransformResultArgs,
7
+ type PrimitiveValueListNode,
8
+ type QueryResult,
9
+ type RootOperationNode,
10
+ type UnknownRow,
11
+ type ValueNode,
12
+ } from 'kysely';
13
+
14
+ type Serializer = (parameter: unknown) => unknown;
15
+ type Deserializer = (parameter: unknown) => unknown;
16
+
17
+ const dateRegex = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/;
18
+
19
+ function isBufferLike(value: object): value is { buffer: unknown } {
20
+ return 'buffer' in value;
21
+ }
22
+
23
+ function skipTransform(parameter: unknown): boolean {
24
+ if (
25
+ parameter === undefined ||
26
+ parameter === null ||
27
+ typeof parameter === 'bigint' ||
28
+ typeof parameter === 'number'
29
+ ) {
30
+ return true;
31
+ }
32
+
33
+ if (typeof parameter === 'object') {
34
+ return isBufferLike(parameter);
35
+ }
36
+
37
+ return false;
38
+ }
39
+
40
+ function maybeJson(parameter: string): boolean {
41
+ return (
42
+ (parameter.startsWith('{') && parameter.endsWith('}')) ||
43
+ (parameter.startsWith('[') && parameter.endsWith(']'))
44
+ );
45
+ }
46
+
47
+ const defaultSerializer: Serializer = (parameter) => {
48
+ if (skipTransform(parameter) || typeof parameter === 'string') {
49
+ return parameter;
50
+ }
51
+
52
+ if (typeof parameter === 'boolean') {
53
+ return String(parameter);
54
+ }
55
+
56
+ if (parameter instanceof Date) {
57
+ return parameter.toISOString();
58
+ }
59
+
60
+ try {
61
+ return JSON.stringify(parameter);
62
+ } catch {
63
+ return parameter;
64
+ }
65
+ };
66
+
67
+ const defaultDeserializer: Deserializer = (parameter) => {
68
+ if (skipTransform(parameter)) {
69
+ return parameter;
70
+ }
71
+
72
+ if (typeof parameter !== 'string') {
73
+ return parameter;
74
+ }
75
+
76
+ if (parameter === 'true') return true;
77
+ if (parameter === 'false') return false;
78
+ if (dateRegex.test(parameter)) return new Date(parameter);
79
+
80
+ if (maybeJson(parameter)) {
81
+ try {
82
+ return JSON.parse(parameter);
83
+ } catch {
84
+ return parameter;
85
+ }
86
+ }
87
+
88
+ return parameter;
89
+ };
90
+
91
+ class SerializeParametersTransformer extends OperationNodeTransformer {
92
+ readonly #serializer: Serializer;
93
+
94
+ constructor(serializer: Serializer) {
95
+ super();
96
+ this.#serializer = serializer;
97
+ }
98
+
99
+ protected override transformPrimitiveValueList(
100
+ node: PrimitiveValueListNode
101
+ ): PrimitiveValueListNode {
102
+ return {
103
+ ...node,
104
+ values: node.values.map((v) => this.#serializer(v)),
105
+ };
106
+ }
107
+
108
+ protected override transformColumnUpdate(
109
+ node: ColumnUpdateNode,
110
+ queryId?: { readonly queryId: string }
111
+ ): ColumnUpdateNode {
112
+ const valueNode = node.value;
113
+ if (valueNode.kind !== 'ValueNode') {
114
+ return super.transformColumnUpdate(node, queryId);
115
+ }
116
+
117
+ const currentValue = (valueNode as ValueNode).value;
118
+ const serializedValue = this.#serializer(currentValue);
119
+ if (currentValue === serializedValue) {
120
+ return super.transformColumnUpdate(node, queryId);
121
+ }
122
+
123
+ const updatedValue: ValueNode = {
124
+ ...(valueNode as ValueNode),
125
+ value: serializedValue,
126
+ };
127
+
128
+ return super.transformColumnUpdate(
129
+ { ...node, value: updatedValue },
130
+ queryId
131
+ );
132
+ }
133
+
134
+ protected override transformValue(node: ValueNode): ValueNode {
135
+ return { ...node, value: this.#serializer(node.value) };
136
+ }
137
+ }
138
+
139
+ class BaseSerializePlugin implements KyselyPlugin {
140
+ readonly #transformer: SerializeParametersTransformer;
141
+ readonly #deserializer: Deserializer;
142
+ readonly #skipNodeSet: Set<RootOperationNode['kind']> | null;
143
+ readonly #ctx: WeakSet<object> | null;
144
+
145
+ /**
146
+ * Base class for {@link SerializePlugin}, without default options.
147
+ */
148
+ constructor(
149
+ serializer: Serializer,
150
+ deserializer: Deserializer,
151
+ skipNodeKind: Array<RootOperationNode['kind']>
152
+ ) {
153
+ this.#transformer = new SerializeParametersTransformer(serializer);
154
+ this.#deserializer = deserializer;
155
+ if (skipNodeKind.length > 0) {
156
+ this.#skipNodeSet = new Set(skipNodeKind);
157
+ this.#ctx = new WeakSet<object>();
158
+ } else {
159
+ this.#skipNodeSet = null;
160
+ this.#ctx = null;
161
+ }
162
+ }
163
+
164
+ transformQuery({
165
+ node,
166
+ queryId,
167
+ }: PluginTransformQueryArgs): RootOperationNode {
168
+ if (this.#skipNodeSet?.has(node.kind)) {
169
+ this.#ctx?.add(queryId);
170
+ return node;
171
+ }
172
+ return this.#transformer.transformNode(node);
173
+ }
174
+
175
+ async transformResult({
176
+ result,
177
+ queryId,
178
+ }: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>> {
179
+ if (this.#ctx?.has(queryId)) {
180
+ return result;
181
+ }
182
+ return { ...result, rows: this.#parseRows(result.rows) };
183
+ }
184
+
185
+ #parseRows(rows: UnknownRow[]): UnknownRow[] {
186
+ const out: UnknownRow[] = [];
187
+ for (const row of rows) {
188
+ if (!row) continue;
189
+ const parsed: Record<string, unknown> = {};
190
+ for (const [key, value] of Object.entries(row)) {
191
+ parsed[key] = this.#deserializer(value);
192
+ }
193
+ out.push(parsed);
194
+ }
195
+ return out;
196
+ }
197
+ }
198
+
199
+ interface SerializePluginOptions {
200
+ serializer?: Serializer;
201
+ deserializer?: Deserializer;
202
+ skipNodeKind?: Array<RootOperationNode['kind']>;
203
+ }
204
+
205
+ export class SerializePlugin extends BaseSerializePlugin {
206
+ constructor(options: SerializePluginOptions = {}) {
207
+ const {
208
+ serializer = defaultSerializer,
209
+ deserializer = defaultDeserializer,
210
+ skipNodeKind = [],
211
+ } = options;
212
+ super(serializer, deserializer, skipNodeKind);
213
+ }
214
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @syncular/core - Structured logging utilities for sync operations
3
+ *
4
+ * Outputs JSON lines for easy parsing by log aggregation tools.
5
+ * Each log event includes a timestamp and event type.
6
+ */
7
+
8
+ /**
9
+ * Sync log event structure
10
+ */
11
+ interface SyncLogEvent {
12
+ /** Event type identifier */
13
+ event: string;
14
+ /** User ID (optional) */
15
+ userId?: string;
16
+ /** Operation duration in milliseconds (optional) */
17
+ durationMs?: number;
18
+ /** Number of rows affected (optional) */
19
+ rowCount?: number;
20
+ /** Whether a full reset was required (optional) */
21
+ resetRequired?: boolean;
22
+ /** Error message if operation failed (optional) */
23
+ error?: string;
24
+ /** Additional arbitrary properties */
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ /**
29
+ * Logger function type - allows custom logging implementations
30
+ */
31
+ type SyncLogger = (event: SyncLogEvent) => void;
32
+
33
+ /**
34
+ * Default logger that outputs JSON lines to console.
35
+ * Non-blocking - defers logging to avoid blocking the event loop.
36
+ *
37
+ * On server (Node.js), uses setImmediate.
38
+ * On client (browser), uses setTimeout(0).
39
+ */
40
+ function createDefaultLogger(): SyncLogger {
41
+ // Detect environment
42
+ const isNode =
43
+ typeof globalThis !== 'undefined' &&
44
+ typeof globalThis.setImmediate === 'function';
45
+
46
+ const defer = isNode
47
+ ? (fn: () => void) => globalThis.setImmediate(fn)
48
+ : (fn: () => void) => setTimeout(fn, 0);
49
+
50
+ return (event: SyncLogEvent) => {
51
+ defer(() => {
52
+ console.log(
53
+ JSON.stringify({
54
+ timestamp: new Date().toISOString(),
55
+ ...event,
56
+ })
57
+ );
58
+ });
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Log a sync event using the default logger.
64
+ * For custom logging, create your own logger with createDefaultLogger pattern.
65
+ */
66
+ export const logSyncEvent: SyncLogger = createDefaultLogger();
67
+
68
+ /**
69
+ * Create a timer for measuring operation duration.
70
+ * Returns the elapsed time in milliseconds when called.
71
+ *
72
+ * @example
73
+ * const elapsed = createSyncTimer();
74
+ * await doSomeWork();
75
+ * logSyncEvent({ event: 'work_complete', durationMs: elapsed() });
76
+ */
77
+ export function createSyncTimer(): () => number {
78
+ const start = performance.now();
79
+ return () => Math.round(performance.now() - start);
80
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @syncular/core - Proxy Protocol Exports
3
+ */
4
+
5
+ export type {
6
+ ProxyHandshake,
7
+ ProxyHandshakeAck,
8
+ ProxyMessage,
9
+ ProxyResponse,
10
+ } from './types';
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @syncular/core - Proxy Protocol Types
3
+ *
4
+ * Shared protocol types between proxy client (Kysely dialect) and server (WebSocket handler).
5
+ */
6
+
7
+ /**
8
+ * Message sent from proxy client to server.
9
+ */
10
+ export interface ProxyMessage {
11
+ /** Correlation ID for matching request/response */
12
+ id: string;
13
+ /** Message type */
14
+ type: 'query' | 'begin' | 'commit' | 'rollback';
15
+ /** SQL query (for 'query' type) */
16
+ sql?: string;
17
+ /** Query parameters (for 'query' type) */
18
+ parameters?: readonly unknown[];
19
+ }
20
+
21
+ /**
22
+ * Response sent from server to proxy client.
23
+ */
24
+ export interface ProxyResponse {
25
+ /** Correlation ID matching the request */
26
+ id: string;
27
+ /** Response type */
28
+ type: 'result' | 'error';
29
+ /** Query result rows (for SELECT queries) */
30
+ rows?: unknown[];
31
+ /** Number of affected rows (for mutations) */
32
+ rowCount?: number;
33
+ /** Error message (for 'error' type) */
34
+ error?: string;
35
+ }
36
+
37
+ /**
38
+ * Handshake message sent when connection is established.
39
+ */
40
+ export interface ProxyHandshake {
41
+ type: 'handshake';
42
+ /** Actor ID for oplog tracking */
43
+ actorId: string;
44
+ /** Client ID for oplog tracking */
45
+ clientId: string;
46
+ }
47
+
48
+ /**
49
+ * Handshake acknowledgement from server.
50
+ */
51
+ export interface ProxyHandshakeAck {
52
+ type: 'handshake_ack';
53
+ /** Whether handshake was successful */
54
+ ok: boolean;
55
+ /** Error message if handshake failed */
56
+ error?: string;
57
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @syncular/core - Blob Zod schemas
3
+ *
4
+ * Runtime validation schemas for blob types.
5
+ */
6
+
7
+ import { z } from 'zod';
8
+
9
+ // ============================================================================
10
+ // Blob Reference Schema
11
+ // ============================================================================
12
+
13
+ export const BlobRefSchema = z.object({
14
+ hash: z.string().regex(/^sha256:[0-9a-f]{64}$/i, 'Invalid blob hash format'),
15
+ size: z.number().int().min(0),
16
+ mimeType: z.string().min(1),
17
+ encrypted: z.boolean().optional(),
18
+ keyId: z.string().optional(),
19
+ });
20
+
21
+ export type BlobRef = z.infer<typeof BlobRefSchema>;
22
+
23
+ // ============================================================================
24
+ // Blob Metadata Schema
25
+ // ============================================================================
26
+
27
+ export const BlobMetadataSchema = z.object({
28
+ hash: z.string().regex(/^sha256:[0-9a-f]{64}$/i),
29
+ size: z.number().int().min(0),
30
+ mimeType: z.string().min(1),
31
+ createdAt: z.string(),
32
+ expiresAt: z.string().optional(),
33
+ uploadComplete: z.boolean(),
34
+ });
35
+
36
+ export type BlobMetadata = z.infer<typeof BlobMetadataSchema>;
37
+
38
+ // ============================================================================
39
+ // Upload Request/Response Schemas
40
+ // ============================================================================
41
+
42
+ export const BlobUploadInitRequestSchema = z.object({
43
+ hash: z.string().regex(/^sha256:[0-9a-f]{64}$/i, 'Invalid blob hash format'),
44
+ size: z.number().int().min(0),
45
+ mimeType: z.string().min(1),
46
+ });
47
+
48
+ export type BlobUploadInitRequest = z.infer<typeof BlobUploadInitRequestSchema>;
49
+
50
+ export const BlobUploadInitResponseSchema = z.object({
51
+ exists: z.boolean(),
52
+ uploadId: z.string().optional(),
53
+ uploadUrl: z.string().url().optional(),
54
+ uploadMethod: z.enum(['PUT', 'POST']).optional(),
55
+ uploadHeaders: z.record(z.string(), z.string()).optional(),
56
+ chunkSize: z.number().int().optional(),
57
+ });
58
+
59
+ export type BlobUploadInitResponse = z.infer<
60
+ typeof BlobUploadInitResponseSchema
61
+ >;
62
+
63
+ export const BlobUploadCompleteRequestSchema = z.object({
64
+ hash: z.string().regex(/^sha256:[0-9a-f]{64}$/i),
65
+ });
66
+
67
+ export type BlobUploadCompleteRequest = z.infer<
68
+ typeof BlobUploadCompleteRequestSchema
69
+ >;
70
+
71
+ export const BlobUploadCompleteResponseSchema = z.object({
72
+ ok: z.boolean(),
73
+ metadata: BlobMetadataSchema.optional(),
74
+ error: z.string().optional(),
75
+ });
76
+
77
+ export type BlobUploadCompleteResponse = z.infer<
78
+ typeof BlobUploadCompleteResponseSchema
79
+ >;
80
+
81
+ // ============================================================================
82
+ // Download URL Request/Response Schemas
83
+ // ============================================================================
84
+
85
+ export const BlobDownloadUrlRequestSchema = z.object({
86
+ hash: z.string().regex(/^sha256:[0-9a-f]{64}$/i),
87
+ });
88
+
89
+ export type BlobDownloadUrlRequest = z.infer<
90
+ typeof BlobDownloadUrlRequestSchema
91
+ >;
92
+
93
+ export const BlobDownloadUrlResponseSchema = z.object({
94
+ url: z.string().url(),
95
+ expiresAt: z.string(),
96
+ metadata: BlobMetadataSchema,
97
+ });
98
+
99
+ export type BlobDownloadUrlResponse = z.infer<
100
+ typeof BlobDownloadUrlResponseSchema
101
+ >;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @syncular/core - Common Zod schemas
3
+ */
4
+
5
+ import { z } from 'zod';
6
+
7
+ // ============================================================================
8
+ // Error Response Schemas
9
+ // ============================================================================
10
+
11
+ export const ErrorResponseSchema = z.object({
12
+ error: z.string(),
13
+ message: z.string().optional(),
14
+ code: z.string().optional(),
15
+ });
16
+
17
+ export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
18
+
19
+ // ============================================================================
20
+ // Pagination Schemas
21
+ // ============================================================================
22
+
23
+ export const PaginationQuerySchema = z.object({
24
+ limit: z.coerce.number().int().min(1).max(100).default(50),
25
+ offset: z.coerce.number().int().min(0).default(0),
26
+ });
27
+
28
+ export type PaginationQuery = z.infer<typeof PaginationQuerySchema>;
29
+
30
+ export const PaginatedResponseSchema = <T extends z.ZodTypeAny>(
31
+ itemSchema: T
32
+ ) =>
33
+ z.object({
34
+ items: z.array(itemSchema),
35
+ total: z.number().int(),
36
+ offset: z.number().int(),
37
+ limit: z.number().int(),
38
+ });
39
+
40
+ export type PaginatedResponse<T> = {
41
+ items: T[];
42
+ total: number;
43
+ offset: number;
44
+ limit: number;
45
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @syncular/core - Schema exports
3
+ */
4
+
5
+ export * from './blobs';
6
+ export * from './common';
7
+ export * from './sync';