@syncular/server 0.0.1 → 0.0.2-126
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/README.md +25 -0
- package/dist/blobs/adapters/database.d.ts.map +1 -1
- package/dist/blobs/adapters/database.js +25 -3
- package/dist/blobs/adapters/database.js.map +1 -1
- package/dist/blobs/adapters/filesystem.d.ts +31 -0
- package/dist/blobs/adapters/filesystem.d.ts.map +1 -0
- package/dist/blobs/adapters/filesystem.js +140 -0
- package/dist/blobs/adapters/filesystem.js.map +1 -0
- package/dist/blobs/adapters/s3.d.ts +3 -2
- package/dist/blobs/adapters/s3.d.ts.map +1 -1
- package/dist/blobs/adapters/s3.js +49 -0
- package/dist/blobs/adapters/s3.js.map +1 -1
- package/dist/blobs/index.d.ts +1 -0
- package/dist/blobs/index.d.ts.map +1 -1
- package/dist/blobs/index.js +6 -5
- package/dist/blobs/index.js.map +1 -1
- package/dist/clients.d.ts +1 -0
- package/dist/clients.d.ts.map +1 -1
- package/dist/clients.js.map +1 -1
- package/dist/compaction.d.ts +1 -1
- package/dist/compaction.js +1 -1
- 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 +2 -0
- package/dist/dialect/index.d.ts.map +1 -1
- package/dist/dialect/index.js +3 -1
- package/dist/dialect/index.js.map +1 -1
- package/dist/dialect/types.d.ts +38 -46
- package/dist/dialect/types.d.ts.map +1 -1
- package/dist/{shapes → handlers}/create-handler.d.ts +18 -5
- package/dist/handlers/create-handler.d.ts.map +1 -0
- package/dist/{shapes → handlers}/create-handler.js +140 -43
- package/dist/handlers/create-handler.js.map +1 -0
- package/dist/handlers/index.d.ts.map +1 -0
- package/dist/handlers/index.js +4 -0
- package/dist/handlers/index.js.map +1 -0
- package/dist/handlers/registry.d.ts.map +1 -0
- package/dist/handlers/registry.js.map +1 -0
- package/dist/{shapes → handlers}/types.d.ts +7 -7
- package/dist/{shapes → handlers}/types.d.ts.map +1 -1
- package/dist/{shapes → handlers}/types.js.map +1 -1
- package/dist/helpers/conflict.d.ts +1 -1
- package/dist/helpers/conflict.d.ts.map +1 -1
- package/dist/helpers/emitted-change.d.ts +1 -1
- package/dist/helpers/emitted-change.d.ts.map +1 -1
- package/dist/helpers/index.js +4 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -16
- package/dist/index.js.map +1 -1
- package/dist/notify.d.ts +47 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +85 -0
- package/dist/notify.js.map +1 -0
- package/dist/proxy/handler.d.ts +1 -1
- package/dist/proxy/handler.d.ts.map +1 -1
- package/dist/proxy/handler.js +15 -11
- package/dist/proxy/handler.js.map +1 -1
- package/dist/proxy/index.d.ts +2 -2
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +3 -3
- package/dist/proxy/index.js.map +1 -1
- package/dist/proxy/mutation-detector.d.ts +4 -0
- package/dist/proxy/mutation-detector.d.ts.map +1 -1
- package/dist/proxy/mutation-detector.js +209 -24
- package/dist/proxy/mutation-detector.js.map +1 -1
- package/dist/proxy/oplog.d.ts +2 -1
- package/dist/proxy/oplog.d.ts.map +1 -1
- package/dist/proxy/oplog.js +15 -9
- package/dist/proxy/oplog.js.map +1 -1
- package/dist/proxy/registry.d.ts +0 -11
- package/dist/proxy/registry.d.ts.map +1 -1
- package/dist/proxy/registry.js +0 -24
- package/dist/proxy/registry.js.map +1 -1
- package/dist/proxy/types.d.ts +2 -0
- package/dist/proxy/types.d.ts.map +1 -1
- package/dist/pull.d.ts +4 -3
- package/dist/pull.d.ts.map +1 -1
- package/dist/pull.js +565 -314
- package/dist/pull.js.map +1 -1
- package/dist/push.d.ts +15 -3
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +359 -229
- package/dist/push.js.map +1 -1
- package/dist/realtime/index.js +1 -1
- package/dist/realtime/types.d.ts +2 -0
- package/dist/realtime/types.d.ts.map +1 -1
- package/dist/schema.d.ts +11 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/snapshot-chunks/db-metadata.d.ts +6 -1
- package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
- package/dist/snapshot-chunks/db-metadata.js +261 -92
- package/dist/snapshot-chunks/db-metadata.js.map +1 -1
- package/dist/snapshot-chunks/index.d.ts +0 -1
- package/dist/snapshot-chunks/index.d.ts.map +1 -1
- package/dist/snapshot-chunks/index.js +2 -3
- package/dist/snapshot-chunks/index.js.map +1 -1
- package/dist/snapshot-chunks/types.d.ts +20 -5
- package/dist/snapshot-chunks/types.d.ts.map +1 -1
- package/dist/snapshot-chunks.d.ts +12 -8
- package/dist/snapshot-chunks.d.ts.map +1 -1
- package/dist/snapshot-chunks.js +40 -12
- package/dist/snapshot-chunks.js.map +1 -1
- package/dist/subscriptions/index.js +1 -1
- package/dist/subscriptions/resolve.d.ts +6 -6
- package/dist/subscriptions/resolve.d.ts.map +1 -1
- package/dist/subscriptions/resolve.js +53 -14
- package/dist/subscriptions/resolve.js.map +1 -1
- package/package.json +28 -7
- package/src/blobs/adapters/database.test.ts +67 -0
- package/src/blobs/adapters/database.ts +34 -9
- package/src/blobs/adapters/filesystem.test.ts +132 -0
- package/src/blobs/adapters/filesystem.ts +189 -0
- package/src/blobs/adapters/s3.test.ts +522 -0
- package/src/blobs/adapters/s3.ts +55 -2
- package/src/blobs/index.ts +1 -0
- package/src/clients.ts +1 -0
- package/src/compaction.ts +1 -1
- package/src/dialect/base.ts +292 -0
- package/src/dialect/helpers.ts +61 -0
- package/src/dialect/index.ts +2 -0
- package/src/dialect/types.ts +50 -54
- package/src/{shapes → handlers}/create-handler.ts +219 -64
- package/src/{shapes → handlers}/types.ts +10 -7
- package/src/helpers/conflict.ts +1 -1
- package/src/helpers/emitted-change.ts +1 -1
- package/src/index.ts +2 -1
- package/src/notify.test.ts +516 -0
- package/src/notify.ts +131 -0
- package/src/proxy/handler.test.ts +120 -0
- package/src/proxy/handler.ts +18 -10
- package/src/proxy/index.ts +2 -1
- package/src/proxy/mutation-detector.test.ts +71 -0
- package/src/proxy/mutation-detector.ts +227 -29
- package/src/proxy/oplog.ts +19 -10
- package/src/proxy/registry.ts +0 -33
- package/src/proxy/types.ts +2 -0
- package/src/pull.ts +788 -405
- package/src/push.ts +507 -312
- package/src/realtime/types.ts +2 -0
- package/src/schema.ts +11 -1
- package/src/snapshot-chunks/db-metadata.test.ts +169 -0
- package/src/snapshot-chunks/db-metadata.ts +347 -105
- package/src/snapshot-chunks/index.ts +0 -1
- package/src/snapshot-chunks/types.ts +31 -5
- package/src/snapshot-chunks.ts +60 -21
- package/src/subscriptions/resolve.ts +73 -18
- package/dist/shapes/create-handler.d.ts.map +0 -1
- package/dist/shapes/create-handler.js.map +0 -1
- package/dist/shapes/index.d.ts.map +0 -1
- package/dist/shapes/index.js +0 -4
- package/dist/shapes/index.js.map +0 -1
- package/dist/shapes/registry.d.ts.map +0 -1
- package/dist/shapes/registry.js.map +0 -1
- package/dist/snapshot-chunks/adapters/s3.d.ts +0 -63
- package/dist/snapshot-chunks/adapters/s3.d.ts.map +0 -1
- package/dist/snapshot-chunks/adapters/s3.js +0 -50
- package/dist/snapshot-chunks/adapters/s3.js.map +0 -1
- package/src/snapshot-chunks/adapters/s3.ts +0 -68
- /package/dist/{shapes → handlers}/index.d.ts +0 -0
- /package/dist/{shapes → handlers}/registry.d.ts +0 -0
- /package/dist/{shapes → handlers}/registry.js +0 -0
- /package/dist/{shapes → handlers}/types.js +0 -0
- /package/src/{shapes → handlers}/index.ts +0 -0
- /package/src/{shapes → handlers}/registry.ts +0 -0
|
@@ -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 handlers = 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
|
+
handlers,
|
|
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
|
+
handlers,
|
|
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
|
+
});
|
package/src/proxy/handler.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
appendReturning,
|
|
13
13
|
detectMutation,
|
|
14
14
|
hasReturningClause,
|
|
15
|
+
hasReturningWildcard,
|
|
15
16
|
} from './mutation-detector';
|
|
16
17
|
import { createOplogEntries } from './oplog';
|
|
17
18
|
import type { ProxyTableRegistry } from './registry';
|
|
@@ -23,7 +24,7 @@ export interface ExecuteProxyQueryArgs<DB extends SyncCoreDb = SyncCoreDb> {
|
|
|
23
24
|
/** Server sync dialect */
|
|
24
25
|
dialect: ServerSyncDialect;
|
|
25
26
|
/** Proxy table registry for oplog generation */
|
|
26
|
-
|
|
27
|
+
handlers: ProxyTableRegistry;
|
|
27
28
|
/** Query context (actor/client IDs) */
|
|
28
29
|
ctx: ProxyQueryContext;
|
|
29
30
|
/** SQL query string */
|
|
@@ -99,7 +100,7 @@ function buildRawQuery(
|
|
|
99
100
|
export async function executeProxyQuery<DB extends SyncCoreDb>(
|
|
100
101
|
args: ExecuteProxyQueryArgs<DB>
|
|
101
102
|
): Promise<ExecuteProxyQueryResult> {
|
|
102
|
-
const { db, dialect,
|
|
103
|
+
const { db, dialect, handlers, ctx, sqlQuery, parameters } = args;
|
|
103
104
|
|
|
104
105
|
const mutation = detectMutation(sqlQuery);
|
|
105
106
|
|
|
@@ -109,10 +110,10 @@ export async function executeProxyQuery<DB extends SyncCoreDb>(
|
|
|
109
110
|
return { rows: result.rows };
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
// Check if this table has a registered
|
|
113
|
-
const
|
|
114
|
-
if (!
|
|
115
|
-
// No
|
|
113
|
+
// Check if this table has a registered handler
|
|
114
|
+
const handler = handlers.get(mutation.tableName);
|
|
115
|
+
if (!handler) {
|
|
116
|
+
// No handler registered - execute without oplog
|
|
116
117
|
// This allows proxy operations on non-synced tables
|
|
117
118
|
const result = await buildRawQuery(sqlQuery, parameters).execute(db);
|
|
118
119
|
return {
|
|
@@ -121,9 +122,15 @@ export async function executeProxyQuery<DB extends SyncCoreDb>(
|
|
|
121
122
|
};
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
// Mutation with registered
|
|
125
|
-
const
|
|
126
|
-
|
|
125
|
+
// Mutation with registered handler - 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);
|
|
127
134
|
|
|
128
135
|
const result = await buildRawQuery(finalSql, parameters).execute(db);
|
|
129
136
|
const affectedRows = result.rows as Record<string, unknown>[];
|
|
@@ -138,7 +145,8 @@ export async function executeProxyQuery<DB extends SyncCoreDb>(
|
|
|
138
145
|
dialect,
|
|
139
146
|
actorId: ctx.actorId,
|
|
140
147
|
clientId: ctx.clientId,
|
|
141
|
-
|
|
148
|
+
partitionId: ctx.partitionId,
|
|
149
|
+
handler,
|
|
142
150
|
operation: mutation.operation,
|
|
143
151
|
rows: affectedRows,
|
|
144
152
|
});
|
package/src/proxy/index.ts
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
// Query execution
|
|
8
8
|
export {
|
|
9
|
+
type ExecuteProxyQueryArgs,
|
|
9
10
|
type ExecuteProxyQueryResult,
|
|
10
11
|
executeProxyQuery,
|
|
11
12
|
} from './handler';
|
|
12
13
|
// Mutation detection
|
|
13
|
-
export { detectMutation } from './mutation-detector';
|
|
14
|
+
export { type DetectedMutation, detectMutation } from './mutation-detector';
|
|
14
15
|
// Oplog creation
|
|
15
16
|
// Registry
|
|
16
17
|
export { ProxyTableRegistry } from './registry';
|
|
@@ -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
|
+
});
|
|
@@ -13,6 +13,205 @@ export interface DetectedMutation {
|
|
|
13
13
|
tableName: string;
|
|
14
14
|
}
|
|
15
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
|
+
|
|
16
215
|
/**
|
|
17
216
|
* Detect if a SQL query is a mutation and extract table info.
|
|
18
217
|
*
|
|
@@ -20,39 +219,29 @@ export interface DetectedMutation {
|
|
|
20
219
|
* @returns Mutation info if detected, null for read queries
|
|
21
220
|
*/
|
|
22
221
|
export function detectMutation(sql: string): DetectedMutation | null {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
tableName: insertMatch[2]!,
|
|
33
|
-
};
|
|
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 };
|
|
34
231
|
}
|
|
35
232
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (updateMatch) {
|
|
41
|
-
return {
|
|
42
|
-
operation: 'upsert',
|
|
43
|
-
tableName: updateMatch[2]!,
|
|
44
|
-
};
|
|
233
|
+
if (lower.startsWith('update')) {
|
|
234
|
+
const tableName = parseTargetTable(statement.replace(/^update\s+/i, ''));
|
|
235
|
+
if (!tableName) return null;
|
|
236
|
+
return { operation: 'upsert', tableName };
|
|
45
237
|
}
|
|
46
238
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
operation: 'delete',
|
|
54
|
-
tableName: deleteMatch[2]!,
|
|
55
|
-
};
|
|
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 };
|
|
56
245
|
}
|
|
57
246
|
|
|
58
247
|
return null;
|
|
@@ -66,6 +255,15 @@ export function hasReturningClause(sql: string): boolean {
|
|
|
66
255
|
return /\bRETURNING\b/i.test(sql);
|
|
67
256
|
}
|
|
68
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
|
+
|
|
69
267
|
/**
|
|
70
268
|
* Append RETURNING * to a mutation query if not already present.
|
|
71
269
|
*
|
package/src/proxy/oplog.ts
CHANGED
|
@@ -29,22 +29,25 @@ export async function createOplogEntries<DB extends SyncCoreDb>(args: {
|
|
|
29
29
|
dialect: ServerSyncDialect;
|
|
30
30
|
actorId: string;
|
|
31
31
|
clientId: string;
|
|
32
|
-
|
|
32
|
+
partitionId?: string;
|
|
33
|
+
handler: ProxyTableHandler;
|
|
33
34
|
operation: SyncOp;
|
|
34
35
|
rows: Record<string, unknown>[];
|
|
35
36
|
}): Promise<{ commitSeq: number; affectedTables: string[] }> {
|
|
36
|
-
const { trx, dialect, actorId, clientId,
|
|
37
|
+
const { trx, dialect, actorId, clientId, handler, operation, rows } = args;
|
|
38
|
+
const partitionId = args.partitionId ?? 'default';
|
|
37
39
|
|
|
38
40
|
if (rows.length === 0) {
|
|
39
41
|
return { commitSeq: 0, affectedTables: [] };
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
const pk =
|
|
43
|
-
const versionCol =
|
|
44
|
+
const pk = handler.primaryKey ?? 'id';
|
|
45
|
+
const versionCol = handler.versionColumn ?? 'server_version';
|
|
44
46
|
|
|
45
47
|
// Create commit record
|
|
46
48
|
const commitResult = await sql<{ commit_seq: number }>`
|
|
47
49
|
insert into ${sql.table('sync_commits')} (
|
|
50
|
+
partition_id,
|
|
48
51
|
actor_id,
|
|
49
52
|
client_id,
|
|
50
53
|
client_commit_id,
|
|
@@ -52,6 +55,7 @@ export async function createOplogEntries<DB extends SyncCoreDb>(args: {
|
|
|
52
55
|
result_json
|
|
53
56
|
)
|
|
54
57
|
values (
|
|
58
|
+
${partitionId},
|
|
55
59
|
${actorId},
|
|
56
60
|
${clientId},
|
|
57
61
|
${`proxy:${generateId()}`},
|
|
@@ -69,14 +73,15 @@ export async function createOplogEntries<DB extends SyncCoreDb>(args: {
|
|
|
69
73
|
|
|
70
74
|
// Compute scopes for all rows and collect changes
|
|
71
75
|
const affectedTablesSet = new Set<string>();
|
|
72
|
-
affectedTablesSet.add(
|
|
76
|
+
affectedTablesSet.add(handler.table);
|
|
73
77
|
|
|
74
78
|
const changes = rows.map((row) => {
|
|
75
|
-
const scopes =
|
|
79
|
+
const scopes = handler.computeScopes(row);
|
|
76
80
|
|
|
77
81
|
return {
|
|
78
82
|
commit_seq: commitSeq,
|
|
79
|
-
|
|
83
|
+
partition_id: partitionId,
|
|
84
|
+
table: handler.table,
|
|
80
85
|
row_id: String(row[pk]),
|
|
81
86
|
op: operation,
|
|
82
87
|
row_json: operation === 'delete' ? null : row,
|
|
@@ -89,6 +94,7 @@ export async function createOplogEntries<DB extends SyncCoreDb>(args: {
|
|
|
89
94
|
await sql`
|
|
90
95
|
insert into ${sql.table('sync_changes')} (
|
|
91
96
|
commit_seq,
|
|
97
|
+
partition_id,
|
|
92
98
|
"table",
|
|
93
99
|
row_id,
|
|
94
100
|
op,
|
|
@@ -100,6 +106,7 @@ export async function createOplogEntries<DB extends SyncCoreDb>(args: {
|
|
|
100
106
|
changes.map(
|
|
101
107
|
(c) => sql`(
|
|
102
108
|
${c.commit_seq},
|
|
109
|
+
${c.partition_id},
|
|
103
110
|
${c.table},
|
|
104
111
|
${c.row_id},
|
|
105
112
|
${c.op},
|
|
@@ -124,12 +131,14 @@ export async function createOplogEntries<DB extends SyncCoreDb>(args: {
|
|
|
124
131
|
// Insert table commits for subscription filtering
|
|
125
132
|
if (affectedTables.length > 0) {
|
|
126
133
|
await sql`
|
|
127
|
-
insert into ${sql.table('sync_table_commits')} ("table", commit_seq)
|
|
134
|
+
insert into ${sql.table('sync_table_commits')} (partition_id, "table", commit_seq)
|
|
128
135
|
values ${sql.join(
|
|
129
|
-
sortedAffectedTables.map(
|
|
136
|
+
sortedAffectedTables.map(
|
|
137
|
+
(table) => sql`(${partitionId}, ${table}, ${commitSeq})`
|
|
138
|
+
),
|
|
130
139
|
sql`, `
|
|
131
140
|
)}
|
|
132
|
-
on conflict ("table", commit_seq) do nothing
|
|
141
|
+
on conflict (partition_id, "table", commit_seq) do nothing
|
|
133
142
|
`.execute(trx);
|
|
134
143
|
}
|
|
135
144
|
|
package/src/proxy/registry.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Registry for proxy table handlers.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { TableRegistry } from '../shapes/registry';
|
|
8
7
|
import type { ProxyTableHandler } from './types';
|
|
9
8
|
|
|
10
9
|
/**
|
|
@@ -55,35 +54,3 @@ export class ProxyTableRegistry {
|
|
|
55
54
|
return Array.from(this.handlers.values());
|
|
56
55
|
}
|
|
57
56
|
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Create a ProxyTableRegistry from existing ServerTableHandlers.
|
|
61
|
-
*
|
|
62
|
-
* This helper extracts extractScopes from ServerTableHandlers that define it,
|
|
63
|
-
* avoiding duplication of scope extraction logic.
|
|
64
|
-
*
|
|
65
|
-
* @param tables - The existing table registry
|
|
66
|
-
* @param tableNameMap - Map from table name to database table name
|
|
67
|
-
*/
|
|
68
|
-
export function createProxyRegistryFromTables(
|
|
69
|
-
tables: TableRegistry,
|
|
70
|
-
tableNameMap: Record<string, string>
|
|
71
|
-
): ProxyTableRegistry {
|
|
72
|
-
const registry = new ProxyTableRegistry();
|
|
73
|
-
|
|
74
|
-
for (const [tableName, dbTableName] of Object.entries(tableNameMap)) {
|
|
75
|
-
const handler = tables.get(tableName);
|
|
76
|
-
if (
|
|
77
|
-
handler &&
|
|
78
|
-
'extractScopes' in handler &&
|
|
79
|
-
typeof handler.extractScopes === 'function'
|
|
80
|
-
) {
|
|
81
|
-
registry.register({
|
|
82
|
-
table: dbTableName,
|
|
83
|
-
computeScopes: handler.extractScopes,
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return registry;
|
|
89
|
-
}
|