create-syncular-app 0.3.0 → 0.4.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/README.md +15 -7
- package/dist/cli.js +7 -3
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +2 -0
- package/dist/scaffold.d.ts +2 -2
- package/dist/scaffold.js +8 -3
- package/package.json +2 -2
- package/src/cli.ts +7 -3
- package/src/constants.ts +2 -0
- package/src/scaffold.ts +8 -3
- package/template/tauri/README.md +91 -0
- package/template/tauri/build-frontend.ts +47 -0
- package/template/tauri/gitignore +11 -0
- package/template/tauri/migrations/0001_initial/up.sql +11 -0
- package/template/tauri/package.json +30 -0
- package/template/tauri/src/frontend/engine.ts +72 -0
- package/template/tauri/src/frontend/index.html +33 -0
- package/template/tauri/src/frontend/main.tsx +202 -0
- package/template/tauri/src/frontend/worker.ts +9 -0
- package/template/tauri/src/server.ts +193 -0
- package/template/tauri/src/smoke.test.ts +92 -0
- package/template/tauri/src/syncular.generated.ts +68 -0
- package/template/tauri/src-tauri/Cargo.toml +27 -0
- package/template/tauri/src-tauri/build.rs +3 -0
- package/template/tauri/src-tauri/capabilities/syncular.json +9 -0
- package/template/tauri/src-tauri/icons/icon.png +0 -0
- package/template/tauri/src-tauri/src/lib.rs +40 -0
- package/template/tauri/src-tauri/src/main.rs +6 -0
- package/template/tauri/src-tauri/tauri.conf.json +29 -0
- package/template/tauri/syncular.ir.json +76 -0
- package/template/tauri/syncular.json +17 -0
- package/template/tauri/tsconfig.json +18 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared React tree — every line below `<SyncProvider>` runs unchanged
|
|
3
|
+
* on BOTH hosts. In a browser it talks to the worker core on OPFS; inside
|
|
4
|
+
* the Tauri webview it talks to the native Rust core in the host process.
|
|
5
|
+
* The only host-aware code is `createEngine()` in `./engine.ts`.
|
|
6
|
+
*
|
|
7
|
+
* - `useRawSql` — the live todo list; re-runs exactly when `todos`
|
|
8
|
+
* invalidates.
|
|
9
|
+
* - `useMutation` — add / toggle / delete; writes go through the outbox.
|
|
10
|
+
* - `useSyncStatus` — outbox depth + upgrading / schema-floor badges.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
SyncProvider,
|
|
14
|
+
useMutation,
|
|
15
|
+
useRawSql,
|
|
16
|
+
useSyncStatus,
|
|
17
|
+
} from '@syncular/react';
|
|
18
|
+
import { StrictMode, useEffect, useState } from 'react';
|
|
19
|
+
import { createRoot } from 'react-dom/client';
|
|
20
|
+
import { type TodosRow, todoListSubscription } from '../syncular.generated';
|
|
21
|
+
import { createEngine, type Engine } from './engine';
|
|
22
|
+
|
|
23
|
+
const LIST_ID = 'welcome';
|
|
24
|
+
|
|
25
|
+
// -- app (host-agnostic from here down) ---------------------------------------
|
|
26
|
+
|
|
27
|
+
function StatusLine() {
|
|
28
|
+
const status = useSyncStatus();
|
|
29
|
+
if (status.isLoading) return <span className="status">connecting…</span>;
|
|
30
|
+
return (
|
|
31
|
+
<span className="status">
|
|
32
|
+
<span className={`badge ${status.outbox === 0 ? 'ok' : 'warn'}`}>
|
|
33
|
+
outbox {status.outbox}
|
|
34
|
+
</span>
|
|
35
|
+
{status.upgrading ? <span className="badge warn">upgrading…</span> : null}
|
|
36
|
+
{status.schemaFloor !== undefined ? (
|
|
37
|
+
<span className="badge warn">schema floor</span>
|
|
38
|
+
) : null}
|
|
39
|
+
</span>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function TodoApp() {
|
|
44
|
+
const { mutate, isPending, error } = useMutation();
|
|
45
|
+
|
|
46
|
+
// Live local read: re-runs exactly when `todos` invalidates.
|
|
47
|
+
const { rows, isLoading } = useRawSql<TodosRow>(
|
|
48
|
+
'SELECT id, list_id AS listId, title, done, position,' +
|
|
49
|
+
' updated_at_ms AS updatedAtMs FROM todos WHERE list_id = ?' +
|
|
50
|
+
' ORDER BY position, id',
|
|
51
|
+
[LIST_ID],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const add = (title: string) => {
|
|
55
|
+
const position =
|
|
56
|
+
rows.reduce((max, row) => Math.max(max, row.position), 0) + 1;
|
|
57
|
+
void mutate([
|
|
58
|
+
{
|
|
59
|
+
table: 'todos',
|
|
60
|
+
op: 'upsert',
|
|
61
|
+
values: {
|
|
62
|
+
id: crypto.randomUUID(),
|
|
63
|
+
listId: LIST_ID,
|
|
64
|
+
title,
|
|
65
|
+
done: false,
|
|
66
|
+
position,
|
|
67
|
+
updatedAtMs: Date.now(),
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
]);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const toggle = (row: TodosRow) => {
|
|
74
|
+
void mutate([
|
|
75
|
+
{
|
|
76
|
+
table: 'todos',
|
|
77
|
+
op: 'upsert',
|
|
78
|
+
values: { ...row, done: !row.done, updatedAtMs: Date.now() },
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const remove = (id: string) => {
|
|
84
|
+
void mutate([{ table: 'todos', op: 'delete', rowId: id }]);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<>
|
|
89
|
+
<header>
|
|
90
|
+
<h1>__PROJECT_NAME__</h1>
|
|
91
|
+
<StatusLine />
|
|
92
|
+
</header>
|
|
93
|
+
|
|
94
|
+
<form
|
|
95
|
+
className="add"
|
|
96
|
+
onSubmit={(e) => {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
const input = e.currentTarget.elements.namedItem(
|
|
99
|
+
'title',
|
|
100
|
+
) as HTMLInputElement;
|
|
101
|
+
const value = input.value.trim();
|
|
102
|
+
if (value.length === 0) return;
|
|
103
|
+
input.value = '';
|
|
104
|
+
add(value);
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
<input name="title" placeholder="a new todo" autoComplete="off" />
|
|
108
|
+
<button type="submit" disabled={isPending}>
|
|
109
|
+
add
|
|
110
|
+
</button>
|
|
111
|
+
</form>
|
|
112
|
+
|
|
113
|
+
{/* Always render the write error: a dead worker/bridge RPC otherwise
|
|
114
|
+
fails silently and "add does nothing" is undebuggable. */}
|
|
115
|
+
{error !== undefined ? (
|
|
116
|
+
<div className="error">write failed: {String(error)}</div>
|
|
117
|
+
) : null}
|
|
118
|
+
|
|
119
|
+
{isLoading ? (
|
|
120
|
+
<div className="empty">loading…</div>
|
|
121
|
+
) : rows.length === 0 ? (
|
|
122
|
+
<div className="empty">no todos yet — add one</div>
|
|
123
|
+
) : (
|
|
124
|
+
<ul className="todos">
|
|
125
|
+
{rows.map((row) => (
|
|
126
|
+
<li key={row.id} className={row.done ? 'done' : ''}>
|
|
127
|
+
<input
|
|
128
|
+
type="checkbox"
|
|
129
|
+
checked={Boolean(row.done)}
|
|
130
|
+
onChange={() => toggle(row)}
|
|
131
|
+
/>
|
|
132
|
+
<span className="title">{row.title}</span>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
className="del"
|
|
136
|
+
title="delete"
|
|
137
|
+
onClick={() => remove(row.id)}
|
|
138
|
+
>
|
|
139
|
+
×
|
|
140
|
+
</button>
|
|
141
|
+
</li>
|
|
142
|
+
))}
|
|
143
|
+
</ul>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
<footer>
|
|
147
|
+
One React tree, two hosts: in the browser this is the worker core on
|
|
148
|
+
OPFS; in the Tauri window it is the native Rust core. The seam is{' '}
|
|
149
|
+
<code>src/frontend/engine.ts</code>.
|
|
150
|
+
</footer>
|
|
151
|
+
</>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function Root() {
|
|
156
|
+
const [engine, setEngine] = useState<Engine | undefined>(undefined);
|
|
157
|
+
const [error, setError] = useState<string | undefined>(undefined);
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
let live: Engine | undefined;
|
|
161
|
+
void createEngine()
|
|
162
|
+
.then(async (e) => {
|
|
163
|
+
live = e;
|
|
164
|
+
await e.subscribe({
|
|
165
|
+
id: 'todos',
|
|
166
|
+
table: 'todos',
|
|
167
|
+
scopes: todoListSubscription.scopes({ listId: LIST_ID }),
|
|
168
|
+
});
|
|
169
|
+
// Ride the socket for realtime; HTTP sync still works without it.
|
|
170
|
+
try {
|
|
171
|
+
await e.connectRealtime();
|
|
172
|
+
} catch {
|
|
173
|
+
// offline / no socket — the host loop keeps syncing over HTTP
|
|
174
|
+
}
|
|
175
|
+
setEngine(e);
|
|
176
|
+
})
|
|
177
|
+
.catch((err: unknown) => setError(String(err)));
|
|
178
|
+
return () => {
|
|
179
|
+
void live?.close();
|
|
180
|
+
};
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
if (error !== undefined) {
|
|
184
|
+
return <div className="empty">failed to start: {error}</div>;
|
|
185
|
+
}
|
|
186
|
+
if (engine === undefined) {
|
|
187
|
+
return <div className="empty">starting client core…</div>;
|
|
188
|
+
}
|
|
189
|
+
return (
|
|
190
|
+
<SyncProvider client={engine}>
|
|
191
|
+
<TodoApp />
|
|
192
|
+
</SyncProvider>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const container = document.getElementById('root');
|
|
197
|
+
if (container === null) throw new Error('missing #root');
|
|
198
|
+
createRoot(container).render(
|
|
199
|
+
<StrictMode>
|
|
200
|
+
<Root />
|
|
201
|
+
</StrictMode>,
|
|
202
|
+
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sync worker: the whole client core (SyncClient + transports + sqlite-wasm
|
|
3
|
+
* on opfs-sahpool) runs here — the page only talks RPC. Built as its own
|
|
4
|
+
* bundle (`/worker.js`) because module workers do not inherit the page's import
|
|
5
|
+
* map.
|
|
6
|
+
*/
|
|
7
|
+
import { startSyncWorker } from '@syncular/client/worker';
|
|
8
|
+
|
|
9
|
+
startSyncWorker();
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WEB half of the one-codebase story, in one Bun process:
|
|
3
|
+
* - POST /sync + GET /segments/:id via the server-hono adapter,
|
|
4
|
+
* - GET /realtime as a WebSocket wired to the server's RealtimeHub (§8.7),
|
|
5
|
+
* - the static frontend: TWO bundles built with Bun.build at startup —
|
|
6
|
+
* /app.js (the React page) and /worker.js (the sync worker running the
|
|
7
|
+
* whole client core on opfs-sahpool). Module workers do not inherit the
|
|
8
|
+
* page's import map, so the sqlite-wasm bare specifier is rewritten to the
|
|
9
|
+
* served vendor path in both bundles.
|
|
10
|
+
*
|
|
11
|
+
* The DESKTOP half is `src-tauri/` — `cargo tauri dev` builds the same
|
|
12
|
+
* frontend (see `build-frontend.ts`) and hosts the native Rust core, which
|
|
13
|
+
* syncs against THIS server. One server, two hosts, one React tree.
|
|
14
|
+
*
|
|
15
|
+
* Storage is bun:sqlite (in-memory by default; set DB_PATH=path for a file).
|
|
16
|
+
*
|
|
17
|
+
* EDIT FIRST: `resolveScopes` + `authenticate` below are the whole
|
|
18
|
+
* authorization story — they run in YOUR backend next to YOUR auth.
|
|
19
|
+
*/
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import {
|
|
22
|
+
createRealtimeHub,
|
|
23
|
+
MemorySegmentStore,
|
|
24
|
+
type RealtimeSession,
|
|
25
|
+
SqliteServerStorage,
|
|
26
|
+
type SyncServerConfig,
|
|
27
|
+
} from '@syncular/server';
|
|
28
|
+
import { createSyncularHono } from '@syncular/server-hono';
|
|
29
|
+
import { schema } from './syncular.generated';
|
|
30
|
+
|
|
31
|
+
const PORT = Number(process.env.PORT ?? 8787);
|
|
32
|
+
const PARTITION = 'demo';
|
|
33
|
+
const ACTOR_ID = 'demo-user';
|
|
34
|
+
|
|
35
|
+
// -- sync server ------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const storage = new SqliteServerStorage(process.env.DB_PATH ?? ':memory:');
|
|
38
|
+
const segments = new MemorySegmentStore();
|
|
39
|
+
/** Demo authorization: the single actor may see every list. Replace with the
|
|
40
|
+
* scope values the authenticated actor is allowed to see. */
|
|
41
|
+
const resolveScopes = () => ({ list_id: ['*'] });
|
|
42
|
+
|
|
43
|
+
const hub = createRealtimeHub({ schema, storage, resolveScopes, segments });
|
|
44
|
+
const config: SyncServerConfig = {
|
|
45
|
+
schema,
|
|
46
|
+
storage,
|
|
47
|
+
segments,
|
|
48
|
+
resolveScopes,
|
|
49
|
+
realtime: hub,
|
|
50
|
+
};
|
|
51
|
+
const hono = createSyncularHono({
|
|
52
|
+
config,
|
|
53
|
+
// Replace with your real auth: return { actorId, partition } or null (401).
|
|
54
|
+
authenticate: async () => ({ actorId: ACTOR_ID, partition: PARTITION }),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// -- frontend build + static assets ------------------------------------------
|
|
58
|
+
|
|
59
|
+
const build = await Bun.build({
|
|
60
|
+
entrypoints: [
|
|
61
|
+
join(import.meta.dir, 'frontend', 'main.tsx'),
|
|
62
|
+
join(import.meta.dir, 'frontend', 'worker.ts'),
|
|
63
|
+
],
|
|
64
|
+
target: 'browser',
|
|
65
|
+
// Resolve syncular packages through their `bun` condition (TS source,
|
|
66
|
+
// shipped in the npm tarballs) — bun transpiles it, and a `--local`
|
|
67
|
+
// workspace link works without a dist build.
|
|
68
|
+
conditions: ['bun'],
|
|
69
|
+
sourcemap: 'inline',
|
|
70
|
+
define: { 'process.env.NODE_ENV': '"development"' },
|
|
71
|
+
external: ['@sqlite.org/sqlite-wasm'],
|
|
72
|
+
});
|
|
73
|
+
async function bundleText(basename: string): Promise<string> {
|
|
74
|
+
const artifact = build.outputs.find((output) =>
|
|
75
|
+
output.path.endsWith(`/${basename}`),
|
|
76
|
+
);
|
|
77
|
+
if (artifact === undefined) {
|
|
78
|
+
throw new Error(`frontend build produced no ${basename}`);
|
|
79
|
+
}
|
|
80
|
+
// Both bundles import sqlite-wasm from the served vendor path. An import map
|
|
81
|
+
// would only cover the page, never the module worker, so the external bare
|
|
82
|
+
// specifier is rewritten in the emitted JS instead.
|
|
83
|
+
const text = await artifact.text();
|
|
84
|
+
return text.replaceAll(
|
|
85
|
+
/(["'])@sqlite\.org\/sqlite-wasm\1/g,
|
|
86
|
+
'"/vendor/sqlite-wasm/index.mjs"',
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const appJs = await bundleText('main.js');
|
|
90
|
+
const workerJs = await bundleText('worker.js');
|
|
91
|
+
const indexHtml = await Bun.file(
|
|
92
|
+
join(import.meta.dir, 'frontend', 'index.html'),
|
|
93
|
+
).text();
|
|
94
|
+
|
|
95
|
+
const wasmDir = dirname(
|
|
96
|
+
Bun.resolveSync('@sqlite.org/sqlite-wasm', import.meta.dir),
|
|
97
|
+
);
|
|
98
|
+
/** Only the files the sqlite-wasm ESM entry actually references. */
|
|
99
|
+
const WASM_FILES: Record<string, string> = {
|
|
100
|
+
'index.mjs': 'text/javascript',
|
|
101
|
+
'sqlite3.wasm': 'application/wasm',
|
|
102
|
+
'sqlite3-opfs-async-proxy.js': 'text/javascript',
|
|
103
|
+
'sqlite3-worker1.mjs': 'text/javascript',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** COOP/COEP so OPFS-capable contexts get cross-origin isolation (not
|
|
107
|
+
* required by opfs-sahpool, but harmless and future-proof). */
|
|
108
|
+
const STATIC_HEADERS = {
|
|
109
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
110
|
+
'Cross-Origin-Embedder-Policy': 'require-corp',
|
|
111
|
+
'Cache-Control': 'no-store',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
function staticResponse(body: string | Uint8Array, type: string): Response {
|
|
115
|
+
return new Response(body as BodyInit, {
|
|
116
|
+
headers: { ...STATIC_HEADERS, 'Content-Type': type },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// -- one process, one port ----------------------------------------------------
|
|
121
|
+
|
|
122
|
+
interface SocketData {
|
|
123
|
+
clientId: string;
|
|
124
|
+
session?: RealtimeSession;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const server = Bun.serve<SocketData, never>({
|
|
128
|
+
port: PORT,
|
|
129
|
+
async fetch(request, bunServer) {
|
|
130
|
+
const url = new URL(request.url);
|
|
131
|
+
const path = url.pathname;
|
|
132
|
+
|
|
133
|
+
if (path === '/realtime') {
|
|
134
|
+
const clientId = url.searchParams.get('clientId') ?? crypto.randomUUID();
|
|
135
|
+
if (bunServer.upgrade(request, { data: { clientId } })) {
|
|
136
|
+
return undefined as unknown as Response;
|
|
137
|
+
}
|
|
138
|
+
return new Response('expected a websocket upgrade', { status: 400 });
|
|
139
|
+
}
|
|
140
|
+
if (path === '/sync' || path.startsWith('/segments/')) {
|
|
141
|
+
return hono.fetch(request);
|
|
142
|
+
}
|
|
143
|
+
if (path === '/' || path === '/index.html') {
|
|
144
|
+
return staticResponse(indexHtml, 'text/html; charset=utf-8');
|
|
145
|
+
}
|
|
146
|
+
if (path === '/app.js') {
|
|
147
|
+
return staticResponse(appJs, 'text/javascript; charset=utf-8');
|
|
148
|
+
}
|
|
149
|
+
if (path === '/worker.js') {
|
|
150
|
+
return staticResponse(workerJs, 'text/javascript; charset=utf-8');
|
|
151
|
+
}
|
|
152
|
+
if (path.startsWith('/vendor/sqlite-wasm/')) {
|
|
153
|
+
const name = path.slice('/vendor/sqlite-wasm/'.length);
|
|
154
|
+
const type = WASM_FILES[name];
|
|
155
|
+
if (type !== undefined) {
|
|
156
|
+
const bytes = await Bun.file(join(wasmDir, name)).bytes();
|
|
157
|
+
return staticResponse(bytes, type);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return new Response('not found', { status: 404 });
|
|
161
|
+
},
|
|
162
|
+
websocket: {
|
|
163
|
+
open(ws) {
|
|
164
|
+
hub
|
|
165
|
+
.connect({
|
|
166
|
+
partition: PARTITION,
|
|
167
|
+
actorId: ACTOR_ID,
|
|
168
|
+
clientId: ws.data.clientId,
|
|
169
|
+
send: (data) => {
|
|
170
|
+
ws.send(data);
|
|
171
|
+
},
|
|
172
|
+
closeSocket: () => ws.close(1008, 'protocol violation (§8.7)'),
|
|
173
|
+
})
|
|
174
|
+
.then((session) => {
|
|
175
|
+
ws.data.session = session;
|
|
176
|
+
})
|
|
177
|
+
.catch(() => ws.close(1011, 'realtime connect failed'));
|
|
178
|
+
},
|
|
179
|
+
message(ws, message) {
|
|
180
|
+
if (typeof message === 'string') {
|
|
181
|
+
ws.data.session?.handleMessage(message);
|
|
182
|
+
} else {
|
|
183
|
+
ws.data.session?.handleBinary(new Uint8Array(message));
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
close(ws) {
|
|
187
|
+
ws.data.session?.close();
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
console.log(`web app: http://localhost:${server.port}`);
|
|
193
|
+
console.log('desktop (Tauri): cd src-tauri && cargo tauri dev');
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tauri template's smoke test. Neither frontend host (browser worker +
|
|
3
|
+
* OPFS, Tauri webview + native core) can run headless in `bun test`, so this
|
|
4
|
+
* proves the piece that CAN rot silently: the server config + generated
|
|
5
|
+
* schema this scaffold ships actually sync. It boots the same server core
|
|
6
|
+
* config as `src/server.ts` on an ephemeral port and drives two bun:sqlite
|
|
7
|
+
* client cores through real HTTP to convergence.
|
|
8
|
+
*
|
|
9
|
+
* The frontend is covered by `tsc` (typecheck); the worker + OPFS path and
|
|
10
|
+
* the native-core path are exercised by the demo apps / conformance suite /
|
|
11
|
+
* tauri example in the syncular tree.
|
|
12
|
+
*/
|
|
13
|
+
import { afterAll, beforeAll, expect, test } from 'bun:test';
|
|
14
|
+
import {
|
|
15
|
+
httpSegmentDownloader,
|
|
16
|
+
httpSyncTransport,
|
|
17
|
+
SyncClient,
|
|
18
|
+
} from '@syncular/client';
|
|
19
|
+
import { openBunDatabase } from '@syncular/client/bun';
|
|
20
|
+
import {
|
|
21
|
+
MemorySegmentStore,
|
|
22
|
+
SqliteServerStorage,
|
|
23
|
+
type SyncServerConfig,
|
|
24
|
+
} from '@syncular/server';
|
|
25
|
+
import { createSyncularHono } from '@syncular/server-hono';
|
|
26
|
+
import { schema, todoListSubscription } from './syncular.generated';
|
|
27
|
+
|
|
28
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
29
|
+
let baseUrl: string;
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
const config: SyncServerConfig = {
|
|
33
|
+
schema,
|
|
34
|
+
storage: new SqliteServerStorage(':memory:'),
|
|
35
|
+
segments: new MemorySegmentStore(),
|
|
36
|
+
resolveScopes: () => ({ list_id: ['*'] }),
|
|
37
|
+
};
|
|
38
|
+
const app = createSyncularHono({
|
|
39
|
+
config,
|
|
40
|
+
authenticate: async () => ({ actorId: 'demo-user', partition: 'demo' }),
|
|
41
|
+
});
|
|
42
|
+
server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
43
|
+
baseUrl = `http://localhost:${server.port}`;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(() => {
|
|
47
|
+
server.stop(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function makeClient(clientId: string): SyncClient {
|
|
51
|
+
return new SyncClient({
|
|
52
|
+
database: openBunDatabase(),
|
|
53
|
+
schema,
|
|
54
|
+
clientId,
|
|
55
|
+
transport: httpSyncTransport(`${baseUrl}/sync`),
|
|
56
|
+
segments: httpSegmentDownloader(`${baseUrl}/segments`),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test('two clients converge through the scaffolded server config', async () => {
|
|
61
|
+
const a = makeClient('client-a');
|
|
62
|
+
const b = makeClient('client-b');
|
|
63
|
+
await a.start();
|
|
64
|
+
await b.start();
|
|
65
|
+
|
|
66
|
+
const scopes = todoListSubscription.scopes({ listId: 'welcome' });
|
|
67
|
+
a.subscribe({ id: 'todos', table: 'todos', scopes });
|
|
68
|
+
b.subscribe({ id: 'todos', table: 'todos', scopes });
|
|
69
|
+
|
|
70
|
+
a.mutate([
|
|
71
|
+
{
|
|
72
|
+
table: 'todos',
|
|
73
|
+
op: 'upsert',
|
|
74
|
+
values: {
|
|
75
|
+
id: 'todo-1',
|
|
76
|
+
list_id: 'welcome',
|
|
77
|
+
title: 'Buy milk',
|
|
78
|
+
done: false,
|
|
79
|
+
position: 1,
|
|
80
|
+
updated_at_ms: Date.now(),
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
await a.syncUntilIdle();
|
|
85
|
+
await b.syncUntilIdle();
|
|
86
|
+
|
|
87
|
+
const rows = b.query('SELECT id, title, done FROM todos ORDER BY id');
|
|
88
|
+
expect(rows).toEqual([{ id: 'todo-1', title: 'Buy milk', done: 0 }]);
|
|
89
|
+
|
|
90
|
+
await a.close();
|
|
91
|
+
await b.close();
|
|
92
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Generated by @syncular/typegen — DO NOT EDIT.
|
|
2
|
+
// irVersion: 1
|
|
3
|
+
// irHash: sha256:2ffc5b70cb96cd546326de9c06868bd988c9fa3459fb3bac8428a81dcc85fca0
|
|
4
|
+
|
|
5
|
+
/** ServerSchema-compatible schema object (SPEC §2.4, §3.1). */
|
|
6
|
+
export const schema = {
|
|
7
|
+
version: 1,
|
|
8
|
+
tables: [
|
|
9
|
+
{
|
|
10
|
+
name: 'todos',
|
|
11
|
+
columns: [
|
|
12
|
+
{ name: 'id', type: 'string', nullable: false },
|
|
13
|
+
{ name: 'list_id', type: 'string', nullable: false },
|
|
14
|
+
{ name: 'title', type: 'string', nullable: false },
|
|
15
|
+
{ name: 'done', type: 'boolean', nullable: false },
|
|
16
|
+
{ name: 'position', type: 'integer', nullable: false },
|
|
17
|
+
{ name: 'updated_at_ms', type: 'integer', nullable: false },
|
|
18
|
+
],
|
|
19
|
+
primaryKey: 'id',
|
|
20
|
+
scopes: [
|
|
21
|
+
{ pattern: 'list:{list_id}', column: 'list_id' },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
/** One todos row (§2.4 column order). */
|
|
28
|
+
export interface TodosRow {
|
|
29
|
+
id: string;
|
|
30
|
+
listId: string;
|
|
31
|
+
title: string;
|
|
32
|
+
done: boolean;
|
|
33
|
+
position: number;
|
|
34
|
+
updatedAtMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Insert shape: nullable columns may be omitted. */
|
|
38
|
+
export interface TodosInsert {
|
|
39
|
+
id: string;
|
|
40
|
+
listId: string;
|
|
41
|
+
title: string;
|
|
42
|
+
done: boolean;
|
|
43
|
+
position: number;
|
|
44
|
+
updatedAtMs: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Update shape: primary key required, all other columns optional. */
|
|
48
|
+
export interface TodosUpdate {
|
|
49
|
+
id: string;
|
|
50
|
+
listId?: string;
|
|
51
|
+
title?: string;
|
|
52
|
+
done?: boolean;
|
|
53
|
+
position?: number;
|
|
54
|
+
updatedAtMs?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TodoListParams {
|
|
58
|
+
listId: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Requested-scope template for the 'todoList' subscription. */
|
|
62
|
+
export const todoListSubscription = {
|
|
63
|
+
name: 'todoList',
|
|
64
|
+
table: 'todos',
|
|
65
|
+
scopes: (params: TodoListParams): Record<string, string[]> => ({
|
|
66
|
+
list_id: [params.listId],
|
|
67
|
+
}),
|
|
68
|
+
} as const;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "app"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
publish = false
|
|
6
|
+
description = "The desktop host: a Tauri shell around the native syncular core"
|
|
7
|
+
|
|
8
|
+
# Standalone crate on purpose: the scaffold is not part of any cargo
|
|
9
|
+
# workspace, so a plain `cargo build` here never walks up into one.
|
|
10
|
+
[workspace]
|
|
11
|
+
|
|
12
|
+
[lib]
|
|
13
|
+
name = "app_lib"
|
|
14
|
+
crate-type = ["staticlib", "cdylib", "rlib"]
|
|
15
|
+
|
|
16
|
+
[build-dependencies]
|
|
17
|
+
tauri-build = { version = "2", features = [] }
|
|
18
|
+
|
|
19
|
+
[dependencies]
|
|
20
|
+
tauri = { version = "2", features = [] }
|
|
21
|
+
# The syncular plugin from crates.io. `native-transport` gives the core its
|
|
22
|
+
# real HTTP + WebSocket transport (ureq + tungstenite) inside the host
|
|
23
|
+
# process — the webview never talks to the sync server directly.
|
|
24
|
+
tauri-plugin-syncular = { version = "0.4", features = ["native-transport"] }
|
|
25
|
+
|
|
26
|
+
[features]
|
|
27
|
+
default = []
|
|
Binary file
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//! The desktop host: registers `tauri-plugin-syncular`, which runs the
|
|
2
|
+
//! NATIVE syncular core (real SQLite file + HTTP/WS transport) inside this
|
|
3
|
+
//! process. The webview's `@syncular/tauri` bridge is a thin RPC client of
|
|
4
|
+
//! it — the React tree in `src/frontend/` is byte-identical to the one the
|
|
5
|
+
//! browser runs against the worker core.
|
|
6
|
+
|
|
7
|
+
use tauri::Manager;
|
|
8
|
+
use tauri_plugin_syncular::SyncularConfig;
|
|
9
|
+
|
|
10
|
+
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
11
|
+
pub fn run() {
|
|
12
|
+
tauri::Builder::default()
|
|
13
|
+
.setup(|app| {
|
|
14
|
+
// Persist the syncular database under the resolved app-data dir
|
|
15
|
+
// so it survives restarts.
|
|
16
|
+
let db_path = app
|
|
17
|
+
.path()
|
|
18
|
+
.app_data_dir()
|
|
19
|
+
.map(|dir| {
|
|
20
|
+
let _ = std::fs::create_dir_all(&dir);
|
|
21
|
+
dir.join("syncular.db").to_string_lossy().into_owned()
|
|
22
|
+
})
|
|
23
|
+
.ok();
|
|
24
|
+
|
|
25
|
+
// Points at this scaffold's own dev server (`bun run dev`,
|
|
26
|
+
// port 8787). Change it to your deployed sync endpoint.
|
|
27
|
+
let config = SyncularConfig {
|
|
28
|
+
base_url: Some("http://localhost:8787".to_owned()),
|
|
29
|
+
db_path,
|
|
30
|
+
// Host-loop cadence with a little jitter (§8.4).
|
|
31
|
+
wake_jitter_ms: 250,
|
|
32
|
+
auto_sync: true,
|
|
33
|
+
..Default::default()
|
|
34
|
+
};
|
|
35
|
+
app.handle().plugin(tauri_plugin_syncular::init(config))?;
|
|
36
|
+
Ok(())
|
|
37
|
+
})
|
|
38
|
+
.run(tauri::generate_context!())
|
|
39
|
+
.expect("error while running the tauri application");
|
|
40
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://schema.tauri.app/config/2",
|
|
3
|
+
"productName": "__PROJECT_NAME__",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"identifier": "com.example.__PROJECT_NAME__",
|
|
6
|
+
"build": {
|
|
7
|
+
"frontendDist": "../dist",
|
|
8
|
+
"beforeDevCommand": "bun run build-frontend",
|
|
9
|
+
"beforeBuildCommand": "bun run build-frontend"
|
|
10
|
+
},
|
|
11
|
+
"app": {
|
|
12
|
+
"windows": [
|
|
13
|
+
{
|
|
14
|
+
"title": "__PROJECT_NAME__",
|
|
15
|
+
"width": 900,
|
|
16
|
+
"height": 700
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"security": {
|
|
20
|
+
"csp": null,
|
|
21
|
+
"capabilities": ["syncular"]
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"bundle": {
|
|
25
|
+
"active": false,
|
|
26
|
+
"targets": "all",
|
|
27
|
+
"icon": ["icons/icon.png"]
|
|
28
|
+
}
|
|
29
|
+
}
|