@syncular/server-hono 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 (76) hide show
  1. package/dist/api-key-auth.d.ts +49 -0
  2. package/dist/api-key-auth.d.ts.map +1 -0
  3. package/dist/api-key-auth.js +110 -0
  4. package/dist/api-key-auth.js.map +1 -0
  5. package/dist/blobs.d.ts +69 -0
  6. package/dist/blobs.d.ts.map +1 -0
  7. package/dist/blobs.js +383 -0
  8. package/dist/blobs.js.map +1 -0
  9. package/dist/console/index.d.ts +8 -0
  10. package/dist/console/index.d.ts.map +1 -0
  11. package/dist/console/index.js +7 -0
  12. package/dist/console/index.js.map +1 -0
  13. package/dist/console/routes.d.ts +106 -0
  14. package/dist/console/routes.d.ts.map +1 -0
  15. package/dist/console/routes.js +1612 -0
  16. package/dist/console/routes.js.map +1 -0
  17. package/dist/console/schemas.d.ts +308 -0
  18. package/dist/console/schemas.d.ts.map +1 -0
  19. package/dist/console/schemas.js +201 -0
  20. package/dist/console/schemas.js.map +1 -0
  21. package/dist/create-server.d.ts +78 -0
  22. package/dist/create-server.d.ts.map +1 -0
  23. package/dist/create-server.js +99 -0
  24. package/dist/create-server.js.map +1 -0
  25. package/dist/index.d.ts +16 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +25 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/openapi.d.ts +45 -0
  30. package/dist/openapi.d.ts.map +1 -0
  31. package/dist/openapi.js +59 -0
  32. package/dist/openapi.js.map +1 -0
  33. package/dist/proxy/connection-manager.d.ts +78 -0
  34. package/dist/proxy/connection-manager.d.ts.map +1 -0
  35. package/dist/proxy/connection-manager.js +251 -0
  36. package/dist/proxy/connection-manager.js.map +1 -0
  37. package/dist/proxy/index.d.ts +8 -0
  38. package/dist/proxy/index.d.ts.map +1 -0
  39. package/dist/proxy/index.js +8 -0
  40. package/dist/proxy/index.js.map +1 -0
  41. package/dist/proxy/routes.d.ts +74 -0
  42. package/dist/proxy/routes.d.ts.map +1 -0
  43. package/dist/proxy/routes.js +147 -0
  44. package/dist/proxy/routes.js.map +1 -0
  45. package/dist/rate-limit.d.ts +101 -0
  46. package/dist/rate-limit.d.ts.map +1 -0
  47. package/dist/rate-limit.js +186 -0
  48. package/dist/rate-limit.js.map +1 -0
  49. package/dist/routes.d.ts +126 -0
  50. package/dist/routes.d.ts.map +1 -0
  51. package/dist/routes.js +788 -0
  52. package/dist/routes.js.map +1 -0
  53. package/dist/ws.d.ts +230 -0
  54. package/dist/ws.d.ts.map +1 -0
  55. package/dist/ws.js +601 -0
  56. package/dist/ws.js.map +1 -0
  57. package/package.json +73 -0
  58. package/src/__tests__/create-server.test.ts +187 -0
  59. package/src/__tests__/pull-chunk-storage.test.ts +189 -0
  60. package/src/__tests__/rate-limit.test.ts +78 -0
  61. package/src/__tests__/realtime-bridge.test.ts +131 -0
  62. package/src/__tests__/ws-connection-manager.test.ts +176 -0
  63. package/src/api-key-auth.ts +179 -0
  64. package/src/blobs.ts +534 -0
  65. package/src/console/index.ts +17 -0
  66. package/src/console/routes.ts +2155 -0
  67. package/src/console/schemas.ts +299 -0
  68. package/src/create-server.ts +180 -0
  69. package/src/index.ts +42 -0
  70. package/src/openapi.ts +74 -0
  71. package/src/proxy/connection-manager.ts +340 -0
  72. package/src/proxy/index.ts +8 -0
  73. package/src/proxy/routes.ts +223 -0
  74. package/src/rate-limit.ts +321 -0
  75. package/src/routes.ts +1186 -0
  76. package/src/ws.ts +789 -0
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@syncular/server-hono",
3
+ "version": "0.0.1-60",
4
+ "description": "Hono adapter for the Syncular server with OpenAPI support",
5
+ "license": "MIT",
6
+ "author": "Benjamin Kniffler",
7
+ "homepage": "https://syncular.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/syncular/syncular.git",
11
+ "directory": "packages/server-hono"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/syncular/syncular/issues"
15
+ },
16
+ "keywords": [
17
+ "sync",
18
+ "offline-first",
19
+ "realtime",
20
+ "database",
21
+ "typescript",
22
+ "hono",
23
+ "openapi"
24
+ ],
25
+ "private": false,
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "type": "module",
30
+ "exports": {
31
+ ".": {
32
+ "bun": "./src/index.ts",
33
+ "import": {
34
+ "types": "./dist/index.d.ts",
35
+ "default": "./dist/index.js"
36
+ }
37
+ }
38
+ },
39
+ "scripts": {
40
+ "test": "bun test src/__tests__",
41
+ "tsgo": "tsgo --noEmit",
42
+ "generate": "bun generate:openapi",
43
+ "generate:openapi": "bun scripts/generate-openapi.ts",
44
+ "build": "rm -rf dist && tsgo",
45
+ "release": "bun pm pack --destination . && npm publish ./*.tgz --tag latest && rm -f ./*.tgz"
46
+ },
47
+ "dependencies": {
48
+ "@hono/standard-validator": "^0.2.2",
49
+ "@standard-community/standard-json": "^0.3.5",
50
+ "@standard-community/standard-openapi": "^0.2.9",
51
+ "@syncular/core": "0.0.1",
52
+ "@syncular/server": "0.0.1",
53
+ "@types/json-schema": "^7.0.15",
54
+ "hono-openapi": "^1.2.0",
55
+ "openapi-types": "^12.1.3"
56
+ },
57
+ "devDependencies": {
58
+ "@syncular/config": "0.0.0",
59
+ "@syncular/dialect-pglite": "0.0.1",
60
+ "@syncular/server-dialect-postgres": "0.0.1",
61
+ "kysely": "*",
62
+ "zod": "*"
63
+ },
64
+ "peerDependencies": {
65
+ "hono": "^4.0.0",
66
+ "kysely": "^0.28.0",
67
+ "zod": "^4.0.0"
68
+ },
69
+ "files": [
70
+ "dist",
71
+ "src"
72
+ ]
73
+ }
@@ -0,0 +1,187 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { createPgliteDb } from '@syncular/dialect-pglite';
3
+ import {
4
+ createServerHandler,
5
+ ensureSyncSchema,
6
+ type SyncCoreDb,
7
+ } from '@syncular/server';
8
+ import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
9
+ import { Hono } from 'hono';
10
+ import { defineWebSocketHelper } from 'hono/ws';
11
+ import type { Kysely } from 'kysely';
12
+ import { createSyncServer } from '../create-server';
13
+ import { getSyncWebSocketConnectionManager } from '../routes';
14
+ import type { WebSocketConnection } from '../ws';
15
+
16
+ interface TasksTable {
17
+ id: string;
18
+ user_id: string;
19
+ title: string;
20
+ server_version: number;
21
+ }
22
+
23
+ interface ServerDb extends SyncCoreDb {
24
+ tasks: TasksTable;
25
+ }
26
+
27
+ interface ClientDb {
28
+ tasks: TasksTable;
29
+ }
30
+
31
+ describe('createSyncServer console configuration', () => {
32
+ let db: Kysely<ServerDb>;
33
+ let previousConsoleToken: string | undefined;
34
+
35
+ beforeEach(async () => {
36
+ db = createPgliteDb<ServerDb>();
37
+ await ensureSyncSchema(db, createPostgresServerDialect());
38
+ previousConsoleToken = process.env.SYNC_CONSOLE_TOKEN;
39
+ delete process.env.SYNC_CONSOLE_TOKEN;
40
+ });
41
+
42
+ afterEach(async () => {
43
+ if (previousConsoleToken === undefined) {
44
+ delete process.env.SYNC_CONSOLE_TOKEN;
45
+ } else {
46
+ process.env.SYNC_CONSOLE_TOKEN = previousConsoleToken;
47
+ }
48
+ await db.destroy();
49
+ });
50
+
51
+ function createTestHandler() {
52
+ return createServerHandler<ServerDb, ClientDb, 'tasks'>({
53
+ table: 'tasks',
54
+ scopes: ['user:{user_id}'],
55
+ resolveScopes: async (ctx) => ({ user_id: [ctx.actorId] }),
56
+ });
57
+ }
58
+
59
+ function createOptions() {
60
+ return {
61
+ db,
62
+ dialect: createPostgresServerDialect(),
63
+ handlers: [createTestHandler()],
64
+ authenticate: async () => ({ actorId: 'u1' }),
65
+ };
66
+ }
67
+
68
+ function createConn(args: {
69
+ actorId: string;
70
+ clientId: string;
71
+ }): WebSocketConnection {
72
+ return {
73
+ actorId: args.actorId,
74
+ clientId: args.clientId,
75
+ transportPath: 'direct',
76
+ get isOpen() {
77
+ return true;
78
+ },
79
+ sendSync() {},
80
+ sendHeartbeat() {},
81
+ sendPresence() {},
82
+ sendError() {},
83
+ close() {},
84
+ };
85
+ }
86
+
87
+ it('keeps console routes disabled when console config is omitted', () => {
88
+ const server = createSyncServer(createOptions());
89
+ expect(server.consoleRoutes).toBeUndefined();
90
+ });
91
+
92
+ it('throws when console is enabled without a token', () => {
93
+ const options = createOptions();
94
+ expect(() =>
95
+ createSyncServer({
96
+ ...options,
97
+ console: {},
98
+ })
99
+ ).toThrow(
100
+ 'Console is enabled but no token is configured. Set `console.token` or SYNC_CONSOLE_TOKEN.'
101
+ );
102
+ });
103
+
104
+ it('accepts SYNC_CONSOLE_TOKEN when console token is omitted', () => {
105
+ process.env.SYNC_CONSOLE_TOKEN = 'env-token';
106
+ const options = createOptions();
107
+ const server = createSyncServer({
108
+ ...options,
109
+ console: {},
110
+ });
111
+ expect(server.consoleRoutes).toBeDefined();
112
+ });
113
+
114
+ it('accepts an explicit console token', () => {
115
+ process.env.SYNC_CONSOLE_TOKEN = 'env-token';
116
+ const options = createOptions();
117
+ const server = createSyncServer({
118
+ ...options,
119
+ console: { token: 'explicit-token' },
120
+ });
121
+ expect(server.consoleRoutes).toBeDefined();
122
+ });
123
+
124
+ it('forwards maxConnectionsPerClient from factory to realtime route', async () => {
125
+ const options = createOptions();
126
+ const upgradeWebSocket = defineWebSocketHelper(async () => {});
127
+
128
+ const server = createSyncServer({
129
+ ...options,
130
+ upgradeWebSocket,
131
+ sync: {
132
+ websocket: {
133
+ maxConnectionsPerClient: 1,
134
+ },
135
+ },
136
+ });
137
+
138
+ const manager = getSyncWebSocketConnectionManager(server.syncRoutes);
139
+ if (!manager) {
140
+ throw new Error('Expected websocket manager to be enabled.');
141
+ }
142
+ manager.register(createConn({ actorId: 'u1', clientId: 'client-1' }), []);
143
+
144
+ const app = new Hono();
145
+ app.route('/sync', server.syncRoutes);
146
+
147
+ const response = await app.request(
148
+ 'http://localhost/sync/realtime?clientId=client-1'
149
+ );
150
+ expect(response.status).toBe(429);
151
+ expect(await response.json()).toEqual({
152
+ error: 'WEBSOCKET_CONNECTION_LIMIT_CLIENT',
153
+ });
154
+ });
155
+
156
+ it('forwards maxConnectionsTotal from factory to realtime route', async () => {
157
+ const options = createOptions();
158
+ const upgradeWebSocket = defineWebSocketHelper(async () => {});
159
+
160
+ const server = createSyncServer({
161
+ ...options,
162
+ upgradeWebSocket,
163
+ sync: {
164
+ websocket: {
165
+ maxConnectionsTotal: 1,
166
+ },
167
+ },
168
+ });
169
+
170
+ const manager = getSyncWebSocketConnectionManager(server.syncRoutes);
171
+ if (!manager) {
172
+ throw new Error('Expected websocket manager to be enabled.');
173
+ }
174
+ manager.register(createConn({ actorId: 'u1', clientId: 'client-1' }), []);
175
+
176
+ const app = new Hono();
177
+ app.route('/sync', server.syncRoutes);
178
+
179
+ const response = await app.request(
180
+ 'http://localhost/sync/realtime?clientId=client-2'
181
+ );
182
+ expect(response.status).toBe(429);
183
+ expect(await response.json()).toEqual({
184
+ error: 'WEBSOCKET_CONNECTION_LIMIT_TOTAL',
185
+ });
186
+ });
187
+ });
@@ -0,0 +1,189 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { gunzipSync } from 'node:zlib';
3
+ import {
4
+ SyncCombinedResponseSchema,
5
+ type SyncPullResponse,
6
+ type SyncSnapshotChunkRef,
7
+ } from '@syncular/core';
8
+ import {
9
+ createServerHandler,
10
+ ensureSyncSchema,
11
+ type SnapshotChunkStorage,
12
+ type SyncCoreDb,
13
+ } from '@syncular/server';
14
+ import { Hono } from 'hono';
15
+ import type { Kysely } from 'kysely';
16
+ import { createBunSqliteDb } from '../../../dialect-bun-sqlite/src';
17
+ import { createSqliteServerDialect } from '../../../server-dialect-sqlite/src';
18
+ import { createSyncRoutes } from '../routes';
19
+
20
+ interface TasksTable {
21
+ id: string;
22
+ user_id: string;
23
+ title: string;
24
+ server_version: number;
25
+ }
26
+
27
+ interface ServerDb extends SyncCoreDb {
28
+ tasks: TasksTable;
29
+ }
30
+
31
+ interface ClientDb {
32
+ tasks: TasksTable;
33
+ }
34
+
35
+ function mustGetFirstChunkId(payload: SyncPullResponse): string {
36
+ const chunkId = payload.subscriptions[0]?.snapshots?.[0]?.chunks?.[0]?.id;
37
+ if (!chunkId) {
38
+ throw new Error('Expected pull bootstrap response to include a chunk id.');
39
+ }
40
+ return chunkId;
41
+ }
42
+
43
+ describe('createSyncRoutes chunkStorage wiring', () => {
44
+ let db: Kysely<ServerDb>;
45
+ const dialect = createSqliteServerDialect();
46
+
47
+ beforeEach(async () => {
48
+ db = createBunSqliteDb<ServerDb>({ path: ':memory:' });
49
+ await ensureSyncSchema(db, dialect);
50
+
51
+ await db.schema
52
+ .createTable('tasks')
53
+ .addColumn('id', 'text', (col) => col.primaryKey())
54
+ .addColumn('user_id', 'text', (col) => col.notNull())
55
+ .addColumn('title', 'text', (col) => col.notNull())
56
+ .addColumn('server_version', 'integer', (col) =>
57
+ col.notNull().defaultTo(0)
58
+ )
59
+ .execute();
60
+ });
61
+
62
+ afterEach(async () => {
63
+ await db.destroy();
64
+ });
65
+
66
+ it('uses external chunk storage in /pull and serves chunks from it', async () => {
67
+ await db
68
+ .insertInto('tasks')
69
+ .values({
70
+ id: 't1',
71
+ user_id: 'u1',
72
+ title: 'Task 1',
73
+ server_version: 1,
74
+ })
75
+ .execute();
76
+
77
+ const tasksHandler = createServerHandler<ServerDb, ClientDb, 'tasks'>({
78
+ table: 'tasks',
79
+ scopes: ['user:{user_id}'],
80
+ resolveScopes: async (ctx) => ({ user_id: [ctx.actorId] }),
81
+ });
82
+
83
+ const externalChunkBodies = new Map<string, Uint8Array>();
84
+ let storeChunkCalls = 0;
85
+ const chunkStorage: SnapshotChunkStorage = {
86
+ name: 'test-external',
87
+ async storeChunk(metadata) {
88
+ storeChunkCalls += 1;
89
+ const ref: SyncSnapshotChunkRef = {
90
+ id: `chunk-${storeChunkCalls}`,
91
+ sha256: metadata.sha256,
92
+ byteLength: metadata.body.length,
93
+ encoding: metadata.encoding,
94
+ compression: metadata.compression,
95
+ };
96
+ externalChunkBodies.set(ref.id, new Uint8Array(metadata.body));
97
+ return ref;
98
+ },
99
+ async readChunk(chunkId: string) {
100
+ const body = externalChunkBodies.get(chunkId);
101
+ return body ? new Uint8Array(body) : null;
102
+ },
103
+ async findChunk() {
104
+ return null;
105
+ },
106
+ async cleanupExpired() {
107
+ return 0;
108
+ },
109
+ };
110
+
111
+ const routes = createSyncRoutes({
112
+ db,
113
+ dialect,
114
+ handlers: [tasksHandler],
115
+ authenticate: async (c) => {
116
+ const actorId = c.req.header('x-user-id');
117
+ return actorId ? { actorId } : null;
118
+ },
119
+ chunkStorage,
120
+ });
121
+
122
+ const app = new Hono();
123
+ app.route('/sync', routes);
124
+
125
+ const pullResponse = await app.request(
126
+ new Request('http://localhost/sync', {
127
+ method: 'POST',
128
+ headers: {
129
+ 'content-type': 'application/json',
130
+ 'x-user-id': 'u1',
131
+ },
132
+ body: JSON.stringify({
133
+ clientId: 'client-1',
134
+ pull: {
135
+ limitCommits: 10,
136
+ limitSnapshotRows: 100,
137
+ maxSnapshotPages: 1,
138
+ subscriptions: [
139
+ {
140
+ id: 'sub-1',
141
+ shape: 'tasks',
142
+ scopes: { user_id: 'u1' },
143
+ cursor: -1,
144
+ },
145
+ ],
146
+ },
147
+ }),
148
+ })
149
+ );
150
+
151
+ expect(pullResponse.status).toBe(200);
152
+ const combined = SyncCombinedResponseSchema.parse(
153
+ await pullResponse.json()
154
+ );
155
+ const parsed = combined.pull!;
156
+ const chunkId = mustGetFirstChunkId(parsed);
157
+ expect(storeChunkCalls).toBe(1);
158
+ expect(externalChunkBodies.has(chunkId)).toBe(true);
159
+
160
+ const storedExternal = externalChunkBodies.get(chunkId);
161
+ if (!storedExternal) {
162
+ throw new Error('Expected external chunk body to be stored.');
163
+ }
164
+
165
+ const decoded = new TextDecoder().decode(gunzipSync(storedExternal));
166
+ const rows = decoded
167
+ .split('\n')
168
+ .filter((line) => line.length > 0)
169
+ .map(
170
+ (line) =>
171
+ JSON.parse(line) as {
172
+ id: string;
173
+ user_id: string;
174
+ title: string;
175
+ server_version: number;
176
+ }
177
+ );
178
+
179
+ const snapshotChunkCountRow = await db
180
+ .selectFrom('sync_snapshot_chunks')
181
+ .select(({ fn }) => fn.countAll().as('count'))
182
+ .executeTakeFirstOrThrow();
183
+
184
+ expect(Number(snapshotChunkCountRow.count)).toBe(0);
185
+ expect(rows).toEqual([
186
+ { id: 't1', user_id: 'u1', title: 'Task 1', server_version: 1 },
187
+ ]);
188
+ }, 10_000);
189
+ });
@@ -0,0 +1,78 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test';
2
+ import { Hono } from 'hono';
3
+ import { createRateLimiter, resetRateLimitStore } from '../rate-limit';
4
+
5
+ afterEach(() => {
6
+ resetRateLimitStore();
7
+ });
8
+
9
+ describe('rate limiter store isolation', () => {
10
+ it('does not share counters across independently configured routes', async () => {
11
+ const app = new Hono();
12
+
13
+ app.use(
14
+ '/pull',
15
+ createRateLimiter({
16
+ maxRequests: 1,
17
+ windowMs: 60_000,
18
+ keyGenerator: () => 'actor-1',
19
+ })
20
+ );
21
+ app.use(
22
+ '/push',
23
+ createRateLimiter({
24
+ maxRequests: 1,
25
+ windowMs: 60_000,
26
+ keyGenerator: () => 'actor-1',
27
+ })
28
+ );
29
+
30
+ app.get('/pull', (c) => c.text('ok'));
31
+ app.get('/push', (c) => c.text('ok'));
32
+
33
+ const pullFirst = await app.request('http://localhost/pull');
34
+ const pushFirst = await app.request('http://localhost/push');
35
+ const pushSecond = await app.request('http://localhost/push');
36
+
37
+ expect(pullFirst.status).toBe(200);
38
+ expect(pushFirst.status).toBe(200);
39
+ expect(pushSecond.status).toBe(429);
40
+ });
41
+
42
+ it('keeps window durations isolated per limiter', async () => {
43
+ const app = new Hono();
44
+
45
+ app.use(
46
+ '/short',
47
+ createRateLimiter({
48
+ maxRequests: 1,
49
+ windowMs: 10,
50
+ keyGenerator: () => 'actor-1',
51
+ })
52
+ );
53
+ app.use(
54
+ '/long',
55
+ createRateLimiter({
56
+ maxRequests: 1,
57
+ windowMs: 1_000,
58
+ keyGenerator: () => 'actor-1',
59
+ })
60
+ );
61
+
62
+ app.get('/short', (c) => c.text('ok'));
63
+ app.get('/long', (c) => c.text('ok'));
64
+
65
+ // Initialize the short-window limiter first.
66
+ expect((await app.request('http://localhost/short')).status).toBe(200);
67
+
68
+ // Exhaust long-window limiter.
69
+ expect((await app.request('http://localhost/long')).status).toBe(200);
70
+ expect((await app.request('http://localhost/long')).status).toBe(429);
71
+
72
+ // Wait longer than short window but shorter than long window.
73
+ await Bun.sleep(30);
74
+
75
+ // Long limiter must still be limited.
76
+ expect((await app.request('http://localhost/long')).status).toBe(429);
77
+ });
78
+ });
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it, mock } from 'bun:test';
2
+ import { createPgliteDb } from '@syncular/dialect-pglite';
3
+ import {
4
+ ensureSyncSchema,
5
+ InMemorySyncRealtimeBroadcaster,
6
+ type SyncCoreDb,
7
+ } from '@syncular/server';
8
+ import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
9
+ import { defineWebSocketHelper } from 'hono/ws';
10
+ import {
11
+ createSyncRoutes,
12
+ getSyncRealtimeUnsubscribe,
13
+ getSyncWebSocketConnectionManager,
14
+ } from '../routes';
15
+
16
+ describe('realtime broadcaster bridge', () => {
17
+ it('notifies local WebSocket connections when another instance publishes a commit', async () => {
18
+ const db = createPgliteDb<SyncCoreDb>();
19
+ const dialect = createPostgresServerDialect();
20
+ await ensureSyncSchema(db, dialect);
21
+
22
+ const commit = await db
23
+ .insertInto('sync_commits')
24
+ .values({
25
+ partition_id: 'default',
26
+ actor_id: 'u1',
27
+ client_id: 'client-1',
28
+ client_commit_id: 'c1',
29
+ meta: null,
30
+ result_json: null,
31
+ })
32
+ .returning(['commit_seq'])
33
+ .executeTakeFirstOrThrow();
34
+
35
+ const commitSeq = Number(commit.commit_seq);
36
+
37
+ await db
38
+ .insertInto('sync_changes')
39
+ .values({
40
+ commit_seq: commitSeq,
41
+ partition_id: 'default',
42
+ table: 'tasks',
43
+ row_id: 't1',
44
+ op: 'upsert',
45
+ row_json: { id: 't1' },
46
+ row_version: 1,
47
+ scopes: { user_id: 'u1' },
48
+ })
49
+ .execute();
50
+
51
+ const broadcaster = new InMemorySyncRealtimeBroadcaster();
52
+ const upgradeWebSocket = defineWebSocketHelper(async () => {});
53
+
54
+ const routes1 = createSyncRoutes({
55
+ db,
56
+ dialect,
57
+ handlers: [],
58
+ authenticate: async () => ({ actorId: 'u1' }),
59
+ sync: {
60
+ websocket: {
61
+ enabled: true,
62
+ upgradeWebSocket,
63
+ heartbeatIntervalMs: 0,
64
+ },
65
+ realtime: { broadcaster, instanceId: 'i1' },
66
+ },
67
+ });
68
+
69
+ const routes2 = createSyncRoutes({
70
+ db,
71
+ dialect,
72
+ handlers: [],
73
+ authenticate: async () => ({ actorId: 'u1' }),
74
+ sync: {
75
+ websocket: {
76
+ enabled: true,
77
+ upgradeWebSocket,
78
+ heartbeatIntervalMs: 0,
79
+ },
80
+ realtime: { broadcaster, instanceId: 'i2' },
81
+ },
82
+ });
83
+
84
+ const mgr2 = getSyncWebSocketConnectionManager(routes2);
85
+ expect(mgr2).toBeTruthy();
86
+
87
+ const onSync = mock((_cursor: number) => {});
88
+
89
+ mgr2!.register(
90
+ {
91
+ actorId: 'u1',
92
+ clientId: 'client-2',
93
+ get isOpen() {
94
+ return true;
95
+ },
96
+ sendSync: onSync,
97
+ sendHeartbeat: mock(() => {}),
98
+ sendPresence: mock(() => {}),
99
+ sendError: mock(() => {}),
100
+ close: mock(() => {}),
101
+ },
102
+ ['default::user:u1']
103
+ );
104
+
105
+ // Publish without scopeKeys to exercise DB lookup on the receiving instance.
106
+ await broadcaster.publish({
107
+ type: 'commit',
108
+ commitSeq,
109
+ sourceInstanceId: 'i1',
110
+ });
111
+ await new Promise((r) => setTimeout(r, 0));
112
+
113
+ expect(onSync).toHaveBeenCalledWith(commitSeq);
114
+
115
+ // Echo suppression: instance2 ignores events it originated.
116
+ onSync.mockClear();
117
+ await broadcaster.publish({
118
+ type: 'commit',
119
+ commitSeq,
120
+ sourceInstanceId: 'i2',
121
+ });
122
+ await new Promise((r) => setTimeout(r, 0));
123
+
124
+ expect(onSync).not.toHaveBeenCalled();
125
+
126
+ getSyncRealtimeUnsubscribe(routes1)?.();
127
+ getSyncRealtimeUnsubscribe(routes2)?.();
128
+ await broadcaster.close();
129
+ await db.destroy();
130
+ });
131
+ });