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 +39 -0
- package/dist/cli.js +197 -0
- package/package.json +47 -0
- package/template/README.md +122 -0
- package/template/_gitignore +5 -0
- package/template/generated/kotlin/SyncularApp.kt +1005 -0
- package/template/generated/rust/diesel_tables.rs +142 -0
- package/template/generated/rust/migrations.rs +32 -0
- package/template/generated/rust/schema.rs +15 -0
- package/template/generated/rust/syncular.rs +926 -0
- package/template/generated/swift/SyncularApp.swift +1191 -0
- package/template/generated/syncular.codegen.json +19 -0
- package/template/index.html +13 -0
- package/template/migrations/0001_initial/down.sql +1 -0
- package/template/migrations/0001_initial/up.sql +8 -0
- package/template/package.json +33 -0
- package/template/scripts/dev.ts +42 -0
- package/template/src/app.tsx +231 -0
- package/template/src/client/syncular.ts +61 -0
- package/template/src/generated/syncular.generated.ts +769 -0
- package/template/src/generated/syncular.server.generated.ts +512 -0
- package/template/src/main.tsx +5 -0
- package/template/src/server/sync-server.ts +129 -0
- package/template/src/styles.css +233 -0
- package/template/syncular.app.ts +23 -0
- package/template/syncular.schema.json +145 -0
- package/template/tsconfig.json +18 -0
- package/template/vite.config.ts +9 -0
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
|