create-syncular-app 0.1.3 → 0.2.1
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 +17 -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 +17 -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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two clients, one server, terminal-visible convergence.
|
|
3
|
+
*
|
|
4
|
+
* A writes a note; B — a completely independent client core with its own local
|
|
5
|
+
* database — bootstraps, syncs, and reads the same row back. This is the proof
|
|
6
|
+
* that sync works end to end, no browser required.
|
|
7
|
+
*
|
|
8
|
+
* Run the server first (`bun run server`), then this script (`bun run
|
|
9
|
+
* clients`). Both talk real HTTP to http://localhost:8787.
|
|
10
|
+
*/
|
|
11
|
+
import { makeClient } from './make-client';
|
|
12
|
+
|
|
13
|
+
const BASE_URL = process.env.SERVER_URL ?? 'http://localhost:8787';
|
|
14
|
+
const LIST_ID = 'welcome';
|
|
15
|
+
|
|
16
|
+
const a = makeClient(BASE_URL, 'client-a');
|
|
17
|
+
const b = makeClient(BASE_URL, 'client-b');
|
|
18
|
+
await a.start();
|
|
19
|
+
await b.start();
|
|
20
|
+
|
|
21
|
+
// Both clients subscribe to the same list (the requested scope).
|
|
22
|
+
const sub = { id: 'notes', table: 'notes', scopes: { list_id: [LIST_ID] } };
|
|
23
|
+
a.subscribe(sub);
|
|
24
|
+
b.subscribe(sub);
|
|
25
|
+
|
|
26
|
+
// A writes a note. mutate() records it locally + queues it for the next push.
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
a.mutate([
|
|
29
|
+
{
|
|
30
|
+
table: 'notes',
|
|
31
|
+
op: 'upsert',
|
|
32
|
+
values: {
|
|
33
|
+
id: 'note-1',
|
|
34
|
+
list_id: LIST_ID,
|
|
35
|
+
body: 'Hello from client A',
|
|
36
|
+
updated_at_ms: now,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
]);
|
|
40
|
+
console.log('A: wrote note-1, pushing…');
|
|
41
|
+
await a.syncUntilIdle(); // push A's outbox to the server
|
|
42
|
+
|
|
43
|
+
console.log('B: syncing…');
|
|
44
|
+
await b.syncUntilIdle(); // B bootstraps the list and applies A's note
|
|
45
|
+
|
|
46
|
+
const rows = b.query('SELECT id, body FROM notes ORDER BY id');
|
|
47
|
+
console.log('B sees:', rows);
|
|
48
|
+
|
|
49
|
+
const converged = rows.length === 1 && rows[0]?.body === 'Hello from client A';
|
|
50
|
+
console.log(converged ? '\n✓ converged' : '\n✗ did NOT converge');
|
|
51
|
+
|
|
52
|
+
await a.close();
|
|
53
|
+
await b.close();
|
|
54
|
+
process.exit(converged ? 0 : 1);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Construct one SyncClient against the running server.
|
|
3
|
+
*
|
|
4
|
+
* The client core is plain library code — it runs on whatever database backend
|
|
5
|
+
* and transport you give it. In the browser that is sqlite-wasm on OPFS + a
|
|
6
|
+
* WebSocket; here it is bun:sqlite + fetch, so the whole thing runs in a
|
|
7
|
+
* terminal with no browser. Everything else is identical to a web build (see
|
|
8
|
+
* the `web` template for the browser story).
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
httpSegmentDownloader,
|
|
12
|
+
httpSyncTransport,
|
|
13
|
+
SyncClient,
|
|
14
|
+
} from '@syncular/client';
|
|
15
|
+
import { openBunDatabase } from '@syncular/client/bun';
|
|
16
|
+
import { schema } from './syncular.generated';
|
|
17
|
+
|
|
18
|
+
export function makeClient(baseUrl: string, clientId: string): SyncClient {
|
|
19
|
+
return new SyncClient({
|
|
20
|
+
database: openBunDatabase(), // in-memory; pass a path to persist
|
|
21
|
+
schema,
|
|
22
|
+
clientId,
|
|
23
|
+
transport: httpSyncTransport(`${baseUrl}/sync`),
|
|
24
|
+
segments: httpSegmentDownloader(`${baseUrl}/segments`),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole sync backend in one Bun process.
|
|
3
|
+
*
|
|
4
|
+
* `createSyncularHono` mounts the protocol routes (POST /sync, GET
|
|
5
|
+
* /segments/:id, PUT|GET /blobs/:id) over the framework-free
|
|
6
|
+
* `@syncular/server` core. Storage is bun:sqlite; the server manages its
|
|
7
|
+
* own `sync_*` tables — the app migration (migrations/0001_initial) only feeds
|
|
8
|
+
* typegen the schema SHAPE, it is never run here.
|
|
9
|
+
*
|
|
10
|
+
* EDIT FIRST: `resolveScopes` is the whole authorization story. It runs in
|
|
11
|
+
* YOUR backend, next to YOUR auth, and decides which scope values an actor may
|
|
12
|
+
* see. Here the demo actor may see every list (`['*']`); a real backend
|
|
13
|
+
* returns the lists the authenticated user belongs to. `authenticate` is where
|
|
14
|
+
* you plug in your real session/token check.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
MemorySegmentStore,
|
|
18
|
+
SqliteServerStorage,
|
|
19
|
+
type SyncServerConfig,
|
|
20
|
+
} from '@syncular/server';
|
|
21
|
+
import { createSyncularHono } from '@syncular/server-hono';
|
|
22
|
+
import { schema } from './syncular.generated';
|
|
23
|
+
|
|
24
|
+
const config: SyncServerConfig = {
|
|
25
|
+
schema,
|
|
26
|
+
storage: new SqliteServerStorage(process.env.DB_PATH ?? ':memory:'),
|
|
27
|
+
segments: new MemorySegmentStore(),
|
|
28
|
+
resolveScopes: () => ({ list_id: ['*'] }),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const app = createSyncularHono({
|
|
32
|
+
config,
|
|
33
|
+
// Replace with your real auth: return { actorId, partition } or null (401).
|
|
34
|
+
authenticate: async () => ({ actorId: 'demo-user', partition: 'demo' }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const port = Number(process.env.PORT ?? 8787);
|
|
38
|
+
Bun.serve({ port, fetch: app.fetch });
|
|
39
|
+
console.log(`sync server: http://localhost:${port}`);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The template's own smoke test: boots the real Hono server on an ephemeral
|
|
3
|
+
* port and drives two independent bun:sqlite client cores through it over real
|
|
4
|
+
* HTTP, asserting they converge. This runs in the scaffolded app's own `bun
|
|
5
|
+
* test`, AND (because the template lives in the workspace) in the v2 sweep — so
|
|
6
|
+
* the template itself cannot rot.
|
|
7
|
+
*/
|
|
8
|
+
import { afterAll, beforeAll, expect, test } from 'bun:test';
|
|
9
|
+
import {
|
|
10
|
+
MemorySegmentStore,
|
|
11
|
+
SqliteServerStorage,
|
|
12
|
+
type SyncServerConfig,
|
|
13
|
+
} from '@syncular/server';
|
|
14
|
+
import { createSyncularHono } from '@syncular/server-hono';
|
|
15
|
+
import { makeClient } from './make-client';
|
|
16
|
+
import { schema } from './syncular.generated';
|
|
17
|
+
|
|
18
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
19
|
+
let baseUrl: string;
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
const config: SyncServerConfig = {
|
|
23
|
+
schema,
|
|
24
|
+
storage: new SqliteServerStorage(':memory:'),
|
|
25
|
+
segments: new MemorySegmentStore(),
|
|
26
|
+
resolveScopes: () => ({ list_id: ['*'] }),
|
|
27
|
+
};
|
|
28
|
+
const app = createSyncularHono({
|
|
29
|
+
config,
|
|
30
|
+
authenticate: async () => ({ actorId: 'demo-user', partition: 'demo' }),
|
|
31
|
+
});
|
|
32
|
+
server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
33
|
+
baseUrl = `http://localhost:${server.port}`;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(() => {
|
|
37
|
+
server.stop(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('two clients converge through the server', async () => {
|
|
41
|
+
const a = makeClient(baseUrl, 'client-a');
|
|
42
|
+
const b = makeClient(baseUrl, 'client-b');
|
|
43
|
+
await a.start();
|
|
44
|
+
await b.start();
|
|
45
|
+
|
|
46
|
+
const sub = { id: 'notes', table: 'notes', scopes: { list_id: ['welcome'] } };
|
|
47
|
+
a.subscribe(sub);
|
|
48
|
+
b.subscribe(sub);
|
|
49
|
+
|
|
50
|
+
a.mutate([
|
|
51
|
+
{
|
|
52
|
+
table: 'notes',
|
|
53
|
+
op: 'upsert',
|
|
54
|
+
values: {
|
|
55
|
+
id: 'note-1',
|
|
56
|
+
list_id: 'welcome',
|
|
57
|
+
body: 'Hello from client A',
|
|
58
|
+
updated_at_ms: Date.now(),
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
await a.syncUntilIdle();
|
|
63
|
+
await b.syncUntilIdle();
|
|
64
|
+
|
|
65
|
+
const rows = b.query('SELECT id, body FROM notes ORDER BY id');
|
|
66
|
+
expect(rows).toEqual([{ id: 'note-1', body: 'Hello from client A' }]);
|
|
67
|
+
|
|
68
|
+
await a.close();
|
|
69
|
+
await b.close();
|
|
70
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Generated by @syncular/typegen — DO NOT EDIT.
|
|
2
|
+
// irVersion: 1
|
|
3
|
+
// irHash: sha256:e2e39c94ce6c13720e1f1579e2523d460fd6bb3fea2f8eaaa114305b7aa332ba
|
|
4
|
+
|
|
5
|
+
/** ServerSchema-compatible schema object (SPEC §2.4, §3.1). */
|
|
6
|
+
export const schema = {
|
|
7
|
+
version: 1,
|
|
8
|
+
tables: [
|
|
9
|
+
{
|
|
10
|
+
name: 'notes',
|
|
11
|
+
columns: [
|
|
12
|
+
{ name: 'id', type: 'string', nullable: false },
|
|
13
|
+
{ name: 'list_id', type: 'string', nullable: false },
|
|
14
|
+
{ name: 'body', type: 'string', nullable: false },
|
|
15
|
+
{ name: 'updated_at_ms', type: 'integer', nullable: false },
|
|
16
|
+
],
|
|
17
|
+
primaryKey: 'id',
|
|
18
|
+
scopes: [
|
|
19
|
+
{ pattern: 'list:{list_id}', column: 'list_id' },
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
/** One notes row (§2.4 column order). */
|
|
26
|
+
export interface NotesRow {
|
|
27
|
+
id: string;
|
|
28
|
+
list_id: string;
|
|
29
|
+
body: string;
|
|
30
|
+
updated_at_ms: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Insert shape: nullable columns may be omitted. */
|
|
34
|
+
export interface NotesInsert {
|
|
35
|
+
id: string;
|
|
36
|
+
list_id: string;
|
|
37
|
+
body: string;
|
|
38
|
+
updated_at_ms: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Update shape: primary key required, all other columns optional. */
|
|
42
|
+
export interface NotesUpdate {
|
|
43
|
+
id: string;
|
|
44
|
+
list_id?: string;
|
|
45
|
+
body?: string;
|
|
46
|
+
updated_at_ms?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Kysely `Database` interface (table → Row); the generic for
|
|
50
|
+
* @syncular/kysely's SyncularDialect. */
|
|
51
|
+
export interface Database {
|
|
52
|
+
notes: NotesRow;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface NotesInListParams {
|
|
56
|
+
listId: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Requested-scope template for the 'notesInList' subscription. */
|
|
60
|
+
export const notesInListSubscription = {
|
|
61
|
+
name: 'notesInList',
|
|
62
|
+
table: 'notes',
|
|
63
|
+
scopes: (params: NotesInListParams): Record<string, string[]> => ({
|
|
64
|
+
list_id: [params.listId],
|
|
65
|
+
}),
|
|
66
|
+
} as const;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"irVersion": 1,
|
|
3
|
+
"schemaVersion": 1,
|
|
4
|
+
"schemaVersions": [
|
|
5
|
+
{
|
|
6
|
+
"version": 1,
|
|
7
|
+
"migrations": [
|
|
8
|
+
"0001_initial"
|
|
9
|
+
]
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"tables": [
|
|
13
|
+
{
|
|
14
|
+
"name": "notes",
|
|
15
|
+
"primaryKey": "id",
|
|
16
|
+
"columns": [
|
|
17
|
+
{
|
|
18
|
+
"name": "id",
|
|
19
|
+
"type": "string",
|
|
20
|
+
"nullable": false
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "list_id",
|
|
24
|
+
"type": "string",
|
|
25
|
+
"nullable": false
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "body",
|
|
29
|
+
"type": "string",
|
|
30
|
+
"nullable": false
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "updated_at_ms",
|
|
34
|
+
"type": "integer",
|
|
35
|
+
"nullable": false
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"scopes": [
|
|
39
|
+
{
|
|
40
|
+
"pattern": "list:{list_id}",
|
|
41
|
+
"variable": "list_id",
|
|
42
|
+
"column": "list_id"
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"extensions": {}
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"subscriptions": [
|
|
49
|
+
{
|
|
50
|
+
"name": "notesInList",
|
|
51
|
+
"table": "notes",
|
|
52
|
+
"scopes": [
|
|
53
|
+
{
|
|
54
|
+
"variable": "list_id",
|
|
55
|
+
"values": [
|
|
56
|
+
{
|
|
57
|
+
"kind": "parameter",
|
|
58
|
+
"name": "listId"
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"extensions": {}
|
|
66
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifestVersion": 1,
|
|
3
|
+
"migrations": "./migrations",
|
|
4
|
+
"output": {
|
|
5
|
+
"ir": "./syncular.ir.json",
|
|
6
|
+
"module": "./src/syncular.generated.ts"
|
|
7
|
+
},
|
|
8
|
+
"schemaVersions": [{ "version": 1, "through": "0001_initial" }],
|
|
9
|
+
"tables": [{ "name": "notes", "scopes": ["list:{list_id}"] }],
|
|
10
|
+
"subscriptions": [
|
|
11
|
+
{
|
|
12
|
+
"name": "notesInList",
|
|
13
|
+
"table": "notes",
|
|
14
|
+
"scopes": { "list_id": ["{listId}"] }
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"customConditions": ["bun"],
|
|
7
|
+
"lib": ["ES2023"],
|
|
8
|
+
"types": ["bun"],
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noUncheckedIndexedAccess": true,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"noEmit": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*.ts"]
|
|
17
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# __PROJECT_NAME__
|
|
2
|
+
|
|
3
|
+
A [syncular](https://github.com/bkniffler/syncular) v2 browser app: a Hono sync
|
|
4
|
+
server, a WebSocket realtime channel, and a single-pane todo UI whose whole
|
|
5
|
+
client core runs in a Web Worker on OPFS. Everything is served by one Bun
|
|
6
|
+
process.
|
|
7
|
+
|
|
8
|
+
## Run it
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
bun install
|
|
12
|
+
bun run generate # syncular.json + migrations → src/syncular.generated.ts
|
|
13
|
+
bun run dev # http://localhost:8787
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Open the URL, add todos, toggle "Go offline" and keep editing — the outbox
|
|
17
|
+
counter grows and drains with idempotent retry on reconnect. Open a second
|
|
18
|
+
browser window on the same URL to watch them converge over the realtime socket.
|
|
19
|
+
|
|
20
|
+
`bun test` runs a server-level convergence smoke test (the browser UI is
|
|
21
|
+
covered by `tsc`).
|
|
22
|
+
|
|
23
|
+
> One core per origin: a second *tab* gets a clear not-leader error (multi-tab
|
|
24
|
+
> followers are a roadmap item). Use two windows/profiles to see two clients.
|
|
25
|
+
|
|
26
|
+
## Layout
|
|
27
|
+
|
|
28
|
+
| File | What it is |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `syncular.json` + `migrations/` | The schema manifest and SQL — typegen's input |
|
|
31
|
+
| `src/syncular.generated.ts` | Generated by `syncular generate` (committed) |
|
|
32
|
+
| `src/server.ts` | Sync server + WebSocket + static frontend, one Bun process |
|
|
33
|
+
| `src/frontend/main.ts` | The single-pane UI, driving a worker core via RPC |
|
|
34
|
+
| `src/frontend/worker.ts` | The sync worker: the whole client core on OPFS |
|
|
35
|
+
| `src/frontend/index.html` | The page shell |
|
|
36
|
+
| `src/smoke.test.ts` | Server-level convergence smoke test (`bun test`) |
|
|
37
|
+
|
|
38
|
+
## What to edit first
|
|
39
|
+
|
|
40
|
+
1. **`src/server.ts` → `resolveScopes`** — the whole authorization story. It
|
|
41
|
+
runs in your backend next to your auth. The starter returns `['*']`; a real
|
|
42
|
+
one returns the scope values the authenticated actor may see.
|
|
43
|
+
2. **`src/server.ts` → `authenticate`** — plug in your real session/token
|
|
44
|
+
check; return `{ actorId, partition }` or `null` for a 401.
|
|
45
|
+
3. **`src/frontend/main.ts`** — the UI. It queries and mutates through the
|
|
46
|
+
worker handle; grow it into your app.
|
|
47
|
+
4. **`migrations/` + `syncular.json`** — add tables and scopes, then
|
|
48
|
+
`bun run generate`.
|
|
49
|
+
|
|
50
|
+
## How the frontend is served
|
|
51
|
+
|
|
52
|
+
`src/server.ts` builds two bundles with `Bun.build` at startup — `/app.js` (the
|
|
53
|
+
page) and `/worker.js` (the sync worker). Module workers do not inherit the
|
|
54
|
+
page's import map, so the `@sqlite.org/sqlite-wasm` bare specifier is rewritten
|
|
55
|
+
to the served `/vendor/sqlite-wasm/` path in both. For production you would
|
|
56
|
+
build these ahead of time and serve them statically.
|
|
57
|
+
|
|
58
|
+
## Next
|
|
59
|
+
|
|
60
|
+
- Want a terminal-only starting point? Scaffold the `minimal` template
|
|
61
|
+
(`bun create syncular-app my-app --template minimal`).
|
|
62
|
+
- The [docs](https://github.com/bkniffler/syncular) explain scopes, conflicts,
|
|
63
|
+
bootstrap, realtime, and blobs.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Your schema, one table to start. typegen reads this to derive the schema
|
|
2
|
+
-- SHAPE (column types, primary key); the server manages its own sync_* tables
|
|
3
|
+
-- and never runs this migration.
|
|
4
|
+
CREATE TABLE todos (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
list_id TEXT NOT NULL,
|
|
7
|
+
title TEXT NOT NULL,
|
|
8
|
+
done BOOLEAN NOT NULL,
|
|
9
|
+
position INTEGER NOT NULL,
|
|
10
|
+
updated_at_ms INTEGER NOT NULL
|
|
11
|
+
);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PROJECT_NAME__",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "bun run src/server.ts",
|
|
7
|
+
"generate": "syncular generate --manifest-dir .",
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
12
|
+
"@syncular/core": "workspace:*",
|
|
13
|
+
"@syncular/server": "workspace:*",
|
|
14
|
+
"@syncular/server-hono": "workspace:*",
|
|
15
|
+
"@syncular/client": "workspace:*"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@syncular/typegen": "workspace:*",
|
|
19
|
+
"@types/bun": "latest",
|
|
20
|
+
"typescript": "^5.9.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>__PROJECT_NAME__</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; }
|
|
9
|
+
body { margin: 0; padding: 2rem; max-width: 32rem; margin-inline: auto; }
|
|
10
|
+
header { display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
|
11
|
+
header h1 { font-size: 1.2rem; margin: 0; }
|
|
12
|
+
.badge { font-size: 0.7rem; padding: 0.1rem 0.45rem; border-radius: 999px; border: 1px solid currentColor; opacity: 0.7; }
|
|
13
|
+
#status { font-size: 0.8rem; opacity: 0.7; min-height: 1.1em; margin: 0 0 1rem; }
|
|
14
|
+
form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
|
15
|
+
form input { flex: 1; padding: 0.4rem 0.6rem; }
|
|
16
|
+
ul { list-style: none; margin: 0; padding: 0; }
|
|
17
|
+
li { display: flex; align-items: center; gap: 0.6rem; padding: 0.4rem 0; border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); }
|
|
18
|
+
li .title { flex: 1; }
|
|
19
|
+
li.done .title { text-decoration: line-through; opacity: 0.55; }
|
|
20
|
+
button { cursor: pointer; }
|
|
21
|
+
</style>
|
|
22
|
+
</head>
|
|
23
|
+
<body>
|
|
24
|
+
<header>
|
|
25
|
+
<h1>__PROJECT_NAME__</h1>
|
|
26
|
+
<span id="outbox" class="badge">outbox 0</span>
|
|
27
|
+
<button id="offline-btn">Go offline</button>
|
|
28
|
+
</header>
|
|
29
|
+
<p id="status">starting…</p>
|
|
30
|
+
<form id="add-form">
|
|
31
|
+
<input id="add-input" placeholder="Add a todo…" autocomplete="off" />
|
|
32
|
+
<button type="submit">Add</button>
|
|
33
|
+
</form>
|
|
34
|
+
<ul id="todos"></ul>
|
|
35
|
+
<script type="module" src="/app.js"></script>
|
|
36
|
+
</body>
|
|
37
|
+
</html>
|