@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.
- package/README.md +63 -0
- package/dist/client.d.ts +25 -0
- package/dist/client.js +83 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +15 -0
- package/dist/migrations.d.ts +19 -0
- package/dist/migrations.js +74 -0
- package/dist/templates.d.ts +33 -0
- package/dist/templates.js +89 -0
- package/dist/tools.d.ts +36 -0
- package/dist/tools.js +22 -0
- package/dist/vite.d.ts +29 -0
- package/dist/vite.js +77 -0
- package/package.json +47 -0
- package/templates/ai-app/KAZZLE.md +21 -0
- package/templates/ai-app/bun.lock +290 -0
- package/templates/ai-app/components/server/index.ts +238 -0
- package/templates/ai-app/components/server/package.json +20 -0
- package/templates/ai-app/components/server/tsconfig.json +11 -0
- package/templates/ai-app/components/ui/index.html +12 -0
- package/templates/ai-app/components/ui/package.json +28 -0
- package/templates/ai-app/components/ui/public/favicon.svg +4 -0
- package/templates/ai-app/components/ui/src/App.tsx +52 -0
- package/templates/ai-app/components/ui/src/api.ts +85 -0
- package/templates/ai-app/components/ui/src/main.tsx +11 -0
- package/templates/ai-app/components/ui/src/styles/app.css +210 -0
- package/templates/ai-app/components/ui/src/styles/theme.css +181 -0
- package/templates/ai-app/components/ui/src/tabs/chat-tab.tsx +75 -0
- package/templates/ai-app/components/ui/src/tabs/extract-tab.tsx +92 -0
- package/templates/ai-app/components/ui/src/tabs/image-tab.tsx +51 -0
- package/templates/ai-app/components/ui/src/tabs/speech-tab.tsx +47 -0
- package/templates/ai-app/components/ui/src/tabs/transcribe-tab.tsx +42 -0
- package/templates/ai-app/components/ui/src/tabs/video-tab.tsx +76 -0
- package/templates/ai-app/components/ui/src/types.ts +53 -0
- package/templates/ai-app/components/ui/src/vite-env.d.ts +1 -0
- package/templates/ai-app/components/ui/tsconfig.json +21 -0
- package/templates/ai-app/components/ui/vite.config.ts +22 -0
- package/templates/ai-app/kazzle.config.ts +9 -0
- package/templates/ai-app/package.json +17 -0
- package/templates/empty-app/KAZZLE.md +10 -0
- package/templates/empty-app/kazzle.config.ts +8 -0
- package/templates/empty-app/package.json +15 -0
- package/templates/manifest.json +10 -0
- package/templates/process-app/KAZZLE.md +10 -0
- package/templates/process-app/components/process/index.ts +155 -0
- package/templates/process-app/components/process/package.json +20 -0
- package/templates/process-app/components/process/tsconfig.json +12 -0
- package/templates/process-app/kazzle.config.ts +32 -0
- package/templates/process-app/package.json +18 -0
- package/templates/process-app/skills/tools/SKILL.md +37 -0
- package/templates/process-app/skills/tools/tools.ts +19 -0
- package/templates/realtime-app/KAZZLE.md +11 -0
- package/templates/realtime-app/bun.lock +887 -0
- package/templates/realtime-app/components/server/deploy.ts +105 -0
- package/templates/realtime-app/components/server/index.ts +40 -0
- package/templates/realtime-app/components/server/migrations/001_items.sql +6 -0
- package/templates/realtime-app/components/server/package.json +20 -0
- package/templates/realtime-app/components/server/routes.ts +137 -0
- package/templates/realtime-app/components/server/tsconfig.json +15 -0
- package/templates/realtime-app/components/ui/index.html +13 -0
- package/templates/realtime-app/components/ui/package.json +36 -0
- package/templates/realtime-app/components/ui/public/favicon.svg +4 -0
- package/templates/realtime-app/components/ui/src/App.tsx +77 -0
- package/templates/realtime-app/components/ui/src/lib/connector.ts +80 -0
- package/templates/realtime-app/components/ui/src/lib/schema.ts +35 -0
- package/templates/realtime-app/components/ui/src/lib/sync.ts +24 -0
- package/templates/realtime-app/components/ui/src/main.tsx +49 -0
- package/templates/realtime-app/components/ui/src/styles/theme.css +181 -0
- package/templates/realtime-app/components/ui/src/vite-env.d.ts +2 -0
- package/templates/realtime-app/components/ui/tsconfig.json +14 -0
- package/templates/realtime-app/components/ui/vite.config.ts +86 -0
- package/templates/realtime-app/kazzle.config.ts +11 -0
- package/templates/realtime-app/package.json +17 -0
- package/templates/shared/icon.svg +4 -0
- package/templates/ui-app/KAZZLE.md +16 -0
- package/templates/ui-app/components/ui/bun.lock +894 -0
- package/templates/ui-app/components/ui/index.html +13 -0
- package/templates/ui-app/components/ui/package.json +30 -0
- package/templates/ui-app/components/ui/public/favicon.svg +4 -0
- package/templates/ui-app/components/ui/src/App.tsx +41 -0
- package/templates/ui-app/components/ui/src/main.tsx +24 -0
- package/templates/ui-app/components/ui/src/styles/theme.css +181 -0
- package/templates/ui-app/components/ui/tsconfig.json +21 -0
- package/templates/ui-app/components/ui/vite.config.ts +97 -0
- package/templates/ui-app/kazzle.config.ts +10 -0
- package/templates/ui-app/package.json +16 -0
- package/templates/ui-db-app/KAZZLE.md +12 -0
- package/templates/ui-db-app/components/server/index.ts +218 -0
- package/templates/ui-db-app/components/server/package.json +21 -0
- package/templates/ui-db-app/components/server/tsconfig.json +11 -0
- package/templates/ui-db-app/components/ui/index.html +12 -0
- package/templates/ui-db-app/components/ui/package.json +28 -0
- package/templates/ui-db-app/components/ui/public/favicon.svg +4 -0
- package/templates/ui-db-app/components/ui/src/App.tsx +221 -0
- package/templates/ui-db-app/components/ui/src/api.ts +79 -0
- package/templates/ui-db-app/components/ui/src/main.tsx +10 -0
- package/templates/ui-db-app/components/ui/src/styles/theme.css +181 -0
- package/templates/ui-db-app/components/ui/src/types.ts +17 -0
- package/templates/ui-db-app/components/ui/src/vite-env.d.ts +1 -0
- package/templates/ui-db-app/components/ui/tsconfig.json +21 -0
- package/templates/ui-db-app/components/ui/vite.config.ts +19 -0
- package/templates/ui-db-app/kazzle.config.ts +9 -0
- 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,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
|
+
);
|