@kazzle/app 0.1.693

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 (103) hide show
  1. package/README.md +63 -0
  2. package/dist/client.d.ts +25 -0
  3. package/dist/client.js +83 -0
  4. package/dist/index.d.ts +86 -0
  5. package/dist/index.js +15 -0
  6. package/dist/migrations.d.ts +19 -0
  7. package/dist/migrations.js +74 -0
  8. package/dist/templates.d.ts +33 -0
  9. package/dist/templates.js +89 -0
  10. package/dist/tools.d.ts +36 -0
  11. package/dist/tools.js +22 -0
  12. package/dist/vite.d.ts +29 -0
  13. package/dist/vite.js +77 -0
  14. package/package.json +47 -0
  15. package/templates/ai-app/KAZZLE.md +21 -0
  16. package/templates/ai-app/bun.lock +290 -0
  17. package/templates/ai-app/components/server/index.ts +238 -0
  18. package/templates/ai-app/components/server/package.json +20 -0
  19. package/templates/ai-app/components/server/tsconfig.json +11 -0
  20. package/templates/ai-app/components/ui/index.html +12 -0
  21. package/templates/ai-app/components/ui/package.json +28 -0
  22. package/templates/ai-app/components/ui/public/favicon.svg +4 -0
  23. package/templates/ai-app/components/ui/src/App.tsx +52 -0
  24. package/templates/ai-app/components/ui/src/api.ts +85 -0
  25. package/templates/ai-app/components/ui/src/main.tsx +11 -0
  26. package/templates/ai-app/components/ui/src/styles/app.css +210 -0
  27. package/templates/ai-app/components/ui/src/styles/theme.css +181 -0
  28. package/templates/ai-app/components/ui/src/tabs/chat-tab.tsx +75 -0
  29. package/templates/ai-app/components/ui/src/tabs/extract-tab.tsx +92 -0
  30. package/templates/ai-app/components/ui/src/tabs/image-tab.tsx +51 -0
  31. package/templates/ai-app/components/ui/src/tabs/speech-tab.tsx +47 -0
  32. package/templates/ai-app/components/ui/src/tabs/transcribe-tab.tsx +42 -0
  33. package/templates/ai-app/components/ui/src/tabs/video-tab.tsx +76 -0
  34. package/templates/ai-app/components/ui/src/types.ts +53 -0
  35. package/templates/ai-app/components/ui/src/vite-env.d.ts +1 -0
  36. package/templates/ai-app/components/ui/tsconfig.json +21 -0
  37. package/templates/ai-app/components/ui/vite.config.ts +22 -0
  38. package/templates/ai-app/kazzle.config.ts +9 -0
  39. package/templates/ai-app/package.json +17 -0
  40. package/templates/empty-app/KAZZLE.md +10 -0
  41. package/templates/empty-app/kazzle.config.ts +8 -0
  42. package/templates/empty-app/package.json +15 -0
  43. package/templates/manifest.json +10 -0
  44. package/templates/process-app/KAZZLE.md +10 -0
  45. package/templates/process-app/components/process/index.ts +155 -0
  46. package/templates/process-app/components/process/package.json +20 -0
  47. package/templates/process-app/components/process/tsconfig.json +12 -0
  48. package/templates/process-app/kazzle.config.ts +32 -0
  49. package/templates/process-app/package.json +18 -0
  50. package/templates/process-app/skills/tools/SKILL.md +37 -0
  51. package/templates/process-app/skills/tools/tools.ts +19 -0
  52. package/templates/realtime-app/KAZZLE.md +11 -0
  53. package/templates/realtime-app/bun.lock +887 -0
  54. package/templates/realtime-app/components/server/deploy.ts +105 -0
  55. package/templates/realtime-app/components/server/index.ts +40 -0
  56. package/templates/realtime-app/components/server/migrations/001_items.sql +6 -0
  57. package/templates/realtime-app/components/server/package.json +20 -0
  58. package/templates/realtime-app/components/server/routes.ts +137 -0
  59. package/templates/realtime-app/components/server/tsconfig.json +15 -0
  60. package/templates/realtime-app/components/ui/index.html +13 -0
  61. package/templates/realtime-app/components/ui/package.json +36 -0
  62. package/templates/realtime-app/components/ui/public/favicon.svg +4 -0
  63. package/templates/realtime-app/components/ui/src/App.tsx +77 -0
  64. package/templates/realtime-app/components/ui/src/lib/connector.ts +80 -0
  65. package/templates/realtime-app/components/ui/src/lib/schema.ts +35 -0
  66. package/templates/realtime-app/components/ui/src/lib/sync.ts +24 -0
  67. package/templates/realtime-app/components/ui/src/main.tsx +49 -0
  68. package/templates/realtime-app/components/ui/src/styles/theme.css +181 -0
  69. package/templates/realtime-app/components/ui/src/vite-env.d.ts +2 -0
  70. package/templates/realtime-app/components/ui/tsconfig.json +14 -0
  71. package/templates/realtime-app/components/ui/vite.config.ts +86 -0
  72. package/templates/realtime-app/kazzle.config.ts +11 -0
  73. package/templates/realtime-app/package.json +17 -0
  74. package/templates/shared/icon.svg +4 -0
  75. package/templates/ui-app/KAZZLE.md +16 -0
  76. package/templates/ui-app/components/ui/bun.lock +894 -0
  77. package/templates/ui-app/components/ui/index.html +13 -0
  78. package/templates/ui-app/components/ui/package.json +30 -0
  79. package/templates/ui-app/components/ui/public/favicon.svg +4 -0
  80. package/templates/ui-app/components/ui/src/App.tsx +41 -0
  81. package/templates/ui-app/components/ui/src/main.tsx +24 -0
  82. package/templates/ui-app/components/ui/src/styles/theme.css +181 -0
  83. package/templates/ui-app/components/ui/tsconfig.json +21 -0
  84. package/templates/ui-app/components/ui/vite.config.ts +97 -0
  85. package/templates/ui-app/kazzle.config.ts +10 -0
  86. package/templates/ui-app/package.json +16 -0
  87. package/templates/ui-db-app/KAZZLE.md +12 -0
  88. package/templates/ui-db-app/components/server/index.ts +218 -0
  89. package/templates/ui-db-app/components/server/package.json +21 -0
  90. package/templates/ui-db-app/components/server/tsconfig.json +11 -0
  91. package/templates/ui-db-app/components/ui/index.html +12 -0
  92. package/templates/ui-db-app/components/ui/package.json +28 -0
  93. package/templates/ui-db-app/components/ui/public/favicon.svg +4 -0
  94. package/templates/ui-db-app/components/ui/src/App.tsx +221 -0
  95. package/templates/ui-db-app/components/ui/src/api.ts +79 -0
  96. package/templates/ui-db-app/components/ui/src/main.tsx +10 -0
  97. package/templates/ui-db-app/components/ui/src/styles/theme.css +181 -0
  98. package/templates/ui-db-app/components/ui/src/types.ts +17 -0
  99. package/templates/ui-db-app/components/ui/src/vite-env.d.ts +1 -0
  100. package/templates/ui-db-app/components/ui/tsconfig.json +21 -0
  101. package/templates/ui-db-app/components/ui/vite.config.ts +19 -0
  102. package/templates/ui-db-app/kazzle.config.ts +9 -0
  103. package/templates/ui-db-app/package.json +17 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Boot-time deploy step for the server component.
3
+ *
4
+ * Two jobs, both idempotent:
5
+ *
6
+ * 1. Apply pending SQL migrations from `migrations/` so the database schema
7
+ * is up-to-date before the HTTP server starts taking traffic. Migration
8
+ * failure is FATAL — we'd rather crash than serve traffic against a
9
+ * half-migrated database.
10
+ * 2. Tell the sync service which tables to replicate to clients. We do this
11
+ * from inside the app — not from a deploy pipeline — so a new table
12
+ * becomes syncable the moment its migration lands. There's no separate
13
+ * "sync config" file to keep in sync with the schema. Sync rules push
14
+ * failure is NON-FATAL: the app still boots and serves HTTP, sync just
15
+ * doesn't pick up schema changes from this boot. The platform retries.
16
+ *
17
+ * The whole function is safe to re-run on every process start. Migrations
18
+ * track their own state in `migrations.applied` (created by 001_init); the
19
+ * sync rules deploy replaces the existing rule set atomically.
20
+ */
21
+
22
+ import postgres from 'postgres';
23
+ import { readdirSync, existsSync } from 'fs';
24
+ import { join } from 'path';
25
+
26
+ export async function deploy() {
27
+ // Use the direct (non-pooled) connection string. Migrations may run DDL
28
+ // that PgBouncer's transaction pooling can't handle, and the connection is
29
+ // closed immediately afterwards so we don't need pooling here anyway.
30
+ const sql = postgres(process.env.DIRECT_DATABASE_URL!);
31
+
32
+ // ─── 1. Migrations ───────────────────────────────────────────────────────
33
+ // Convention: numbered `.sql` files in `migrations/`, sorted lexically.
34
+ // Each file is one transaction. To add a migration: create
35
+ // `migrations/002_<description>.sql` and ship — it will run on next boot.
36
+ const migrationsDir = join(import.meta.dir, 'migrations');
37
+ if (existsSync(migrationsDir)) {
38
+ const files = readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
39
+ for (const file of files) {
40
+ const content = await Bun.file(join(migrationsDir, file)).text();
41
+ await sql.unsafe(content);
42
+ console.log(`[deploy] Applied migration: ${file}`);
43
+ }
44
+ }
45
+
46
+ // ─── 2. Sync rules ───────────────────────────────────────────────────────
47
+ // Introspect every base table in the `public` schema. The sync service is
48
+ // told to replicate all of them to every authenticated client.
49
+ //
50
+ // If you need fine-grained access control (e.g. each user only sees their
51
+ // own rows), replace the wildcard `SELECT *` below with a query that
52
+ // references `auth.user_id()` — the sync service evaluates that function
53
+ // per-connection using the JWT's `sub` claim. Example:
54
+ //
55
+ // SELECT * FROM notes WHERE owner_id = auth.user_id()
56
+ const tables = await sql`
57
+ SELECT table_name FROM information_schema.tables
58
+ WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
59
+ AND table_name NOT IN ('powersync', 'pg_stat_statements')
60
+ `;
61
+
62
+ if (tables.length === 0) {
63
+ console.log('[deploy] No tables found — skipping sync streams');
64
+ await sql.end();
65
+ return;
66
+ }
67
+
68
+ // Sync rules are written in YAML and posted to the sync service's admin
69
+ // API. "edition: 3" is the current rules format. The `auto_subscribe: true`
70
+ // flag means clients receive these tables without having to explicitly
71
+ // subscribe — appropriate for a small app with a single rule group.
72
+ const queries = tables.map(t => ` - SELECT * FROM ${t.table_name}`).join('\n');
73
+ const streamsYaml = `config:\n edition: 3\nstreams:\n app_data:\n auto_subscribe: true\n queries:\n${queries}`;
74
+
75
+ // NON-FATAL: if the sync service is unreachable (DNS, network, dead Fly
76
+ // app, etc.) the app still boots and serves HTTP. Sync just won't pick up
77
+ // schema changes from this particular boot. Holding the entire app hostage
78
+ // on a sync push failure means a single dead PowerSync instance makes the
79
+ // whole product look broken — UI shell included.
80
+ try {
81
+ const res = await fetch(`${process.env.APP_SYNC_URL}/api/sync-rules/v1/deploy`, {
82
+ method: 'POST',
83
+ headers: {
84
+ 'Content-Type': 'application/yaml',
85
+ // The admin token is a shared secret between this app and its sync
86
+ // service — different from the per-client JWTs minted in routes.ts.
87
+ 'Authorization': `Bearer ${process.env.APP_SYNC_API_TOKEN}`,
88
+ },
89
+ body: streamsYaml,
90
+ signal: AbortSignal.timeout(10_000),
91
+ });
92
+
93
+ if (!res.ok) {
94
+ const body = await res.text();
95
+ console.warn(`[deploy] Sync streams deploy failed (HTTP ${res.status}): ${body}. Continuing without re-deploying sync rules.`);
96
+ } else {
97
+ console.log('[deploy] Sync streams deployed successfully');
98
+ }
99
+ } catch (err) {
100
+ const message = err instanceof Error ? err.message : String(err);
101
+ console.warn(`[deploy] Sync streams deploy failed: ${message}. Continuing without re-deploying sync rules.`);
102
+ }
103
+
104
+ await sql.end();
105
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * {{APP_NAME}} — server component entry point.
3
+ *
4
+ * Order matters here. We run `deploy()` to completion BEFORE starting the
5
+ * HTTP server so the schema is current before we accept traffic. Migrations
6
+ * are fatal: if they throw, the process crashes rather than serving against
7
+ * a half-migrated database. Sync rules push is non-fatal — see deploy.ts.
8
+ *
9
+ * `await` at the top level requires `"type": "module"` in package.json
10
+ * (Bun and Node both support this). The platform supervisor restarts the
11
+ * process if `deploy()` throws.
12
+ */
13
+
14
+ import { deploy } from './deploy';
15
+ import { routes } from './routes';
16
+
17
+ // PORT and HOST come from the platform when running under `kazzle run`.
18
+ // The fallback (3001 / 127.0.0.1) is only for direct `bun run index.ts`
19
+ // invocations during local hacking.
20
+ // ─────────────────────────────────────────────────────────────────────────
21
+ // DO NOT CHANGE THE NEXT 4 LINES. DO NOT ADD FALLBACKS. DO NOT HARDCODE.
22
+ // PORT and HOST are injected by `kazzle run`. Missing values must throw so
23
+ // the platform sees a fast, readable failure instead of binding to the
24
+ // wrong port.
25
+ // ─────────────────────────────────────────────────────────────────────────
26
+ if (!process.env.PORT || !process.env.HOST) {
27
+ throw new Error('PORT and HOST must be set by "kazzle run" — never hardcode them.');
28
+ }
29
+ const PORT = Number(process.env.PORT);
30
+ const HOST = process.env.HOST;
31
+
32
+ await deploy();
33
+
34
+ const server = Bun.serve({
35
+ hostname: HOST,
36
+ port: PORT,
37
+ fetch: routes,
38
+ });
39
+
40
+ console.log(`[{{APP_NAME}}] Server listening on ${server.hostname}:${server.port}`);
@@ -0,0 +1,6 @@
1
+ CREATE TABLE IF NOT EXISTS items (
2
+ id text PRIMARY KEY,
3
+ text text NOT NULL,
4
+ done integer NOT NULL DEFAULT 0,
5
+ created_at bigint NOT NULL
6
+ );
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "{{APP_SLUG}}-server",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "start": "bun run index.ts",
7
+ "dev": "kazzle run -- bun --watch index.ts",
8
+ "typecheck": "tsc --noEmit --allowImportingTsExtensions",
9
+ "check": "bun run typecheck"
10
+ },
11
+ "dependencies": {
12
+ "hono": "^4.12.16",
13
+ "jose": "^6.0.11",
14
+ "postgres": "^3.4.5"
15
+ },
16
+ "devDependencies": {
17
+ "@types/bun": "^1.3.6",
18
+ "typescript": "^5.8.3"
19
+ }
20
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * HTTP routes for the realtime app's server component.
3
+ *
4
+ * Two endpoints, both required by the offline-first sync engine running in the
5
+ * UI:
6
+ *
7
+ * GET /sync/token — hands the UI a short-lived signed JWT it uses to
8
+ * open a WebSocket to the sync service.
9
+ * POST /sync — receives batched client mutations (PUT/PATCH/DELETE)
10
+ * and writes them straight into Postgres. The sync
11
+ * service then streams them back out to every other
12
+ * connected client.
13
+ *
14
+ * Everything that talks to the database goes through `postgres.js`. We use the
15
+ * tagged-template form (sql`...`) deliberately — see the upload handler below
16
+ * for why.
17
+ */
18
+
19
+ import { importJWK, SignJWT } from 'jose';
20
+ import postgres from 'postgres';
21
+
22
+ // One module-level pool. The default settings are fine for a single Fly
23
+ // machine; postgres.js auto-commits each query at the end of execution, which
24
+ // is the behaviour we want for an autocommit CRUD endpoint.
25
+ const sql = postgres(process.env.DATABASE_URL!);
26
+
27
+ // The signing key is loaded lazily so a misconfigured env var doesn't crash
28
+ // the server before it can even serve /health. The kid travels with the JWT
29
+ // header so the sync service can pick the right verifier key from its JWKS.
30
+ let privateKey: CryptoKey;
31
+ let kid: string;
32
+
33
+ async function getSigningKey() {
34
+ if (privateKey) return { privateKey, kid };
35
+ const jwk = JSON.parse(process.env.APP_SYNC_SIGNING_KEY!);
36
+ kid = jwk.kid;
37
+ privateKey = await importJWK(jwk, 'EdDSA') as CryptoKey;
38
+ return { privateKey, kid };
39
+ }
40
+
41
+ export async function routes(req: Request): Promise<Response> {
42
+ const url = new URL(req.url);
43
+
44
+ // Liveness probe used by the platform supervisor. Must stay cheap and never
45
+ // touch the database — if the DB is briefly unreachable we still want the
46
+ // process to be considered "up" so the supervisor doesn't kill it.
47
+ if (url.pathname === '/health') {
48
+ return Response.json({ status: 'ok' });
49
+ }
50
+
51
+ // ─── Credentials endpoint ────────────────────────────────────────────────
52
+ //
53
+ // The UI's sync client calls this on boot and again whenever its token is
54
+ // about to expire. We mint a fresh JWT and tell the client which sync
55
+ // service URL to talk to.
56
+ //
57
+ // Important claims:
58
+ // - alg: EdDSA matches the signing key. The sync service's JWKS entry
59
+ // declares the same alg.
60
+ // - sub: every JWT verified by the sync service MUST carry a `sub`
61
+ // claim. The service rejects tokens without one (PSYNC_S2101).
62
+ // Use a stable identifier here — even a constant is fine for
63
+ // single-tenant apps; multi-tenant apps should use a real user id.
64
+ // - aud: the audience is the sync service URL. The service is
65
+ // configured with the same value; a mismatch means the JWT is
66
+ // accepted by us and rejected by it, which manifests as an endless
67
+ // reconnect loop in the browser.
68
+ // - exp: keep this short. The client refreshes well before expiry, so
69
+ // 1h is plenty.
70
+ if (url.pathname === '/sync/token') {
71
+ const { privateKey: key, kid: k } = await getSigningKey();
72
+ const token = await new SignJWT({})
73
+ .setProtectedHeader({ alg: 'EdDSA', kid: k })
74
+ .setSubject('app-user')
75
+ .setIssuedAt()
76
+ .setExpirationTime('1h')
77
+ .setAudience(process.env.APP_SYNC_URL!)
78
+ .sign(key);
79
+
80
+ return Response.json({ token, endpoint: process.env.APP_SYNC_URL });
81
+ }
82
+
83
+ // ─── Upload endpoint ─────────────────────────────────────────────────────
84
+ //
85
+ // The UI's sync client batches every local write into a transaction and
86
+ // POSTs the batch here. Each entry has:
87
+ // - table: the target table name
88
+ // - op: PUT | PATCH | DELETE
89
+ // - id: the row's primary key
90
+ // - data: for PUT/PATCH, the column values being set
91
+ //
92
+ // We MUST use postgres.js's tagged-template form (`sql\`...\``) for the
93
+ // actual SQL. The older `sql.unsafe(query, params)` API silently runs in
94
+ // an extended-query mode whose writes are not visible to other connections
95
+ // until the connection is reused — the route happily returns 200 but the
96
+ // row never shows up to anyone else. The tagged-template form auto-commits
97
+ // every query, which is what we want.
98
+ //
99
+ // The `sql(value, ...keys)` helper is context-aware: inside `INSERT INTO
100
+ // ... ${...}` it expands to `(col1, col2, ...) VALUES ('a', 'b', ...)`,
101
+ // inside `SET ${...}` it expands to `col1 = 'a', col2 = 'b'`. Same call,
102
+ // two meanings, both safe (values are sent as bind parameters).
103
+ if (req.method === 'POST' && url.pathname === '/sync') {
104
+ const { ops } = await req.json() as {
105
+ ops: Array<{ table: string; op: string; id: string; data: Record<string, unknown> }>;
106
+ };
107
+
108
+ for (const op of ops) {
109
+ if (op.op === 'PUT') {
110
+ // Build a single row object so the id participates in the INSERT
111
+ // column list. ON CONFLICT then needs to update everything *except*
112
+ // the primary key.
113
+ const row: Record<string, unknown> = { id: op.id, ...op.data };
114
+ const cols = Object.keys(row);
115
+ const updateCols = cols.filter(c => c !== 'id');
116
+ await (sql as unknown as (s: TemplateStringsArray, ...args: unknown[]) => Promise<unknown>)`
117
+ INSERT INTO ${sql(op.table)} ${sql(row, ...cols)}
118
+ ON CONFLICT (id) DO UPDATE SET ${sql(row, ...updateCols)}
119
+ `;
120
+ } else if (op.op === 'PATCH') {
121
+ const cols = Object.keys(op.data);
122
+ await (sql as unknown as (s: TemplateStringsArray, ...args: unknown[]) => Promise<unknown>)`
123
+ UPDATE ${sql(op.table)} SET ${sql(op.data, ...cols)} WHERE id = ${op.id}
124
+ `;
125
+ } else if (op.op === 'DELETE') {
126
+ await sql`DELETE FROM ${sql(op.table)} WHERE id = ${op.id}`;
127
+ }
128
+ }
129
+
130
+ // The sync client treats 200 as "this batch is durable, drop it from
131
+ // my local upload queue". If you ever need to push back (e.g. validation
132
+ // failed) return a non-2xx so the client retries.
133
+ return Response.json({ ok: true });
134
+ }
135
+
136
+ return new Response('Not found', { status: 404 });
137
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "allowImportingTsExtensions": true,
11
+ "noEmit": true,
12
+ "types": ["bun"]
13
+ },
14
+ "include": ["*.ts"]
15
+ }
@@ -0,0 +1,13 @@
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.0" />
6
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
+ <title>{{APP_NAME}}</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "kazzle-realtime-app-template",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "kazzle run -- vite",
8
+ "typecheck": "tsc --noEmit",
9
+ "build": "vite build",
10
+ "preview": "kazzle run -- vite preview",
11
+ "check": "bun run typecheck && bun run build"
12
+ },
13
+ "dependencies": {
14
+ "@kazzle/app": "{{KAZZLE_APP_VERSION}}",
15
+ "react": "^19.1.0",
16
+ "react-dom": "^19.1.0",
17
+ "@powersync/react": "1.10.0",
18
+ "@powersync/web": "1.37.1",
19
+ "jose": "^6.0.11",
20
+ "postgres": "^3.4.5",
21
+ "uuid": "^11.1.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^25.1.0",
25
+ "@types/react": "^19.1.0",
26
+ "@types/react-dom": "^19.1.0",
27
+ "@tailwindcss/vite": "^4.1.0",
28
+ "@types/uuid": "^10.0.0",
29
+ "@vitejs/plugin-react": "^4.5.2",
30
+ "tailwindcss": "^4.1.0",
31
+ "typescript": "^5.8.3",
32
+ "vite": "^6.3.5",
33
+ "vite-plugin-pwa": "^1.0.0",
34
+ "workbox-window": "^7.3.0"
35
+ }
36
+ }
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <rect width="64" height="64" rx="14" fill="hsl(230,65%,88%)"/>
3
+ <text x="32" y="46" text-anchor="middle" font-family="system-ui,sans-serif" font-size="40" font-weight="600" fill="hsl(230,50%,25%)">K</text>
4
+ </svg>
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Example UI component showing the read + write patterns customers should
3
+ * use throughout the rest of the app:
4
+ *
5
+ * - Reads use `useQuery`. The result is reactive — when any row matched
6
+ * by the query changes (locally or via a sync push from another client),
7
+ * the component re-renders automatically. There's no `useEffect`, no
8
+ * manual subscription, no fetch on mount.
9
+ * - Writes go straight to the local DB via `db.execute`. The call resolves
10
+ * as soon as the row is in local SQLite (milliseconds), and the upload
11
+ * to the server happens asynchronously in the background. The UI never
12
+ * waits on the network.
13
+ *
14
+ * IDs are generated client-side with `uuid()`. This is intentional: it lets
15
+ * the row exist locally before the server has heard about it, which is what
16
+ * makes offline-first work. The server upserts on the id we send.
17
+ */
18
+
19
+ import { useQuery } from '@powersync/react';
20
+ import { useState } from 'react';
21
+ import { v4 as uuid } from 'uuid';
22
+ import { db } from './lib/sync';
23
+
24
+ export default function App() {
25
+ // The SQL is plain SQLite. Joins, aggregates, ORDER BY — anything SQLite
26
+ // understands is fair game. Parameters can be passed as a second argument:
27
+ // `useQuery('SELECT * FROM items WHERE done = ?', [0])`.
28
+ const { data: items } = useQuery<{ id: string; text: string; done: number }>(
29
+ 'SELECT * FROM items ORDER BY created_at DESC',
30
+ );
31
+ const [draft, setDraft] = useState('');
32
+
33
+ const addItem = async (event: React.FormEvent) => {
34
+ event.preventDefault();
35
+ const text = draft.trim();
36
+ if (!text) return;
37
+ // INSERT writes to local SQLite. The connector's upload loop picks it
38
+ // up on the next tick and POSTs it to /sync.
39
+ await db.execute(
40
+ 'INSERT INTO items (id, text, done, created_at) VALUES (?, ?, ?, ?)',
41
+ [uuid(), text, 0, Date.now()],
42
+ );
43
+ setDraft('');
44
+ };
45
+
46
+ const toggle = async (id: string, done: number) => {
47
+ await db.execute('UPDATE items SET done = ? WHERE id = ?', [done ? 0 : 1, id]);
48
+ };
49
+
50
+ return (
51
+ <div className="page">
52
+ <h1>{{APP_NAME}}</h1>
53
+ {/* `add-form` / `add-input` are stable hooks used by the template smoke
54
+ test; styling comes from the `.row` / `.input` / `.btn` primitives. */}
55
+ <form className="add-form row" onSubmit={addItem}>
56
+ <input
57
+ className="add-input input flex-1"
58
+ value={draft}
59
+ onChange={event => setDraft(event.target.value)}
60
+ placeholder="Add an item"
61
+ />
62
+ <button className="btn btn-primary" type="submit">Add item</button>
63
+ </form>
64
+ <ul className="stack m-0 list-none p-0">
65
+ {items.map(item => (
66
+ <li
67
+ key={item.id}
68
+ className={`item card cursor-pointer ${item.done ? 'item-done muted line-through' : ''}`}
69
+ onClick={() => toggle(item.id, item.done)}
70
+ >
71
+ {item.text}
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ </div>
76
+ );
77
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The "connector" is the bridge between the local sync engine and the app's
3
+ * own server. The sync engine asks the connector two questions:
4
+ *
5
+ * 1. fetchCredentials() — "what JWT and endpoint should I use to open the
6
+ * sync WebSocket?" Called on first connect and again whenever the token
7
+ * is close to expiring.
8
+ * 2. uploadData() — "I've batched the user's pending local writes
9
+ * into a transaction. Send them somewhere durable and tell me when
10
+ * they're persisted."
11
+ *
12
+ * Everything else — reads, conflict resolution, retries, reconnection — is
13
+ * handled by the sync engine. The connector intentionally has no read path.
14
+ */
15
+
16
+ import {
17
+ type AbstractPowerSyncDatabase,
18
+ type PowerSyncBackendConnector,
19
+ type CrudEntry,
20
+ } from '@powersync/web';
21
+
22
+ // Endpoint paths can be overridden at build time via Vite env vars. In dev,
23
+ // the defaults work because `vite.config.ts` proxies `/sync*` to the server
24
+ // component. In prod, both surfaces are served from the same origin so the
25
+ // relative paths just work.
26
+ const TOKEN_ENDPOINT = import.meta.env.VITE_APP_SYNC_TOKEN_ENDPOINT || '/sync/token';
27
+ const SYNC_UPLOAD_ENDPOINT = import.meta.env.VITE_SYNC_UPLOAD_ENDPOINT || '/sync';
28
+
29
+ export class AppConnector implements PowerSyncBackendConnector {
30
+ /**
31
+ * Mint a fresh JWT by asking our own server. We never store the token —
32
+ * the sync engine caches it internally and calls back here when it needs
33
+ * a new one, so refresh-on-expiry is automatic.
34
+ */
35
+ async fetchCredentials() {
36
+ const res = await fetch(TOKEN_ENDPOINT);
37
+ if (!res.ok) throw new Error(`Token fetch failed: ${res.status}`);
38
+ const { token, endpoint } = await res.json();
39
+ return { endpoint, token };
40
+ }
41
+
42
+ /**
43
+ * Push one batch of local writes to the server.
44
+ *
45
+ * The sync engine guarantees:
46
+ * - We're only called when there's work to do.
47
+ * - Each `tx` is a coherent unit — if the engine called this with five
48
+ * ops and we ack four, the unacked one will be retried.
49
+ * - `tx.complete()` MUST be called only after the server has confirmed
50
+ * durability. If we ack before the POST succeeds, a crash here loses
51
+ * the user's write.
52
+ *
53
+ * If `uploadData` throws, the sync engine backs off and retries the same
54
+ * transaction later. That's the desired behaviour for transient errors
55
+ * (network blip, server restart). For permanent errors (validation
56
+ * failure, schema mismatch) you'd want to either tx.complete() to drop
57
+ * the bad write or surface it to the user — neither is implemented here
58
+ * because the server route accepts everything the schema allows.
59
+ */
60
+ async uploadData(database: AbstractPowerSyncDatabase) {
61
+ const tx = await database.getNextCrudTransaction();
62
+ if (!tx) return;
63
+
64
+ const ops = tx.crud.map((entry: CrudEntry) => ({
65
+ table: entry.table,
66
+ op: entry.op,
67
+ id: entry.id,
68
+ data: entry.opData,
69
+ }));
70
+
71
+ const res = await fetch(SYNC_UPLOAD_ENDPOINT, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({ ops }),
75
+ });
76
+
77
+ if (!res.ok) throw new Error(`Sync upload failed: ${res.status}`);
78
+ await tx.complete();
79
+ }
80
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Client-side schema declaration.
3
+ *
4
+ * This is the contract between the local sync engine and the Postgres tables
5
+ * defined in `components/server/migrations/`. The engine uses it to:
6
+ * - shape the local SQLite tables that mirror Postgres,
7
+ * - decide which incoming rows to accept,
8
+ * - type the results returned from `useQuery`.
9
+ *
10
+ * Rules of thumb:
11
+ * - Every Postgres table the UI reads from MUST appear here.
12
+ * - Column names must match the Postgres column names exactly.
13
+ * - Primary keys (`id`) are implicit — don't redeclare them.
14
+ * - SQLite has fewer types than Postgres. Use `column.text` for strings,
15
+ * `column.integer` for numbers and booleans (0/1), `column.real` for
16
+ * floats. Timestamps are stored as `integer` (epoch millis) for cheap
17
+ * comparisons.
18
+ *
19
+ * To add a new table:
20
+ * 1. Add the migration in `components/server/migrations/`.
21
+ * 2. Add a `new Table({...})` for it here.
22
+ * 3. Add it to the `new Schema({...})` call below.
23
+ * 4. Restart the server — `deploy.ts` will pick it up and tell the sync
24
+ * service to start replicating it.
25
+ */
26
+
27
+ import { column, Schema, Table } from '@powersync/web';
28
+
29
+ const items = new Table({
30
+ text: column.text,
31
+ done: column.integer,
32
+ created_at: column.integer,
33
+ });
34
+
35
+ export const appSchema = new Schema({ items });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Local sync database.
3
+ *
4
+ * This is the single instance every component reads from and writes to. Under
5
+ * the hood it's a SQLite database running in the browser (via WASM) that
6
+ * mirrors a subset of the server-side Postgres tables. Reads are local and
7
+ * synchronous-fast; writes go to the local DB first and are streamed to the
8
+ * server in the background by the connector.
9
+ *
10
+ * The schema here MUST match the columns declared in `schema.ts`. The local
11
+ * engine refuses to materialise rows whose columns it doesn't know about.
12
+ */
13
+
14
+ import { PowerSyncDatabase } from '@powersync/web';
15
+ import { appSchema } from './schema';
16
+
17
+ export const db = new PowerSyncDatabase({
18
+ schema: appSchema,
19
+ // `database.dbFilename` is the IndexedDB key used to persist the local
20
+ // SQLite file across reloads. Each app gets its own — namespaced by app
21
+ // name to avoid collisions when multiple apps run on the same origin
22
+ // during development.
23
+ database: { dbFilename: '{{APP_NAME}}.db' },
24
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * UI entry point.
3
+ *
4
+ * Boot order:
5
+ * 1. Mount React with the local sync database as context. The DB instance
6
+ * is already constructed (see `lib/sync.ts`) but its WASM SQLite engine
7
+ * hasn't been initialised yet — that happens asynchronously.
8
+ * 2. Kick off `db.init()` (open the local SQLite file) and `db.connect()`
9
+ * (open the WebSocket to the sync service). Both are fire-and-forget;
10
+ * the React tree renders immediately and `useQuery` calls return empty
11
+ * arrays until the local store is ready, then update reactively.
12
+ *
13
+ * Why we don't `await` these calls:
14
+ * - The local SQLite engine is loaded from a WASM module. On a cold cache
15
+ * it can take a second or two. Awaiting it here blocks the browser's
16
+ * first paint and the user sees a blank page for that whole window.
17
+ * - Connecting to the sync service requires a network round-trip to fetch
18
+ * a JWT and open a WebSocket. The UI should be visible and interactive
19
+ * long before that resolves.
20
+ *
21
+ * The PowerSync React adapter handles the "not yet ready" state correctly —
22
+ * components reading from the DB just see an empty result until it isn't.
23
+ */
24
+
25
+ import { StrictMode } from 'react';
26
+ import { createRoot } from 'react-dom/client';
27
+ import { PowerSyncContext } from '@powersync/react';
28
+ import { registerSW } from 'virtual:pwa-register';
29
+ import { db } from './lib/sync';
30
+ import { AppConnector } from './lib/connector';
31
+ import App from './App';
32
+ import './styles/theme.css';
33
+
34
+ db.init();
35
+ db.connect(new AppConnector());
36
+
37
+ // Register the service worker in production builds only. The plugin's
38
+ // `virtual:pwa-register` is a no-op in dev (devOptions.enabled: false).
39
+ // `autoUpdate` swaps to a new SW as soon as one is available; subsequent
40
+ // reloads serve from the new cache.
41
+ registerSW({ immediate: true });
42
+
43
+ createRoot(document.getElementById('root')!).render(
44
+ <StrictMode>
45
+ <PowerSyncContext.Provider value={db}>
46
+ <App />
47
+ </PowerSyncContext.Provider>
48
+ </StrictMode>
49
+ );