create-syncular-app 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # create-syncular-app
2
+
3
+ Scaffold a local-first [Syncular](https://syncular.dev) app in one command:
4
+
5
+ ```bash
6
+ bunx create-syncular-app my-app
7
+ # or
8
+ npm create syncular-app@latest my-app
9
+ pnpm create syncular-app my-app
10
+ ```
11
+
12
+ Then:
13
+
14
+ ```bash
15
+ cd my-app
16
+ bun install
17
+ bun dev
18
+ ```
19
+
20
+ You get a minimal, working local-first todo app:
21
+
22
+ - SQL schema in `migrations/`, mapped to subscriptions/scopes in
23
+ `syncular.app.ts`
24
+ - a committed, pre-generated TypeScript client (`src/generated/`) — no extra
25
+ toolchain needed to run; regenerate with `npx syncular generate`
26
+ - a Hono sync server on Bun + SQLite (`src/server/sync-server.ts`)
27
+ - a React UI on `@syncular/client/react` with live queries and offline-queued
28
+ mutations (`src/app.tsx`)
29
+
30
+ The scaffolded README explains the project layout and how to evolve the
31
+ schema.
32
+
33
+ ## Links
34
+
35
+ - Documentation: https://syncular.dev/docs
36
+ - GitHub: https://github.com/syncular/syncular
37
+ - Issues: https://github.com/syncular/syncular/issues
38
+
39
+ > Status: Alpha. APIs and storage layouts may change between releases.
package/dist/cli.js ADDED
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import {
5
+ cpSync,
6
+ existsSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ renameSync,
10
+ writeFileSync
11
+ } from "node:fs";
12
+ import { basename, join, resolve } from "node:path";
13
+ import { createInterface } from "node:readline/promises";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+ var FALLBACK_SYNCULAR_VERSION_RANGE = "^0.1.0";
16
+ function usage() {
17
+ return `usage: create-syncular-app <target-dir>
18
+
19
+ Scaffolds a local-first Syncular starter app (tasks table, Hono sync server,
20
+ React client) into <target-dir>.
21
+
22
+ examples:
23
+ bunx create-syncular-app my-app
24
+ npm create syncular-app@latest my-app
25
+ pnpm create syncular-app my-app
26
+ `;
27
+ }
28
+ function parseCreateCliArgs(argv) {
29
+ const options = { help: false };
30
+ for (const arg of argv) {
31
+ if (arg === "--help" || arg === "-h") {
32
+ options.help = true;
33
+ continue;
34
+ }
35
+ if (arg.startsWith("-")) {
36
+ throw new Error(`Unknown option: ${arg}
37
+
38
+ ${usage()}`);
39
+ }
40
+ if (options.targetDir !== undefined) {
41
+ throw new Error(`Unexpected extra argument: ${arg}
42
+
43
+ ${usage()}`);
44
+ }
45
+ options.targetDir = arg;
46
+ }
47
+ return options;
48
+ }
49
+ function packageNameFromDirectory(targetDir) {
50
+ const name = basename(resolve(targetDir)).toLowerCase().replace(/[^a-z0-9-_.~]+/g, "-").replace(/^[-._]+|[-._]+$/g, "");
51
+ return name.length > 0 ? name : "syncular-app";
52
+ }
53
+ function syncularDependencyRange(ownVersion) {
54
+ const version = ownVersion?.trim();
55
+ if (!version || version === "0.0.0") {
56
+ return FALLBACK_SYNCULAR_VERSION_RANGE;
57
+ }
58
+ return `^${version}`;
59
+ }
60
+ function isSyncularPackage(dependencyName) {
61
+ return dependencyName === "syncular" || dependencyName.startsWith("@syncular/");
62
+ }
63
+ function rewriteTemplatePackageJson(source, options) {
64
+ const pkg = JSON.parse(source);
65
+ pkg.name = options.packageName;
66
+ for (const section of [pkg.dependencies, pkg.devDependencies]) {
67
+ if (!section)
68
+ continue;
69
+ for (const [name, range] of Object.entries(section)) {
70
+ if (isSyncularPackage(name) && range.startsWith("workspace:")) {
71
+ section[name] = options.syncularRange;
72
+ }
73
+ }
74
+ }
75
+ return `${JSON.stringify(pkg, null, 2)}
76
+ `;
77
+ }
78
+ function detectPackageManager(userAgent = process.env.npm_config_user_agent) {
79
+ if (!userAgent)
80
+ return "bun";
81
+ if (userAgent.startsWith("bun"))
82
+ return "bun";
83
+ if (userAgent.startsWith("pnpm"))
84
+ return "pnpm";
85
+ if (userAgent.startsWith("yarn"))
86
+ return "yarn";
87
+ if (userAgent.startsWith("npm"))
88
+ return "npm";
89
+ return "bun";
90
+ }
91
+ function nextStepsMessage(targetDir, packageManager) {
92
+ const install = packageManager === "yarn" ? "yarn" : `${packageManager} install`;
93
+ const dev = packageManager === "bun" ? "bun dev" : `${packageManager} run dev`;
94
+ return `
95
+ Done. Next steps:
96
+
97
+ cd ${targetDir}
98
+ ${install}
99
+ ${dev}
100
+
101
+ The dev script starts the sync server (http://127.0.0.1:4100/health) and the
102
+ Vite client (http://127.0.0.1:5173). The app itself runs on Bun (the sync
103
+ server uses Bun.serve + bun:sqlite); install it from https://bun.sh if needed.
104
+
105
+ Read the generated README.md for the project layout and how to evolve the
106
+ schema.
107
+ `;
108
+ }
109
+ function readOwnVersion() {
110
+ try {
111
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
112
+ return packageJson.version;
113
+ } catch {
114
+ return;
115
+ }
116
+ }
117
+ function templateDirectory() {
118
+ return fileURLToPath(new URL("../template", import.meta.url));
119
+ }
120
+ function directoryIsEmpty(path) {
121
+ return readdirSync(path).length === 0;
122
+ }
123
+ async function promptTargetDir() {
124
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
125
+ try {
126
+ const answer = await rl.question("Target directory (e.g. my-app): ");
127
+ return answer.trim();
128
+ } finally {
129
+ rl.close();
130
+ }
131
+ }
132
+ function scaffoldApp(targetDirInput) {
133
+ const targetDir = resolve(targetDirInput);
134
+ const templateDir = templateDirectory();
135
+ if (!existsSync(templateDir)) {
136
+ throw new Error(`Bundled template not found at ${templateDir}`);
137
+ }
138
+ if (existsSync(targetDir) && !directoryIsEmpty(targetDir)) {
139
+ throw new Error(`Target directory is not empty: ${targetDir}`);
140
+ }
141
+ cpSync(templateDir, targetDir, { recursive: true });
142
+ const gitignorePlaceholder = join(targetDir, "_gitignore");
143
+ if (existsSync(gitignorePlaceholder)) {
144
+ renameSync(gitignorePlaceholder, join(targetDir, ".gitignore"));
145
+ }
146
+ const packageName = packageNameFromDirectory(targetDir);
147
+ const syncularRange = syncularDependencyRange(readOwnVersion());
148
+ const packageJsonPath = join(targetDir, "package.json");
149
+ writeFileSync(packageJsonPath, rewriteTemplatePackageJson(readFileSync(packageJsonPath, "utf8"), {
150
+ packageName,
151
+ syncularRange
152
+ }));
153
+ return { targetDir, packageName, syncularRange };
154
+ }
155
+ async function runCreateSyncularAppCli(argv = process.argv.slice(2)) {
156
+ try {
157
+ const options = parseCreateCliArgs(argv);
158
+ if (options.help) {
159
+ console.log(usage());
160
+ return 0;
161
+ }
162
+ let targetDirInput = options.targetDir;
163
+ if (!targetDirInput) {
164
+ targetDirInput = await promptTargetDir();
165
+ }
166
+ if (!targetDirInput) {
167
+ console.error("[create-syncular-app] A target directory is required.");
168
+ console.error(usage());
169
+ return 1;
170
+ }
171
+ const result = scaffoldApp(targetDirInput);
172
+ console.log(`[create-syncular-app] Scaffolded ${result.packageName} into ${result.targetDir} (Syncular packages at ${result.syncularRange}).`);
173
+ console.log(nextStepsMessage(targetDirInput, detectPackageManager()));
174
+ return 0;
175
+ } catch (error) {
176
+ const message = error instanceof Error ? error.message : String(error);
177
+ console.error(`[create-syncular-app] ${message}`);
178
+ return 1;
179
+ }
180
+ }
181
+ function isMainModule() {
182
+ const entrypoint = process.argv[1];
183
+ return entrypoint !== undefined && import.meta.url === pathToFileURL(entrypoint).href;
184
+ }
185
+ if (isMainModule()) {
186
+ process.exitCode = await runCreateSyncularAppCli();
187
+ }
188
+ export {
189
+ syncularDependencyRange,
190
+ scaffoldApp,
191
+ runCreateSyncularAppCli,
192
+ rewriteTemplatePackageJson,
193
+ parseCreateCliArgs,
194
+ packageNameFromDirectory,
195
+ nextStepsMessage,
196
+ detectPackageManager
197
+ };
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "create-syncular-app",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a local-first Syncular app (`bunx create-syncular-app my-app`)",
5
+ "license": "Apache-2.0",
6
+ "author": "Benjamin Kniffler",
7
+ "homepage": "https://syncular.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/syncular/syncular.git",
11
+ "directory": "packages/create-syncular-app"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/syncular/syncular/issues"
15
+ },
16
+ "keywords": [
17
+ "sync",
18
+ "offline-first",
19
+ "realtime",
20
+ "database",
21
+ "typescript",
22
+ "create",
23
+ "scaffold"
24
+ ],
25
+ "sideEffects": false,
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "type": "module",
30
+ "bin": {
31
+ "create-syncular-app": "./dist/cli.js"
32
+ },
33
+ "scripts": {
34
+ "tsgo": "tsgo --noEmit",
35
+ "build": "rm -rf dist && bun run build:cli",
36
+ "build:cli": "bun build ./src/cli.ts --target=node --format=esm --outfile=./dist/cli.js",
37
+ "smoke": "bun scripts/smoke.ts",
38
+ "release": "bun run build:cli && syncular-publish"
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "template"
43
+ ],
44
+ "devDependencies": {
45
+ "@syncular/config": "workspace:*"
46
+ }
47
+ }
@@ -0,0 +1,122 @@
1
+ # Syncular starter app
2
+
3
+ A minimal local-first todo app built with [Syncular](https://syncular.dev):
4
+ the client writes to its own SQLite database (persisted in IndexedDB) and
5
+ syncs with a Hono server over HTTP + WebSocket realtime. It works offline and
6
+ reconciles when the connection returns.
7
+
8
+ ## Run it
9
+
10
+ ```sh
11
+ bun install
12
+ bun dev
13
+ ```
14
+
15
+ This starts:
16
+
17
+ - the sync server on `http://127.0.0.1:4100` (health check at `/health`,
18
+ sync routes under `/sync`, data persisted in `data/sync.sqlite`)
19
+ - Vite on `http://127.0.0.1:5173`
20
+
21
+ Open the app in two browser windows and watch edits replicate. Stop the dev
22
+ server while the page stays open: edits queue locally and sync when the
23
+ server is back.
24
+
25
+ > **Why Bun?** The dev script and sync server run on Bun (`Bun.serve`,
26
+ > `bun:sqlite` via `@syncular/server/bun-sqlite`) because Bun runs
27
+ > TypeScript directly and ships SQLite with zero native build steps — one
28
+ > runtime for the server, scripts and tooling. The browser client and the
29
+ > Vite build are runtime-agnostic. If you need a Node server instead, swap
30
+ > the dialect for `@syncular/server/better-sqlite3` and serve the Hono app
31
+ > with `@hono/node-server` + `@hono/node-ws`.
32
+
33
+ ## Project structure
34
+
35
+ ```
36
+ migrations/ SQL schema, one folder per migration (up.sql/down.sql)
37
+ syncular.app.ts App contract: tables -> subscriptions + scopes
38
+ generated/ Codegen handoff (syncular.codegen.json, Rust/Swift/Kotlin)
39
+ src/generated/ Generated TypeScript client + server modules (committed)
40
+ src/server/ Hono sync server (auth, handlers, server-side tables)
41
+ src/client/syncular.ts Client wiring: local DB, sync lifecycle, managed client
42
+ src/app.tsx React UI built on @syncular/client/react hooks
43
+ scripts/dev.ts Runs sync server + Vite together
44
+ ```
45
+
46
+ How a change flows: `useMutations().tasks.insert(...)` writes to the local
47
+ SQLite database and queues an outbox entry → the sync engine pushes it to
48
+ `/sync` → the server handler authorizes it against your scopes and assigns a
49
+ `server_version` → other subscribed clients receive it over the WebSocket and
50
+ `useSyncQuery` re-renders.
51
+
52
+ ## Regenerating the client (`bun run codegen`)
53
+
54
+ `src/generated/` is **committed**, so the app installs and runs without any
55
+ extra toolchain. Regenerate it after changing `migrations/` or
56
+ `syncular.app.ts`:
57
+
58
+ ```sh
59
+ bun run codegen # npx syncular generate
60
+ bun run codegen:check # CI: verify committed output is current
61
+ ```
62
+
63
+ `syncular generate` needs two tools:
64
+
65
+ - **Bun** — runs the TypeScript manifest step (`syncular-typegen`).
66
+ - **Rust (cargo)** — the generator itself is the `syncular-codegen` crate.
67
+ On first run the CLI installs it automatically with
68
+ `cargo install syncular-codegen --locked` into a local cache (get Rust from
69
+ https://rustup.rs). No Rust is needed to run the app itself.
70
+
71
+ ## Next steps
72
+
73
+ ### Add a table
74
+
75
+ 1. Add a migration, e.g. `migrations/0002_projects/up.sql`:
76
+
77
+ ```sql
78
+ CREATE TABLE IF NOT EXISTS projects (
79
+ id TEXT PRIMARY KEY,
80
+ name TEXT NOT NULL,
81
+ user_id TEXT NOT NULL,
82
+ server_version BIGINT NOT NULL DEFAULT 0
83
+ ) WITHOUT ROWID;
84
+ ```
85
+
86
+ (plus a `down.sql` with `DROP TABLE IF EXISTS projects;`)
87
+
88
+ 2. Register it in `syncular.app.ts`:
89
+
90
+ ```ts
91
+ projects: syncedTable({
92
+ table: 'projects',
93
+ subscriptionId: 'sub-projects',
94
+ scopes: [scope('user_id', { source: 'actorId', required: true })],
95
+ serverVersion: 'server_version',
96
+ sqliteWithoutRowid: true,
97
+ }),
98
+ ```
99
+
100
+ 3. Run `bun run codegen`, then mirror the table on the server: add a
101
+ `createServerHandler` for `projects` in `src/server/sync-server.ts` and a
102
+ matching `createTable` in `ensureAppTables`. Subscribe the client by
103
+ adding `projectSubscription({ actorId })` in `src/client/syncular.ts`.
104
+
105
+ ### Change scopes
106
+
107
+ Scopes decide which rows each user receives. `scope('user_id', { source:
108
+ 'actorId' })` means "sync rows whose `user_id` equals the signed-in actor".
109
+ For shared data, scope by a different column (e.g. `team_id`) and return the
110
+ user's teams from `resolveScopes` on the server.
111
+
112
+ ### Real auth
113
+
114
+ The starter authenticates every request as one demo user with a static
115
+ token. Replace `authenticate` in `src/server/sync-server.ts` with your
116
+ session/JWT validation, and have `src/client/syncular.ts` send your real
117
+ token and the signed-in user's id as `actorId`.
118
+
119
+ ## Learn more
120
+
121
+ - Docs: https://syncular.dev/docs
122
+ - CLI reference: https://syncular.dev/docs/reference/cli
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ dist
3
+ data
4
+ *.log
5
+ .DS_Store