create-syncular-app 0.58.0 → 0.60.0
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/package.json +1 -1
- package/template/minimal/src/clients.ts +6 -4
- package/template/minimal/src/smoke.test.ts +4 -8
- package/template/minimal/src/syncular.generated.ts +47 -21
- package/template/minimal/syncular.ir.json +4 -7
- package/template/minimal/syncular.json +1 -5
- package/template/minimal/syncular.migrations.lock.json +3 -2
- package/template/tauri/README.md +6 -1
- package/template/tauri/migrations/0001_initial/up.sql +3 -0
- package/template/tauri/src/server.ts +25 -11
- package/template/tauri/src/smoke.test.ts +3 -3
- package/template/tauri/src/syncular.generated.ts +55 -20
- package/template/tauri/src/syncular.queries.ts +2 -3
- package/template/tauri/src-tauri/Cargo.toml +1 -1
- package/template/tauri/src-tauri/tauri.conf.json +1 -1
- package/template/tauri/syncular.ir.json +13 -7
- package/template/tauri/syncular.json +1 -5
- package/template/tauri/syncular.migrations.lock.json +44 -41
- package/template/web/README.md +4 -1
- package/template/web/src/frontend/main.ts +2 -5
- package/template/web/src/server.ts +25 -11
- package/template/web/src/smoke.test.ts +3 -3
- package/template/web/src/syncular.generated.ts +47 -21
- package/template/web/syncular.ir.json +4 -7
- package/template/web/syncular.json +1 -5
- package/template/web/syncular.migrations.lock.json +43 -40
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* clients`). Both talk real HTTP to http://localhost:8787.
|
|
10
10
|
*/
|
|
11
11
|
import { makeClient } from './make-client';
|
|
12
|
+
import { todosInListSubscription } from './syncular.generated';
|
|
12
13
|
|
|
13
14
|
const BASE_URL = process.env.SERVER_URL ?? 'http://localhost:8787';
|
|
14
15
|
const LIST_ID = 'groceries';
|
|
@@ -18,10 +19,11 @@ const b = makeClient(BASE_URL, 'client-b');
|
|
|
18
19
|
await a.start();
|
|
19
20
|
await b.start();
|
|
20
21
|
|
|
21
|
-
// Both clients subscribe to the
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
// Both clients subscribe to the list's unit: one scope map covers every table
|
|
23
|
+
// scoped by `list_id`.
|
|
24
|
+
const list = todosInListSubscription.scopes({ listId: LIST_ID });
|
|
25
|
+
a.subscribe(list);
|
|
26
|
+
b.subscribe(list);
|
|
25
27
|
|
|
26
28
|
// A writes a todo. mutate() records it locally + queues it for the next push.
|
|
27
29
|
a.mutate([
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from '@syncular/server';
|
|
14
14
|
import { createSyncularHono } from '@syncular/server-hono';
|
|
15
15
|
import { makeClient } from './make-client';
|
|
16
|
-
import { schema } from './syncular.generated';
|
|
16
|
+
import { schema, todosInListSubscription } from './syncular.generated';
|
|
17
17
|
|
|
18
18
|
let server: ReturnType<typeof Bun.serve>;
|
|
19
19
|
let baseUrl: string;
|
|
@@ -43,13 +43,9 @@ test('two clients converge through the server', async () => {
|
|
|
43
43
|
await a.start();
|
|
44
44
|
await b.start();
|
|
45
45
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
scopes: { list_id: ['groceries'] },
|
|
50
|
-
};
|
|
51
|
-
a.subscribe(sub);
|
|
52
|
-
b.subscribe(sub);
|
|
46
|
+
const list = todosInListSubscription.scopes({ listId: 'groceries' });
|
|
47
|
+
a.subscribe(list);
|
|
48
|
+
b.subscribe(list);
|
|
53
49
|
|
|
54
50
|
a.mutate([
|
|
55
51
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Generated by @syncular/typegen — DO NOT EDIT.
|
|
2
2
|
// irVersion: 1
|
|
3
|
-
// irHash: sha256:
|
|
3
|
+
// irHash: sha256:7a54dda11222e7937ece27856682da95d651ebc94cb95f9db77748f3282ffba2
|
|
4
4
|
|
|
5
5
|
/** Structural descriptor consumed by renderer bindings; phantom type
|
|
6
6
|
* fields make row/insert/update/id inference available without imports. */
|
|
@@ -18,24 +18,51 @@ export interface SyncTable<Row, Insert, Update, Id> {
|
|
|
18
18
|
|
|
19
19
|
/** ServerSchema-compatible schema object (SPEC §2.4, §3.1). */
|
|
20
20
|
export const schema = {
|
|
21
|
-
version: 1,
|
|
22
|
-
tables: [
|
|
21
|
+
"version": 1,
|
|
22
|
+
"tables": [
|
|
23
23
|
{
|
|
24
|
-
name:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
"name": "todos",
|
|
25
|
+
"primaryKey": "id",
|
|
26
|
+
"columns": [
|
|
27
|
+
{
|
|
28
|
+
"name": "id",
|
|
29
|
+
"type": "string",
|
|
30
|
+
"nullable": false
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "list_id",
|
|
34
|
+
"type": "string",
|
|
35
|
+
"nullable": false
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "title",
|
|
39
|
+
"type": "string",
|
|
40
|
+
"nullable": false
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "done",
|
|
44
|
+
"type": "boolean",
|
|
45
|
+
"nullable": false
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "position",
|
|
49
|
+
"type": "integer",
|
|
50
|
+
"nullable": false
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "updated_at_ms",
|
|
54
|
+
"type": "integer",
|
|
55
|
+
"nullable": false
|
|
56
|
+
}
|
|
32
57
|
],
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
58
|
+
"scopes": [
|
|
59
|
+
{
|
|
60
|
+
"pattern": "list:{list_id}",
|
|
61
|
+
"column": "list_id"
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
]
|
|
39
66
|
} as const;
|
|
40
67
|
|
|
41
68
|
/** One todos row (§2.4 column order). */
|
|
@@ -79,11 +106,10 @@ export interface TodosInListParams {
|
|
|
79
106
|
listId: string;
|
|
80
107
|
}
|
|
81
108
|
|
|
82
|
-
/**
|
|
109
|
+
/** Unit template for the 'todosInList' subscription (SPEC §4.1). */
|
|
83
110
|
export const todosInListSubscription = {
|
|
84
111
|
name: 'todosInList',
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
list_id: [params.listId],
|
|
112
|
+
scopes: (params: TodosInListParams): Record<string, string> => ({
|
|
113
|
+
list_id: params.listId,
|
|
88
114
|
}),
|
|
89
115
|
} as const;
|
|
@@ -58,16 +58,13 @@
|
|
|
58
58
|
"subscriptions": [
|
|
59
59
|
{
|
|
60
60
|
"name": "todosInList",
|
|
61
|
-
"table": "todos",
|
|
62
61
|
"scopes": [
|
|
63
62
|
{
|
|
64
63
|
"variable": "list_id",
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
]
|
|
64
|
+
"value": {
|
|
65
|
+
"kind": "parameter",
|
|
66
|
+
"name": "listId"
|
|
67
|
+
}
|
|
71
68
|
}
|
|
72
69
|
]
|
|
73
70
|
}
|
|
@@ -8,10 +8,6 @@
|
|
|
8
8
|
"schemaVersions": [{ "version": 1, "through": "0001_initial" }],
|
|
9
9
|
"tables": [{ "name": "todos", "scopes": ["list:{list_id}"] }],
|
|
10
10
|
"subscriptions": [
|
|
11
|
-
{
|
|
12
|
-
"name": "todosInList",
|
|
13
|
-
"table": "todos",
|
|
14
|
-
"scopes": { "list_id": ["{listId}"] }
|
|
15
|
-
}
|
|
11
|
+
{ "name": "todosInList", "scopes": { "list_id": "{listId}" } }
|
|
16
12
|
]
|
|
17
13
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"formatVersion":
|
|
2
|
+
"formatVersion": 3,
|
|
3
3
|
"migrations": [
|
|
4
4
|
{
|
|
5
5
|
"name": "0001_initial",
|
|
6
|
-
"sha256": "87b23cd0f8e3677ca6fa7ca67eb7c8373a285607b6de10854cc43df1290bb0fa"
|
|
6
|
+
"sha256": "87b23cd0f8e3677ca6fa7ca67eb7c8373a285607b6de10854cc43df1290bb0fa",
|
|
7
|
+
"schemaVersion": 1
|
|
7
8
|
}
|
|
8
9
|
],
|
|
9
10
|
"head": {
|
package/template/tauri/README.md
CHANGED
|
@@ -83,9 +83,14 @@ On desktop, the plugin owns the database path and the transport — see
|
|
|
83
83
|
table before using a child wildcard. See
|
|
84
84
|
[Scopes & authorization](https://syncular.dev/concepts-scopes/).
|
|
85
85
|
2. **`src/server.ts` → `authenticate`** — plug in your real session/token
|
|
86
|
-
check; return `{ actorId, partition }` or `null` for a 401.
|
|
86
|
+
check; return `{ actorId, partition }` or `null` for a 401. `POST /sync`
|
|
87
|
+
and the `/realtime` upgrade both run it. A browser WebSocket sends cookies
|
|
88
|
+
but no custom headers, so authenticate the upgrade from a cookie or a
|
|
89
|
+
[short-lived ticket](https://syncular.dev/guide-auth/#realtime-tickets).
|
|
87
90
|
3. **`src-tauri/src/lib.rs` → `base_url`** — point the native core at your
|
|
88
91
|
deployed sync endpoint (rotating auth goes through `client.setHeaders`).
|
|
92
|
+
Replace `localhost:8787` in the `connect-src` of the window's CSP
|
|
93
|
+
(`src-tauri/tauri.conf.json`) with the same origin.
|
|
89
94
|
4. **`src/frontend/main.tsx`** — the UI. Everything under `<SyncProvider>` is
|
|
90
95
|
shared code; grow it into your app.
|
|
91
96
|
5. **`migrations/` + `syncular.json`** — append migrations and add tables or
|
|
@@ -30,8 +30,6 @@ import { buildSqliteImage, SqliteServerStorage } from '@syncular/server/sqlite';
|
|
|
30
30
|
import { schema } from './syncular.generated';
|
|
31
31
|
|
|
32
32
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
33
|
-
const PARTITION = 'demo';
|
|
34
|
-
const ACTOR_ID = 'demo-user';
|
|
35
33
|
|
|
36
34
|
// -- sync server ------------------------------------------------------------
|
|
37
35
|
|
|
@@ -57,11 +55,19 @@ const config: SyncServerConfig = {
|
|
|
57
55
|
realtime: hub,
|
|
58
56
|
};
|
|
59
57
|
await ensureSyncServerReady(config);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
// §8.5: a client that hears nothing for twice this interval drops the socket.
|
|
59
|
+
setInterval(() => hub.heartbeat(), 30_000);
|
|
60
|
+
|
|
61
|
+
/** Replace with your real auth: return { actorId, partition } or null (401).
|
|
62
|
+
* POST /sync and the /realtime upgrade both run it. A browser WebSocket sends
|
|
63
|
+
* cookies but no custom headers, so authenticate the upgrade from a cookie or
|
|
64
|
+
* a query parameter. */
|
|
65
|
+
async function authenticate(
|
|
66
|
+
_request: Request,
|
|
67
|
+
): Promise<{ actorId: string; partition: string } | null> {
|
|
68
|
+
return { actorId: 'demo-user', partition: 'demo' };
|
|
69
|
+
}
|
|
70
|
+
const hono = createSyncularHono({ config, authenticate });
|
|
65
71
|
|
|
66
72
|
// -- frontend build + static assets ------------------------------------------
|
|
67
73
|
|
|
@@ -129,6 +135,8 @@ function staticResponse(body: string | Uint8Array, type: string): Response {
|
|
|
129
135
|
// -- one process, one port ----------------------------------------------------
|
|
130
136
|
|
|
131
137
|
interface SocketData {
|
|
138
|
+
actorId: string;
|
|
139
|
+
partition: string;
|
|
132
140
|
clientId: string;
|
|
133
141
|
session?: RealtimeSession;
|
|
134
142
|
}
|
|
@@ -140,9 +148,15 @@ const server = Bun.serve<SocketData, never>({
|
|
|
140
148
|
const path = url.pathname;
|
|
141
149
|
|
|
142
150
|
if (path === '/realtime') {
|
|
151
|
+
const auth = await authenticate(request);
|
|
152
|
+
if (auth === null) {
|
|
153
|
+
return new Response('realtime requires authentication', {
|
|
154
|
+
status: 401,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
143
157
|
const clientId = url.searchParams.get('clientId') ?? crypto.randomUUID();
|
|
144
|
-
if (bunServer.upgrade(request, { data: { clientId } })) {
|
|
145
|
-
return
|
|
158
|
+
if (bunServer.upgrade(request, { data: { ...auth, clientId } })) {
|
|
159
|
+
return;
|
|
146
160
|
}
|
|
147
161
|
return new Response('expected a websocket upgrade', { status: 400 });
|
|
148
162
|
}
|
|
@@ -172,8 +186,8 @@ const server = Bun.serve<SocketData, never>({
|
|
|
172
186
|
open(ws) {
|
|
173
187
|
hub
|
|
174
188
|
.connect({
|
|
175
|
-
partition:
|
|
176
|
-
actorId:
|
|
189
|
+
partition: ws.data.partition,
|
|
190
|
+
actorId: ws.data.actorId,
|
|
177
191
|
clientId: ws.data.clientId,
|
|
178
192
|
send: (data) => {
|
|
179
193
|
ws.send(data);
|
|
@@ -63,9 +63,9 @@ test('two clients converge through the scaffolded server config', async () => {
|
|
|
63
63
|
await a.start();
|
|
64
64
|
await b.start();
|
|
65
65
|
|
|
66
|
-
const
|
|
67
|
-
a.subscribe(
|
|
68
|
-
b.subscribe(
|
|
66
|
+
const list = todoListSubscription.scopes({ listId: 'welcome' });
|
|
67
|
+
a.subscribe(list);
|
|
68
|
+
b.subscribe(list);
|
|
69
69
|
|
|
70
70
|
a.mutate([
|
|
71
71
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Generated by @syncular/typegen — DO NOT EDIT.
|
|
2
2
|
// irVersion: 1
|
|
3
|
-
// irHash: sha256:
|
|
3
|
+
// irHash: sha256:6bcec8061116d46f6e483f2f338f9d706972d272572c67b510e54a78e6b966c8
|
|
4
4
|
|
|
5
5
|
/** Structural descriptor consumed by renderer bindings; phantom type
|
|
6
6
|
* fields make row/insert/update/id inference available without imports. */
|
|
@@ -18,24 +18,60 @@ export interface SyncTable<Row, Insert, Update, Id> {
|
|
|
18
18
|
|
|
19
19
|
/** ServerSchema-compatible schema object (SPEC §2.4, §3.1). */
|
|
20
20
|
export const schema = {
|
|
21
|
-
version: 1,
|
|
22
|
-
tables: [
|
|
21
|
+
"version": 1,
|
|
22
|
+
"tables": [
|
|
23
23
|
{
|
|
24
|
-
name:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
"name": "todos",
|
|
25
|
+
"primaryKey": "id",
|
|
26
|
+
"columns": [
|
|
27
|
+
{
|
|
28
|
+
"name": "id",
|
|
29
|
+
"type": "string",
|
|
30
|
+
"nullable": false
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "list_id",
|
|
34
|
+
"type": "string",
|
|
35
|
+
"nullable": false
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "title",
|
|
39
|
+
"type": "string",
|
|
40
|
+
"nullable": false
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "done",
|
|
44
|
+
"type": "boolean",
|
|
45
|
+
"nullable": false
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "position",
|
|
49
|
+
"type": "integer",
|
|
50
|
+
"nullable": false
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "updated_at_ms",
|
|
54
|
+
"type": "integer",
|
|
55
|
+
"nullable": false
|
|
56
|
+
}
|
|
32
57
|
],
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
58
|
+
"scopes": [
|
|
59
|
+
{
|
|
60
|
+
"pattern": "list:{list_id}",
|
|
61
|
+
"column": "list_id"
|
|
62
|
+
}
|
|
36
63
|
],
|
|
37
|
-
|
|
38
|
-
|
|
64
|
+
"indexes": [
|
|
65
|
+
{
|
|
66
|
+
"name": "idx_todos_list",
|
|
67
|
+
"columns": [
|
|
68
|
+
"list_id"
|
|
69
|
+
],
|
|
70
|
+
"unique": false
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
]
|
|
39
75
|
} as const;
|
|
40
76
|
|
|
41
77
|
/** One todos row (§2.4 column order). */
|
|
@@ -79,11 +115,10 @@ export interface TodoListParams {
|
|
|
79
115
|
listId: string;
|
|
80
116
|
}
|
|
81
117
|
|
|
82
|
-
/**
|
|
118
|
+
/** Unit template for the 'todoList' subscription (SPEC §4.1). */
|
|
83
119
|
export const todoListSubscription = {
|
|
84
120
|
name: 'todoList',
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
list_id: [params.listId],
|
|
121
|
+
scopes: (params: TodoListParams): Record<string, string> => ({
|
|
122
|
+
list_id: params.listId,
|
|
88
123
|
}),
|
|
89
124
|
} as const;
|
|
@@ -98,12 +98,11 @@ export interface QueryResultColumn {
|
|
|
98
98
|
readonly nullable: boolean;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** A window base (SPEC §4.8) and the unit values a query reads. */
|
|
101
102
|
export interface WindowCoverage {
|
|
102
103
|
readonly base: {
|
|
103
|
-
readonly table: string;
|
|
104
104
|
readonly variable: string;
|
|
105
|
-
readonly fixedScopes?: Readonly<Record<string,
|
|
106
|
-
readonly params?: string;
|
|
105
|
+
readonly fixedScopes?: Readonly<Record<string, string>>;
|
|
107
106
|
};
|
|
108
107
|
readonly units: readonly string[];
|
|
109
108
|
}
|
|
@@ -21,7 +21,7 @@ tauri = { version = "2", features = [] }
|
|
|
21
21
|
# The syncular plugin from crates.io. `native-transport` gives the core its
|
|
22
22
|
# real HTTP + WebSocket transport (ureq + tungstenite) inside the host
|
|
23
23
|
# process — the webview never talks to the sync server directly.
|
|
24
|
-
tauri-plugin-syncular = { version = "0.
|
|
24
|
+
tauri-plugin-syncular = { version = "0.60.0", features = ["native-transport"] }
|
|
25
25
|
|
|
26
26
|
[features]
|
|
27
27
|
default = []
|
|
@@ -52,22 +52,28 @@
|
|
|
52
52
|
"column": "list_id"
|
|
53
53
|
}
|
|
54
54
|
],
|
|
55
|
+
"indexes": [
|
|
56
|
+
{
|
|
57
|
+
"name": "idx_todos_list",
|
|
58
|
+
"columns": [
|
|
59
|
+
"list_id"
|
|
60
|
+
],
|
|
61
|
+
"unique": false
|
|
62
|
+
}
|
|
63
|
+
],
|
|
55
64
|
"extensions": {}
|
|
56
65
|
}
|
|
57
66
|
],
|
|
58
67
|
"subscriptions": [
|
|
59
68
|
{
|
|
60
69
|
"name": "todoList",
|
|
61
|
-
"table": "todos",
|
|
62
70
|
"scopes": [
|
|
63
71
|
{
|
|
64
72
|
"variable": "list_id",
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
]
|
|
73
|
+
"value": {
|
|
74
|
+
"kind": "parameter",
|
|
75
|
+
"name": "listId"
|
|
76
|
+
}
|
|
71
77
|
}
|
|
72
78
|
]
|
|
73
79
|
}
|
|
@@ -10,10 +10,6 @@
|
|
|
10
10
|
"schemaVersions": [{ "version": 1, "through": "0001_initial" }],
|
|
11
11
|
"tables": [{ "name": "todos", "scopes": ["list:{list_id}"] }],
|
|
12
12
|
"subscriptions": [
|
|
13
|
-
{
|
|
14
|
-
"name": "todoList",
|
|
15
|
-
"table": "todos",
|
|
16
|
-
"scopes": { "list_id": ["{listId}"] }
|
|
17
|
-
}
|
|
13
|
+
{ "name": "todoList", "scopes": { "list_id": "{listId}" } }
|
|
18
14
|
]
|
|
19
15
|
}
|
|
@@ -1,47 +1,50 @@
|
|
|
1
1
|
{
|
|
2
|
-
"formatVersion":
|
|
2
|
+
"formatVersion": 3,
|
|
3
3
|
"migrations": [
|
|
4
4
|
{
|
|
5
5
|
"name": "0001_initial",
|
|
6
|
-
"sha256": "
|
|
7
|
-
"
|
|
8
|
-
{
|
|
9
|
-
"name": "todos",
|
|
10
|
-
"primaryKey": "id",
|
|
11
|
-
"columns": [
|
|
12
|
-
{
|
|
13
|
-
"name": "id",
|
|
14
|
-
"type": "string",
|
|
15
|
-
"nullable": false
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
"name": "list_id",
|
|
19
|
-
"type": "string",
|
|
20
|
-
"nullable": false
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
"name": "title",
|
|
24
|
-
"type": "string",
|
|
25
|
-
"nullable": false
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
"name": "done",
|
|
29
|
-
"type": "boolean",
|
|
30
|
-
"nullable": false
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
"name": "position",
|
|
34
|
-
"type": "integer",
|
|
35
|
-
"nullable": false
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"name": "updated_at_ms",
|
|
39
|
-
"type": "integer",
|
|
40
|
-
"nullable": false
|
|
41
|
-
}
|
|
42
|
-
]
|
|
43
|
-
}
|
|
44
|
-
]
|
|
6
|
+
"sha256": "33fdb7df2e4dc4c598137acdacf009922ea1d107b2c4284efe62ec25eb681116",
|
|
7
|
+
"schemaVersion": 1
|
|
45
8
|
}
|
|
46
|
-
]
|
|
9
|
+
],
|
|
10
|
+
"head": {
|
|
11
|
+
"tables": [
|
|
12
|
+
{
|
|
13
|
+
"name": "todos",
|
|
14
|
+
"primaryKey": "id",
|
|
15
|
+
"columns": [
|
|
16
|
+
{
|
|
17
|
+
"name": "id",
|
|
18
|
+
"type": "string",
|
|
19
|
+
"nullable": false
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "list_id",
|
|
23
|
+
"type": "string",
|
|
24
|
+
"nullable": false
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "title",
|
|
28
|
+
"type": "string",
|
|
29
|
+
"nullable": false
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "done",
|
|
33
|
+
"type": "boolean",
|
|
34
|
+
"nullable": false
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "position",
|
|
38
|
+
"type": "integer",
|
|
39
|
+
"nullable": false
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "updated_at_ms",
|
|
43
|
+
"type": "integer",
|
|
44
|
+
"nullable": false
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
47
50
|
}
|
package/template/web/README.md
CHANGED
|
@@ -54,7 +54,10 @@ covered by `tsc`).
|
|
|
54
54
|
See
|
|
55
55
|
[Scopes & authorization](https://syncular.dev/concepts-scopes/).
|
|
56
56
|
2. **`src/server.ts` → `authenticate`** — plug in your real session/token
|
|
57
|
-
check; return `{ actorId, partition }` or `null` for a 401.
|
|
57
|
+
check; return `{ actorId, partition }` or `null` for a 401. `POST /sync`
|
|
58
|
+
and the `/realtime` upgrade both run it. A browser WebSocket sends cookies
|
|
59
|
+
but no custom headers, so authenticate the upgrade from a cookie or a
|
|
60
|
+
[short-lived ticket](https://syncular.dev/guide-auth/#realtime-tickets).
|
|
58
61
|
3. **`src/frontend/main.ts`** — the UI. It queries and mutates through the
|
|
59
62
|
worker handle; grow it into your app.
|
|
60
63
|
4. **`migrations/` + `syncular.json`** — append migrations and add tables or
|
|
@@ -72,11 +72,8 @@ async function main(): Promise<void> {
|
|
|
72
72
|
onSynced: () => void refresh(),
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
table: 'todos',
|
|
78
|
-
scopes: todoListSubscription.scopes({ listId: LIST_ID }),
|
|
79
|
-
});
|
|
75
|
+
// One unit per list: the scope map covers every table scoped by `list_id`.
|
|
76
|
+
await handle.subscribe(todoListSubscription.scopes({ listId: LIST_ID }));
|
|
80
77
|
|
|
81
78
|
// The supported host policy owns connect + catch-up, retries transient
|
|
82
79
|
// startup/socket loss, and suspends while this page is offline.
|
|
@@ -26,8 +26,6 @@ import { buildSqliteImage, SqliteServerStorage } from '@syncular/server/sqlite';
|
|
|
26
26
|
import { schema } from './syncular.generated';
|
|
27
27
|
|
|
28
28
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
29
|
-
const PARTITION = 'demo';
|
|
30
|
-
const ACTOR_ID = 'demo-user';
|
|
31
29
|
|
|
32
30
|
// -- sync server ------------------------------------------------------------
|
|
33
31
|
|
|
@@ -53,11 +51,19 @@ const config: SyncServerConfig = {
|
|
|
53
51
|
realtime: hub,
|
|
54
52
|
};
|
|
55
53
|
await ensureSyncServerReady(config);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
// §8.5: a client that hears nothing for twice this interval drops the socket.
|
|
55
|
+
setInterval(() => hub.heartbeat(), 30_000);
|
|
56
|
+
|
|
57
|
+
/** Replace with your real auth: return { actorId, partition } or null (401).
|
|
58
|
+
* POST /sync and the /realtime upgrade both run it. A browser WebSocket sends
|
|
59
|
+
* cookies but no custom headers, so authenticate the upgrade from a cookie or
|
|
60
|
+
* a query parameter. */
|
|
61
|
+
async function authenticate(
|
|
62
|
+
_request: Request,
|
|
63
|
+
): Promise<{ actorId: string; partition: string } | null> {
|
|
64
|
+
return { actorId: 'demo-user', partition: 'demo' };
|
|
65
|
+
}
|
|
66
|
+
const hono = createSyncularHono({ config, authenticate });
|
|
61
67
|
|
|
62
68
|
// -- frontend build + static assets ------------------------------------------
|
|
63
69
|
|
|
@@ -124,6 +130,8 @@ function staticResponse(body: string | Uint8Array, type: string): Response {
|
|
|
124
130
|
// -- one process, one port ----------------------------------------------------
|
|
125
131
|
|
|
126
132
|
interface SocketData {
|
|
133
|
+
actorId: string;
|
|
134
|
+
partition: string;
|
|
127
135
|
clientId: string;
|
|
128
136
|
session?: RealtimeSession;
|
|
129
137
|
}
|
|
@@ -135,9 +143,15 @@ const server = Bun.serve<SocketData, never>({
|
|
|
135
143
|
const path = url.pathname;
|
|
136
144
|
|
|
137
145
|
if (path === '/realtime') {
|
|
146
|
+
const auth = await authenticate(request);
|
|
147
|
+
if (auth === null) {
|
|
148
|
+
return new Response('realtime requires authentication', {
|
|
149
|
+
status: 401,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
138
152
|
const clientId = url.searchParams.get('clientId') ?? crypto.randomUUID();
|
|
139
|
-
if (bunServer.upgrade(request, { data: { clientId } })) {
|
|
140
|
-
return
|
|
153
|
+
if (bunServer.upgrade(request, { data: { ...auth, clientId } })) {
|
|
154
|
+
return;
|
|
141
155
|
}
|
|
142
156
|
return new Response('expected a websocket upgrade', { status: 400 });
|
|
143
157
|
}
|
|
@@ -167,8 +181,8 @@ const server = Bun.serve<SocketData, never>({
|
|
|
167
181
|
open(ws) {
|
|
168
182
|
hub
|
|
169
183
|
.connect({
|
|
170
|
-
partition:
|
|
171
|
-
actorId:
|
|
184
|
+
partition: ws.data.partition,
|
|
185
|
+
actorId: ws.data.actorId,
|
|
172
186
|
clientId: ws.data.clientId,
|
|
173
187
|
send: (data) => {
|
|
174
188
|
ws.send(data);
|
|
@@ -62,9 +62,9 @@ test('two clients converge through the scaffolded server config', async () => {
|
|
|
62
62
|
await a.start();
|
|
63
63
|
await b.start();
|
|
64
64
|
|
|
65
|
-
const
|
|
66
|
-
a.subscribe(
|
|
67
|
-
b.subscribe(
|
|
65
|
+
const list = todoListSubscription.scopes({ listId: 'welcome' });
|
|
66
|
+
a.subscribe(list);
|
|
67
|
+
b.subscribe(list);
|
|
68
68
|
|
|
69
69
|
a.mutate([
|
|
70
70
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Generated by @syncular/typegen — DO NOT EDIT.
|
|
2
2
|
// irVersion: 1
|
|
3
|
-
// irHash: sha256:
|
|
3
|
+
// irHash: sha256:9c3077b1f537f8d84725f38d0b5234f7a53520777265f36f10059651d2cddd32
|
|
4
4
|
|
|
5
5
|
/** Structural descriptor consumed by renderer bindings; phantom type
|
|
6
6
|
* fields make row/insert/update/id inference available without imports. */
|
|
@@ -18,24 +18,51 @@ export interface SyncTable<Row, Insert, Update, Id> {
|
|
|
18
18
|
|
|
19
19
|
/** ServerSchema-compatible schema object (SPEC §2.4, §3.1). */
|
|
20
20
|
export const schema = {
|
|
21
|
-
version: 1,
|
|
22
|
-
tables: [
|
|
21
|
+
"version": 1,
|
|
22
|
+
"tables": [
|
|
23
23
|
{
|
|
24
|
-
name:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
"name": "todos",
|
|
25
|
+
"primaryKey": "id",
|
|
26
|
+
"columns": [
|
|
27
|
+
{
|
|
28
|
+
"name": "id",
|
|
29
|
+
"type": "string",
|
|
30
|
+
"nullable": false
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "list_id",
|
|
34
|
+
"type": "string",
|
|
35
|
+
"nullable": false
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "title",
|
|
39
|
+
"type": "string",
|
|
40
|
+
"nullable": false
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "done",
|
|
44
|
+
"type": "boolean",
|
|
45
|
+
"nullable": false
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "position",
|
|
49
|
+
"type": "integer",
|
|
50
|
+
"nullable": false
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "updated_at_ms",
|
|
54
|
+
"type": "integer",
|
|
55
|
+
"nullable": false
|
|
56
|
+
}
|
|
32
57
|
],
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
58
|
+
"scopes": [
|
|
59
|
+
{
|
|
60
|
+
"pattern": "list:{list_id}",
|
|
61
|
+
"column": "list_id"
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
]
|
|
39
66
|
} as const;
|
|
40
67
|
|
|
41
68
|
/** One todos row (§2.4 column order). */
|
|
@@ -79,11 +106,10 @@ export interface TodoListParams {
|
|
|
79
106
|
listId: string;
|
|
80
107
|
}
|
|
81
108
|
|
|
82
|
-
/**
|
|
109
|
+
/** Unit template for the 'todoList' subscription (SPEC §4.1). */
|
|
83
110
|
export const todoListSubscription = {
|
|
84
111
|
name: 'todoList',
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
list_id: [params.listId],
|
|
112
|
+
scopes: (params: TodoListParams): Record<string, string> => ({
|
|
113
|
+
list_id: params.listId,
|
|
88
114
|
}),
|
|
89
115
|
} as const;
|
|
@@ -58,16 +58,13 @@
|
|
|
58
58
|
"subscriptions": [
|
|
59
59
|
{
|
|
60
60
|
"name": "todoList",
|
|
61
|
-
"table": "todos",
|
|
62
61
|
"scopes": [
|
|
63
62
|
{
|
|
64
63
|
"variable": "list_id",
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
]
|
|
64
|
+
"value": {
|
|
65
|
+
"kind": "parameter",
|
|
66
|
+
"name": "listId"
|
|
67
|
+
}
|
|
71
68
|
}
|
|
72
69
|
]
|
|
73
70
|
}
|
|
@@ -8,10 +8,6 @@
|
|
|
8
8
|
"schemaVersions": [{ "version": 1, "through": "0001_initial" }],
|
|
9
9
|
"tables": [{ "name": "todos", "scopes": ["list:{list_id}"] }],
|
|
10
10
|
"subscriptions": [
|
|
11
|
-
{
|
|
12
|
-
"name": "todoList",
|
|
13
|
-
"table": "todos",
|
|
14
|
-
"scopes": { "list_id": ["{listId}"] }
|
|
15
|
-
}
|
|
11
|
+
{ "name": "todoList", "scopes": { "list_id": "{listId}" } }
|
|
16
12
|
]
|
|
17
13
|
}
|
|
@@ -1,47 +1,50 @@
|
|
|
1
1
|
{
|
|
2
|
-
"formatVersion":
|
|
2
|
+
"formatVersion": 3,
|
|
3
3
|
"migrations": [
|
|
4
4
|
{
|
|
5
5
|
"name": "0001_initial",
|
|
6
6
|
"sha256": "87b23cd0f8e3677ca6fa7ca67eb7c8373a285607b6de10854cc43df1290bb0fa",
|
|
7
|
-
"
|
|
8
|
-
{
|
|
9
|
-
"name": "todos",
|
|
10
|
-
"primaryKey": "id",
|
|
11
|
-
"columns": [
|
|
12
|
-
{
|
|
13
|
-
"name": "id",
|
|
14
|
-
"type": "string",
|
|
15
|
-
"nullable": false
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
"name": "list_id",
|
|
19
|
-
"type": "string",
|
|
20
|
-
"nullable": false
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
"name": "title",
|
|
24
|
-
"type": "string",
|
|
25
|
-
"nullable": false
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
"name": "done",
|
|
29
|
-
"type": "boolean",
|
|
30
|
-
"nullable": false
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
"name": "position",
|
|
34
|
-
"type": "integer",
|
|
35
|
-
"nullable": false
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"name": "updated_at_ms",
|
|
39
|
-
"type": "integer",
|
|
40
|
-
"nullable": false
|
|
41
|
-
}
|
|
42
|
-
]
|
|
43
|
-
}
|
|
44
|
-
]
|
|
7
|
+
"schemaVersion": 1
|
|
45
8
|
}
|
|
46
|
-
]
|
|
9
|
+
],
|
|
10
|
+
"head": {
|
|
11
|
+
"tables": [
|
|
12
|
+
{
|
|
13
|
+
"name": "todos",
|
|
14
|
+
"primaryKey": "id",
|
|
15
|
+
"columns": [
|
|
16
|
+
{
|
|
17
|
+
"name": "id",
|
|
18
|
+
"type": "string",
|
|
19
|
+
"nullable": false
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "list_id",
|
|
23
|
+
"type": "string",
|
|
24
|
+
"nullable": false
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "title",
|
|
28
|
+
"type": "string",
|
|
29
|
+
"nullable": false
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "done",
|
|
33
|
+
"type": "boolean",
|
|
34
|
+
"nullable": false
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "position",
|
|
38
|
+
"type": "integer",
|
|
39
|
+
"nullable": false
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "updated_at_ms",
|
|
43
|
+
"type": "integer",
|
|
44
|
+
"nullable": false
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
47
50
|
}
|