@syncular/core 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.
- package/dist/blobs.d.ts +146 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +47 -0
- package/dist/blobs.js.map +1 -0
- package/dist/conflict.d.ts +22 -0
- package/dist/conflict.d.ts.map +1 -0
- package/dist/conflict.js +81 -0
- package/dist/conflict.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/kysely-serialize.d.ts +22 -0
- package/dist/kysely-serialize.d.ts.map +1 -0
- package/dist/kysely-serialize.js +147 -0
- package/dist/kysely-serialize.js.map +1 -0
- package/dist/logger.d.ts +29 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +26 -0
- package/dist/logger.js.map +1 -0
- package/dist/proxy/index.d.ts +5 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +5 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/types.d.ts +54 -0
- package/dist/proxy/types.d.ts.map +1 -0
- package/dist/proxy/types.js +7 -0
- package/dist/proxy/types.js.map +1 -0
- package/dist/schemas/blobs.d.ts +76 -0
- package/dist/schemas/blobs.d.ts.map +1 -0
- package/dist/schemas/blobs.js +63 -0
- package/dist/schemas/blobs.js.map +1 -0
- package/dist/schemas/common.d.ts +28 -0
- package/dist/schemas/common.d.ts.map +1 -0
- package/dist/schemas/common.js +26 -0
- package/dist/schemas/common.js.map +1 -0
- package/dist/schemas/index.d.ts +7 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +7 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/schemas/sync.d.ts +391 -0
- package/dist/schemas/sync.d.ts.map +1 -0
- package/dist/schemas/sync.js +157 -0
- package/dist/schemas/sync.js.map +1 -0
- package/dist/scopes/index.d.ts +65 -0
- package/dist/scopes/index.d.ts.map +1 -0
- package/dist/scopes/index.js +67 -0
- package/dist/scopes/index.js.map +1 -0
- package/dist/snapshot-chunks.d.ts +26 -0
- package/dist/snapshot-chunks.d.ts.map +1 -0
- package/dist/snapshot-chunks.js +89 -0
- package/dist/snapshot-chunks.js.map +1 -0
- package/dist/telemetry.d.ts +114 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +113 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/transforms.d.ts +146 -0
- package/dist/transforms.d.ts.map +1 -0
- package/dist/transforms.js +155 -0
- package/dist/transforms.js.map +1 -0
- package/dist/types.d.ts +129 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/id.d.ts +2 -0
- package/dist/utils/id.d.ts.map +1 -0
- package/dist/utils/id.js +8 -0
- package/dist/utils/id.js.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/object.d.ts +2 -0
- package/dist/utils/object.d.ts.map +1 -0
- package/dist/utils/object.js +4 -0
- package/dist/utils/object.js.map +1 -0
- package/package.json +57 -0
- package/src/__tests__/conflict.test.ts +325 -0
- package/src/__tests__/telemetry.test.ts +170 -0
- package/src/__tests__/utils.test.ts +27 -0
- package/src/blobs.ts +202 -0
- package/src/conflict.ts +92 -0
- package/src/index.ts +36 -0
- package/src/kysely-serialize.ts +214 -0
- package/src/logger.ts +38 -0
- package/src/proxy/index.ts +10 -0
- package/src/proxy/types.ts +57 -0
- package/src/schemas/blobs.ts +101 -0
- package/src/schemas/common.ts +45 -0
- package/src/schemas/index.ts +7 -0
- package/src/schemas/sync.ts +226 -0
- package/src/scopes/index.ts +122 -0
- package/src/snapshot-chunks.ts +112 -0
- package/src/telemetry.ts +238 -0
- package/src/transforms.ts +256 -0
- package/src/types.ts +158 -0
- package/src/utils/id.ts +7 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/object.ts +3 -0
package/src/blobs.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/core - Blob types for media/binary handling
|
|
3
|
+
*
|
|
4
|
+
* Content-addressable blob storage with presigned URL support.
|
|
5
|
+
* Protocol types (BlobRef, BlobMetadata, etc.) live in ./schemas/blobs.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BlobRef } from './schemas/blobs';
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Client Transport Types
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Transport interface for client-server blob communication.
|
|
16
|
+
* This is used by the client blob manager to communicate with the server.
|
|
17
|
+
*/
|
|
18
|
+
export interface BlobTransport {
|
|
19
|
+
/**
|
|
20
|
+
* Initiate a blob upload.
|
|
21
|
+
* Returns presigned URL info or indicates blob already exists (dedup).
|
|
22
|
+
*/
|
|
23
|
+
initiateUpload(args: {
|
|
24
|
+
hash: string;
|
|
25
|
+
size: number;
|
|
26
|
+
mimeType: string;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
exists: boolean;
|
|
29
|
+
uploadUrl?: string;
|
|
30
|
+
uploadMethod?: 'PUT' | 'POST';
|
|
31
|
+
uploadHeaders?: Record<string, string>;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Complete a blob upload.
|
|
36
|
+
* Call this after uploading to the presigned URL.
|
|
37
|
+
*/
|
|
38
|
+
completeUpload(hash: string): Promise<{ ok: boolean; error?: string }>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get a presigned download URL.
|
|
42
|
+
*/
|
|
43
|
+
getDownloadUrl(hash: string): Promise<{
|
|
44
|
+
url: string;
|
|
45
|
+
expiresAt: string;
|
|
46
|
+
}>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// Storage Adapter Types (Server-side)
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Options for signing an upload URL.
|
|
55
|
+
*/
|
|
56
|
+
export interface BlobSignUploadOptions {
|
|
57
|
+
/** SHA-256 hash (for naming and checksum validation) */
|
|
58
|
+
hash: string;
|
|
59
|
+
/** Content size in bytes */
|
|
60
|
+
size: number;
|
|
61
|
+
/** MIME type */
|
|
62
|
+
mimeType: string;
|
|
63
|
+
/** URL expiration in seconds */
|
|
64
|
+
expiresIn: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Result of signing an upload URL.
|
|
69
|
+
*/
|
|
70
|
+
export interface BlobSignedUpload {
|
|
71
|
+
/** The URL to upload to */
|
|
72
|
+
url: string;
|
|
73
|
+
/** HTTP method */
|
|
74
|
+
method: 'PUT' | 'POST';
|
|
75
|
+
/** Required headers */
|
|
76
|
+
headers?: Record<string, string>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Options for signing a download URL.
|
|
81
|
+
*/
|
|
82
|
+
export interface BlobSignDownloadOptions {
|
|
83
|
+
/** SHA-256 hash */
|
|
84
|
+
hash: string;
|
|
85
|
+
/** URL expiration in seconds */
|
|
86
|
+
expiresIn: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Adapter for blob storage backends (S3, R2, custom).
|
|
91
|
+
* Implementations handle actual storage; the sync server orchestrates.
|
|
92
|
+
*/
|
|
93
|
+
export interface BlobStorageAdapter {
|
|
94
|
+
/** Adapter name for logging/debugging */
|
|
95
|
+
readonly name: string;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Generate a presigned URL for uploading a blob.
|
|
99
|
+
* The URL should enforce checksum validation if the backend supports it.
|
|
100
|
+
*/
|
|
101
|
+
signUpload(options: BlobSignUploadOptions): Promise<BlobSignedUpload>;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Generate a presigned URL for downloading a blob.
|
|
105
|
+
*/
|
|
106
|
+
signDownload(options: BlobSignDownloadOptions): Promise<string>;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check if a blob exists in storage.
|
|
110
|
+
*/
|
|
111
|
+
exists(hash: string): Promise<boolean>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Delete a blob (for garbage collection).
|
|
115
|
+
*/
|
|
116
|
+
delete(hash: string): Promise<void>;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get blob metadata from storage (optional).
|
|
120
|
+
* Used to verify uploads completed successfully.
|
|
121
|
+
*/
|
|
122
|
+
getMetadata?(
|
|
123
|
+
hash: string
|
|
124
|
+
): Promise<{ size: number; mimeType?: string } | null>;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Store blob data directly (for adapters that support direct storage).
|
|
128
|
+
* Used for snapshot chunks and other internal data.
|
|
129
|
+
*/
|
|
130
|
+
put?(
|
|
131
|
+
hash: string,
|
|
132
|
+
data: Uint8Array,
|
|
133
|
+
metadata?: Record<string, unknown>
|
|
134
|
+
): Promise<void>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Store blob data directly from a stream.
|
|
138
|
+
* Preferred for large payloads to avoid full buffering in memory.
|
|
139
|
+
*/
|
|
140
|
+
putStream?(
|
|
141
|
+
hash: string,
|
|
142
|
+
stream: ReadableStream<Uint8Array>,
|
|
143
|
+
metadata?: Record<string, unknown>
|
|
144
|
+
): Promise<void>;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get blob data directly (for adapters that support direct retrieval).
|
|
148
|
+
*/
|
|
149
|
+
get?(hash: string): Promise<Uint8Array | null>;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get blob data directly as a stream (for adapters that support stream retrieval).
|
|
153
|
+
*/
|
|
154
|
+
getStream?(hash: string): Promise<ReadableStream<Uint8Array> | null>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// Utility Functions
|
|
159
|
+
// ============================================================================
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Create a BlobRef from upload metadata.
|
|
163
|
+
*/
|
|
164
|
+
export function createBlobRef(args: {
|
|
165
|
+
hash: string;
|
|
166
|
+
size: number;
|
|
167
|
+
mimeType: string;
|
|
168
|
+
encrypted?: boolean;
|
|
169
|
+
keyId?: string;
|
|
170
|
+
}): BlobRef {
|
|
171
|
+
const ref: BlobRef = {
|
|
172
|
+
hash: args.hash,
|
|
173
|
+
size: args.size,
|
|
174
|
+
mimeType: args.mimeType,
|
|
175
|
+
};
|
|
176
|
+
if (args.encrypted) {
|
|
177
|
+
ref.encrypted = true;
|
|
178
|
+
if (args.keyId) {
|
|
179
|
+
ref.keyId = args.keyId;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return ref;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Parse a blob hash, validating format.
|
|
187
|
+
* @returns The hex hash without prefix, or null if invalid.
|
|
188
|
+
*/
|
|
189
|
+
export function parseBlobHash(hash: string): string | null {
|
|
190
|
+
if (!hash.startsWith('sha256:')) return null;
|
|
191
|
+
const hex = hash.slice(7);
|
|
192
|
+
if (hex.length !== 64) return null;
|
|
193
|
+
if (!/^[0-9a-f]+$/i.test(hex)) return null;
|
|
194
|
+
return hex.toLowerCase();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Create a blob hash string from hex.
|
|
199
|
+
*/
|
|
200
|
+
export function createBlobHash(hexHash: string): string {
|
|
201
|
+
return `sha256:${hexHash.toLowerCase()}`;
|
|
202
|
+
}
|
package/src/conflict.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/core - Pure conflict detection and field-level merge utilities
|
|
3
|
+
*
|
|
4
|
+
* These are pure functions with no database dependencies.
|
|
5
|
+
* Database-specific conflict detection (triggers, etc.) lives in @syncular/server.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { MergeResult } from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Performs field-level merge between client changes and server state.
|
|
12
|
+
*
|
|
13
|
+
* Merge logic:
|
|
14
|
+
* - If only client changed a field -> use client's value
|
|
15
|
+
* - If only server changed a field -> keep server's value
|
|
16
|
+
* - If both changed same field to different values -> true conflict
|
|
17
|
+
*
|
|
18
|
+
* @param baseRow - The row state when client started editing (from base_version)
|
|
19
|
+
* @param serverRow - Current server row state
|
|
20
|
+
* @param clientPayload - Client's intended changes
|
|
21
|
+
* @returns MergeResult indicating if merge is possible and the result
|
|
22
|
+
*/
|
|
23
|
+
export function performFieldLevelMerge(
|
|
24
|
+
baseRow: Record<string, unknown> | null,
|
|
25
|
+
serverRow: Record<string, unknown>,
|
|
26
|
+
clientPayload: Record<string, unknown>
|
|
27
|
+
): MergeResult {
|
|
28
|
+
// If no base row (new insert), client payload wins entirely
|
|
29
|
+
if (!baseRow) {
|
|
30
|
+
return { canMerge: true, mergedPayload: clientPayload };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const conflictingFields: string[] = [];
|
|
34
|
+
const mergedPayload: Record<string, unknown> = { ...serverRow };
|
|
35
|
+
|
|
36
|
+
// Check each field in the client payload
|
|
37
|
+
for (const [field, clientValue] of Object.entries(clientPayload)) {
|
|
38
|
+
const baseValue = baseRow[field];
|
|
39
|
+
const serverValue = serverRow[field];
|
|
40
|
+
|
|
41
|
+
const clientChanged = !deepEqual(baseValue, clientValue);
|
|
42
|
+
const serverChanged = !deepEqual(baseValue, serverValue);
|
|
43
|
+
|
|
44
|
+
if (clientChanged && serverChanged) {
|
|
45
|
+
// Both changed the same field
|
|
46
|
+
if (!deepEqual(clientValue, serverValue)) {
|
|
47
|
+
// Changed to different values - true conflict
|
|
48
|
+
conflictingFields.push(field);
|
|
49
|
+
}
|
|
50
|
+
// If they changed to the same value, no conflict - use either
|
|
51
|
+
mergedPayload[field] = clientValue;
|
|
52
|
+
} else if (clientChanged) {
|
|
53
|
+
// Only client changed - use client's value
|
|
54
|
+
mergedPayload[field] = clientValue;
|
|
55
|
+
}
|
|
56
|
+
// If only server changed or neither changed, keep server value (already in mergedPayload)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (conflictingFields.length > 0) {
|
|
60
|
+
return { canMerge: false, conflictingFields };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { canMerge: true, mergedPayload };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Deep equality check for values (handles primitives, arrays, objects)
|
|
68
|
+
*/
|
|
69
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
70
|
+
if (a === b) return true;
|
|
71
|
+
if (a === null || b === null) return a === b;
|
|
72
|
+
if (typeof a !== typeof b) return false;
|
|
73
|
+
|
|
74
|
+
if (typeof a === 'object') {
|
|
75
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
76
|
+
if (a.length !== b.length) return false;
|
|
77
|
+
return a.every((item, index) => deepEqual(item, b[index]));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (Array.isArray(a) || Array.isArray(b)) return false;
|
|
81
|
+
|
|
82
|
+
const aObj = a as Record<string, unknown>;
|
|
83
|
+
const bObj = b as Record<string, unknown>;
|
|
84
|
+
const aKeys = Object.keys(aObj);
|
|
85
|
+
const bKeys = Object.keys(bObj);
|
|
86
|
+
|
|
87
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
88
|
+
return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false;
|
|
92
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/core - Shared types and utilities for sync infrastructure
|
|
3
|
+
*
|
|
4
|
+
* This package contains:
|
|
5
|
+
* - Protocol types (commit-log + subscriptions)
|
|
6
|
+
* - Pure conflict detection and merge utilities
|
|
7
|
+
* - Logging utilities
|
|
8
|
+
* - Data transformation hooks (optional)
|
|
9
|
+
* - Blob types for media/binary handling
|
|
10
|
+
* - Zod schemas for runtime validation and OpenAPI
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Blob transport/storage types and utilities (protocol types come from ./schemas)
|
|
14
|
+
export * from './blobs';
|
|
15
|
+
// Conflict detection utilities
|
|
16
|
+
export * from './conflict';
|
|
17
|
+
// Kysely plugin utilities
|
|
18
|
+
export * from './kysely-serialize';
|
|
19
|
+
// Logging utilities
|
|
20
|
+
export * from './logger';
|
|
21
|
+
// Proxy protocol types
|
|
22
|
+
export * from './proxy';
|
|
23
|
+
// Schemas (Zod)
|
|
24
|
+
export * from './schemas';
|
|
25
|
+
// Scope types, patterns, and utilities
|
|
26
|
+
export * from './scopes';
|
|
27
|
+
// Snapshot chunk encoding helpers
|
|
28
|
+
export * from './snapshot-chunks';
|
|
29
|
+
// Telemetry abstraction
|
|
30
|
+
export * from './telemetry';
|
|
31
|
+
// Data transformation hooks
|
|
32
|
+
export * from './transforms';
|
|
33
|
+
// Transport and conflict types (protocol types come from ./schemas)
|
|
34
|
+
export * from './types';
|
|
35
|
+
// Shared runtime utilities
|
|
36
|
+
export * from './utils';
|
|
@@ -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,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/core - Structured logging utilities for sync operations
|
|
3
|
+
*
|
|
4
|
+
* Uses the active telemetry backend configured via `configureSyncTelemetry()`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getSyncTelemetry, type SyncTelemetryEvent } from './telemetry';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sync log event structure.
|
|
11
|
+
*/
|
|
12
|
+
export type SyncLogEvent = SyncTelemetryEvent;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Logger function type.
|
|
16
|
+
*/
|
|
17
|
+
export type SyncLogger = (event: SyncLogEvent) => void;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Log a sync event using the currently configured telemetry backend.
|
|
21
|
+
*/
|
|
22
|
+
export const logSyncEvent: SyncLogger = (event) => {
|
|
23
|
+
getSyncTelemetry().log(event);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a timer for measuring operation duration.
|
|
28
|
+
* Returns the elapsed time in milliseconds when called.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* const elapsed = createSyncTimer();
|
|
32
|
+
* await doSomeWork();
|
|
33
|
+
* logSyncEvent({ event: 'work_complete', durationMs: elapsed() });
|
|
34
|
+
*/
|
|
35
|
+
export function createSyncTimer(): () => number {
|
|
36
|
+
const start = performance.now();
|
|
37
|
+
return () => Math.round(performance.now() - start);
|
|
38
|
+
}
|
|
@@ -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
|
+
}
|