create-syncular-app 0.1.2 → 0.2.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 +70 -27
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +131 -179
- package/dist/constants.d.ts +40 -0
- package/dist/constants.js +46 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/scaffold.d.ts +44 -0
- package/dist/scaffold.js +106 -0
- package/package.json +22 -15
- package/src/cli.ts +174 -0
- package/src/constants.ts +53 -0
- package/src/index.ts +3 -0
- package/src/scaffold.ts +161 -0
- package/template/minimal/README.md +45 -0
- package/template/minimal/gitignore +5 -0
- package/template/minimal/migrations/0001_initial/up.sql +9 -0
- package/template/minimal/package.json +22 -0
- package/template/minimal/src/clients.ts +54 -0
- package/template/minimal/src/make-client.ts +26 -0
- package/template/minimal/src/server.ts +39 -0
- package/template/minimal/src/smoke.test.ts +70 -0
- package/template/minimal/src/syncular.generated.ts +66 -0
- package/template/minimal/syncular.ir.json +66 -0
- package/template/minimal/syncular.json +17 -0
- package/template/minimal/tsconfig.json +16 -0
- package/template/web/README.md +63 -0
- package/template/web/gitignore +5 -0
- package/template/web/migrations/0001_initial/up.sql +11 -0
- package/template/web/package.json +22 -0
- package/template/web/src/frontend/index.html +37 -0
- package/template/web/src/frontend/main.ts +191 -0
- package/template/web/src/frontend/worker.ts +9 -0
- package/template/web/src/server.ts +183 -0
- package/template/web/src/smoke.test.ts +91 -0
- package/template/web/src/syncular.generated.ts +74 -0
- package/template/web/syncular.ir.json +76 -0
- package/template/web/syncular.json +17 -0
- package/template/web/tsconfig.json +16 -0
- package/template/README.md +0 -122
- package/template/_gitignore +0 -5
- package/template/generated/kotlin/SyncularApp.kt +0 -1005
- package/template/generated/rust/diesel_tables.rs +0 -142
- package/template/generated/rust/migrations.rs +0 -32
- package/template/generated/rust/schema.rs +0 -15
- package/template/generated/rust/syncular.rs +0 -926
- package/template/generated/swift/SyncularApp.swift +0 -1191
- package/template/generated/syncular.codegen.json +0 -19
- package/template/index.html +0 -13
- package/template/migrations/0001_initial/down.sql +0 -1
- package/template/migrations/0001_initial/up.sql +0 -8
- package/template/package.json +0 -33
- package/template/scripts/dev.ts +0 -42
- package/template/src/app.tsx +0 -231
- package/template/src/client/syncular.ts +0 -61
- package/template/src/generated/syncular.generated.ts +0 -769
- package/template/src/generated/syncular.server.generated.ts +0 -512
- package/template/src/main.tsx +0 -5
- package/template/src/server/sync-server.ts +0 -129
- package/template/src/styles.css +0 -233
- package/template/syncular.app.ts +0 -23
- package/template/syncular.schema.json +0 -145
- package/template/tsconfig.json +0 -18
- package/template/vite.config.ts +0 -9
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"tables": {
|
|
3
|
-
"tasks": {
|
|
4
|
-
"serverVersionColumn": "server_version",
|
|
5
|
-
"subscriptionId": "sub-tasks",
|
|
6
|
-
"scopes": [
|
|
7
|
-
{
|
|
8
|
-
"name": "user_id",
|
|
9
|
-
"column": "user_id",
|
|
10
|
-
"source": "actorId",
|
|
11
|
-
"required": true
|
|
12
|
-
}
|
|
13
|
-
],
|
|
14
|
-
"sqliteWithoutRowid": true
|
|
15
|
-
}
|
|
16
|
-
},
|
|
17
|
-
"typescriptOutputPath": "src/generated/syncular.generated.ts",
|
|
18
|
-
"typescriptServerOutputPath": "src/generated/syncular.server.generated.ts"
|
|
19
|
-
}
|
package/template/index.html
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
<link rel="icon" href="data:," />
|
|
6
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
-
<title>Syncular App</title>
|
|
8
|
-
</head>
|
|
9
|
-
<body>
|
|
10
|
-
<div id="root"></div>
|
|
11
|
-
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
-
</body>
|
|
13
|
-
</html>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
DROP TABLE IF EXISTS tasks;
|
package/template/package.json
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "syncular-app-template",
|
|
3
|
-
"version": "0.0.0",
|
|
4
|
-
"private": true,
|
|
5
|
-
"type": "module",
|
|
6
|
-
"scripts": {
|
|
7
|
-
"dev": "bun scripts/dev.ts",
|
|
8
|
-
"build": "vite build",
|
|
9
|
-
"preview": "bun scripts/dev.ts --preview",
|
|
10
|
-
"codegen": "syncular generate",
|
|
11
|
-
"codegen:check": "syncular generate --check",
|
|
12
|
-
"typecheck": "tsc --noEmit"
|
|
13
|
-
},
|
|
14
|
-
"dependencies": {
|
|
15
|
-
"@syncular/client": "workspace:*",
|
|
16
|
-
"@syncular/core": "workspace:*",
|
|
17
|
-
"@syncular/server": "workspace:*",
|
|
18
|
-
"@syncular/typegen": "workspace:*",
|
|
19
|
-
"hono": "^4.12.15",
|
|
20
|
-
"kysely": "^0.28.0",
|
|
21
|
-
"kysely-bun-sqlite": "^0.4.0",
|
|
22
|
-
"react": "^19.2.4",
|
|
23
|
-
"react-dom": "^19.2.4"
|
|
24
|
-
},
|
|
25
|
-
"devDependencies": {
|
|
26
|
-
"@types/bun": "^1.3.11",
|
|
27
|
-
"@types/react": "^19.2.14",
|
|
28
|
-
"@types/react-dom": "^19.2.3",
|
|
29
|
-
"syncular": "workspace:*",
|
|
30
|
-
"typescript": "^6.0.2",
|
|
31
|
-
"vite": "^8.0.14"
|
|
32
|
-
}
|
|
33
|
-
}
|
package/template/scripts/dev.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { createServer as createViteServer, preview } from 'vite';
|
|
2
|
-
import { startSyncServer } from '../src/server/sync-server';
|
|
3
|
-
|
|
4
|
-
const previewMode = process.argv.includes('--preview');
|
|
5
|
-
|
|
6
|
-
const syncServer = await startSyncServer({
|
|
7
|
-
port: Number(process.env.SYNC_PORT ?? 4100),
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
async function stop() {
|
|
11
|
-
await syncServer.close();
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
process.on('SIGINT', () => {
|
|
15
|
-
void stop().finally(() => process.exit(0));
|
|
16
|
-
});
|
|
17
|
-
process.on('SIGTERM', () => {
|
|
18
|
-
void stop().finally(() => process.exit(0));
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
if (previewMode) {
|
|
22
|
-
const previewServer = await preview({
|
|
23
|
-
preview: {
|
|
24
|
-
host: '127.0.0.1',
|
|
25
|
-
port: 4173,
|
|
26
|
-
},
|
|
27
|
-
});
|
|
28
|
-
previewServer.printUrls();
|
|
29
|
-
} else {
|
|
30
|
-
const vite = await createViteServer({
|
|
31
|
-
server: {
|
|
32
|
-
host: '127.0.0.1',
|
|
33
|
-
port: Number(process.env.PORT ?? 5173),
|
|
34
|
-
strictPort: false,
|
|
35
|
-
},
|
|
36
|
-
});
|
|
37
|
-
await vite.listen();
|
|
38
|
-
vite.printUrls();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
console.log(`[syncular] sync server listening at ${syncServer.origin}`);
|
|
42
|
-
await new Promise(() => {});
|
package/template/src/app.tsx
DELETED
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import type { SyncularClientStatus } from '@syncular/client';
|
|
2
|
-
import { createSyncularReact } from '@syncular/client/react';
|
|
3
|
-
import type { FormEvent } from 'react';
|
|
4
|
-
import { useEffect, useRef, useState } from 'react';
|
|
5
|
-
import {
|
|
6
|
-
type AppDb,
|
|
7
|
-
type AppSyncClient,
|
|
8
|
-
appActorId,
|
|
9
|
-
openAppClient,
|
|
10
|
-
type Task,
|
|
11
|
-
} from './client/syncular';
|
|
12
|
-
|
|
13
|
-
// One hook set, bound to this app's database schema.
|
|
14
|
-
const {
|
|
15
|
-
SyncProvider,
|
|
16
|
-
useMutations,
|
|
17
|
-
useOutboxStats,
|
|
18
|
-
useSyncQuery,
|
|
19
|
-
useSyncStatus,
|
|
20
|
-
} = createSyncularReact<AppDb>();
|
|
21
|
-
|
|
22
|
-
export function App() {
|
|
23
|
-
const [client, setClient] = useState<AppSyncClient | null>(null);
|
|
24
|
-
const [openError, setOpenError] = useState<string | null>(null);
|
|
25
|
-
|
|
26
|
-
useEffect(() => {
|
|
27
|
-
let disposed = false;
|
|
28
|
-
let opened: AppSyncClient | null = null;
|
|
29
|
-
|
|
30
|
-
void openAppClient()
|
|
31
|
-
.then((nextClient) => {
|
|
32
|
-
if (disposed) {
|
|
33
|
-
void nextClient.close().catch(() => undefined);
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
opened = nextClient;
|
|
37
|
-
setClient(nextClient);
|
|
38
|
-
})
|
|
39
|
-
.catch((error) => {
|
|
40
|
-
if (!disposed) setOpenError(errorMessage(error));
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
return () => {
|
|
44
|
-
disposed = true;
|
|
45
|
-
if (opened) void opened.close().catch(() => undefined);
|
|
46
|
-
};
|
|
47
|
-
}, []);
|
|
48
|
-
|
|
49
|
-
return (
|
|
50
|
-
<main className="app-shell">
|
|
51
|
-
<header className="topbar">
|
|
52
|
-
<p className="eyebrow">Syncular</p>
|
|
53
|
-
<h1>Local-first tasks</h1>
|
|
54
|
-
</header>
|
|
55
|
-
|
|
56
|
-
{openError ? <p className="error-line">{openError}</p> : null}
|
|
57
|
-
|
|
58
|
-
<section className="client-pane" aria-label="Tasks">
|
|
59
|
-
{client ? (
|
|
60
|
-
<SyncProvider client={client}>
|
|
61
|
-
<TaskPane />
|
|
62
|
-
</SyncProvider>
|
|
63
|
-
) : (
|
|
64
|
-
<p className="empty-state">Opening local database…</p>
|
|
65
|
-
)}
|
|
66
|
-
</section>
|
|
67
|
-
</main>
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function TaskPane() {
|
|
72
|
-
// Live query: re-renders whenever synced rows change, locally or remotely.
|
|
73
|
-
const { data: tasks, error: queryError } = useSyncQuery(
|
|
74
|
-
({ selectFrom }) =>
|
|
75
|
-
selectFrom('tasks')
|
|
76
|
-
.selectAll()
|
|
77
|
-
.orderBy('completed', 'asc')
|
|
78
|
-
.orderBy('created_at', 'desc'),
|
|
79
|
-
{ tables: ['tasks'] }
|
|
80
|
-
);
|
|
81
|
-
const mutations = useMutations();
|
|
82
|
-
const outbox = useOutboxStats();
|
|
83
|
-
const status = useSyncStatus();
|
|
84
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
85
|
-
|
|
86
|
-
const rows = tasks ?? [];
|
|
87
|
-
const doneCount = rows.filter((task) => task.completed).length;
|
|
88
|
-
const queued = (outbox?.pending ?? 0) + (outbox?.sending ?? 0);
|
|
89
|
-
|
|
90
|
-
const addTask = (event: FormEvent) => {
|
|
91
|
-
event.preventDefault();
|
|
92
|
-
const title = inputRef.current?.value.trim();
|
|
93
|
-
if (!title) return;
|
|
94
|
-
inputRef.current!.value = '';
|
|
95
|
-
void mutations.tasks
|
|
96
|
-
.insert({
|
|
97
|
-
id: crypto.randomUUID(),
|
|
98
|
-
title,
|
|
99
|
-
completed: 0,
|
|
100
|
-
user_id: appActorId,
|
|
101
|
-
created_at: Date.now(),
|
|
102
|
-
})
|
|
103
|
-
.catch(() => undefined);
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
return (
|
|
107
|
-
<>
|
|
108
|
-
<div className="pane-header">
|
|
109
|
-
<h2>Tasks</h2>
|
|
110
|
-
<StatusBadge state={paneStatus(status)} />
|
|
111
|
-
</div>
|
|
112
|
-
|
|
113
|
-
<form className="add-row" onSubmit={addTask}>
|
|
114
|
-
<input ref={inputRef} aria-label="New task" placeholder="New task" />
|
|
115
|
-
<button type="submit" aria-label="Add task">
|
|
116
|
-
+
|
|
117
|
-
</button>
|
|
118
|
-
</form>
|
|
119
|
-
|
|
120
|
-
{mutations.$error ? (
|
|
121
|
-
<p className="error-line">{mutations.$error.message}</p>
|
|
122
|
-
) : null}
|
|
123
|
-
{queryError ? <p className="error-line">{queryError.message}</p> : null}
|
|
124
|
-
{queued > 0 ? (
|
|
125
|
-
<p className="offline-line">
|
|
126
|
-
{queued} queued change{queued === 1 ? '' : 's'} waiting to sync.
|
|
127
|
-
</p>
|
|
128
|
-
) : null}
|
|
129
|
-
|
|
130
|
-
<div className="task-list">
|
|
131
|
-
{rows.length === 0 ? (
|
|
132
|
-
<p className="empty-state">
|
|
133
|
-
No tasks yet. Add one above — it is written to the local database
|
|
134
|
-
first and synced in the background.
|
|
135
|
-
</p>
|
|
136
|
-
) : (
|
|
137
|
-
rows.map((task) => (
|
|
138
|
-
<TaskItem key={task.id} task={task} mutations={mutations} />
|
|
139
|
-
))
|
|
140
|
-
)}
|
|
141
|
-
</div>
|
|
142
|
-
|
|
143
|
-
<p className="pane-footnote">
|
|
144
|
-
{rows.length} task{rows.length === 1 ? '' : 's'} · {doneCount} done
|
|
145
|
-
</p>
|
|
146
|
-
</>
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function TaskItem({
|
|
151
|
-
task,
|
|
152
|
-
mutations,
|
|
153
|
-
}: {
|
|
154
|
-
task: Task;
|
|
155
|
-
mutations: ReturnType<typeof useMutations>;
|
|
156
|
-
}) {
|
|
157
|
-
const toggle = () => {
|
|
158
|
-
void mutations.tasks
|
|
159
|
-
.update(
|
|
160
|
-
task.id,
|
|
161
|
-
{ completed: task.completed ? 0 : 1 },
|
|
162
|
-
{ baseVersion: task.server_version }
|
|
163
|
-
)
|
|
164
|
-
.catch(() => undefined);
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const remove = () => {
|
|
168
|
-
void mutations.tasks
|
|
169
|
-
.delete(task.id, { baseVersion: task.server_version })
|
|
170
|
-
.catch(() => undefined);
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
return (
|
|
174
|
-
<div className="task-row">
|
|
175
|
-
<button
|
|
176
|
-
className="icon-button"
|
|
177
|
-
type="button"
|
|
178
|
-
aria-label={task.completed ? 'Mark task open' : 'Mark task done'}
|
|
179
|
-
onClick={toggle}
|
|
180
|
-
>
|
|
181
|
-
{task.completed ? '✓' : '○'}
|
|
182
|
-
</button>
|
|
183
|
-
<span className={task.completed ? 'done' : undefined}>{task.title}</span>
|
|
184
|
-
<button
|
|
185
|
-
className="icon-button muted"
|
|
186
|
-
type="button"
|
|
187
|
-
aria-label="Delete task"
|
|
188
|
-
onClick={remove}
|
|
189
|
-
>
|
|
190
|
-
×
|
|
191
|
-
</button>
|
|
192
|
-
</div>
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
type PaneStatus = 'syncing' | 'offline' | 'attention' | 'error' | 'ready';
|
|
197
|
-
|
|
198
|
-
function StatusBadge({ state }: { state: PaneStatus }) {
|
|
199
|
-
const label =
|
|
200
|
-
state === 'syncing'
|
|
201
|
-
? 'Syncing'
|
|
202
|
-
: state === 'offline'
|
|
203
|
-
? 'Offline'
|
|
204
|
-
: state === 'attention'
|
|
205
|
-
? 'Review'
|
|
206
|
-
: state === 'error'
|
|
207
|
-
? 'Error'
|
|
208
|
-
: 'Ready';
|
|
209
|
-
|
|
210
|
-
return (
|
|
211
|
-
<div className={`status-badge ${state}`}>
|
|
212
|
-
<span className="status-dot" />
|
|
213
|
-
<span>{label}</span>
|
|
214
|
-
</div>
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function paneStatus(status: SyncularClientStatus): PaneStatus {
|
|
219
|
-
const phase = status.lifecycle.phase;
|
|
220
|
-
if (phase === 'syncing' || phase === 'recovering' || phase === 'connecting') {
|
|
221
|
-
return 'syncing';
|
|
222
|
-
}
|
|
223
|
-
if (phase === 'offline') return 'offline';
|
|
224
|
-
if (phase === 'authRequired') return 'error';
|
|
225
|
-
if (status.requiresAction || phase === 'degraded') return 'attention';
|
|
226
|
-
return 'ready';
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function errorMessage(error: unknown): string {
|
|
230
|
-
return error instanceof Error ? error.message : String(error);
|
|
231
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import type { SyncularClientLike, SyncularLiveQueries } from '@syncular/client';
|
|
2
|
-
import type { Selectable } from 'kysely';
|
|
3
|
-
import {
|
|
4
|
-
createSyncularAppDatabase,
|
|
5
|
-
type SyncularAppDb,
|
|
6
|
-
} from '../generated/syncular.generated';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Kysely-style database schema for the React hooks. Columns the database
|
|
10
|
-
* defaults (like the server-assigned `server_version`) are emitted as
|
|
11
|
-
* Kysely `Generated<>` columns, so `useMutations().tasks.insert(...)` does
|
|
12
|
-
* not require them.
|
|
13
|
-
*/
|
|
14
|
-
export type AppDb = SyncularAppDb;
|
|
15
|
-
|
|
16
|
-
export type Task = Selectable<AppDb['tasks']>;
|
|
17
|
-
|
|
18
|
-
/** The managed client surface that `@syncular/client/react` consumes. */
|
|
19
|
-
export type AppSyncClient = SyncularClientLike<AppDb> & SyncularLiveQueries;
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Demo auth: the starter server accepts this static token and maps it to a
|
|
23
|
-
* single user. Replace with your real auth (and pass the signed-in user's id
|
|
24
|
-
* as `actorId`).
|
|
25
|
-
*/
|
|
26
|
-
export const appActorId = 'demo-user';
|
|
27
|
-
const appToken = 'demo-user';
|
|
28
|
-
|
|
29
|
-
const syncBaseUrl =
|
|
30
|
-
import.meta.env.VITE_SYNCULAR_SYNC_URL ?? 'http://127.0.0.1:4100/sync';
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Opens the local database (SQLite persisted in IndexedDB). The generated
|
|
34
|
-
* factory installs the app schema, registers the default subscriptions, and
|
|
35
|
-
* starts the sync lifecycle (HTTP sync + WebSocket realtime) before
|
|
36
|
-
* resolving, so the returned client is ready for the React hooks.
|
|
37
|
-
*/
|
|
38
|
-
export async function openAppClient(): Promise<AppSyncClient> {
|
|
39
|
-
const database = await createSyncularAppDatabase({
|
|
40
|
-
config: {
|
|
41
|
-
baseUrl: syncBaseUrl,
|
|
42
|
-
actorId: appActorId,
|
|
43
|
-
clientId: 'web',
|
|
44
|
-
fileName: 'syncular-app-v1.sqlite',
|
|
45
|
-
storage: 'indexedDb',
|
|
46
|
-
},
|
|
47
|
-
requestTimeoutMs: 15_000,
|
|
48
|
-
getHeaders: async () => ({
|
|
49
|
-
authorization: `Bearer ${appToken}`,
|
|
50
|
-
}),
|
|
51
|
-
realtime: {
|
|
52
|
-
params: { token: appToken },
|
|
53
|
-
initialReconnectDelayMs: 500,
|
|
54
|
-
maxReconnectDelayMs: 5_000,
|
|
55
|
-
},
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// The cast bridges the generated mutations interface, which does not
|
|
59
|
-
// declare the structural `$table` helper the hooks type against.
|
|
60
|
-
return database as unknown as AppSyncClient;
|
|
61
|
-
}
|