@syncular/server 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 (211) hide show
  1. package/dist/blobs/adapters/database.d.ts +83 -0
  2. package/dist/blobs/adapters/database.d.ts.map +1 -0
  3. package/dist/blobs/adapters/database.js +180 -0
  4. package/dist/blobs/adapters/database.js.map +1 -0
  5. package/dist/blobs/adapters/s3.d.ts +82 -0
  6. package/dist/blobs/adapters/s3.d.ts.map +1 -0
  7. package/dist/blobs/adapters/s3.js +170 -0
  8. package/dist/blobs/adapters/s3.js.map +1 -0
  9. package/dist/blobs/index.d.ts +9 -0
  10. package/dist/blobs/index.d.ts.map +1 -0
  11. package/dist/blobs/index.js +9 -0
  12. package/dist/blobs/index.js.map +1 -0
  13. package/dist/blobs/manager.d.ts +195 -0
  14. package/dist/blobs/manager.d.ts.map +1 -0
  15. package/dist/blobs/manager.js +440 -0
  16. package/dist/blobs/manager.js.map +1 -0
  17. package/dist/blobs/migrate.d.ts +27 -0
  18. package/dist/blobs/migrate.d.ts.map +1 -0
  19. package/dist/blobs/migrate.js +119 -0
  20. package/dist/blobs/migrate.js.map +1 -0
  21. package/dist/blobs/types.d.ts +54 -0
  22. package/dist/blobs/types.d.ts.map +1 -0
  23. package/dist/blobs/types.js +5 -0
  24. package/dist/blobs/types.js.map +1 -0
  25. package/dist/clients.d.ts +14 -0
  26. package/dist/clients.d.ts.map +1 -0
  27. package/dist/clients.js +7 -0
  28. package/dist/clients.js.map +1 -0
  29. package/dist/compaction.d.ts +27 -0
  30. package/dist/compaction.d.ts.map +1 -0
  31. package/dist/compaction.js +49 -0
  32. package/dist/compaction.js.map +1 -0
  33. package/dist/dialect/index.d.ts +5 -0
  34. package/dist/dialect/index.d.ts.map +1 -0
  35. package/dist/dialect/index.js +5 -0
  36. package/dist/dialect/index.js.map +1 -0
  37. package/dist/dialect/types.d.ts +170 -0
  38. package/dist/dialect/types.d.ts.map +1 -0
  39. package/dist/dialect/types.js +8 -0
  40. package/dist/dialect/types.js.map +1 -0
  41. package/dist/helpers/conflict.d.ts +52 -0
  42. package/dist/helpers/conflict.d.ts.map +1 -0
  43. package/dist/helpers/conflict.js +49 -0
  44. package/dist/helpers/conflict.js.map +1 -0
  45. package/dist/helpers/emitted-change.d.ts +56 -0
  46. package/dist/helpers/emitted-change.d.ts.map +1 -0
  47. package/dist/helpers/emitted-change.js +46 -0
  48. package/dist/helpers/emitted-change.js.map +1 -0
  49. package/dist/helpers/index.d.ts +10 -0
  50. package/dist/helpers/index.d.ts.map +1 -0
  51. package/dist/helpers/index.js +10 -0
  52. package/dist/helpers/index.js.map +1 -0
  53. package/dist/helpers/paginate.d.ts +49 -0
  54. package/dist/helpers/paginate.d.ts.map +1 -0
  55. package/dist/helpers/paginate.js +54 -0
  56. package/dist/helpers/paginate.js.map +1 -0
  57. package/dist/helpers/scope-strings.d.ts +74 -0
  58. package/dist/helpers/scope-strings.d.ts.map +1 -0
  59. package/dist/helpers/scope-strings.js +82 -0
  60. package/dist/helpers/scope-strings.js.map +1 -0
  61. package/dist/index.d.ts +28 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +27 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/migrate.d.ts +14 -0
  66. package/dist/migrate.d.ts.map +1 -0
  67. package/dist/migrate.js +13 -0
  68. package/dist/migrate.js.map +1 -0
  69. package/dist/proxy/handler.d.ts +42 -0
  70. package/dist/proxy/handler.d.ts.map +1 -0
  71. package/dist/proxy/handler.js +99 -0
  72. package/dist/proxy/handler.js.map +1 -0
  73. package/dist/proxy/index.d.ts +9 -0
  74. package/dist/proxy/index.d.ts.map +1 -0
  75. package/dist/proxy/index.js +14 -0
  76. package/dist/proxy/index.js.map +1 -0
  77. package/dist/proxy/mutation-detector.d.ts +31 -0
  78. package/dist/proxy/mutation-detector.d.ts.map +1 -0
  79. package/dist/proxy/mutation-detector.js +61 -0
  80. package/dist/proxy/mutation-detector.js.map +1 -0
  81. package/dist/proxy/oplog.d.ts +30 -0
  82. package/dist/proxy/oplog.d.ts.map +1 -0
  83. package/dist/proxy/oplog.js +110 -0
  84. package/dist/proxy/oplog.js.map +1 -0
  85. package/dist/proxy/registry.d.ts +35 -0
  86. package/dist/proxy/registry.d.ts.map +1 -0
  87. package/dist/proxy/registry.js +49 -0
  88. package/dist/proxy/registry.js.map +1 -0
  89. package/dist/proxy/types.d.ts +44 -0
  90. package/dist/proxy/types.d.ts.map +1 -0
  91. package/dist/proxy/types.js +7 -0
  92. package/dist/proxy/types.js.map +1 -0
  93. package/dist/prune.d.ts +37 -0
  94. package/dist/prune.d.ts.map +1 -0
  95. package/dist/prune.js +112 -0
  96. package/dist/prune.js.map +1 -0
  97. package/dist/pull.d.ts +31 -0
  98. package/dist/pull.d.ts.map +1 -0
  99. package/dist/pull.js +414 -0
  100. package/dist/pull.js.map +1 -0
  101. package/dist/push.d.ts +33 -0
  102. package/dist/push.d.ts.map +1 -0
  103. package/dist/push.js +329 -0
  104. package/dist/push.js.map +1 -0
  105. package/dist/realtime/in-memory.d.ts +13 -0
  106. package/dist/realtime/in-memory.d.ts.map +1 -0
  107. package/dist/realtime/in-memory.js +28 -0
  108. package/dist/realtime/in-memory.js.map +1 -0
  109. package/dist/realtime/index.d.ts +3 -0
  110. package/dist/realtime/index.d.ts.map +1 -0
  111. package/dist/realtime/index.js +2 -0
  112. package/dist/realtime/index.js.map +1 -0
  113. package/dist/realtime/types.d.ts +50 -0
  114. package/dist/realtime/types.d.ts.map +1 -0
  115. package/dist/realtime/types.js +7 -0
  116. package/dist/realtime/types.js.map +1 -0
  117. package/dist/schema.d.ts +164 -0
  118. package/dist/schema.d.ts.map +1 -0
  119. package/dist/schema.js +10 -0
  120. package/dist/schema.js.map +1 -0
  121. package/dist/shapes/create-handler.d.ts +119 -0
  122. package/dist/shapes/create-handler.d.ts.map +1 -0
  123. package/dist/shapes/create-handler.js +327 -0
  124. package/dist/shapes/create-handler.js.map +1 -0
  125. package/dist/shapes/index.d.ts +4 -0
  126. package/dist/shapes/index.d.ts.map +1 -0
  127. package/dist/shapes/index.js +4 -0
  128. package/dist/shapes/index.js.map +1 -0
  129. package/dist/shapes/registry.d.ts +20 -0
  130. package/dist/shapes/registry.d.ts.map +1 -0
  131. package/dist/shapes/registry.js +88 -0
  132. package/dist/shapes/registry.js.map +1 -0
  133. package/dist/shapes/types.d.ts +204 -0
  134. package/dist/shapes/types.d.ts.map +1 -0
  135. package/dist/shapes/types.js +2 -0
  136. package/dist/shapes/types.js.map +1 -0
  137. package/dist/snapshot-chunks/adapters/s3.d.ts +63 -0
  138. package/dist/snapshot-chunks/adapters/s3.d.ts.map +1 -0
  139. package/dist/snapshot-chunks/adapters/s3.js +50 -0
  140. package/dist/snapshot-chunks/adapters/s3.js.map +1 -0
  141. package/dist/snapshot-chunks/db-metadata.d.ts +33 -0
  142. package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -0
  143. package/dist/snapshot-chunks/db-metadata.js +169 -0
  144. package/dist/snapshot-chunks/db-metadata.js.map +1 -0
  145. package/dist/snapshot-chunks/index.d.ts +9 -0
  146. package/dist/snapshot-chunks/index.d.ts.map +1 -0
  147. package/dist/snapshot-chunks/index.js +9 -0
  148. package/dist/snapshot-chunks/index.js.map +1 -0
  149. package/dist/snapshot-chunks/types.d.ts +65 -0
  150. package/dist/snapshot-chunks/types.d.ts.map +1 -0
  151. package/dist/snapshot-chunks/types.js +8 -0
  152. package/dist/snapshot-chunks/types.js.map +1 -0
  153. package/dist/snapshot-chunks.d.ts +59 -0
  154. package/dist/snapshot-chunks.d.ts.map +1 -0
  155. package/dist/snapshot-chunks.js +202 -0
  156. package/dist/snapshot-chunks.js.map +1 -0
  157. package/dist/stats.d.ts +19 -0
  158. package/dist/stats.d.ts.map +1 -0
  159. package/dist/stats.js +57 -0
  160. package/dist/stats.js.map +1 -0
  161. package/dist/subscriptions/index.d.ts +2 -0
  162. package/dist/subscriptions/index.d.ts.map +1 -0
  163. package/dist/subscriptions/index.js +2 -0
  164. package/dist/subscriptions/index.js.map +1 -0
  165. package/dist/subscriptions/resolve.d.ts +35 -0
  166. package/dist/subscriptions/resolve.d.ts.map +1 -0
  167. package/dist/subscriptions/resolve.js +134 -0
  168. package/dist/subscriptions/resolve.js.map +1 -0
  169. package/package.json +80 -0
  170. package/src/blobs/adapters/database.ts +290 -0
  171. package/src/blobs/adapters/s3.ts +271 -0
  172. package/src/blobs/index.ts +9 -0
  173. package/src/blobs/manager.ts +600 -0
  174. package/src/blobs/migrate.ts +150 -0
  175. package/src/blobs/types.ts +70 -0
  176. package/src/clients.ts +21 -0
  177. package/src/compaction.ts +77 -0
  178. package/src/dialect/index.ts +5 -0
  179. package/src/dialect/types.ts +222 -0
  180. package/src/helpers/conflict.ts +64 -0
  181. package/src/helpers/emitted-change.ts +69 -0
  182. package/src/helpers/index.ts +10 -0
  183. package/src/helpers/paginate.ts +82 -0
  184. package/src/helpers/scope-strings.ts +101 -0
  185. package/src/index.ts +28 -0
  186. package/src/migrate.ts +20 -0
  187. package/src/proxy/handler.ts +152 -0
  188. package/src/proxy/index.ts +18 -0
  189. package/src/proxy/mutation-detector.ts +83 -0
  190. package/src/proxy/oplog.ts +144 -0
  191. package/src/proxy/registry.ts +56 -0
  192. package/src/proxy/types.ts +46 -0
  193. package/src/prune.ts +200 -0
  194. package/src/pull.ts +551 -0
  195. package/src/push.ts +457 -0
  196. package/src/realtime/in-memory.ts +33 -0
  197. package/src/realtime/index.ts +5 -0
  198. package/src/realtime/types.ts +55 -0
  199. package/src/schema.ts +172 -0
  200. package/src/shapes/create-handler.ts +590 -0
  201. package/src/shapes/index.ts +3 -0
  202. package/src/shapes/registry.ts +109 -0
  203. package/src/shapes/types.ts +267 -0
  204. package/src/snapshot-chunks/adapters/s3.ts +68 -0
  205. package/src/snapshot-chunks/db-metadata.ts +238 -0
  206. package/src/snapshot-chunks/index.ts +9 -0
  207. package/src/snapshot-chunks/types.ts +79 -0
  208. package/src/snapshot-chunks.ts +301 -0
  209. package/src/stats.ts +104 -0
  210. package/src/subscriptions/index.ts +1 -0
  211. package/src/subscriptions/resolve.ts +185 -0
@@ -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,152 @@
1
+ /**
2
+ * @syncular/server - Proxy Query Handler
3
+ *
4
+ * Executes proxied queries with automatic oplog generation for mutations.
5
+ */
6
+
7
+ import type { Kysely, RawBuilder } from 'kysely';
8
+ import { sql } from 'kysely';
9
+ import type { ServerSyncDialect } from '../dialect/types';
10
+ import type { SyncCoreDb } from '../schema';
11
+ import {
12
+ appendReturning,
13
+ detectMutation,
14
+ hasReturningClause,
15
+ } from './mutation-detector';
16
+ import { createOplogEntries } from './oplog';
17
+ import type { ProxyTableRegistry } from './registry';
18
+ import type { ProxyQueryContext } from './types';
19
+
20
+ export interface ExecuteProxyQueryArgs<DB extends SyncCoreDb = SyncCoreDb> {
21
+ /** Database connection or transaction */
22
+ db: Kysely<DB>;
23
+ /** Server sync dialect */
24
+ dialect: ServerSyncDialect;
25
+ /** Proxy table registry for oplog generation */
26
+ shapes: ProxyTableRegistry;
27
+ /** Query context (actor/client IDs) */
28
+ ctx: ProxyQueryContext;
29
+ /** SQL query string */
30
+ sqlQuery: string;
31
+ /** Query parameters */
32
+ parameters: readonly unknown[];
33
+ }
34
+
35
+ export interface ExecuteProxyQueryResult {
36
+ /** Query result rows (for SELECT or RETURNING) */
37
+ rows?: unknown[];
38
+ /** Number of affected rows (for mutations) */
39
+ rowCount?: number;
40
+ /** Commit sequence if oplog was created */
41
+ commitSeq?: number;
42
+ /** Affected tables if oplog was created */
43
+ affectedTables?: string[];
44
+ }
45
+
46
+ /**
47
+ * Build a raw SQL query with parameters using Kysely's sql helper.
48
+ *
49
+ * This converts parameterized SQL (using $1, $2, etc.) to Kysely's format.
50
+ */
51
+ function buildRawQuery(
52
+ sqlQuery: string,
53
+ parameters: readonly unknown[]
54
+ ): RawBuilder<unknown> {
55
+ // If no parameters, just use sql.raw
56
+ if (parameters.length === 0) {
57
+ return sql.raw(sqlQuery);
58
+ }
59
+
60
+ // Parse the SQL and split by parameter placeholders ($1, $2, etc.)
61
+ // Then use sql.join to build the query with proper parameter binding
62
+ const parts: RawBuilder<unknown>[] = [];
63
+ let lastIndex = 0;
64
+ const paramRegex = /\$(\d+)/g;
65
+ let match: RegExpExecArray | null;
66
+
67
+ while ((match = paramRegex.exec(sqlQuery)) !== null) {
68
+ // Add the SQL before this parameter
69
+ if (match.index > lastIndex) {
70
+ parts.push(sql.raw(sqlQuery.slice(lastIndex, match.index)));
71
+ }
72
+ // Add the parameter value (1-indexed in SQL, 0-indexed in array)
73
+ const paramIndex = Number.parseInt(match[1]!, 10) - 1;
74
+ if (paramIndex >= 0 && paramIndex < parameters.length) {
75
+ // Use sql.value to create a proper parameter binding
76
+ parts.push(sql.val(parameters[paramIndex]));
77
+ } else {
78
+ // Keep the original placeholder if out of bounds (shouldn't happen)
79
+ parts.push(sql.raw(match[0]));
80
+ }
81
+ lastIndex = match.index + match[0].length;
82
+ }
83
+
84
+ // Add remaining SQL after last parameter
85
+ if (lastIndex < sqlQuery.length) {
86
+ parts.push(sql.raw(sqlQuery.slice(lastIndex)));
87
+ }
88
+
89
+ // Join all parts together
90
+ return sql.join(parts, sql.raw(''));
91
+ }
92
+
93
+ /**
94
+ * Execute a proxied query with automatic oplog generation for mutations.
95
+ *
96
+ * - Read queries: Execute directly and return rows
97
+ * - Mutations: Append RETURNING *, execute, create oplog entries
98
+ */
99
+ export async function executeProxyQuery<DB extends SyncCoreDb>(
100
+ args: ExecuteProxyQueryArgs<DB>
101
+ ): Promise<ExecuteProxyQueryResult> {
102
+ const { db, dialect, shapes, ctx, sqlQuery, parameters } = args;
103
+
104
+ const mutation = detectMutation(sqlQuery);
105
+
106
+ if (!mutation) {
107
+ // Read query - execute directly
108
+ const result = await buildRawQuery(sqlQuery, parameters).execute(db);
109
+ return { rows: result.rows };
110
+ }
111
+
112
+ // Check if this table has a registered shape
113
+ const shape = shapes.get(mutation.tableName);
114
+ if (!shape) {
115
+ // No shape registered - execute without oplog
116
+ // This allows proxy operations on non-synced tables
117
+ const result = await buildRawQuery(sqlQuery, parameters).execute(db);
118
+ return {
119
+ rows: result.rows,
120
+ rowCount: Number(result.numAffectedRows ?? 0),
121
+ };
122
+ }
123
+
124
+ // Mutation with registered shape - append RETURNING * and create oplog
125
+ const needsReturning = !hasReturningClause(sqlQuery);
126
+ const finalSql = needsReturning ? appendReturning(sqlQuery) : sqlQuery;
127
+
128
+ const result = await buildRawQuery(finalSql, parameters).execute(db);
129
+ const affectedRows = result.rows as Record<string, unknown>[];
130
+
131
+ if (affectedRows.length === 0) {
132
+ return { rowCount: 0 };
133
+ }
134
+
135
+ // Create oplog entries
136
+ const { commitSeq, affectedTables } = await createOplogEntries({
137
+ trx: db,
138
+ dialect,
139
+ actorId: ctx.actorId,
140
+ clientId: ctx.clientId,
141
+ partitionId: ctx.partitionId,
142
+ shape,
143
+ operation: mutation.operation,
144
+ rows: affectedRows,
145
+ });
146
+
147
+ return {
148
+ rowCount: affectedRows.length,
149
+ commitSeq,
150
+ affectedTables,
151
+ };
152
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @syncular/server - Proxy Exports
3
+ *
4
+ * Server-side proxy functionality for database access.
5
+ */
6
+
7
+ // Query execution
8
+ export {
9
+ type ExecuteProxyQueryArgs,
10
+ type ExecuteProxyQueryResult,
11
+ executeProxyQuery,
12
+ } from './handler';
13
+ // Mutation detection
14
+ export { type DetectedMutation, detectMutation } from './mutation-detector';
15
+ // Oplog creation
16
+ // Registry
17
+ export { ProxyTableRegistry } from './registry';
18
+ // Types
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @syncular/server - Mutation Detector
3
+ *
4
+ * Detects whether a SQL query is a mutation (INSERT/UPDATE/DELETE).
5
+ */
6
+
7
+ import type { SyncOp } from '@syncular/core';
8
+
9
+ export interface DetectedMutation {
10
+ /** Operation type */
11
+ operation: SyncOp;
12
+ /** Table name being modified */
13
+ tableName: string;
14
+ }
15
+
16
+ /**
17
+ * Detect if a SQL query is a mutation and extract table info.
18
+ *
19
+ * @param sql - The SQL query string
20
+ * @returns Mutation info if detected, null for read queries
21
+ */
22
+ export function detectMutation(sql: string): DetectedMutation | null {
23
+ const trimmed = sql.trim();
24
+
25
+ // INSERT INTO [schema.]table
26
+ const insertMatch = trimmed.match(
27
+ /^\s*INSERT\s+INTO\s+(?:["']?(\w+)["']?\.)?["']?(\w+)["']?/i
28
+ );
29
+ if (insertMatch) {
30
+ return {
31
+ operation: 'upsert',
32
+ tableName: insertMatch[2]!,
33
+ };
34
+ }
35
+
36
+ // UPDATE [schema.]table
37
+ const updateMatch = trimmed.match(
38
+ /^\s*UPDATE\s+(?:["']?(\w+)["']?\.)?["']?(\w+)["']?/i
39
+ );
40
+ if (updateMatch) {
41
+ return {
42
+ operation: 'upsert',
43
+ tableName: updateMatch[2]!,
44
+ };
45
+ }
46
+
47
+ // DELETE FROM [schema.]table
48
+ const deleteMatch = trimmed.match(
49
+ /^\s*DELETE\s+FROM\s+(?:["']?(\w+)["']?\.)?["']?(\w+)["']?/i
50
+ );
51
+ if (deleteMatch) {
52
+ return {
53
+ operation: 'delete',
54
+ tableName: deleteMatch[2]!,
55
+ };
56
+ }
57
+
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * Check if SQL already has a RETURNING clause.
63
+ */
64
+ export function hasReturningClause(sql: string): boolean {
65
+ // Simple check - look for RETURNING keyword not in a string
66
+ return /\bRETURNING\b/i.test(sql);
67
+ }
68
+
69
+ /**
70
+ * Append RETURNING * to a mutation query if not already present.
71
+ *
72
+ * @param sql - The SQL query string
73
+ * @returns Modified SQL with RETURNING *
74
+ */
75
+ export function appendReturning(sql: string): string {
76
+ if (hasReturningClause(sql)) {
77
+ return sql;
78
+ }
79
+
80
+ // Remove trailing semicolon if present
81
+ const trimmed = sql.trim().replace(/;\s*$/, '');
82
+ return `${trimmed} RETURNING *`;
83
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @syncular/server - Oplog Creation
3
+ *
4
+ * Creates sync oplog entries for proxy mutations.
5
+ */
6
+
7
+ import { randomUUID } from 'node:crypto';
8
+ import type { SyncOp } from '@syncular/core';
9
+ import { type Kysely, sql } from 'kysely';
10
+ import type { ServerSyncDialect } from '../dialect/types';
11
+ import type { SyncCoreDb } from '../schema';
12
+ import type { ProxyTableHandler } from './types';
13
+
14
+ /**
15
+ * Generate a random ID for commit tracking.
16
+ */
17
+ function generateId(): string {
18
+ return randomUUID();
19
+ }
20
+
21
+ /**
22
+ * Create oplog entries for affected rows.
23
+ *
24
+ * This is called after a mutation to record the changes in the sync oplog,
25
+ * making them visible to sync clients.
26
+ */
27
+ export async function createOplogEntries<DB extends SyncCoreDb>(args: {
28
+ trx: Kysely<DB>;
29
+ dialect: ServerSyncDialect;
30
+ actorId: string;
31
+ clientId: string;
32
+ partitionId?: string;
33
+ shape: ProxyTableHandler;
34
+ operation: SyncOp;
35
+ rows: Record<string, unknown>[];
36
+ }): Promise<{ commitSeq: number; affectedTables: string[] }> {
37
+ const { trx, dialect, actorId, clientId, shape, operation, rows } = args;
38
+ const partitionId = args.partitionId ?? 'default';
39
+
40
+ if (rows.length === 0) {
41
+ return { commitSeq: 0, affectedTables: [] };
42
+ }
43
+
44
+ const pk = shape.primaryKey ?? 'id';
45
+ const versionCol = shape.versionColumn ?? 'server_version';
46
+
47
+ // Create commit record
48
+ const commitResult = await sql<{ commit_seq: number }>`
49
+ insert into ${sql.table('sync_commits')} (
50
+ partition_id,
51
+ actor_id,
52
+ client_id,
53
+ client_commit_id,
54
+ meta,
55
+ result_json
56
+ )
57
+ values (
58
+ ${partitionId},
59
+ ${actorId},
60
+ ${clientId},
61
+ ${`proxy:${generateId()}`},
62
+ ${null},
63
+ ${null}
64
+ )
65
+ returning commit_seq
66
+ `.execute(trx);
67
+
68
+ const commitRow = commitResult.rows[0];
69
+ if (!commitRow) {
70
+ throw new Error('Failed to insert sync_commits row');
71
+ }
72
+ const commitSeq = Number(commitRow.commit_seq);
73
+
74
+ // Compute scopes for all rows and collect changes
75
+ const affectedTablesSet = new Set<string>();
76
+ affectedTablesSet.add(shape.table);
77
+
78
+ const changes = rows.map((row) => {
79
+ const scopes = shape.computeScopes(row);
80
+
81
+ return {
82
+ commit_seq: commitSeq,
83
+ partition_id: partitionId,
84
+ table: shape.table,
85
+ row_id: String(row[pk]),
86
+ op: operation,
87
+ row_json: operation === 'delete' ? null : row,
88
+ row_version: row[versionCol] != null ? Number(row[versionCol]) : null,
89
+ scopes: dialect.scopesToDb(scopes),
90
+ };
91
+ });
92
+
93
+ // Insert changes
94
+ await sql`
95
+ insert into ${sql.table('sync_changes')} (
96
+ commit_seq,
97
+ partition_id,
98
+ "table",
99
+ row_id,
100
+ op,
101
+ row_json,
102
+ row_version,
103
+ scopes
104
+ )
105
+ values ${sql.join(
106
+ changes.map(
107
+ (c) => sql`(
108
+ ${c.commit_seq},
109
+ ${c.partition_id},
110
+ ${c.table},
111
+ ${c.row_id},
112
+ ${c.op},
113
+ ${c.row_json},
114
+ ${c.row_version},
115
+ ${c.scopes}
116
+ )`
117
+ ),
118
+ sql`, `
119
+ )}
120
+ `.execute(trx);
121
+
122
+ // Update commit with affected tables
123
+ const affectedTables = Array.from(affectedTablesSet);
124
+ const sortedAffectedTables = affectedTables.sort();
125
+ await sql`
126
+ update ${sql.table('sync_commits')}
127
+ set change_count = ${rows.length}, affected_tables = ${sortedAffectedTables}
128
+ where commit_seq = ${commitSeq}
129
+ `.execute(trx);
130
+
131
+ // Insert table commits for subscription filtering
132
+ if (affectedTables.length > 0) {
133
+ await sql`
134
+ insert into ${sql.table('sync_table_commits')} (partition_id, "table", commit_seq)
135
+ values ${sql.join(
136
+ sortedAffectedTables.map((table) => sql`(${partitionId}, ${table}, ${commitSeq})`),
137
+ sql`, `
138
+ )}
139
+ on conflict (partition_id, "table", commit_seq) do nothing
140
+ `.execute(trx);
141
+ }
142
+
143
+ return { commitSeq, affectedTables };
144
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @syncular/server - Proxy Table Registry
3
+ *
4
+ * Registry for proxy table handlers.
5
+ */
6
+
7
+ import type { ProxyTableHandler } from './types';
8
+
9
+ /**
10
+ * Registry for proxy table handlers.
11
+ *
12
+ * Maps table names to table handlers for oplog generation.
13
+ */
14
+ export class ProxyTableRegistry {
15
+ private handlers = new Map<string, ProxyTableHandler>();
16
+
17
+ /**
18
+ * Register a proxy table handler.
19
+ */
20
+ register(handler: ProxyTableHandler): this {
21
+ this.handlers.set(handler.table, handler);
22
+ return this;
23
+ }
24
+
25
+ /**
26
+ * Get handler by table name.
27
+ */
28
+ get(tableName: string): ProxyTableHandler | undefined {
29
+ return this.handlers.get(tableName);
30
+ }
31
+
32
+ /**
33
+ * Get handler by table name or throw.
34
+ */
35
+ getOrThrow(tableName: string): ProxyTableHandler {
36
+ const handler = this.handlers.get(tableName);
37
+ if (!handler) {
38
+ throw new Error(`No proxy table registered for table: ${tableName}`);
39
+ }
40
+ return handler;
41
+ }
42
+
43
+ /**
44
+ * Check if a table has a registered handler.
45
+ */
46
+ has(tableName: string): boolean {
47
+ return this.handlers.has(tableName);
48
+ }
49
+
50
+ /**
51
+ * Get all registered handlers.
52
+ */
53
+ getAll(): ProxyTableHandler[] {
54
+ return Array.from(this.handlers.values());
55
+ }
56
+ }