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.
Files changed (64) hide show
  1. package/README.md +70 -27
  2. package/dist/cli.d.ts +11 -0
  3. package/dist/cli.js +131 -179
  4. package/dist/constants.d.ts +40 -0
  5. package/dist/constants.js +46 -0
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.js +3 -0
  8. package/dist/scaffold.d.ts +44 -0
  9. package/dist/scaffold.js +106 -0
  10. package/package.json +22 -15
  11. package/src/cli.ts +174 -0
  12. package/src/constants.ts +53 -0
  13. package/src/index.ts +3 -0
  14. package/src/scaffold.ts +161 -0
  15. package/template/minimal/README.md +45 -0
  16. package/template/minimal/gitignore +5 -0
  17. package/template/minimal/migrations/0001_initial/up.sql +9 -0
  18. package/template/minimal/package.json +22 -0
  19. package/template/minimal/src/clients.ts +54 -0
  20. package/template/minimal/src/make-client.ts +26 -0
  21. package/template/minimal/src/server.ts +39 -0
  22. package/template/minimal/src/smoke.test.ts +70 -0
  23. package/template/minimal/src/syncular.generated.ts +66 -0
  24. package/template/minimal/syncular.ir.json +66 -0
  25. package/template/minimal/syncular.json +17 -0
  26. package/template/minimal/tsconfig.json +17 -0
  27. package/template/web/README.md +63 -0
  28. package/template/web/gitignore +5 -0
  29. package/template/web/migrations/0001_initial/up.sql +11 -0
  30. package/template/web/package.json +22 -0
  31. package/template/web/src/frontend/index.html +37 -0
  32. package/template/web/src/frontend/main.ts +191 -0
  33. package/template/web/src/frontend/worker.ts +9 -0
  34. package/template/web/src/server.ts +183 -0
  35. package/template/web/src/smoke.test.ts +91 -0
  36. package/template/web/src/syncular.generated.ts +74 -0
  37. package/template/web/syncular.ir.json +76 -0
  38. package/template/web/syncular.json +17 -0
  39. package/template/web/tsconfig.json +17 -0
  40. package/template/README.md +0 -122
  41. package/template/_gitignore +0 -5
  42. package/template/generated/kotlin/SyncularApp.kt +0 -1005
  43. package/template/generated/rust/diesel_tables.rs +0 -142
  44. package/template/generated/rust/migrations.rs +0 -32
  45. package/template/generated/rust/schema.rs +0 -15
  46. package/template/generated/rust/syncular.rs +0 -926
  47. package/template/generated/swift/SyncularApp.swift +0 -1191
  48. package/template/generated/syncular.codegen.json +0 -19
  49. package/template/index.html +0 -13
  50. package/template/migrations/0001_initial/down.sql +0 -1
  51. package/template/migrations/0001_initial/up.sql +0 -8
  52. package/template/package.json +0 -33
  53. package/template/scripts/dev.ts +0 -42
  54. package/template/src/app.tsx +0 -231
  55. package/template/src/client/syncular.ts +0 -61
  56. package/template/src/generated/syncular.generated.ts +0 -769
  57. package/template/src/generated/syncular.server.generated.ts +0 -512
  58. package/template/src/main.tsx +0 -5
  59. package/template/src/server/sync-server.ts +0 -129
  60. package/template/src/styles.css +0 -233
  61. package/template/syncular.app.ts +0 -23
  62. package/template/syncular.schema.json +0 -145
  63. package/template/tsconfig.json +0 -18
  64. 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,5 @@
1
+ node_modules/
2
+ *.log
3
+ .DS_Store
4
+ *.sqlite
5
+ *.sqlite-*
@@ -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>