@syncular/server 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/adapters/database.d.ts +83 -0
- package/dist/blobs/adapters/database.d.ts.map +1 -0
- package/dist/blobs/adapters/database.js +202 -0
- package/dist/blobs/adapters/database.js.map +1 -0
- package/dist/blobs/adapters/s3.d.ts +82 -0
- package/dist/blobs/adapters/s3.d.ts.map +1 -0
- package/dist/blobs/adapters/s3.js +170 -0
- package/dist/blobs/adapters/s3.js.map +1 -0
- package/dist/blobs/index.d.ts +9 -0
- package/dist/blobs/index.d.ts.map +1 -0
- package/dist/blobs/index.js +9 -0
- package/dist/blobs/index.js.map +1 -0
- package/dist/blobs/manager.d.ts +195 -0
- package/dist/blobs/manager.d.ts.map +1 -0
- package/dist/blobs/manager.js +440 -0
- package/dist/blobs/manager.js.map +1 -0
- package/dist/blobs/migrate.d.ts +27 -0
- package/dist/blobs/migrate.d.ts.map +1 -0
- package/dist/blobs/migrate.js +119 -0
- package/dist/blobs/migrate.js.map +1 -0
- package/dist/blobs/types.d.ts +54 -0
- package/dist/blobs/types.d.ts.map +1 -0
- package/dist/blobs/types.js +5 -0
- package/dist/blobs/types.js.map +1 -0
- package/dist/clients.d.ts +14 -0
- package/dist/clients.d.ts.map +1 -0
- package/dist/clients.js +7 -0
- package/dist/clients.js.map +1 -0
- package/dist/compaction.d.ts +27 -0
- package/dist/compaction.d.ts.map +1 -0
- package/dist/compaction.js +49 -0
- package/dist/compaction.js.map +1 -0
- package/dist/dialect/base.d.ts +83 -0
- package/dist/dialect/base.d.ts.map +1 -0
- package/dist/dialect/base.js +144 -0
- package/dist/dialect/base.js.map +1 -0
- package/dist/dialect/helpers.d.ts +10 -0
- package/dist/dialect/helpers.d.ts.map +1 -0
- package/dist/dialect/helpers.js +59 -0
- package/dist/dialect/helpers.js.map +1 -0
- package/dist/dialect/index.d.ts +7 -0
- package/dist/dialect/index.d.ts.map +1 -0
- package/dist/dialect/index.js +7 -0
- package/dist/dialect/index.js.map +1 -0
- package/dist/dialect/types.d.ts +149 -0
- package/dist/dialect/types.d.ts.map +1 -0
- package/dist/dialect/types.js +8 -0
- package/dist/dialect/types.js.map +1 -0
- package/dist/helpers/conflict.d.ts +52 -0
- package/dist/helpers/conflict.d.ts.map +1 -0
- package/dist/helpers/conflict.js +49 -0
- package/dist/helpers/conflict.js.map +1 -0
- package/dist/helpers/emitted-change.d.ts +56 -0
- package/dist/helpers/emitted-change.d.ts.map +1 -0
- package/dist/helpers/emitted-change.js +46 -0
- package/dist/helpers/emitted-change.js.map +1 -0
- package/dist/helpers/index.d.ts +10 -0
- package/dist/helpers/index.d.ts.map +1 -0
- package/dist/helpers/index.js +10 -0
- package/dist/helpers/index.js.map +1 -0
- package/dist/helpers/paginate.d.ts +49 -0
- package/dist/helpers/paginate.d.ts.map +1 -0
- package/dist/helpers/paginate.js +54 -0
- package/dist/helpers/paginate.js.map +1 -0
- package/dist/helpers/scope-strings.d.ts +74 -0
- package/dist/helpers/scope-strings.d.ts.map +1 -0
- package/dist/helpers/scope-strings.js +82 -0
- package/dist/helpers/scope-strings.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/migrate.d.ts +14 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +13 -0
- package/dist/migrate.js.map +1 -0
- package/dist/proxy/handler.d.ts +42 -0
- package/dist/proxy/handler.d.ts.map +1 -0
- package/dist/proxy/handler.js +102 -0
- package/dist/proxy/handler.js.map +1 -0
- package/dist/proxy/index.d.ts +9 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +14 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/mutation-detector.d.ts +35 -0
- package/dist/proxy/mutation-detector.d.ts.map +1 -0
- package/dist/proxy/mutation-detector.js +246 -0
- package/dist/proxy/mutation-detector.js.map +1 -0
- package/dist/proxy/oplog.d.ts +30 -0
- package/dist/proxy/oplog.d.ts.map +1 -0
- package/dist/proxy/oplog.js +110 -0
- package/dist/proxy/oplog.js.map +1 -0
- package/dist/proxy/registry.d.ts +35 -0
- package/dist/proxy/registry.d.ts.map +1 -0
- package/dist/proxy/registry.js +49 -0
- package/dist/proxy/registry.js.map +1 -0
- package/dist/proxy/types.d.ts +44 -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/prune.d.ts +37 -0
- package/dist/prune.d.ts.map +1 -0
- package/dist/prune.js +112 -0
- package/dist/prune.js.map +1 -0
- package/dist/pull.d.ts +31 -0
- package/dist/pull.d.ts.map +1 -0
- package/dist/pull.js +608 -0
- package/dist/pull.js.map +1 -0
- package/dist/push.d.ts +33 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +412 -0
- package/dist/push.js.map +1 -0
- package/dist/realtime/in-memory.d.ts +13 -0
- package/dist/realtime/in-memory.d.ts.map +1 -0
- package/dist/realtime/in-memory.js +28 -0
- package/dist/realtime/in-memory.js.map +1 -0
- package/dist/realtime/index.d.ts +3 -0
- package/dist/realtime/index.d.ts.map +1 -0
- package/dist/realtime/index.js +2 -0
- package/dist/realtime/index.js.map +1 -0
- package/dist/realtime/types.d.ts +50 -0
- package/dist/realtime/types.d.ts.map +1 -0
- package/dist/realtime/types.js +7 -0
- package/dist/realtime/types.js.map +1 -0
- package/dist/schema.d.ts +164 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +10 -0
- package/dist/schema.js.map +1 -0
- package/dist/shapes/create-handler.d.ts +119 -0
- package/dist/shapes/create-handler.d.ts.map +1 -0
- package/dist/shapes/create-handler.js +327 -0
- package/dist/shapes/create-handler.js.map +1 -0
- package/dist/shapes/index.d.ts +4 -0
- package/dist/shapes/index.d.ts.map +1 -0
- package/dist/shapes/index.js +4 -0
- package/dist/shapes/index.js.map +1 -0
- package/dist/shapes/registry.d.ts +20 -0
- package/dist/shapes/registry.d.ts.map +1 -0
- package/dist/shapes/registry.js +88 -0
- package/dist/shapes/registry.js.map +1 -0
- package/dist/shapes/types.d.ts +204 -0
- package/dist/shapes/types.d.ts.map +1 -0
- package/dist/shapes/types.js +2 -0
- package/dist/shapes/types.js.map +1 -0
- package/dist/snapshot-chunks/adapters/s3.d.ts +74 -0
- package/dist/snapshot-chunks/adapters/s3.d.ts.map +1 -0
- package/dist/snapshot-chunks/adapters/s3.js +50 -0
- package/dist/snapshot-chunks/adapters/s3.js.map +1 -0
- package/dist/snapshot-chunks/db-metadata.d.ts +38 -0
- package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -0
- package/dist/snapshot-chunks/db-metadata.js +324 -0
- package/dist/snapshot-chunks/db-metadata.js.map +1 -0
- package/dist/snapshot-chunks/index.d.ts +9 -0
- package/dist/snapshot-chunks/index.d.ts.map +1 -0
- package/dist/snapshot-chunks/index.js +9 -0
- package/dist/snapshot-chunks/index.js.map +1 -0
- package/dist/snapshot-chunks/types.d.ts +78 -0
- package/dist/snapshot-chunks/types.d.ts.map +1 -0
- package/dist/snapshot-chunks/types.js +8 -0
- package/dist/snapshot-chunks/types.js.map +1 -0
- package/dist/snapshot-chunks.d.ts +60 -0
- package/dist/snapshot-chunks.d.ts.map +1 -0
- package/dist/snapshot-chunks.js +223 -0
- package/dist/snapshot-chunks.js.map +1 -0
- package/dist/stats.d.ts +19 -0
- package/dist/stats.d.ts.map +1 -0
- package/dist/stats.js +57 -0
- package/dist/stats.js.map +1 -0
- package/dist/subscriptions/index.d.ts +2 -0
- package/dist/subscriptions/index.d.ts.map +1 -0
- package/dist/subscriptions/index.js +2 -0
- package/dist/subscriptions/index.js.map +1 -0
- package/dist/subscriptions/resolve.d.ts +35 -0
- package/dist/subscriptions/resolve.d.ts.map +1 -0
- package/dist/subscriptions/resolve.js +134 -0
- package/dist/subscriptions/resolve.js.map +1 -0
- package/package.json +80 -0
- package/src/blobs/adapters/database.test.ts +67 -0
- package/src/blobs/adapters/database.ts +315 -0
- package/src/blobs/adapters/s3.ts +271 -0
- package/src/blobs/index.ts +9 -0
- package/src/blobs/manager.ts +600 -0
- package/src/blobs/migrate.ts +150 -0
- package/src/blobs/types.ts +70 -0
- package/src/clients.ts +21 -0
- package/src/compaction.ts +77 -0
- package/src/dialect/base.ts +292 -0
- package/src/dialect/helpers.ts +61 -0
- package/src/dialect/index.ts +7 -0
- package/src/dialect/types.ts +197 -0
- package/src/helpers/conflict.ts +64 -0
- package/src/helpers/emitted-change.ts +69 -0
- package/src/helpers/index.ts +10 -0
- package/src/helpers/paginate.ts +82 -0
- package/src/helpers/scope-strings.ts +101 -0
- package/src/index.ts +28 -0
- package/src/migrate.ts +20 -0
- package/src/proxy/handler.test.ts +120 -0
- package/src/proxy/handler.ts +159 -0
- package/src/proxy/index.ts +18 -0
- package/src/proxy/mutation-detector.test.ts +71 -0
- package/src/proxy/mutation-detector.ts +281 -0
- package/src/proxy/oplog.ts +146 -0
- package/src/proxy/registry.ts +56 -0
- package/src/proxy/types.ts +46 -0
- package/src/prune.ts +200 -0
- package/src/pull.ts +858 -0
- package/src/push.ts +583 -0
- package/src/realtime/in-memory.ts +33 -0
- package/src/realtime/index.ts +5 -0
- package/src/realtime/types.ts +55 -0
- package/src/schema.ts +172 -0
- package/src/shapes/create-handler.ts +590 -0
- package/src/shapes/index.ts +3 -0
- package/src/shapes/registry.ts +109 -0
- package/src/shapes/types.ts +267 -0
- package/src/snapshot-chunks/adapters/s3.ts +68 -0
- package/src/snapshot-chunks/db-metadata.test.ts +100 -0
- package/src/snapshot-chunks/db-metadata.ts +466 -0
- package/src/snapshot-chunks/index.ts +9 -0
- package/src/snapshot-chunks/types.ts +103 -0
- package/src/snapshot-chunks.ts +329 -0
- package/src/stats.ts +104 -0
- package/src/subscriptions/index.ts +1 -0
- package/src/subscriptions/resolve.ts +185 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/server - Server Sync Dialect Interface
|
|
3
|
+
*
|
|
4
|
+
* Abstracts database-specific operations for commit-log sync.
|
|
5
|
+
* Supports the new JSONB scopes model.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ScopeValues, StoredScopes, SyncOp } from '@syncular/core';
|
|
9
|
+
import type { Kysely, Transaction } from 'kysely';
|
|
10
|
+
import type { SyncChangeRow, SyncCommitRow, SyncCoreDb } from '../schema';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Common database executor type that works with both Kysely and Transaction.
|
|
14
|
+
* Generic version allows for extended database types that include sync tables.
|
|
15
|
+
*/
|
|
16
|
+
export type DbExecutor<DB extends SyncCoreDb = SyncCoreDb> =
|
|
17
|
+
| Kysely<DB>
|
|
18
|
+
| Transaction<DB>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Supported dialect names.
|
|
22
|
+
*/
|
|
23
|
+
export type ServerSyncDialectName = string;
|
|
24
|
+
|
|
25
|
+
export interface IncrementalPullRowsArgs {
|
|
26
|
+
table: string;
|
|
27
|
+
scopes: ScopeValues;
|
|
28
|
+
cursor: number;
|
|
29
|
+
limitCommits: number;
|
|
30
|
+
partitionId?: string;
|
|
31
|
+
batchSize?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IncrementalPullRow {
|
|
35
|
+
commit_seq: number;
|
|
36
|
+
actor_id: string;
|
|
37
|
+
created_at: string;
|
|
38
|
+
change_id: number;
|
|
39
|
+
table: string;
|
|
40
|
+
row_id: string;
|
|
41
|
+
op: SyncOp;
|
|
42
|
+
row_json: unknown | null;
|
|
43
|
+
row_version: number | null;
|
|
44
|
+
scopes: StoredScopes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ServerSyncDialect {
|
|
48
|
+
readonly name: ServerSyncDialectName;
|
|
49
|
+
|
|
50
|
+
/** Create sync tables + indexes (idempotent) */
|
|
51
|
+
ensureSyncSchema<DB extends SyncCoreDb>(db: Kysely<DB>): Promise<void>;
|
|
52
|
+
|
|
53
|
+
/** Create console-specific tables (e.g., sync_request_events) - optional */
|
|
54
|
+
ensureConsoleSchema?<DB extends SyncCoreDb>(db: Kysely<DB>): Promise<void>;
|
|
55
|
+
|
|
56
|
+
/** Execute callback in a transaction (or directly if transactions not supported). */
|
|
57
|
+
executeInTransaction<DB extends SyncCoreDb, T>(
|
|
58
|
+
db: Kysely<DB>,
|
|
59
|
+
fn: (executor: DbExecutor<DB>) => Promise<T>
|
|
60
|
+
): Promise<T>;
|
|
61
|
+
|
|
62
|
+
/** Set REPEATABLE READ (or closest equivalent) */
|
|
63
|
+
setRepeatableRead<DB extends SyncCoreDb>(trx: DbExecutor<DB>): Promise<void>;
|
|
64
|
+
|
|
65
|
+
/** Read the maximum committed commit_seq (0 if none) */
|
|
66
|
+
readMaxCommitSeq<DB extends SyncCoreDb>(
|
|
67
|
+
db: DbExecutor<DB>,
|
|
68
|
+
options?: { partitionId?: string }
|
|
69
|
+
): Promise<number>;
|
|
70
|
+
|
|
71
|
+
/** Read the minimum committed commit_seq (0 if none) */
|
|
72
|
+
readMinCommitSeq<DB extends SyncCoreDb>(
|
|
73
|
+
db: DbExecutor<DB>,
|
|
74
|
+
options?: { partitionId?: string }
|
|
75
|
+
): Promise<number>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read the next commit sequence numbers that have changes for the given tables.
|
|
79
|
+
* Must return commit_seq values in ascending order.
|
|
80
|
+
*/
|
|
81
|
+
readCommitSeqsForPull<DB extends SyncCoreDb>(
|
|
82
|
+
db: DbExecutor<DB>,
|
|
83
|
+
args: {
|
|
84
|
+
cursor: number;
|
|
85
|
+
limitCommits: number;
|
|
86
|
+
tables: string[];
|
|
87
|
+
partitionId?: string;
|
|
88
|
+
}
|
|
89
|
+
): Promise<number[]>;
|
|
90
|
+
|
|
91
|
+
/** Read commit metadata for commit_seq values */
|
|
92
|
+
readCommits<DB extends SyncCoreDb>(
|
|
93
|
+
db: DbExecutor<DB>,
|
|
94
|
+
commitSeqs: number[],
|
|
95
|
+
options?: { partitionId?: string }
|
|
96
|
+
): Promise<SyncCommitRow[]>;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read changes for commit_seq values, filtered by table and scopes.
|
|
100
|
+
* Uses JSONB filtering for scope matching.
|
|
101
|
+
*/
|
|
102
|
+
readChangesForCommits<DB extends SyncCoreDb>(
|
|
103
|
+
db: DbExecutor<DB>,
|
|
104
|
+
args: {
|
|
105
|
+
commitSeqs: number[];
|
|
106
|
+
table: string;
|
|
107
|
+
scopes: ScopeValues;
|
|
108
|
+
partitionId?: string;
|
|
109
|
+
}
|
|
110
|
+
): Promise<SyncChangeRow[]>;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Incremental pull iterator for a subscription.
|
|
114
|
+
*
|
|
115
|
+
* Yields change rows joined with commit metadata and filtered by
|
|
116
|
+
* the subscription's table and scope values.
|
|
117
|
+
*/
|
|
118
|
+
iterateIncrementalPullRows<DB extends SyncCoreDb>(
|
|
119
|
+
db: DbExecutor<DB>,
|
|
120
|
+
args: IncrementalPullRowsArgs
|
|
121
|
+
): AsyncGenerator<IncrementalPullRow>;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Optional compaction of the change log to reduce storage.
|
|
125
|
+
*
|
|
126
|
+
* Keeps full history for the most recent N hours.
|
|
127
|
+
* For older history, keeps only the newest change per (table, row_id, scopes).
|
|
128
|
+
*/
|
|
129
|
+
compactChanges<DB extends SyncCoreDb>(
|
|
130
|
+
db: DbExecutor<DB>,
|
|
131
|
+
args: { fullHistoryHours: number }
|
|
132
|
+
): Promise<number>;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Record/update a client cursor for tracking and pruning.
|
|
136
|
+
*/
|
|
137
|
+
recordClientCursor<DB extends SyncCoreDb>(
|
|
138
|
+
db: DbExecutor<DB>,
|
|
139
|
+
args: {
|
|
140
|
+
partitionId?: string;
|
|
141
|
+
clientId: string;
|
|
142
|
+
actorId: string;
|
|
143
|
+
cursor: number;
|
|
144
|
+
effectiveScopes: ScopeValues;
|
|
145
|
+
}
|
|
146
|
+
): Promise<void>;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Convert a StoredScopes object to database representation.
|
|
150
|
+
* For Postgres: returns as-is (native JSONB)
|
|
151
|
+
* For SQLite: returns JSON.stringify()
|
|
152
|
+
*/
|
|
153
|
+
scopesToDb(scopes: StoredScopes): unknown;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Convert database scopes representation to StoredScopes.
|
|
157
|
+
*/
|
|
158
|
+
dbToScopes(value: unknown): StoredScopes;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether the dialect supports SELECT ... FOR UPDATE row locking.
|
|
162
|
+
* Postgres: true
|
|
163
|
+
* SQLite: false (uses database-level locking)
|
|
164
|
+
*/
|
|
165
|
+
readonly supportsForUpdate: boolean;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Whether the dialect supports SAVEPOINT / ROLLBACK TO SAVEPOINT.
|
|
169
|
+
* Postgres/SQLite: true
|
|
170
|
+
* D1 (Durable Object): false (blocks raw SAVEPOINT statements)
|
|
171
|
+
*/
|
|
172
|
+
readonly supportsSavepoints: boolean;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Read distinct tables from sync_changes for a given commit.
|
|
176
|
+
* Used for realtime notifications.
|
|
177
|
+
*/
|
|
178
|
+
readAffectedTablesFromChanges<DB extends SyncCoreDb>(
|
|
179
|
+
db: DbExecutor<DB>,
|
|
180
|
+
commitSeq: number,
|
|
181
|
+
options?: { partitionId?: string }
|
|
182
|
+
): Promise<string[]>;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Convert database array representation to string[].
|
|
186
|
+
* For Postgres: returns as-is (native array)
|
|
187
|
+
* For SQLite: returns JSON.parse() or empty array if null
|
|
188
|
+
*/
|
|
189
|
+
dbToArray(value: unknown): string[];
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Convert string[] to database array representation.
|
|
193
|
+
* For Postgres: returns as-is (native array)
|
|
194
|
+
* For SQLite: returns JSON.stringify()
|
|
195
|
+
*/
|
|
196
|
+
arrayToDb(values: string[]): unknown;
|
|
197
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/server - Conflict result builder
|
|
3
|
+
*
|
|
4
|
+
* Helper for building conflict results in server table handlers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ApplyOperationResult } from '../shapes/types';
|
|
8
|
+
|
|
9
|
+
export interface BuildConflictResultArgs {
|
|
10
|
+
/** Index of the operation in the batch */
|
|
11
|
+
opIndex: number;
|
|
12
|
+
/** Current server row data */
|
|
13
|
+
serverRow: unknown;
|
|
14
|
+
/** Current server version */
|
|
15
|
+
serverVersion: number;
|
|
16
|
+
/** Client's base version (what they thought they were updating) */
|
|
17
|
+
baseVersion: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build a conflict result for applyOperation.
|
|
22
|
+
*
|
|
23
|
+
* Use this when the client's base version doesn't match the server's current version,
|
|
24
|
+
* indicating a concurrent modification conflict.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* const handler: ServerTableHandler = {
|
|
29
|
+
* table: 'tasks',
|
|
30
|
+
* async applyOperation(ctx, op, opIndex) {
|
|
31
|
+
* const existing = await ctx.db
|
|
32
|
+
* .selectFrom('tasks')
|
|
33
|
+
* .selectAll()
|
|
34
|
+
* .where('id', '=', op.row_id)
|
|
35
|
+
* .executeTakeFirst();
|
|
36
|
+
*
|
|
37
|
+
* // Check for version conflict
|
|
38
|
+
* if (existing && op.base_version !== null && existing.version !== op.base_version) {
|
|
39
|
+
* return {
|
|
40
|
+
* result: buildConflictResult({
|
|
41
|
+
* opIndex,
|
|
42
|
+
* serverRow: existing,
|
|
43
|
+
* serverVersion: existing.version,
|
|
44
|
+
* baseVersion: op.base_version,
|
|
45
|
+
* }),
|
|
46
|
+
* };
|
|
47
|
+
* }
|
|
48
|
+
*
|
|
49
|
+
* // ... apply the operation
|
|
50
|
+
* },
|
|
51
|
+
* };
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export function buildConflictResult(
|
|
55
|
+
args: BuildConflictResultArgs
|
|
56
|
+
): ApplyOperationResult['result'] {
|
|
57
|
+
return {
|
|
58
|
+
opIndex: args.opIndex,
|
|
59
|
+
status: 'conflict',
|
|
60
|
+
message: `Version conflict: server=${args.serverVersion}, base=${args.baseVersion}`,
|
|
61
|
+
server_version: args.serverVersion,
|
|
62
|
+
server_row: args.serverRow,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/server - Emitted change builder
|
|
3
|
+
*
|
|
4
|
+
* Helper for creating emitted changes in server table handlers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { StoredScopes } from '@syncular/core';
|
|
8
|
+
import type { EmittedChange } from '../shapes/types';
|
|
9
|
+
|
|
10
|
+
export interface CreateEmittedChangeArgs {
|
|
11
|
+
/** Table name */
|
|
12
|
+
table: string;
|
|
13
|
+
/** Row primary key */
|
|
14
|
+
rowId: string;
|
|
15
|
+
/** Operation type */
|
|
16
|
+
op: 'upsert' | 'delete';
|
|
17
|
+
/** Row data (null for delete) */
|
|
18
|
+
row: unknown | null;
|
|
19
|
+
/** Row version (null if not versioned) */
|
|
20
|
+
version: number | null;
|
|
21
|
+
/**
|
|
22
|
+
* Scope values for this change (stored as JSONB).
|
|
23
|
+
* Example: { user_id: 'U1', project_id: 'P1' }
|
|
24
|
+
*/
|
|
25
|
+
scopes: StoredScopes;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create an emitted change for broadcasting to subscribed clients.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* const handler: ServerTableHandler = {
|
|
34
|
+
* table: 'tasks',
|
|
35
|
+
* async applyOperation(ctx, op, opIndex) {
|
|
36
|
+
* // ... apply the operation ...
|
|
37
|
+
*
|
|
38
|
+
* const newVersion = await getTaskVersion(ctx.db, op.row_id);
|
|
39
|
+
* const updatedRow = await getTask(ctx.db, op.row_id);
|
|
40
|
+
*
|
|
41
|
+
* return {
|
|
42
|
+
* result: { opIndex, status: 'applied', newVersion },
|
|
43
|
+
* emittedChanges: [
|
|
44
|
+
* createEmittedChange({
|
|
45
|
+
* table: 'tasks',
|
|
46
|
+
* rowId: op.row_id,
|
|
47
|
+
* op: 'upsert',
|
|
48
|
+
* row: updatedRow,
|
|
49
|
+
* version: newVersion,
|
|
50
|
+
* scopes: { user_id: updatedRow.user_id },
|
|
51
|
+
* }),
|
|
52
|
+
* ],
|
|
53
|
+
* };
|
|
54
|
+
* },
|
|
55
|
+
* };
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function createEmittedChange(
|
|
59
|
+
args: CreateEmittedChangeArgs
|
|
60
|
+
): EmittedChange {
|
|
61
|
+
return {
|
|
62
|
+
table: args.table,
|
|
63
|
+
row_id: args.rowId,
|
|
64
|
+
op: args.op,
|
|
65
|
+
row_json: args.op === 'delete' ? null : args.row,
|
|
66
|
+
row_version: args.version,
|
|
67
|
+
scopes: args.scopes,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/server - Pagination helper
|
|
3
|
+
*
|
|
4
|
+
* Simplifies cursor-based pagination for snapshot queries.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { SelectQueryBuilder } from 'kysely';
|
|
8
|
+
|
|
9
|
+
export interface PaginateOptions {
|
|
10
|
+
/** Cursor value to start from (null for first page) */
|
|
11
|
+
cursor: string | null;
|
|
12
|
+
/** Number of rows per page */
|
|
13
|
+
limit: number;
|
|
14
|
+
/** Column to use for cursor-based pagination (default: 'id') */
|
|
15
|
+
cursorColumn?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PaginateResult<T> {
|
|
19
|
+
/** Rows for this page */
|
|
20
|
+
rows: T[];
|
|
21
|
+
/** Cursor for next page (null if no more pages) */
|
|
22
|
+
nextCursor: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Apply cursor-based pagination to a Kysely query.
|
|
27
|
+
*
|
|
28
|
+
* This helper simplifies implementing snapshot pagination by:
|
|
29
|
+
* - Applying cursor filter if provided
|
|
30
|
+
* - Ordering by the cursor column
|
|
31
|
+
* - Fetching limit + 1 to determine if there's a next page
|
|
32
|
+
* - Computing the next cursor
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* const handler: ServerTableHandler = {
|
|
37
|
+
* table: 'tasks',
|
|
38
|
+
* async snapshot(ctx) {
|
|
39
|
+
* const query = ctx.db
|
|
40
|
+
* .selectFrom('tasks')
|
|
41
|
+
* .selectAll()
|
|
42
|
+
* .where('user_id', '=', ctx.actorId);
|
|
43
|
+
*
|
|
44
|
+
* return paginate(query, {
|
|
45
|
+
* cursor: ctx.cursor,
|
|
46
|
+
* limit: ctx.limit,
|
|
47
|
+
* });
|
|
48
|
+
* },
|
|
49
|
+
* };
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export async function paginate<T>(
|
|
53
|
+
query: SelectQueryBuilder<any, any, T>,
|
|
54
|
+
options: PaginateOptions
|
|
55
|
+
): Promise<PaginateResult<T>> {
|
|
56
|
+
const { cursor, limit, cursorColumn = 'id' } = options;
|
|
57
|
+
|
|
58
|
+
// Apply cursor filter if resuming from a previous page
|
|
59
|
+
let q = query;
|
|
60
|
+
if (cursor) {
|
|
61
|
+
q = q.where(cursorColumn, '>', cursor) as typeof q;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Order by cursor column and fetch limit + 1 to check for more pages
|
|
65
|
+
const rows = await q
|
|
66
|
+
.orderBy(cursorColumn, 'asc')
|
|
67
|
+
.limit(limit + 1)
|
|
68
|
+
.execute();
|
|
69
|
+
|
|
70
|
+
// Determine if there are more pages
|
|
71
|
+
const hasMore = rows.length > limit;
|
|
72
|
+
const pageRows = hasMore ? rows.slice(0, limit) : rows;
|
|
73
|
+
|
|
74
|
+
// Compute next cursor from last row
|
|
75
|
+
const nextCursor = hasMore
|
|
76
|
+
? (((pageRows[pageRows.length - 1] as Record<string, unknown>)?.[
|
|
77
|
+
cursorColumn
|
|
78
|
+
] as string) ?? null)
|
|
79
|
+
: null;
|
|
80
|
+
|
|
81
|
+
return { rows: pageRows, nextCursor };
|
|
82
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/server - Scope string utilities
|
|
3
|
+
*
|
|
4
|
+
* Helpers for creating and parsing scope strings.
|
|
5
|
+
* Scope strings identify partitions of data for sync subscriptions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Result from parsing a scope key
|
|
10
|
+
*/
|
|
11
|
+
interface ParsedScopeKey {
|
|
12
|
+
/** The prefix (first segment) */
|
|
13
|
+
prefix: string;
|
|
14
|
+
/** The remaining values (segments after prefix) */
|
|
15
|
+
values: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a scope string from a prefix and values.
|
|
20
|
+
*
|
|
21
|
+
* Scope strings use colon separators: `prefix:value1:value2`
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* // Simple scope string
|
|
26
|
+
* createScopeKey('user', 'alice')
|
|
27
|
+
* // => 'user:alice'
|
|
28
|
+
*
|
|
29
|
+
* // Multi-value scope string
|
|
30
|
+
* createScopeKey('user', 'alice', 'project', 'proj-1')
|
|
31
|
+
* // => 'user:alice:project:proj-1'
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function createScopeKey(prefix: string, ...values: string[]): string {
|
|
35
|
+
return [prefix, ...values].join(':');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse a scope string into its prefix and values.
|
|
40
|
+
*
|
|
41
|
+
* Returns null if the key is invalid or doesn't match the expected prefix.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* // Parse any scope string
|
|
46
|
+
* parseScopeKey('user:alice')
|
|
47
|
+
* // => { prefix: 'user', values: ['alice'] }
|
|
48
|
+
*
|
|
49
|
+
* // Parse with expected prefix
|
|
50
|
+
* parseScopeKey('user:alice', 'user')
|
|
51
|
+
* // => { prefix: 'user', values: ['alice'] }
|
|
52
|
+
*
|
|
53
|
+
* // Returns null if prefix doesn't match
|
|
54
|
+
* parseScopeKey('user:alice', 'project')
|
|
55
|
+
* // => null
|
|
56
|
+
*
|
|
57
|
+
* // Multi-value scope string
|
|
58
|
+
* parseScopeKey('user:alice:project:proj-1')
|
|
59
|
+
* // => { prefix: 'user', values: ['alice', 'project', 'proj-1'] }
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function parseScopeKey(
|
|
63
|
+
key: string,
|
|
64
|
+
expectedPrefix?: string
|
|
65
|
+
): ParsedScopeKey | null {
|
|
66
|
+
const parts = key.split(':');
|
|
67
|
+
if (parts.length < 1) return null;
|
|
68
|
+
|
|
69
|
+
const [prefix, ...values] = parts;
|
|
70
|
+
if (!prefix) return null;
|
|
71
|
+
|
|
72
|
+
// Check expected prefix if provided
|
|
73
|
+
if (expectedPrefix && prefix !== expectedPrefix) return null;
|
|
74
|
+
|
|
75
|
+
return { prefix, values };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extract a specific value from a scope string by index.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```typescript
|
|
83
|
+
* // Get first value after prefix
|
|
84
|
+
* getScopeKeyValue('user:alice:project:proj-1', 0)
|
|
85
|
+
* // => 'alice'
|
|
86
|
+
*
|
|
87
|
+
* // Get second value
|
|
88
|
+
* getScopeKeyValue('user:alice:project:proj-1', 2)
|
|
89
|
+
* // => 'proj-1'
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export function getScopeKeyValue(
|
|
93
|
+
key: string,
|
|
94
|
+
valueIndex: number
|
|
95
|
+
): string | null {
|
|
96
|
+
const parsed = parseScopeKey(key);
|
|
97
|
+
if (!parsed) return null;
|
|
98
|
+
return parsed.values[valueIndex] ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type { ParsedScopeKey };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/server - Server-side sync infrastructure
|
|
3
|
+
*
|
|
4
|
+
* Commit-log based sync with:
|
|
5
|
+
* - commit log + change log
|
|
6
|
+
* - scopes + subscriptions (partial sync + auth)
|
|
7
|
+
* - commit-level idempotency
|
|
8
|
+
* - blob/media storage
|
|
9
|
+
*/
|
|
10
|
+
export * from '@syncular/core';
|
|
11
|
+
|
|
12
|
+
export * from './blobs';
|
|
13
|
+
export * from './clients';
|
|
14
|
+
export * from './compaction';
|
|
15
|
+
export * from './dialect';
|
|
16
|
+
export * from './helpers';
|
|
17
|
+
export * from './migrate';
|
|
18
|
+
export * from './proxy';
|
|
19
|
+
export * from './prune';
|
|
20
|
+
export * from './pull';
|
|
21
|
+
export * from './push';
|
|
22
|
+
export * from './realtime';
|
|
23
|
+
export * from './schema';
|
|
24
|
+
export * from './shapes';
|
|
25
|
+
export * from './snapshot-chunks';
|
|
26
|
+
export type { SnapshotChunkStorage } from './snapshot-chunks/types';
|
|
27
|
+
export * from './stats';
|
|
28
|
+
export * from './subscriptions';
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/server - Schema setup
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Kysely } from 'kysely';
|
|
6
|
+
import type { ServerSyncDialect } from './dialect/types';
|
|
7
|
+
import type { SyncCoreDb } from './schema';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Ensures the sync schema exists in the database.
|
|
11
|
+
* Safe to call multiple times (idempotent).
|
|
12
|
+
*
|
|
13
|
+
* @typeParam DB - Your database type that extends SyncCoreDb
|
|
14
|
+
*/
|
|
15
|
+
export async function ensureSyncSchema<DB extends SyncCoreDb>(
|
|
16
|
+
db: Kysely<DB>,
|
|
17
|
+
dialect: ServerSyncDialect
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
await dialect.ensureSyncSchema(db);
|
|
20
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
import { createBunSqliteDb } from '../../../dialect-bun-sqlite/src';
|
|
4
|
+
import { createSqliteServerDialect } from '../../../server-dialect-sqlite/src';
|
|
5
|
+
import { ensureSyncSchema } from '../migrate';
|
|
6
|
+
import type { SyncCoreDb } from '../schema';
|
|
7
|
+
import { executeProxyQuery } from './handler';
|
|
8
|
+
import { ProxyTableRegistry } from './registry';
|
|
9
|
+
|
|
10
|
+
interface TasksTable {
|
|
11
|
+
id: string;
|
|
12
|
+
user_id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
server_version: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ProxyTestDb extends SyncCoreDb {
|
|
18
|
+
tasks: TasksTable;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('executeProxyQuery', () => {
|
|
22
|
+
let db: Kysely<ProxyTestDb>;
|
|
23
|
+
const dialect = createSqliteServerDialect();
|
|
24
|
+
const shapes = new ProxyTableRegistry().register({
|
|
25
|
+
table: 'tasks',
|
|
26
|
+
computeScopes: (row) => ({
|
|
27
|
+
user_id: String(row.user_id),
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
db = createBunSqliteDb<ProxyTestDb>({ path: ':memory:' });
|
|
33
|
+
await ensureSyncSchema(db, dialect);
|
|
34
|
+
|
|
35
|
+
await db.schema
|
|
36
|
+
.createTable('tasks')
|
|
37
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
38
|
+
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
39
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
40
|
+
.addColumn('server_version', 'integer', (col) => col.notNull())
|
|
41
|
+
.execute();
|
|
42
|
+
|
|
43
|
+
await db
|
|
44
|
+
.insertInto('tasks')
|
|
45
|
+
.values({
|
|
46
|
+
id: 't1',
|
|
47
|
+
user_id: 'u1',
|
|
48
|
+
title: 'old title',
|
|
49
|
+
server_version: 1,
|
|
50
|
+
})
|
|
51
|
+
.execute();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
await db.destroy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('tracks comment-prefixed mutations in the sync oplog', async () => {
|
|
59
|
+
const result = await executeProxyQuery({
|
|
60
|
+
db,
|
|
61
|
+
dialect,
|
|
62
|
+
shapes,
|
|
63
|
+
ctx: { actorId: 'actor-1', clientId: 'proxy-client-1' },
|
|
64
|
+
sqlQuery:
|
|
65
|
+
'/* admin */ UPDATE tasks SET title = $1, server_version = server_version + 1 WHERE id = $2',
|
|
66
|
+
parameters: ['new title', 't1'],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result.rowCount).toBe(1);
|
|
70
|
+
expect(result.commitSeq).toBeGreaterThan(0);
|
|
71
|
+
|
|
72
|
+
const commitCount = await db
|
|
73
|
+
.selectFrom('sync_commits')
|
|
74
|
+
.select(({ fn }) => fn.countAll().as('count'))
|
|
75
|
+
.executeTakeFirstOrThrow();
|
|
76
|
+
expect(Number(commitCount.count)).toBe(1);
|
|
77
|
+
|
|
78
|
+
const changeCount = await db
|
|
79
|
+
.selectFrom('sync_changes')
|
|
80
|
+
.select(({ fn }) => fn.countAll().as('count'))
|
|
81
|
+
.executeTakeFirstOrThrow();
|
|
82
|
+
expect(Number(changeCount.count)).toBe(1);
|
|
83
|
+
|
|
84
|
+
const updated = await db
|
|
85
|
+
.selectFrom('tasks')
|
|
86
|
+
.select(['title', 'server_version'])
|
|
87
|
+
.where('id', '=', 't1')
|
|
88
|
+
.executeTakeFirstOrThrow();
|
|
89
|
+
expect(updated.title).toBe('new title');
|
|
90
|
+
expect(updated.server_version).toBe(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('rejects non-wildcard RETURNING on synced-table mutations', async () => {
|
|
94
|
+
await expect(
|
|
95
|
+
executeProxyQuery({
|
|
96
|
+
db,
|
|
97
|
+
dialect,
|
|
98
|
+
shapes,
|
|
99
|
+
ctx: { actorId: 'actor-1', clientId: 'proxy-client-1' },
|
|
100
|
+
sqlQuery: 'UPDATE tasks SET title = $1 WHERE id = $2 RETURNING id',
|
|
101
|
+
parameters: ['blocked title', 't1'],
|
|
102
|
+
})
|
|
103
|
+
).rejects.toThrow(
|
|
104
|
+
'Proxy mutation on synced table "tasks" must use RETURNING * (or omit RETURNING)'
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const commitCount = await db
|
|
108
|
+
.selectFrom('sync_commits')
|
|
109
|
+
.select(({ fn }) => fn.countAll().as('count'))
|
|
110
|
+
.executeTakeFirstOrThrow();
|
|
111
|
+
expect(Number(commitCount.count)).toBe(0);
|
|
112
|
+
|
|
113
|
+
const row = await db
|
|
114
|
+
.selectFrom('tasks')
|
|
115
|
+
.select(['title'])
|
|
116
|
+
.where('id', '=', 't1')
|
|
117
|
+
.executeTakeFirstOrThrow();
|
|
118
|
+
expect(row.title).toBe('old title');
|
|
119
|
+
});
|
|
120
|
+
});
|