@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.
Files changed (225) 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 +202 -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/base.d.ts +83 -0
  34. package/dist/dialect/base.d.ts.map +1 -0
  35. package/dist/dialect/base.js +144 -0
  36. package/dist/dialect/base.js.map +1 -0
  37. package/dist/dialect/helpers.d.ts +10 -0
  38. package/dist/dialect/helpers.d.ts.map +1 -0
  39. package/dist/dialect/helpers.js +59 -0
  40. package/dist/dialect/helpers.js.map +1 -0
  41. package/dist/dialect/index.d.ts +7 -0
  42. package/dist/dialect/index.d.ts.map +1 -0
  43. package/dist/dialect/index.js +7 -0
  44. package/dist/dialect/index.js.map +1 -0
  45. package/dist/dialect/types.d.ts +149 -0
  46. package/dist/dialect/types.d.ts.map +1 -0
  47. package/dist/dialect/types.js +8 -0
  48. package/dist/dialect/types.js.map +1 -0
  49. package/dist/helpers/conflict.d.ts +52 -0
  50. package/dist/helpers/conflict.d.ts.map +1 -0
  51. package/dist/helpers/conflict.js +49 -0
  52. package/dist/helpers/conflict.js.map +1 -0
  53. package/dist/helpers/emitted-change.d.ts +56 -0
  54. package/dist/helpers/emitted-change.d.ts.map +1 -0
  55. package/dist/helpers/emitted-change.js +46 -0
  56. package/dist/helpers/emitted-change.js.map +1 -0
  57. package/dist/helpers/index.d.ts +10 -0
  58. package/dist/helpers/index.d.ts.map +1 -0
  59. package/dist/helpers/index.js +10 -0
  60. package/dist/helpers/index.js.map +1 -0
  61. package/dist/helpers/paginate.d.ts +49 -0
  62. package/dist/helpers/paginate.d.ts.map +1 -0
  63. package/dist/helpers/paginate.js +54 -0
  64. package/dist/helpers/paginate.js.map +1 -0
  65. package/dist/helpers/scope-strings.d.ts +74 -0
  66. package/dist/helpers/scope-strings.d.ts.map +1 -0
  67. package/dist/helpers/scope-strings.js +82 -0
  68. package/dist/helpers/scope-strings.js.map +1 -0
  69. package/dist/index.d.ts +28 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +27 -0
  72. package/dist/index.js.map +1 -0
  73. package/dist/migrate.d.ts +14 -0
  74. package/dist/migrate.d.ts.map +1 -0
  75. package/dist/migrate.js +13 -0
  76. package/dist/migrate.js.map +1 -0
  77. package/dist/proxy/handler.d.ts +42 -0
  78. package/dist/proxy/handler.d.ts.map +1 -0
  79. package/dist/proxy/handler.js +102 -0
  80. package/dist/proxy/handler.js.map +1 -0
  81. package/dist/proxy/index.d.ts +9 -0
  82. package/dist/proxy/index.d.ts.map +1 -0
  83. package/dist/proxy/index.js +14 -0
  84. package/dist/proxy/index.js.map +1 -0
  85. package/dist/proxy/mutation-detector.d.ts +35 -0
  86. package/dist/proxy/mutation-detector.d.ts.map +1 -0
  87. package/dist/proxy/mutation-detector.js +246 -0
  88. package/dist/proxy/mutation-detector.js.map +1 -0
  89. package/dist/proxy/oplog.d.ts +30 -0
  90. package/dist/proxy/oplog.d.ts.map +1 -0
  91. package/dist/proxy/oplog.js +110 -0
  92. package/dist/proxy/oplog.js.map +1 -0
  93. package/dist/proxy/registry.d.ts +35 -0
  94. package/dist/proxy/registry.d.ts.map +1 -0
  95. package/dist/proxy/registry.js +49 -0
  96. package/dist/proxy/registry.js.map +1 -0
  97. package/dist/proxy/types.d.ts +44 -0
  98. package/dist/proxy/types.d.ts.map +1 -0
  99. package/dist/proxy/types.js +7 -0
  100. package/dist/proxy/types.js.map +1 -0
  101. package/dist/prune.d.ts +37 -0
  102. package/dist/prune.d.ts.map +1 -0
  103. package/dist/prune.js +112 -0
  104. package/dist/prune.js.map +1 -0
  105. package/dist/pull.d.ts +31 -0
  106. package/dist/pull.d.ts.map +1 -0
  107. package/dist/pull.js +608 -0
  108. package/dist/pull.js.map +1 -0
  109. package/dist/push.d.ts +33 -0
  110. package/dist/push.d.ts.map +1 -0
  111. package/dist/push.js +412 -0
  112. package/dist/push.js.map +1 -0
  113. package/dist/realtime/in-memory.d.ts +13 -0
  114. package/dist/realtime/in-memory.d.ts.map +1 -0
  115. package/dist/realtime/in-memory.js +28 -0
  116. package/dist/realtime/in-memory.js.map +1 -0
  117. package/dist/realtime/index.d.ts +3 -0
  118. package/dist/realtime/index.d.ts.map +1 -0
  119. package/dist/realtime/index.js +2 -0
  120. package/dist/realtime/index.js.map +1 -0
  121. package/dist/realtime/types.d.ts +50 -0
  122. package/dist/realtime/types.d.ts.map +1 -0
  123. package/dist/realtime/types.js +7 -0
  124. package/dist/realtime/types.js.map +1 -0
  125. package/dist/schema.d.ts +164 -0
  126. package/dist/schema.d.ts.map +1 -0
  127. package/dist/schema.js +10 -0
  128. package/dist/schema.js.map +1 -0
  129. package/dist/shapes/create-handler.d.ts +119 -0
  130. package/dist/shapes/create-handler.d.ts.map +1 -0
  131. package/dist/shapes/create-handler.js +327 -0
  132. package/dist/shapes/create-handler.js.map +1 -0
  133. package/dist/shapes/index.d.ts +4 -0
  134. package/dist/shapes/index.d.ts.map +1 -0
  135. package/dist/shapes/index.js +4 -0
  136. package/dist/shapes/index.js.map +1 -0
  137. package/dist/shapes/registry.d.ts +20 -0
  138. package/dist/shapes/registry.d.ts.map +1 -0
  139. package/dist/shapes/registry.js +88 -0
  140. package/dist/shapes/registry.js.map +1 -0
  141. package/dist/shapes/types.d.ts +204 -0
  142. package/dist/shapes/types.d.ts.map +1 -0
  143. package/dist/shapes/types.js +2 -0
  144. package/dist/shapes/types.js.map +1 -0
  145. package/dist/snapshot-chunks/adapters/s3.d.ts +74 -0
  146. package/dist/snapshot-chunks/adapters/s3.d.ts.map +1 -0
  147. package/dist/snapshot-chunks/adapters/s3.js +50 -0
  148. package/dist/snapshot-chunks/adapters/s3.js.map +1 -0
  149. package/dist/snapshot-chunks/db-metadata.d.ts +38 -0
  150. package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -0
  151. package/dist/snapshot-chunks/db-metadata.js +324 -0
  152. package/dist/snapshot-chunks/db-metadata.js.map +1 -0
  153. package/dist/snapshot-chunks/index.d.ts +9 -0
  154. package/dist/snapshot-chunks/index.d.ts.map +1 -0
  155. package/dist/snapshot-chunks/index.js +9 -0
  156. package/dist/snapshot-chunks/index.js.map +1 -0
  157. package/dist/snapshot-chunks/types.d.ts +78 -0
  158. package/dist/snapshot-chunks/types.d.ts.map +1 -0
  159. package/dist/snapshot-chunks/types.js +8 -0
  160. package/dist/snapshot-chunks/types.js.map +1 -0
  161. package/dist/snapshot-chunks.d.ts +60 -0
  162. package/dist/snapshot-chunks.d.ts.map +1 -0
  163. package/dist/snapshot-chunks.js +223 -0
  164. package/dist/snapshot-chunks.js.map +1 -0
  165. package/dist/stats.d.ts +19 -0
  166. package/dist/stats.d.ts.map +1 -0
  167. package/dist/stats.js +57 -0
  168. package/dist/stats.js.map +1 -0
  169. package/dist/subscriptions/index.d.ts +2 -0
  170. package/dist/subscriptions/index.d.ts.map +1 -0
  171. package/dist/subscriptions/index.js +2 -0
  172. package/dist/subscriptions/index.js.map +1 -0
  173. package/dist/subscriptions/resolve.d.ts +35 -0
  174. package/dist/subscriptions/resolve.d.ts.map +1 -0
  175. package/dist/subscriptions/resolve.js +134 -0
  176. package/dist/subscriptions/resolve.js.map +1 -0
  177. package/package.json +80 -0
  178. package/src/blobs/adapters/database.test.ts +67 -0
  179. package/src/blobs/adapters/database.ts +315 -0
  180. package/src/blobs/adapters/s3.ts +271 -0
  181. package/src/blobs/index.ts +9 -0
  182. package/src/blobs/manager.ts +600 -0
  183. package/src/blobs/migrate.ts +150 -0
  184. package/src/blobs/types.ts +70 -0
  185. package/src/clients.ts +21 -0
  186. package/src/compaction.ts +77 -0
  187. package/src/dialect/base.ts +292 -0
  188. package/src/dialect/helpers.ts +61 -0
  189. package/src/dialect/index.ts +7 -0
  190. package/src/dialect/types.ts +197 -0
  191. package/src/helpers/conflict.ts +64 -0
  192. package/src/helpers/emitted-change.ts +69 -0
  193. package/src/helpers/index.ts +10 -0
  194. package/src/helpers/paginate.ts +82 -0
  195. package/src/helpers/scope-strings.ts +101 -0
  196. package/src/index.ts +28 -0
  197. package/src/migrate.ts +20 -0
  198. package/src/proxy/handler.test.ts +120 -0
  199. package/src/proxy/handler.ts +159 -0
  200. package/src/proxy/index.ts +18 -0
  201. package/src/proxy/mutation-detector.test.ts +71 -0
  202. package/src/proxy/mutation-detector.ts +281 -0
  203. package/src/proxy/oplog.ts +146 -0
  204. package/src/proxy/registry.ts +56 -0
  205. package/src/proxy/types.ts +46 -0
  206. package/src/prune.ts +200 -0
  207. package/src/pull.ts +858 -0
  208. package/src/push.ts +583 -0
  209. package/src/realtime/in-memory.ts +33 -0
  210. package/src/realtime/index.ts +5 -0
  211. package/src/realtime/types.ts +55 -0
  212. package/src/schema.ts +172 -0
  213. package/src/shapes/create-handler.ts +590 -0
  214. package/src/shapes/index.ts +3 -0
  215. package/src/shapes/registry.ts +109 -0
  216. package/src/shapes/types.ts +267 -0
  217. package/src/snapshot-chunks/adapters/s3.ts +68 -0
  218. package/src/snapshot-chunks/db-metadata.test.ts +100 -0
  219. package/src/snapshot-chunks/db-metadata.ts +466 -0
  220. package/src/snapshot-chunks/index.ts +9 -0
  221. package/src/snapshot-chunks/types.ts +103 -0
  222. package/src/snapshot-chunks.ts +329 -0
  223. package/src/stats.ts +104 -0
  224. package/src/subscriptions/index.ts +1 -0
  225. package/src/subscriptions/resolve.ts +185 -0
@@ -0,0 +1,159 @@
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
+ hasReturningWildcard,
16
+ } from './mutation-detector';
17
+ import { createOplogEntries } from './oplog';
18
+ import type { ProxyTableRegistry } from './registry';
19
+ import type { ProxyQueryContext } from './types';
20
+
21
+ export interface ExecuteProxyQueryArgs<DB extends SyncCoreDb = SyncCoreDb> {
22
+ /** Database connection or transaction */
23
+ db: Kysely<DB>;
24
+ /** Server sync dialect */
25
+ dialect: ServerSyncDialect;
26
+ /** Proxy table registry for oplog generation */
27
+ shapes: ProxyTableRegistry;
28
+ /** Query context (actor/client IDs) */
29
+ ctx: ProxyQueryContext;
30
+ /** SQL query string */
31
+ sqlQuery: string;
32
+ /** Query parameters */
33
+ parameters: readonly unknown[];
34
+ }
35
+
36
+ export interface ExecuteProxyQueryResult {
37
+ /** Query result rows (for SELECT or RETURNING) */
38
+ rows?: unknown[];
39
+ /** Number of affected rows (for mutations) */
40
+ rowCount?: number;
41
+ /** Commit sequence if oplog was created */
42
+ commitSeq?: number;
43
+ /** Affected tables if oplog was created */
44
+ affectedTables?: string[];
45
+ }
46
+
47
+ /**
48
+ * Build a raw SQL query with parameters using Kysely's sql helper.
49
+ *
50
+ * This converts parameterized SQL (using $1, $2, etc.) to Kysely's format.
51
+ */
52
+ function buildRawQuery(
53
+ sqlQuery: string,
54
+ parameters: readonly unknown[]
55
+ ): RawBuilder<unknown> {
56
+ // If no parameters, just use sql.raw
57
+ if (parameters.length === 0) {
58
+ return sql.raw(sqlQuery);
59
+ }
60
+
61
+ // Parse the SQL and split by parameter placeholders ($1, $2, etc.)
62
+ // Then use sql.join to build the query with proper parameter binding
63
+ const parts: RawBuilder<unknown>[] = [];
64
+ let lastIndex = 0;
65
+ const paramRegex = /\$(\d+)/g;
66
+ let match: RegExpExecArray | null;
67
+
68
+ while ((match = paramRegex.exec(sqlQuery)) !== null) {
69
+ // Add the SQL before this parameter
70
+ if (match.index > lastIndex) {
71
+ parts.push(sql.raw(sqlQuery.slice(lastIndex, match.index)));
72
+ }
73
+ // Add the parameter value (1-indexed in SQL, 0-indexed in array)
74
+ const paramIndex = Number.parseInt(match[1]!, 10) - 1;
75
+ if (paramIndex >= 0 && paramIndex < parameters.length) {
76
+ // Use sql.value to create a proper parameter binding
77
+ parts.push(sql.val(parameters[paramIndex]));
78
+ } else {
79
+ // Keep the original placeholder if out of bounds (shouldn't happen)
80
+ parts.push(sql.raw(match[0]));
81
+ }
82
+ lastIndex = match.index + match[0].length;
83
+ }
84
+
85
+ // Add remaining SQL after last parameter
86
+ if (lastIndex < sqlQuery.length) {
87
+ parts.push(sql.raw(sqlQuery.slice(lastIndex)));
88
+ }
89
+
90
+ // Join all parts together
91
+ return sql.join(parts, sql.raw(''));
92
+ }
93
+
94
+ /**
95
+ * Execute a proxied query with automatic oplog generation for mutations.
96
+ *
97
+ * - Read queries: Execute directly and return rows
98
+ * - Mutations: Append RETURNING *, execute, create oplog entries
99
+ */
100
+ export async function executeProxyQuery<DB extends SyncCoreDb>(
101
+ args: ExecuteProxyQueryArgs<DB>
102
+ ): Promise<ExecuteProxyQueryResult> {
103
+ const { db, dialect, shapes, ctx, sqlQuery, parameters } = args;
104
+
105
+ const mutation = detectMutation(sqlQuery);
106
+
107
+ if (!mutation) {
108
+ // Read query - execute directly
109
+ const result = await buildRawQuery(sqlQuery, parameters).execute(db);
110
+ return { rows: result.rows };
111
+ }
112
+
113
+ // Check if this table has a registered shape
114
+ const shape = shapes.get(mutation.tableName);
115
+ if (!shape) {
116
+ // No shape registered - execute without oplog
117
+ // This allows proxy operations on non-synced tables
118
+ const result = await buildRawQuery(sqlQuery, parameters).execute(db);
119
+ return {
120
+ rows: result.rows,
121
+ rowCount: Number(result.numAffectedRows ?? 0),
122
+ };
123
+ }
124
+
125
+ // Mutation with registered shape - append RETURNING * and create oplog
126
+ const hasReturning = hasReturningClause(sqlQuery);
127
+ if (hasReturning && !hasReturningWildcard(sqlQuery)) {
128
+ throw new Error(
129
+ `Proxy mutation on synced table "${mutation.tableName}" must use RETURNING * (or omit RETURNING)`
130
+ );
131
+ }
132
+
133
+ const finalSql = hasReturning ? sqlQuery : appendReturning(sqlQuery);
134
+
135
+ const result = await buildRawQuery(finalSql, parameters).execute(db);
136
+ const affectedRows = result.rows as Record<string, unknown>[];
137
+
138
+ if (affectedRows.length === 0) {
139
+ return { rowCount: 0 };
140
+ }
141
+
142
+ // Create oplog entries
143
+ const { commitSeq, affectedTables } = await createOplogEntries({
144
+ trx: db,
145
+ dialect,
146
+ actorId: ctx.actorId,
147
+ clientId: ctx.clientId,
148
+ partitionId: ctx.partitionId,
149
+ shape,
150
+ operation: mutation.operation,
151
+ rows: affectedRows,
152
+ });
153
+
154
+ return {
155
+ rowCount: affectedRows.length,
156
+ commitSeq,
157
+ affectedTables,
158
+ };
159
+ }
@@ -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,71 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ appendReturning,
4
+ detectMutation,
5
+ hasReturningWildcard,
6
+ } from './mutation-detector';
7
+
8
+ describe('detectMutation', () => {
9
+ it('detects comment-prefixed update statements', () => {
10
+ const detected = detectMutation(`
11
+ /* admin tooling */
12
+ UPDATE tasks
13
+ SET title = 'updated'
14
+ WHERE id = 't1'
15
+ `);
16
+
17
+ expect(detected).toEqual({
18
+ operation: 'upsert',
19
+ tableName: 'tasks',
20
+ });
21
+ });
22
+
23
+ it('detects cte-prefixed update statements', () => {
24
+ const detected = detectMutation(`
25
+ WITH touched AS (
26
+ SELECT id FROM tasks WHERE id = 't1'
27
+ )
28
+ UPDATE tasks
29
+ SET title = 'updated'
30
+ WHERE id IN (SELECT id FROM touched)
31
+ `);
32
+
33
+ expect(detected).toEqual({
34
+ operation: 'upsert',
35
+ tableName: 'tasks',
36
+ });
37
+ });
38
+
39
+ it('returns null for cte-prefixed read queries', () => {
40
+ const detected = detectMutation(`
41
+ WITH filtered AS (
42
+ SELECT id FROM tasks WHERE user_id = 'u1'
43
+ )
44
+ SELECT * FROM filtered
45
+ `);
46
+
47
+ expect(detected).toBeNull();
48
+ });
49
+ });
50
+
51
+ describe('returning helpers', () => {
52
+ it('recognizes wildcard RETURNING clauses', () => {
53
+ expect(
54
+ hasReturningWildcard('UPDATE tasks SET title = $1 RETURNING *')
55
+ ).toBe(true);
56
+ expect(
57
+ hasReturningWildcard(
58
+ 'UPDATE tasks SET title = $1 RETURNING tasks.id, tasks.*'
59
+ )
60
+ ).toBe(true);
61
+ expect(
62
+ hasReturningWildcard('UPDATE tasks SET title = $1 RETURNING id')
63
+ ).toBe(false);
64
+ });
65
+
66
+ it('appends RETURNING * when missing', () => {
67
+ expect(appendReturning('UPDATE tasks SET title = $1')).toBe(
68
+ 'UPDATE tasks SET title = $1 RETURNING *'
69
+ );
70
+ });
71
+ });
@@ -0,0 +1,281 @@
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
+ function isWordStart(ch: string): boolean {
17
+ return /[A-Za-z_]/.test(ch);
18
+ }
19
+
20
+ function isWordPart(ch: string): boolean {
21
+ return /[A-Za-z0-9_$]/.test(ch);
22
+ }
23
+
24
+ function skipLeadingNoise(sql: string): string {
25
+ let index = 0;
26
+ while (index < sql.length) {
27
+ while (index < sql.length && /[\s;]/.test(sql[index]!)) {
28
+ index += 1;
29
+ }
30
+
31
+ if (sql.startsWith('--', index)) {
32
+ index += 2;
33
+ while (index < sql.length && sql[index] !== '\n') {
34
+ index += 1;
35
+ }
36
+ continue;
37
+ }
38
+
39
+ if (sql.startsWith('/*', index)) {
40
+ const end = sql.indexOf('*/', index + 2);
41
+ if (end === -1) return '';
42
+ index = end + 2;
43
+ continue;
44
+ }
45
+
46
+ break;
47
+ }
48
+
49
+ return sql.slice(index);
50
+ }
51
+
52
+ function extractMainStatement(sql: string): string {
53
+ const normalized = skipLeadingNoise(sql);
54
+ if (!normalized.toLowerCase().startsWith('with')) {
55
+ return normalized;
56
+ }
57
+
58
+ const lower = normalized.toLowerCase();
59
+ const rootKeywords = new Set(['insert', 'update', 'delete', 'select']);
60
+
61
+ let index = 0;
62
+ let depth = 0;
63
+ let inSingleQuote = false;
64
+ let inDoubleQuote = false;
65
+ let inLineComment = false;
66
+ let inBlockComment = false;
67
+
68
+ while (index < normalized.length) {
69
+ const ch = normalized[index]!;
70
+ const next = normalized[index + 1];
71
+
72
+ if (inLineComment) {
73
+ if (ch === '\n') inLineComment = false;
74
+ index += 1;
75
+ continue;
76
+ }
77
+ if (inBlockComment) {
78
+ if (ch === '*' && next === '/') {
79
+ inBlockComment = false;
80
+ index += 2;
81
+ continue;
82
+ }
83
+ index += 1;
84
+ continue;
85
+ }
86
+ if (inSingleQuote) {
87
+ if (ch === "'" && next === "'") {
88
+ index += 2;
89
+ continue;
90
+ }
91
+ if (ch === "'") inSingleQuote = false;
92
+ index += 1;
93
+ continue;
94
+ }
95
+ if (inDoubleQuote) {
96
+ if (ch === '"' && next === '"') {
97
+ index += 2;
98
+ continue;
99
+ }
100
+ if (ch === '"') inDoubleQuote = false;
101
+ index += 1;
102
+ continue;
103
+ }
104
+
105
+ if (ch === '-' && next === '-') {
106
+ inLineComment = true;
107
+ index += 2;
108
+ continue;
109
+ }
110
+ if (ch === '/' && next === '*') {
111
+ inBlockComment = true;
112
+ index += 2;
113
+ continue;
114
+ }
115
+ if (ch === "'") {
116
+ inSingleQuote = true;
117
+ index += 1;
118
+ continue;
119
+ }
120
+ if (ch === '"') {
121
+ inDoubleQuote = true;
122
+ index += 1;
123
+ continue;
124
+ }
125
+ if (ch === '(') {
126
+ depth += 1;
127
+ index += 1;
128
+ continue;
129
+ }
130
+ if (ch === ')') {
131
+ if (depth > 0) depth -= 1;
132
+ index += 1;
133
+ continue;
134
+ }
135
+
136
+ if (depth === 0 && isWordStart(ch)) {
137
+ const tokenStart = index;
138
+ index += 1;
139
+ while (index < normalized.length && isWordPart(normalized[index]!)) {
140
+ index += 1;
141
+ }
142
+ const token = lower.slice(tokenStart, index);
143
+ if (
144
+ token !== 'with' &&
145
+ token !== 'recursive' &&
146
+ rootKeywords.has(token)
147
+ ) {
148
+ return normalized.slice(tokenStart);
149
+ }
150
+ continue;
151
+ }
152
+
153
+ index += 1;
154
+ }
155
+
156
+ return normalized;
157
+ }
158
+
159
+ function parseIdentifier(
160
+ input: string,
161
+ startIndex: number
162
+ ): { name: string; nextIndex: number } | null {
163
+ let index = startIndex;
164
+ while (index < input.length && /\s/.test(input[index]!)) {
165
+ index += 1;
166
+ }
167
+ if (index >= input.length) return null;
168
+
169
+ if (input[index] === '"') {
170
+ index += 1;
171
+ let value = '';
172
+ while (index < input.length) {
173
+ const ch = input[index]!;
174
+ if (ch === '"' && input[index + 1] === '"') {
175
+ value += '"';
176
+ index += 2;
177
+ continue;
178
+ }
179
+ if (ch === '"') {
180
+ index += 1;
181
+ return { name: value, nextIndex: index };
182
+ }
183
+ value += ch;
184
+ index += 1;
185
+ }
186
+ return null;
187
+ }
188
+
189
+ if (!isWordStart(input[index]!)) return null;
190
+ const first = index;
191
+ index += 1;
192
+ while (index < input.length && isWordPart(input[index]!)) {
193
+ index += 1;
194
+ }
195
+ return { name: input.slice(first, index), nextIndex: index };
196
+ }
197
+
198
+ function parseTargetTable(input: string): string | null {
199
+ const first = parseIdentifier(input, 0);
200
+ if (!first) return null;
201
+
202
+ let index = first.nextIndex;
203
+ while (index < input.length && /\s/.test(input[index]!)) {
204
+ index += 1;
205
+ }
206
+ if (input[index] !== '.') {
207
+ return first.name;
208
+ }
209
+
210
+ const second = parseIdentifier(input, index + 1);
211
+ if (!second) return null;
212
+ return second.name;
213
+ }
214
+
215
+ /**
216
+ * Detect if a SQL query is a mutation and extract table info.
217
+ *
218
+ * @param sql - The SQL query string
219
+ * @returns Mutation info if detected, null for read queries
220
+ */
221
+ export function detectMutation(sql: string): DetectedMutation | null {
222
+ const statement = extractMainStatement(sql).trimStart();
223
+ const lower = statement.toLowerCase();
224
+
225
+ if (lower.startsWith('insert')) {
226
+ const tableName = parseTargetTable(
227
+ statement.replace(/^insert\s+into\s+/i, '')
228
+ );
229
+ if (!tableName) return null;
230
+ return { operation: 'upsert', tableName };
231
+ }
232
+
233
+ if (lower.startsWith('update')) {
234
+ const tableName = parseTargetTable(statement.replace(/^update\s+/i, ''));
235
+ if (!tableName) return null;
236
+ return { operation: 'upsert', tableName };
237
+ }
238
+
239
+ if (lower.startsWith('delete')) {
240
+ const tableName = parseTargetTable(
241
+ statement.replace(/^delete\s+from\s+/i, '')
242
+ );
243
+ if (!tableName) return null;
244
+ return { operation: 'delete', tableName };
245
+ }
246
+
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * Check if SQL already has a RETURNING clause.
252
+ */
253
+ export function hasReturningClause(sql: string): boolean {
254
+ // Simple check - look for RETURNING keyword not in a string
255
+ return /\bRETURNING\b/i.test(sql);
256
+ }
257
+
258
+ /**
259
+ * Check if SQL has a wildcard RETURNING clause (RETURNING * or alias.*).
260
+ */
261
+ export function hasReturningWildcard(sql: string): boolean {
262
+ const match = sql.match(/\bRETURNING\b([\s\S]*)$/i);
263
+ if (!match) return false;
264
+ return /(^|,)\s*(?:[A-Za-z_][A-Za-z0-9_$]*\.)?\*/i.test(match[1]);
265
+ }
266
+
267
+ /**
268
+ * Append RETURNING * to a mutation query if not already present.
269
+ *
270
+ * @param sql - The SQL query string
271
+ * @returns Modified SQL with RETURNING *
272
+ */
273
+ export function appendReturning(sql: string): string {
274
+ if (hasReturningClause(sql)) {
275
+ return sql;
276
+ }
277
+
278
+ // Remove trailing semicolon if present
279
+ const trimmed = sql.trim().replace(/;\s*$/, '');
280
+ return `${trimmed} RETURNING *`;
281
+ }
@@ -0,0 +1,146 @@
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(
137
+ (table) => sql`(${partitionId}, ${table}, ${commitSeq})`
138
+ ),
139
+ sql`, `
140
+ )}
141
+ on conflict (partition_id, "table", commit_seq) do nothing
142
+ `.execute(trx);
143
+ }
144
+
145
+ return { commitSeq, affectedTables };
146
+ }