From 60c819b38e36243448100007d5ce59c4903bcbea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 01:02:46 +0000 Subject: [PATCH 01/12] Local development: createBoolClient apiKey + the `bool` CLI (link, types, entities, deploy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK half of using a Bool project as a managed backend from your own machine (the platform half adds the connection/types/drops endpoints). - createBoolClient({ ..., apiKey }): a boolsk_/boolk_ data key rides the api_key header on every gateway call (db/users/ai), so the client works from Node, a local Vite app, or CI. No apiKey → behavior unchanged. - New zero-dep CLI, bin `bool` (dist/cli-entry.js): - link --project : fetches the connection descriptor, writes bool.config.json, puts the owner-only admin key in .env.bool (gitignored), pulls entity types. - types: refreshes bool/types.d.ts from the project's entity schemas. - entities: prints the declared models + fields. - deploy: zips the source (store-only zip, node_modules/.git/env excluded) and publishes via the drop pipeline, polling to ready/failed. - Tests: cli.test.ts (hermetic, stubbed fetch + temp dirs), zip.test.ts (structure + CRC vector + optional unzip round-trip), client.test.ts local- development block (api_key header on all three planes). - 0.2.0-next.11, changelog + README section. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011zKXrczjUj4pce5BH9Y1HL --- CHANGELOG.md | 36 +++- README.md | 42 +++- package.json | 7 +- src/cli-entry.ts | 6 + src/cli.test.ts | 271 ++++++++++++++++++++++++++ src/cli.ts | 455 +++++++++++++++++++++++++++++++++++++++++++ src/client.test.ts | 36 ++++ src/client.ts | 14 ++ src/entities.test.ts | 2 +- src/entities.ts | 8 +- src/zip.test.ts | 71 +++++++ src/zip.ts | 106 ++++++++++ 12 files changed, 1041 insertions(+), 13 deletions(-) create mode 100644 src/cli-entry.ts create mode 100644 src/cli.test.ts create mode 100644 src/cli.ts create mode 100644 src/zip.test.ts create mode 100644 src/zip.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8887de2..4b214f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 0.2.0-next.11 + +Local development: use a Bool project as a managed backend from your own +machine, and publish back to Bool — without leaving your editor. + +- `createBoolClient({ ..., apiKey })` — a Bool data API key (`boolsk_` project + admin key, or a `boolk_` end-user key) is sent as the `api_key` header on + every gateway call (db, users, ai), so the client now works from anywhere: + Node scripts, a local Vite app, CI. Without `apiKey`, behavior is unchanged. +- New CLI (`npx bool-sdk `, zero dependencies): + - `link --project ` — connects a local folder to a Bool project. Writes + `bool.config.json` (public connection config), puts the project's admin + data key in `.env.bool` (gitignored; owner only), and pulls entity types. + - `types` — regenerates `bool/types.d.ts` from the project's entity schemas, + so `bool.entities.` is fully typed locally. + - `entities` — prints the project's declared entities + fields. + - `deploy` — zips the app source (node_modules/.git/env files excluded) and + publishes it on Bool via the drop pipeline: Bool builds in the cloud and + the project URL stays stable. + - Platform calls authenticate with a personal access token (`--token` or + `BOOL_TOKEN`). + +Requires the local-dev endpoints in the Bool platform repo +(`/api/projects/[id]/connection`, `/api/projects/[id]/entities/types`, +`POST /api/drops`). + ## 0.2.0-next.10 Adds `bool.ai` — the AI battery. A deployed app can call a model with NO API key @@ -40,7 +66,7 @@ already-created app on the stable `^0.1.0` range too. ## 0.2.0-next.8 -Adds per-user API keys (Base44 convention): the gateway's `/users/me` lazily +Adds per-user API keys: the gateway's `/users/me` lazily mints and returns a personal `api_key` for the signed-in end user. - `BoolUser.apiKey?: string` — typed access to the key. @@ -53,7 +79,7 @@ change that accepts `api_key` and stamps `sub` accordingly. ## 0.2.0-next.7 -- **Entities pagination cap raised 1000 → 5000, matching Base44.** `list` and +- **Entities pagination cap raised 1000 → 5000.** `list` and `filter` still page (50 rows by default) but now allow up to 5000 rows per call. A `limit` above the cap **throws** instead of silently truncating, so over-large reads fail loudly rather than returning a partial result the caller @@ -72,7 +98,7 @@ app renders its sign-in screen rather than a blank page. Adds a regression test. ## 0.2.0 -Adds the **entities data layer** — a Base44-parity data API over the gateway so +Adds the **entities data layer** — a high-level data API over the gateway so apps read/write data without touching Supabase, SQL, or credentials directly: ```ts @@ -82,7 +108,7 @@ await bool.entities.todos.update(one.id, { done: true }); await bool.entities.todos.filter({ status: "active", count: { $gte: 10 } }); ``` -`bool.entities.` mirrors Base44's entity surface one-to-one: +`bool.entities.
` exposes the full entity surface: - **Reads:** `list`, `filter`, `get` — with `sort` (`-col`), `limit`, `skip`, and `fields` (column selection). - **Writes:** `create`, `bulkCreate`, `update`, `bulkUpdate`, `delete`. @@ -96,7 +122,7 @@ await bool.entities.todos.filter({ status: "active", count: { $gte: 10 } }); Methods return row data directly and throw on error. Additive and backward-compatible — `bool.db` / `supabase` still work. -Known gaps vs. Base44 (documented, follow-ups): `updateMany` with +Known gaps (documented, follow-ups): `updateMany` with `$inc/$mul/$push/$pull` is read-modify-write (not atomic under concurrent writers — a Postgres RPC would make it atomic); `$size` (filter by array length) isn't expressible over PostgREST and is omitted. diff --git a/README.md b/README.md index 2ff47e8..f008db7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ tested, and upgradable independently of any one app. ## What it does - **Entities data API.** `client.entities.
` is the recommended way to - read/write data — a one-to-one mirror of Base44's entity surface: `list`, + read/write data — a familiar high-level entity surface: `list`, `filter`, `get`, `create`, `bulkCreate`, `update`, `bulkUpdate`, `updateMany`, `delete`, `deleteMany`, `importEntities`, `subscribe`. It hides Supabase/SQL entirely; methods return rows directly and throw on error: @@ -68,6 +68,46 @@ tested, and upgradable independently of any one app. `useBoolAuth()`, ``, and the headless `useSignInForm()` state machine that login forms bind to. +## Local development (Bool as a managed backend) + +You can also build an app on your OWN machine — your own editor, your own +framework, a coding agent — and use a Bool project as its backend (data, +end-user auth, AI), then publish it back to Bool. This is the one case where +you do install this package yourself. + +```sh +npm install bool-sdk +export BOOL_TOKEN=bool_live_… # personal access token (Bool → Settings) +npx bool link --project # writes bool.config.json + .env.bool + types +npx bool types # refresh bool/types.d.ts after schema changes +npx bool entities # list the project's data models +npx bool deploy # zip the source, Bool builds + hosts it +``` + +`link` fetches the project's connection config and (owner-only) its admin data +key. The key goes to `.env.bool` (gitignored) — it authorizes full data access +through the gateway, so treat it like any secret. Then: + +```ts +import { createBoolClient } from "bool-sdk"; +import config from "./bool.config.json"; + +export const bool = createBoolClient({ + supabaseUrl: config.supabaseUrl, + supabaseAnonKey: config.supabaseAnonKey, + schema: config.schema, + appOrigin: config.appOrigin, + slug: config.slug, + apiKey: process.env.BOOL_API_KEY, // from .env.bool +}); + +const todos = await bool.entities.todos.list(); // typed via bool/types.d.ts +``` + +Coding agents can do all of the above through Bool's MCP server instead +(`list_entities`, `define_entity`, `list_records`, `get_entity_types`, +`get_project_connection`, …) — see the platform docs. + ## Usage ```ts diff --git a/package.json b/package.json index 863d544..351a167 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,13 @@ { "name": "bool-sdk", - "version": "0.2.0-next.10", - "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI battery, and the React auth layer.", + "version": "0.2.0-next.11", + "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "bool": "./dist/cli-entry.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/src/cli-entry.ts b/src/cli-entry.ts new file mode 100644 index 0000000..a595fdb --- /dev/null +++ b/src/cli-entry.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// npm bin entry for the bool-sdk CLI (package.json "bin"). All logic lives in +// cli.ts so tests can drive it with stubbed deps. +import { runCli } from "./cli.js"; + +process.exit(await runCli(process.argv.slice(2))); diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..c7215e7 --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,271 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { CONFIG_FILE, ENV_FILE, collectDeployEntries, isDeployExcluded, parseArgs, runCli } from "./cli.js"; + +const CONNECTION = { + projectId: "p1", + name: "My App", + slug: "my-app", + schema: "app_abc123", + appOrigin: "https://bool.so", + appUrl: "https://my-app.bool.so", + supabaseUrl: "https://apps.supabase.test", + supabaseAnonKey: "anon-key", +}; + +type Call = { url: string; init?: RequestInit }; + +function makeDeps(cwd: string, routes: Record Response>) { + const calls: Call[] = []; + const logs: string[] = []; + const errors: string[] = []; + const deps = { + cwd, + env: { BOOL_TOKEN: "bool_live_test" } as Record, + log: (m: string) => logs.push(m), + error: (m: string) => errors.push(m), + sleep: async () => {}, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, init }); + const path = url.startsWith("http") ? new URL(url).pathname : url; + const handler = routes[path]; + if (!handler) return new Response(JSON.stringify({ error: "no route " + path }), { status: 404 }); + return handler(init); + }, + }; + return { deps, calls, logs, errors }; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +let cwd: string; +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "bool-cli-")); +}); + +describe("parseArgs", () => { + test("parses command and flag forms", () => { + expect(parseArgs(["link", "--project", "p1", "--api-url=https://x", "--verbose"])).toEqual({ + command: "link", + flags: { project: "p1", "api-url": "https://x", verbose: true }, + }); + }); +}); + +describe("link", () => { + test("writes config + env + gitignore and pulls types", async () => { + const { deps, calls, logs } = makeDeps(cwd, { + "/api/projects/p1/connection": () => json(CONNECTION), + "/api/projects/p1/api-key": () => json({ apiKey: "boolsk_secret" }), + "/api/projects/p1/entities/types": () => new Response("// types here"), + }); + const code = await runCli(["link", "--project", "p1", "--api-url", "https://bool.test"], deps); + expect(code).toBe(0); + + const config = JSON.parse(readFileSync(join(cwd, CONFIG_FILE), "utf8")); + expect(config).toMatchObject({ + projectId: "p1", + slug: "my-app", + apiUrl: "https://bool.test", + schema: "app_abc123", + supabaseAnonKey: "anon-key", + typesPath: "bool/types.d.ts", + }); + // The secret goes to .env.bool (gitignored), never into the config. + expect(JSON.stringify(config)).not.toContain("boolsk_secret"); + expect(readFileSync(join(cwd, ENV_FILE), "utf8")).toBe("BOOL_API_KEY=boolsk_secret\n"); + expect(readFileSync(join(cwd, ".gitignore"), "utf8")).toContain(ENV_FILE); + expect(readFileSync(join(cwd, "bool/types.d.ts"), "utf8")).toBe("// types here"); + + // Platform calls carry the PAT. + for (const c of calls) { + expect(new Headers(c.init?.headers).get("authorization")).toBe("Bearer bool_live_test"); + } + expect(logs.join("\n")).toContain("Linked to \"My App\""); + }); + + test("still links when the api key is owner-only (404)", async () => { + const { deps, logs } = makeDeps(cwd, { + "/api/projects/p1/connection": () => json(CONNECTION), + "/api/projects/p1/api-key": () => json({ error: "Not found" }, 404), + "/api/projects/p1/entities/types": () => new Response("// t"), + }); + expect(await runCli(["link", "--project", "p1"], deps)).toBe(0); + expect(existsSync(join(cwd, ENV_FILE))).toBe(false); + expect(logs.join("\n")).toContain("Skipped the admin data key"); + }); + + test("fails without a token", async () => { + const { deps, errors } = makeDeps(cwd, {}); + deps.env = {}; + expect(await runCli(["link", "--project", "p1"], deps)).toBe(1); + expect(errors.join("\n")).toContain("BOOL_TOKEN"); + }); + + test("surfaces a connection error (v1 project)", async () => { + const { deps, errors } = makeDeps(cwd, { + "/api/projects/p1/connection": () => + json({ error: "not_gateway_project", message: "This project runs on the v1 runtime" }, 409), + }); + expect(await runCli(["link", "--project", "p1"], deps)).toBe(1); + expect(errors.join("\n")).toContain("v1 runtime"); + }); +}); + +describe("types", () => { + test("refreshes the types file from config", async () => { + writeFileSync( + join(cwd, CONFIG_FILE), + JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test", typesPath: "bool/types.d.ts" }), + ); + const { deps } = makeDeps(cwd, { + "/api/projects/p1/entities/types": () => new Response("// fresh"), + }); + expect(await runCli(["types", "--out", "custom/entities.d.ts"], deps)).toBe(0); + expect(readFileSync(join(cwd, "custom/entities.d.ts"), "utf8")).toBe("// fresh"); + }); + + test("requires a link first", async () => { + const { deps, errors } = makeDeps(cwd, {}); + expect(await runCli(["types"], deps)).toBe(1); + expect(errors.join("\n")).toContain("bool link"); + }); +}); + +describe("deploy exclusions", () => { + test("excludes deps, VCS, build output, env files, and the link config", () => { + for (const p of [ + "node_modules/react/index.js", + ".git/HEAD", + "dist/bundle.js", + "build/x", + ".next/y", + ".env", + ".env.bool", + ".env.local", + "bool.config.json", + "src/.DS_Store", + ]) { + expect(isDeployExcluded(p)).toBe(true); + } + for (const p of ["index.html", "src/main.ts", "package.json", "bool/types.d.ts", "public/env-info.txt"]) { + expect(isDeployExcluded(p)).toBe(false); + } + }); + + test("collectDeployEntries walks the tree with exclusions applied", () => { + writeFileSync(join(cwd, "index.html"), ""); + mkdirSync(join(cwd, "src"), { recursive: true }); + writeFileSync(join(cwd, "src/main.ts"), "code"); + mkdirSync(join(cwd, "node_modules/x"), { recursive: true }); + writeFileSync(join(cwd, "node_modules/x/i.js"), "dep"); + writeFileSync(join(cwd, ".env.bool"), "BOOL_API_KEY=secret"); + writeFileSync(join(cwd, CONFIG_FILE), "{}"); + const paths = collectDeployEntries(cwd).map((e) => e.path); + expect(paths).toEqual(["index.html", "src/main.ts"]); + }); +}); + +describe("deploy", () => { + test("zips, uploads, polls to ready", async () => { + writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test" })); + writeFileSync(join(cwd, "index.html"), ""); + let polls = 0; + const { deps, calls, logs } = makeDeps(cwd, { + "/api/drops": (init) => { + expect(JSON.parse(String(init?.body))).toEqual({ project_id: "p1" }); + return json({ + drop_id: "d1", + upload_url: "https://storage.test/upload/d1", + upload_headers: { "Content-Type": "application/zip" }, + status_url: "https://bool.test/api/drops/d1/status?sig=x", + max_upload_size_bytes: 10_000_000, + }); + }, + "/upload/d1": () => new Response(null, { status: 200 }), + "/api/drops/d1/status": () => + ++polls < 3 + ? json({ status: "building", url: null, error: null }) + : json({ status: "ready", url: "https://my-app.bool.so", error: null }), + }); + expect(await runCli(["deploy"], deps)).toBe(0); + expect(polls).toBe(3); + // The upload PUT carried zip bytes. + const put = calls.find((c) => c.url.includes("/upload/d1"))!; + expect(put.init?.method).toBe("PUT"); + expect(logs.join("\n")).toContain("Live at https://my-app.bool.so"); + }); + + test("reports a failed build", async () => { + writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test" })); + writeFileSync(join(cwd, "index.html"), ""); + const { deps, errors } = makeDeps(cwd, { + "/api/drops": () => + json({ + drop_id: "d1", + upload_url: "https://storage.test/upload/d1", + upload_headers: {}, + status_url: "https://bool.test/api/drops/d1/status", + max_upload_size_bytes: 10_000_000, + }), + "/upload/d1": () => new Response(null, { status: 200 }), + "/api/drops/d1/status": () => + json({ status: "failed", url: null, error: { code: "BUILD_FAILED", message: "vite exited 1" } }), + }); + expect(await runCli(["deploy"], deps)).toBe(1); + expect(errors.join("\n")).toContain("vite exited 1"); + }); + + test("refuses a directory without index.html", async () => { + writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test" })); + const { deps, errors } = makeDeps(cwd, {}); + expect(await runCli(["deploy"], deps)).toBe(1); + expect(errors.join("\n")).toContain("index.html"); + }); +}); + +describe("entities", () => { + test("prints the entity reference", async () => { + writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test" })); + const { deps, logs } = makeDeps(cwd, { + "/api/projects/p1/entities": () => + json({ + entities: [ + { + name: "todos", + access: "private", + fields: [ + { name: "id", type: "string", required: true }, + { name: "title", type: "string", required: true }, + ], + }, + ], + }), + }); + expect(await runCli(["entities"], deps)).toBe(0); + const out = logs.join("\n"); + expect(out).toContain("todos (private)"); + expect(out).toContain("title: string (required)"); + }); +}); + +describe("help / unknown", () => { + test("no command prints usage and exits 1", async () => { + const { deps, logs } = makeDeps(cwd, {}); + expect(await runCli([], deps)).toBe(1); + expect(logs.join("\n")).toContain("Usage:"); + }); + test("unknown command errors", async () => { + const { deps, errors } = makeDeps(cwd, {}); + expect(await runCli(["frobnicate"], deps)).toBe(1); + expect(errors.join("\n")).toContain('Unknown command "frobnicate"'); + }); +}); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..e8ae1e2 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,455 @@ +// The Bool CLI (`bool`, shipped in the bool-sdk package): develop a LOCAL app +// against a Bool project as its managed backend, then publish it back to Bool. +// Run via `npx bool-sdk ` / `bunx bool-sdk`, or just `bool ` +// once installed. Zero dependencies — plain fetch + node:fs. +// +// bool link --project connect this folder to a Bool project: +// writes bool.config.json (public config), +// .env.bool (the secret BOOL_API_KEY), and +// pulls the generated entity types +// bool types refresh bool/types.d.ts from the project's +// entity schemas +// bool entities list the project's entities + fields +// bool deploy [--dir .] zip the app source and publish it on Bool +// (Bool builds in the cloud; the URL is stable) +// +// Auth: platform API calls (link/types/entities/deploy) use a personal access +// token — pass --token or set BOOL_TOKEN (create one in Bool → Settings → +// Access tokens). The app's DATA access uses the project api key `link` puts +// in .env.bool, which the app passes to createBoolClient as `apiKey`. + +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { createZip, type ZipEntry } from "./zip.js"; + +export const CONFIG_FILE = "bool.config.json"; +export const ENV_FILE = ".env.bool"; +const DEFAULT_API_URL = "https://bool.com"; +const DEFAULT_TYPES_PATH = "bool/types.d.ts"; + +/** The public, committable half of a link — everything createBoolClient needs + * except the secret api key (that lives in .env.bool / BOOL_API_KEY). */ +export type BoolConfig = { + projectId: string; + slug: string; + apiUrl: string; + appOrigin: string; + appUrl: string; + schema: string; + supabaseUrl: string; + supabaseAnonKey: string; + typesPath: string; +}; + +/** Injectable effects so tests run hermetically (stub fetch, pin cwd/env). + * `fetch` is a plain fetch-shaped function (not Bun's `typeof fetch`, which + * demands a `preconnect` property a stub doesn't have — same cast client.ts + * documents). */ +export type CliDeps = { + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; + cwd: string; + env: Record; + log: (msg: string) => void; + error: (msg: string) => void; + sleep: (ms: number) => Promise; +}; + +function defaults(): CliDeps { + return { + fetch: (...args) => fetch(...args), + cwd: process.cwd(), + env: process.env, + log: (m) => console.log(m), + error: (m) => console.error(m), + sleep: (ms) => new Promise((r) => setTimeout(r, ms)), + }; +} + +/** Tiny flag parser: `--key value` / `--key=value` / bare `--flag`. */ +export function parseArgs(argv: string[]): { + command: string | undefined; + flags: Record; +} { + const [command, ...rest] = argv; + const flags: Record = {}; + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]!; + if (!arg.startsWith("--")) continue; + const eq = arg.indexOf("="); + if (eq !== -1) { + flags[arg.slice(2, eq)] = arg.slice(eq + 1); + } else if (i + 1 < rest.length && !rest[i + 1]!.startsWith("--")) { + flags[arg.slice(2)] = rest[++i]!; + } else { + flags[arg.slice(2)] = true; + } + } + return { command, flags }; +} + +class CliError extends Error {} + +function str(v: string | boolean | undefined): string | undefined { + return typeof v === "string" && v !== "" ? v : undefined; +} + +function token(flags: Record, deps: CliDeps): string { + const t = str(flags.token) ?? deps.env.BOOL_TOKEN; + if (!t) { + throw new CliError( + "No access token. Pass --token or set BOOL_TOKEN (create one in Bool → Settings → Access tokens).", + ); + } + return t; +} + +async function api( + deps: CliDeps, + tok: string, + base: string, + path: string, +): Promise { + const res = await deps.fetch(`${base.replace(/\/$/, "")}${path}`, { + headers: { authorization: `Bearer ${tok}` }, + }); + return res; +} + +async function apiJson( + deps: CliDeps, + tok: string, + base: string, + path: string, +): Promise { + const res = await api(deps, tok, base, path); + const body = await res.json().catch(() => null); + if (!res.ok) { + const msg = + (body as { error?: string; message?: string } | null)?.message ?? + (body as { error?: string } | null)?.error ?? + `HTTP ${res.status}`; + throw new CliError(`${path} failed: ${msg}`); + } + return body as T; +} + +export function readConfig(cwd: string): BoolConfig { + const path = join(cwd, CONFIG_FILE); + if (!existsSync(path)) { + throw new CliError( + `No ${CONFIG_FILE} here — run \`bool link --project \` first.`, + ); + } + return JSON.parse(readFileSync(path, "utf8")) as BoolConfig; +} + +/** Idempotently set KEY=value in .env.bool and make sure .gitignore hides it. */ +function writeEnvKey(cwd: string, key: string, value: string): void { + const envPath = join(cwd, ENV_FILE); + const line = `${key}=${value}`; + if (existsSync(envPath)) { + const lines = readFileSync(envPath, "utf8").split("\n"); + const i = lines.findIndex((l) => l.startsWith(`${key}=`)); + if (i !== -1) lines[i] = line; + else lines.push(line); + writeFileSync(envPath, lines.filter((l, j) => l || j < lines.length - 1).join("\n") + "\n"); + } else { + writeFileSync(envPath, line + "\n"); + } + const gitignore = join(cwd, ".gitignore"); + const existing = existsSync(gitignore) ? readFileSync(gitignore, "utf8") : ""; + if (!existing.split("\n").some((l) => l.trim() === ENV_FILE)) { + appendFileSync( + gitignore, + (existing && !existing.endsWith("\n") ? "\n" : "") + ENV_FILE + "\n", + ); + } +} + +type Connection = { + projectId: string; + name: string; + slug: string; + schema: string; + appOrigin: string; + appUrl: string; + supabaseUrl: string; + supabaseAnonKey: string; +}; + +async function cmdLink( + flags: Record, + deps: CliDeps, +): Promise { + const projectId = str(flags.project); + if (!projectId) throw new CliError("Usage: bool link --project [--api-url ] [--token ]"); + const apiUrl = str(flags["api-url"]) ?? deps.env.BOOL_API_URL ?? DEFAULT_API_URL; + const tok = token(flags, deps); + + const conn = await apiJson( + deps, + tok, + apiUrl, + `/api/projects/${projectId}/connection`, + ); + + const config: BoolConfig = { + projectId: conn.projectId, + slug: conn.slug, + apiUrl, + appOrigin: conn.appOrigin, + appUrl: conn.appUrl, + schema: conn.schema, + supabaseUrl: conn.supabaseUrl, + supabaseAnonKey: conn.supabaseAnonKey, + typesPath: str(flags.types) ?? DEFAULT_TYPES_PATH, + }; + writeFileSync(join(deps.cwd, CONFIG_FILE), JSON.stringify(config, null, 2) + "\n"); + deps.log(`Linked to "${conn.name}" (${conn.projectId}) — wrote ${CONFIG_FILE}.`); + + // The admin data key is owner-only; a non-owner link still works for + // types/entities/deploy, they just bring their own key. + const keyRes = await api(deps, tok, apiUrl, `/api/projects/${projectId}/api-key`); + if (keyRes.ok) { + const { apiKey } = (await keyRes.json()) as { apiKey: string }; + writeEnvKey(deps.cwd, "BOOL_API_KEY", apiKey); + deps.log(`Wrote the project's admin data key to ${ENV_FILE} (gitignored — keep it secret).`); + } else { + deps.log( + `Skipped the admin data key (${keyRes.status === 404 ? "owner-only" : `HTTP ${keyRes.status}`}) — set BOOL_API_KEY yourself to read/write data locally.`, + ); + } + + await pullTypes(config, tok, deps); + + deps.log(` +Next steps: + 1. Load ${ENV_FILE} into your env (or copy BOOL_API_KEY into your own .env). + 2. Create the client: + + import { createBoolClient } from "bool-sdk"; + import config from "./${CONFIG_FILE}"; + + export const bool = createBoolClient({ + supabaseUrl: config.supabaseUrl, + supabaseAnonKey: config.supabaseAnonKey, + schema: config.schema, + appOrigin: config.appOrigin, + slug: config.slug, + apiKey: process.env.BOOL_API_KEY, // import.meta.env.VITE_BOOL_API_KEY in Vite + }); + + 3. Use your data: await bool.entities..list() + 4. Publish anytime: bool deploy`); + return 0; +} + +async function pullTypes(config: BoolConfig, tok: string, deps: CliDeps): Promise { + const res = await api( + deps, + tok, + config.apiUrl, + `/api/projects/${config.projectId}/entities/types`, + ); + if (!res.ok) throw new CliError(`Fetching entity types failed: HTTP ${res.status}`); + const body = await res.text(); + const out = resolve(deps.cwd, config.typesPath || DEFAULT_TYPES_PATH); + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, body); + deps.log(`Wrote entity types to ${relative(deps.cwd, out)}.`); +} + +async function cmdTypes( + flags: Record, + deps: CliDeps, +): Promise { + const config = readConfig(deps.cwd); + if (str(flags.out)) config.typesPath = str(flags.out)!; + await pullTypes(config, token(flags, deps), deps); + return 0; +} + +async function cmdEntities( + flags: Record, + deps: CliDeps, +): Promise { + const config = readConfig(deps.cwd); + const { entities } = await apiJson<{ + entities: Array<{ + name: string; + access: string; + fields: Array<{ name: string; type: string; required: boolean }>; + }>; + }>(deps, token(flags, deps), config.apiUrl, `/api/projects/${config.projectId}/entities`); + if (entities.length === 0) { + deps.log("No entities declared yet — define one in the Bool editor (or via the MCP define_entity tool)."); + return 0; + } + for (const e of entities) { + deps.log(`${e.name} (${e.access})`); + for (const f of e.fields) { + deps.log(` ${f.name}: ${f.type}${f.required ? " (required)" : ""}`); + } + } + return 0; +} + +// Never ship these into a deploy archive: build output and deps are rebuilt in +// the cloud; env files and the local link config are machine-local (and the +// env files hold secrets). +const DEPLOY_EXCLUDE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next"]); +export function isDeployExcluded(rel: string): boolean { + const top = rel.split("/")[0]!; + if (DEPLOY_EXCLUDE_DIRS.has(top)) return true; + const base = rel.split("/").pop()!; + if (base === CONFIG_FILE) return true; + if (base === ".env" || base.startsWith(".env.")) return true; + if (base === ".DS_Store") return true; + return false; +} + +export function collectDeployEntries(dir: string): ZipEntry[] { + const entries: ZipEntry[] = []; + const walk = (abs: string) => { + for (const name of readdirSync(abs).sort()) { + const child = join(abs, name); + const rel = relative(dir, child).split("\\").join("/"); + if (isDeployExcluded(rel)) continue; + const st = statSync(child); + if (st.isDirectory()) walk(child); + else if (st.isFile()) entries.push({ path: rel, data: new Uint8Array(readFileSync(child)) }); + } + }; + walk(dir); + return entries; +} + +type DropCreated = { + drop_id: string; + upload_url: string; + upload_headers: Record; + status_url: string; + max_upload_size_bytes: number; +}; +type DropStatus = { + status: string; + url: string | null; + error: { code?: string; message?: string } | null; +}; + +const DEPLOY_POLL_MS = 2500; +const DEPLOY_TIMEOUT_MS = 15 * 60 * 1000; + +async function cmdDeploy( + flags: Record, + deps: CliDeps, +): Promise { + const config = readConfig(deps.cwd); + const tok = token(flags, deps); + const dir = resolve(deps.cwd, str(flags.dir) ?? "."); + + const entries = collectDeployEntries(dir); + if (!entries.some((e) => e.path === "index.html")) { + throw new CliError( + `No index.html at the root of ${dir} — deploy from your app's root (or pass --dir).`, + ); + } + const archive = createZip(entries); + deps.log(`Packed ${entries.length} files (${(archive.length / 1024).toFixed(1)} KB). Creating drop…`); + + const createRes = await deps.fetch(`${config.apiUrl.replace(/\/$/, "")}/api/drops`, { + method: "POST", + headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, + body: JSON.stringify({ project_id: config.projectId }), + }); + const created = (await createRes.json().catch(() => null)) as DropCreated | { error?: string } | null; + if (!createRes.ok || !created || !("upload_url" in created)) { + throw new CliError( + `Creating the drop failed: ${(created as { error?: string } | null)?.error ?? `HTTP ${createRes.status}`}`, + ); + } + if (archive.length > created.max_upload_size_bytes) { + throw new CliError( + `Archive is ${archive.length} bytes — over the ${created.max_upload_size_bytes}-byte limit.`, + ); + } + + const putRes = await deps.fetch(created.upload_url, { + method: "PUT", + headers: created.upload_headers ?? { "Content-Type": "application/zip" }, + // createZip returns an exact-sized view, so its backing buffer IS the + // archive; the cast bridges Uint8Array vs BodyInit typing. + body: archive.buffer as ArrayBuffer, + }); + if (!putRes.ok) throw new CliError(`Uploading the archive failed: HTTP ${putRes.status}`); + deps.log("Uploaded. Bool is building in the cloud…"); + + const deadline = Date.now() + DEPLOY_TIMEOUT_MS; + while (Date.now() < deadline) { + const statusRes = await deps.fetch(created.status_url); + const status = (await statusRes.json().catch(() => null)) as DropStatus | null; + if (!statusRes.ok || !status) throw new CliError(`Polling drop status failed: HTTP ${statusRes.status}`); + if (status.status === "ready") { + deps.log(`Live at ${status.url ?? config.appUrl}`); + return 0; + } + if (status.status === "failed") { + throw new CliError( + `Deploy failed: ${status.error?.message ?? status.error?.code ?? "unknown error"}`, + ); + } + await deps.sleep(DEPLOY_POLL_MS); + } + throw new CliError("Timed out waiting for the deploy — check the project on Bool."); +} + +const USAGE = `bool — develop locally against a Bool project, deploy to Bool + +Usage: + bool link --project [--api-url ] [--token ] [--types ] + bool types [--out ] [--token ] + bool entities [--token ] + bool deploy [--dir ] [--token ] + +Auth: pass --token or set BOOL_TOKEN (Bool → Settings → Access tokens). +Data key: link writes BOOL_API_KEY to ${ENV_FILE} (owner only) — pass it to +createBoolClient as \`apiKey\`.`; + +export async function runCli(argv: string[], overrides?: Partial): Promise { + const deps: CliDeps = { ...defaults(), ...overrides }; + const { command, flags } = parseArgs(argv); + try { + switch (command) { + case "link": + return await cmdLink(flags, deps); + case "types": + return await cmdTypes(flags, deps); + case "entities": + return await cmdEntities(flags, deps); + case "deploy": + return await cmdDeploy(flags, deps); + case undefined: + case "help": + case "--help": + deps.log(USAGE); + return command ? 0 : 1; + default: + deps.error(`Unknown command "${command}".\n\n${USAGE}`); + return 1; + } + } catch (err) { + if (err instanceof CliError) { + deps.error(err.message); + return 1; + } + throw err; + } +} diff --git a/src/client.test.ts b/src/client.test.ts index b8cc44f..40498c8 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -287,6 +287,42 @@ describe("per-user API key", () => { }); }); +describe("local development (config.apiKey)", () => { + const LOCAL = { ...CONFIG, apiKey: "boolsk_admin123" }; + + test("db calls carry the api_key header", async () => { + const client = createBoolClient(LOCAL); + await client.db.from("todos").select("*"); + expect(headersOf(calls[0]!).get("api_key")).toBe("boolsk_admin123"); + }); + + test("users-plane calls carry the api_key header", async () => { + respond = () => + new Response(JSON.stringify({ user: { id: "u1" } }), { + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(LOCAL); + await client.auth.getUser(); + expect(headersOf(calls[0]!).get("api_key")).toBe("boolsk_admin123"); + }); + + test("ai-plane calls carry the api_key header", async () => { + respond = () => + new Response(JSON.stringify({ text: "hi" }), { + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(LOCAL); + await client.ai.generate("hello"); + expect(headersOf(calls[0]!).get("api_key")).toBe("boolsk_admin123"); + }); + + test("no api_key header without the config option", async () => { + const client = createBoolClient(CONFIG); + await client.db.from("todos").select("*"); + expect(headersOf(calls[0]!).get("api_key")).toBeNull(); + }); +}); + describe("bool.ai battery", () => { test("generate(prompt) POSTs to the ai plane and returns text", async () => { respond = () => diff --git a/src/client.ts b/src/client.ts index e045a6b..e56e603 100644 --- a/src/client.ts +++ b/src/client.ts @@ -51,6 +51,16 @@ export type BoolClientConfig = { * sandbox can't send the live-gate cookie). Empty when deployed — the * cookie is used then. (VITE_BOOL_VIEWER_TOKEN) */ viewerToken?: string; + /** LOCAL / external development: a Bool data API key sent as the `api_key` + * header on every gateway call, instead of the cookie/viewer-token identity a + * deployed app uses. Two kinds (both minted by the platform): + * - `boolsk_…` — the project's OWNER/ADMIN key: full access to ALL rows + * (bypasses per-user RLS). For your own scripts/backends. SECRET — load + * it from an env var (`bool link` writes .env.bool), never commit it. + * - `boolk_…` — one END USER's personal key: acts exactly as that user. + * With an apiKey set the client works from anywhere (Node, a local Vite app, + * CI) — this is what `bool link` wires up. */ + apiKey?: string; }; /** The signed-in end user (mirrors what the gateway returns from /me). Never @@ -187,6 +197,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { appOrigin = "", slug = "", viewerToken = "", + apiKey = "", } = config; let euSessionToken = (() => { @@ -236,6 +247,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { const headers = new Headers(init?.headers ?? {}); if (viewerToken) headers.set("x-bool-viewer", viewerToken); if (euSessionToken) headers.set("x-bool-eu-session", euSessionToken); + if (apiKey) headers.set("api_key", apiKey); // credentials:include so the live-gate identity cookie flows to the // gateway (same-origin or custom-domain); the viewer token covers the // cross-origin preview. @@ -269,6 +281,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { }; if (viewerToken) headers["x-bool-viewer"] = viewerToken; if (euSessionToken) headers["x-bool-eu-session"] = euSessionToken; + if (apiKey) headers["api_key"] = apiKey; const res = await fetch(`${GATEWAY}/_bool/${GATEWAY_API}/users${path}`, { ...init, headers, @@ -493,6 +506,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { const headers: Record = { "content-type": "application/json" }; if (viewerToken) headers["x-bool-viewer"] = viewerToken; if (euSessionToken) headers["x-bool-eu-session"] = euSessionToken; + if (apiKey) headers["api_key"] = apiKey; return headers; } const ai: BoolAi = { diff --git a/src/entities.test.ts b/src/entities.test.ts index 2d53b00..08a1e6f 100644 --- a/src/entities.test.ts +++ b/src/entities.test.ts @@ -81,7 +81,7 @@ describe("entities: read paths → PostgREST", () => { expect(q.get("archived_at")).toBe("is.null"); }); - test("filter() maps the richer Base44 operators", async () => { + test("filter() maps the richer Mongo-style operators", async () => { const bool = createBoolClient(CONFIG); await bool.entities.todos.filter({ a: { $ne: 1 }, diff --git a/src/entities.ts b/src/entities.ts index 89f3120..7da8305 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -1,4 +1,4 @@ -// The Bool entities layer: a Base44-style data API over the gateway-routed +// The Bool entities layer: a high-level data API over the gateway-routed // Supabase client. App code (and the AI that writes it) works with // `bool.entities.
` instead of raw `supabase.from(...)`, so a generated // app never mentions Supabase, SQL, PostgREST, or credentials — the whole @@ -9,7 +9,7 @@ // await bool.entities.todos.update(one.id, { done: true }); // await bool.entities.todos.filter({ status: "active", count: { $gte: 10 } }); // -// The method surface and filter DSL mirror Base44's entities module one-to-one +// The method surface and filter DSL follow a familiar app-builder convention // (MongoDB-style `$`-operators, `-col` sort, bulk + *Many ops), so there's no // need to drop down to raw SQL. Methods return row data directly and THROW on // error (unlike supabase-js's `{ data, error }`), so app code reads clean. @@ -19,7 +19,7 @@ import type { BoolChangePayload, BoolDb } from "./client.js"; * Entity tables always have `created_at`, so it's the default. */ export type SortSpec = string; -/** MongoDB-style comparison operators for a single field. Mirrors Base44. */ +/** MongoDB-style comparison operators for a single field. */ export type FilterOperators = Partial<{ $eq: unknown; $ne: unknown; @@ -84,7 +84,7 @@ export type ImportResult = { }; /** CRUD + realtime for one entity table. `T` defaults to `any` (untyped); - * apps can pass a row type for autocomplete. Mirrors Base44's EntityHandler. */ + * apps can pass a row type for autocomplete. */ export interface EntityHandler { /** Rows, newest first by default. `limit` defaults to 50, max 5000 — page * through larger result sets with `limit` + `skip` (an over-cap `limit` diff --git a/src/zip.test.ts b/src/zip.test.ts new file mode 100644 index 0000000..cbcdb7a --- /dev/null +++ b/src/zip.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { crc32, createZip } from "./zip.js"; + +const enc = new TextEncoder(); + +function u32At(buf: Uint8Array, off: number): number { + return (buf[off]! | (buf[off + 1]! << 8) | (buf[off + 2]! << 16) | (buf[off + 3]! << 24)) >>> 0; +} +function u16At(buf: Uint8Array, off: number): number { + return buf[off]! | (buf[off + 1]! << 8); +} + +describe("crc32", () => { + test("matches the standard check vector", () => { + // The canonical CRC-32 test vector: "123456789" → 0xCBF43926. + expect(crc32(enc.encode("123456789"))).toBe(0xcbf43926); + }); + test("empty input", () => { + expect(crc32(new Uint8Array())).toBe(0); + }); +}); + +describe("createZip", () => { + test("produces a structurally valid store-only archive", () => { + const entries = [ + { path: "index.html", data: enc.encode("

hi

") }, + { path: "src/main.ts", data: enc.encode("console.log(1)\n") }, + ]; + const zip = createZip(entries); + + // First local file header signature. + expect(u32At(zip, 0)).toBe(0x04034b50); + + // End-of-central-directory record: last 22 bytes (no comment). + const eocd = zip.length - 22; + expect(u32At(zip, eocd)).toBe(0x06054b50); + expect(u16At(zip, eocd + 8)).toBe(2); // total entries + const cdSize = u32At(zip, eocd + 12); + const cdOffset = u32At(zip, eocd + 16); + expect(cdOffset + cdSize).toBe(eocd); + + // First central directory header + its filename. + expect(u32At(zip, cdOffset)).toBe(0x02014b50); + const nameLen = u16At(zip, cdOffset + 28); + const name = new TextDecoder().decode( + zip.slice(cdOffset + 46, cdOffset + 46 + nameLen), + ); + expect(name).toBe("index.html"); + + // Stored (method 0) with the right CRC + sizes for entry 0. + expect(u16At(zip, cdOffset + 10)).toBe(0); // method + expect(u32At(zip, cdOffset + 16)).toBe(crc32(entries[0]!.data)); + expect(u32At(zip, cdOffset + 20)).toBe(entries[0]!.data.length); + + // The raw bytes are embedded verbatim right after the local header. + const localNameLen = u16At(zip, 26); + const dataStart = 30 + localNameLen; + expect(new TextDecoder().decode(zip.slice(dataStart, dataStart + 11))).toBe("

hi

"); + }); + + test("unzips with the system unzip when available (integration sanity)", async () => { + const which = Bun.spawnSync(["sh", "-c", "command -v unzip"]); + if (which.exitCode !== 0) return; // no unzip on this machine — skip silently + const dir = `${process.env.TMPDIR ?? "/tmp"}/bool-sdk-zip-test-${Date.now()}`; + const zipPath = `${dir}/a.zip`; + await Bun.write(zipPath, createZip([{ path: "hello.txt", data: enc.encode("hello world") }]).buffer as ArrayBuffer); + const res = Bun.spawnSync(["unzip", "-o", zipPath, "-d", dir]); + expect(res.exitCode).toBe(0); + expect(await Bun.file(`${dir}/hello.txt`).text()).toBe("hello world"); + }); +}); diff --git a/src/zip.ts b/src/zip.ts new file mode 100644 index 0000000..742b4c0 --- /dev/null +++ b/src/zip.ts @@ -0,0 +1,106 @@ +// Minimal ZIP writer for `bool deploy` — packs a file map into a valid, +// STORE-only (no compression) ZIP archive with zero dependencies. Bool's drop +// pipeline unpacks server-side, so wire size matters less than having no deps; +// source trees are small and the platform's max-archive cap still applies. +// +// Format: local file header + data per entry, then the central directory and +// end-of-central-directory record (the classic ZIP layout, no zip64 — fine for +// < 4 GB archives and < 65k files, both far beyond a project source tree). + +const textEncoder = new TextEncoder(); + +// Standard CRC-32 (the ZIP polynomial), table-driven. +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; +})(); + +export function crc32(data: Uint8Array): number { + let crc = 0xffffffff; + for (let i = 0; i < data.length; i++) { + crc = CRC_TABLE[(crc ^ data[i]!) & 0xff]! ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function u16(v: number): Uint8Array { + return new Uint8Array([v & 0xff, (v >>> 8) & 0xff]); +} +function u32(v: number): Uint8Array { + return new Uint8Array([v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff]); +} +function concat(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { + out.set(p, off); + off += p.length; + } + return out; +} + +export type ZipEntry = { path: string; data: Uint8Array }; + +/** Build a STORE-only ZIP archive from entries. Paths are archive-relative, + * forward-slashed (e.g. "index.html", "src/App.tsx"). */ +export function createZip(entries: ZipEntry[]): Uint8Array { + const locals: Uint8Array[] = []; + const centrals: Uint8Array[] = []; + let offset = 0; + + for (const entry of entries) { + const name = textEncoder.encode(entry.path); + const crc = crc32(entry.data); + const size = entry.data.length; + // Fixed DOS date/time (ZIP has no "unset"); the platform ignores mtimes. + const dosTime = u16(0); + const dosDate = u16(0x21); // 1980-01-01 + const common = concat([ + u16(20), // version needed + u16(0x0800), // flags: UTF-8 names + u16(0), // method: store + dosTime, + dosDate, + u32(crc), + u32(size), // compressed (= raw for store) + u32(size), // uncompressed + u16(name.length), + u16(0), // extra len + ]); + const local = concat([u32(0x04034b50), common, name, entry.data]); + locals.push(local); + centrals.push( + concat([ + u32(0x02014b50), + u16(20), // version made by + common, + u16(0), // comment len + u16(0), // disk start + u16(0), // internal attrs + u32(0), // external attrs + u32(offset), // local header offset + name, + ]), + ); + offset += local.length; + } + + const centralDir = concat(centrals); + const end = concat([ + u32(0x06054b50), + u16(0), // disk + u16(0), // central dir disk + u16(entries.length), + u16(entries.length), + u32(centralDir.length), + u32(offset), // central dir offset + u16(0), // comment len + ]); + return concat([...locals, centralDir, end]); +} From 014d3b104b9b17d7bdf812082096421e457fc038 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 01:23:11 +0000 Subject: [PATCH 02/12] Entity schema push/pull from disk: `bool entities push` / `bool entities pull` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-trip the entity schema files (bool/entities/*.jsonc) between a project and a local working copy: - `bool entities pull` writes the project's schema files verbatim (paths from the network are confined to bool/entities/ — anything else is skipped) and refreshes types. - `bool entities push` parses every local .jsonc (same whole-line-comment format the platform writes) and declares each on the project via POST /api/projects/[id]/entities — additive migrations server-side, per-file results + warnings reported, exit 1 if any file fails. Types refreshed after. - parseArgs now captures positional subcommands. Requires the entities POST + schemas endpoints from the platform PR. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011zKXrczjUj4pce5BH9Y1HL --- CHANGELOG.md | 4 ++ README.md | 2 + src/cli.test.ts | 87 ++++++++++++++++++++++++++ src/cli.ts | 160 ++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 247 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b214f4..f4e908b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ machine, and publish back to Bool — without leaving your editor. - `types` — regenerates `bool/types.d.ts` from the project's entity schemas, so `bool.entities.` is fully typed locally. - `entities` — prints the project's declared entities + fields. + - `entities pull` / `entities push` — round-trip the entity schema files + (`bool/entities/*.jsonc`) between the project and disk: pull writes them + verbatim, push declares every local file on the project (additive + migrations server-side; per-file results and warnings reported). - `deploy` — zips the app source (node_modules/.git/env files excluded) and publishes it on Bool via the drop pipeline: Bool builds in the cloud and the project URL stays stable. diff --git a/README.md b/README.md index f008db7..f407a67 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ export BOOL_TOKEN=bool_live_… # personal access token (Bool → Settings npx bool link --project # writes bool.config.json + .env.bool + types npx bool types # refresh bool/types.d.ts after schema changes npx bool entities # list the project's data models +npx bool entities pull # write schema files to bool/entities/ +npx bool entities push # declare edited local schemas on the project npx bool deploy # zip the source, Bool builds + hosts it ``` diff --git a/src/cli.test.ts b/src/cli.test.ts index c7215e7..4e226cf 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -55,9 +55,17 @@ describe("parseArgs", () => { test("parses command and flag forms", () => { expect(parseArgs(["link", "--project", "p1", "--api-url=https://x", "--verbose"])).toEqual({ command: "link", + positionals: [], flags: { project: "p1", "api-url": "https://x", verbose: true }, }); }); + test("captures subcommand positionals", () => { + expect(parseArgs(["entities", "push", "--dir", "x"])).toEqual({ + command: "entities", + positionals: ["push"], + flags: { dir: "x" }, + }); + }); }); describe("link", () => { @@ -257,6 +265,85 @@ describe("entities", () => { }); }); +describe("entities push / pull", () => { + const BANNER = "// Data model managed by Bool.\n"; + + test("push declares every local schema and refreshes types", async () => { + writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test", typesPath: "bool/types.d.ts" })); + mkdirSync(join(cwd, "bool/entities"), { recursive: true }); + writeFileSync( + join(cwd, "bool/entities/todos.jsonc"), + BANNER + + JSON.stringify({ + name: "todos", + type: "object", + properties: { title: { type: "string" } }, + required: ["title"], + "x-bool-access": "private", + }), + ); + writeFileSync( + join(cwd, "bool/entities/games.jsonc"), + BANNER + JSON.stringify({ name: "games", type: "object", properties: { score: { type: "number" } }, "x-bool-access": "public" }), + ); + const pushed: unknown[] = []; + const { deps, logs } = makeDeps(cwd, { + "/api/projects/p1/entities": (init) => { + const body = JSON.parse(String(init?.body)); + pushed.push(body); + return json({ ok: true, entity: body.name, changed: body.name === "todos", warnings: body.name === "todos" ? ["heads up"] : [] }); + }, + "/api/projects/p1/entities/types": () => new Response("// regenerated"), + }); + expect(await runCli(["entities", "push"], deps)).toBe(0); + // Alphabetical file order; access carried through from x-bool-access. + expect(pushed.map((p) => (p as { name: string }).name)).toEqual(["games", "todos"]); + expect((pushed[0] as { access: string }).access).toBe("public"); + expect((pushed[1] as { access: string; required: string[] }).required).toEqual(["title"]); + const out = logs.join("\n"); + expect(out).toContain("✓ todos: migrated"); + expect(out).toContain("✓ games: already up to date"); + expect(out).toContain("⚠ heads up"); + expect(readFileSync(join(cwd, "bool/types.d.ts"), "utf8")).toBe("// regenerated"); + }); + + test("push reports per-entity failures and exits 1", async () => { + writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test" })); + mkdirSync(join(cwd, "bool/entities"), { recursive: true }); + writeFileSync(join(cwd, "bool/entities/bad.jsonc"), "{ not json"); + writeFileSync( + join(cwd, "bool/entities/ok.jsonc"), + JSON.stringify({ name: "ok", properties: { a: { type: "string" } } }), + ); + const { deps, errors } = makeDeps(cwd, { + "/api/projects/p1/entities": () => json({ ok: true, entity: "ok", changed: false, warnings: [] }), + "/api/projects/p1/entities/types": () => new Response("// t"), + }); + expect(await runCli(["entities", "push"], deps)).toBe(1); + expect(errors.join("\n")).toContain("✗ bad.jsonc"); + expect(errors.join("\n")).toContain("1 of 2 entities failed"); + }); + + test("pull writes the raw schema files and refreshes types", async () => { + writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify({ ...CONNECTION, apiUrl: "https://bool.test", typesPath: "bool/types.d.ts" })); + const { deps, errors } = makeDeps(cwd, { + "/api/projects/p1/entities/schemas": () => + json({ + schemas: [ + { path: "bool/entities/todos.jsonc", content: '// banner\n{"name":"todos"}' }, + { path: "../pull-escape-evil.txt", content: "evil" }, + ], + }), + "/api/projects/p1/entities/types": () => new Response("// t"), + }); + expect(await runCli(["entities", "pull"], deps)).toBe(0); + expect(readFileSync(join(cwd, "bool/entities/todos.jsonc"), "utf8")).toBe('// banner\n{"name":"todos"}'); + // A hostile path from the network is skipped, never written. + expect(errors.join("\n")).toContain("Skipping unexpected path"); + expect(existsSync(join(cwd, "../pull-escape-evil.txt"))).toBe(false); + }); +}); + describe("help / unknown", () => { test("no command prints usage and exits 1", async () => { const { deps, logs } = makeDeps(cwd, {}); diff --git a/src/cli.ts b/src/cli.ts index e8ae1e2..0100fe5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,9 @@ // bool types refresh bool/types.d.ts from the project's // entity schemas // bool entities list the project's entities + fields +// bool entities pull write the project's schema files to bool/entities/ +// bool entities push declare local bool/entities/*.jsonc on the +// project (additive migrations, server-side) // bool deploy [--dir .] zip the app source and publish it on Bool // (Bool builds in the cloud; the URL is stable) // @@ -73,16 +76,22 @@ function defaults(): CliDeps { }; } -/** Tiny flag parser: `--key value` / `--key=value` / bare `--flag`. */ +/** Tiny arg parser: `--key value` / `--key=value` / bare `--flag`, plus bare + * positionals (subcommands like `entities push`). */ export function parseArgs(argv: string[]): { command: string | undefined; + positionals: string[]; flags: Record; } { const [command, ...rest] = argv; const flags: Record = {}; + const positionals: string[] = []; for (let i = 0; i < rest.length; i++) { const arg = rest[i]!; - if (!arg.startsWith("--")) continue; + if (!arg.startsWith("--")) { + positionals.push(arg); + continue; + } const eq = arg.indexOf("="); if (eq !== -1) { flags[arg.slice(2, eq)] = arg.slice(eq + 1); @@ -92,7 +101,7 @@ export function parseArgs(argv: string[]): { flags[arg.slice(2)] = true; } } - return { command, flags }; + return { command, positionals, flags }; } class CliError extends Error {} @@ -277,6 +286,133 @@ async function cmdTypes( return 0; } +/** Where local entity schema files live — the same layout the platform uses + * inside a project, so pull/push round-trip byte-for-byte. */ +export const ENTITIES_DIR = "bool/entities"; + +/** Parse one local entity `.jsonc` file (JSON with whole-line `//` comments — + * the platform's banner format). Mirrors the server's parser. */ +export function parseEntitySchemaFile(content: string): { + name: string; + properties: Record; + required?: string[]; + access: "private" | "public"; +} | null { + const stripped = content + .split("\n") + .filter((line) => !line.trimStart().startsWith("//")) + .join("\n"); + let doc: unknown; + try { + doc = JSON.parse(stripped); + } catch { + return null; + } + if (!doc || typeof doc !== "object") return null; + const obj = doc as Record; + if (typeof obj.name !== "string" || !obj.properties || typeof obj.properties !== "object") { + return null; + } + return { + name: obj.name, + properties: obj.properties as Record, + required: Array.isArray(obj.required) + ? (obj.required.filter((r) => typeof r === "string") as string[]) + : undefined, + access: obj["x-bool-access"] === "public" ? "public" : "private", + }; +} + +/** `bool entities push`: declare every local bool/entities/*.jsonc on the + * project (additive-only server-side — it never drops columns), then refresh + * types. Continues past a bad file and reports it; exits 1 if any failed. */ +async function cmdEntitiesPush( + flags: Record, + deps: CliDeps, +): Promise { + const config = readConfig(deps.cwd); + const tok = token(flags, deps); + const dir = resolve(deps.cwd, str(flags.dir) ?? ENTITIES_DIR); + if (!existsSync(dir)) { + throw new CliError(`No ${relative(deps.cwd, dir)}/ directory — run \`bool entities pull\` first, or create .jsonc files there.`); + } + const files = readdirSync(dir) + .filter((f) => f.endsWith(".jsonc")) + .sort(); + if (files.length === 0) { + throw new CliError(`No .jsonc entity files in ${relative(deps.cwd, dir)}/.`); + } + + let failed = 0; + for (const file of files) { + const parsed = parseEntitySchemaFile(readFileSync(join(dir, file), "utf8")); + if (!parsed) { + deps.error(`✗ ${file}: not a valid entity schema (needs "name" + "properties")`); + failed++; + continue; + } + const res = await deps.fetch( + `${config.apiUrl.replace(/\/$/, "")}/api/projects/${config.projectId}/entities`, + { + method: "POST", + headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, + body: JSON.stringify(parsed), + }, + ); + const body = (await res.json().catch(() => null)) as + | { ok?: boolean; changed?: boolean; warnings?: string[]; error?: string } + | null; + if (!res.ok || !body?.ok) { + deps.error(`✗ ${parsed.name}: ${body?.error ?? `HTTP ${res.status}`}`); + failed++; + continue; + } + deps.log(`✓ ${parsed.name}: ${body.changed ? "migrated" : "already up to date"}`); + for (const w of body.warnings ?? []) deps.log(` ⚠ ${w}`); + } + + await pullTypes(config, tok, deps); + if (failed > 0) { + deps.error(`${failed} of ${files.length} entities failed to push.`); + return 1; + } + return 0; +} + +/** `bool entities pull`: write the project's entity schema files verbatim into + * bool/entities/ (so they can be edited and pushed back), then refresh types. */ +async function cmdEntitiesPull( + flags: Record, + deps: CliDeps, +): Promise { + const config = readConfig(deps.cwd); + const tok = token(flags, deps); + const { schemas } = await apiJson<{ schemas: Array<{ path: string; content: string }> }>( + deps, + tok, + config.apiUrl, + `/api/projects/${config.projectId}/entities/schemas`, + ); + if (schemas.length === 0) { + deps.log("The project has no declared entities yet — nothing to pull."); + return 0; + } + for (const s of schemas) { + // Paths come from the platform (`bool/entities/.jsonc`), but never + // trust a path from the network with the filesystem: resolve and confine. + const out = resolve(deps.cwd, s.path); + if (!out.startsWith(resolve(deps.cwd, ENTITIES_DIR) + "/")) { + deps.error(`Skipping unexpected path from server: ${s.path}`); + continue; + } + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, s.content); + deps.log(`✓ ${s.path}`); + } + await pullTypes(config, tok, deps); + return 0; +} + async function cmdEntities( flags: Record, deps: CliDeps, @@ -416,7 +552,9 @@ const USAGE = `bool — develop locally against a Bool project, deploy to Bool Usage: bool link --project [--api-url ] [--token ] [--types ] bool types [--out ] [--token ] - bool entities [--token ] + bool entities [--token ] list the project's data models + bool entities pull [--token ] write schemas to ${ENTITIES_DIR}/ + bool entities push [--dir ] declare local schemas on the project bool deploy [--dir ] [--token ] Auth: pass --token or set BOOL_TOKEN (Bool → Settings → Access tokens). @@ -425,7 +563,7 @@ createBoolClient as \`apiKey\`.`; export async function runCli(argv: string[], overrides?: Partial): Promise { const deps: CliDeps = { ...defaults(), ...overrides }; - const { command, flags } = parseArgs(argv); + const { command, positionals, flags } = parseArgs(argv); try { switch (command) { case "link": @@ -433,7 +571,17 @@ export async function runCli(argv: string[], overrides?: Partial): Prom case "types": return await cmdTypes(flags, deps); case "entities": - return await cmdEntities(flags, deps); + switch (positionals[0]) { + case undefined: + return await cmdEntities(flags, deps); + case "push": + return await cmdEntitiesPush(flags, deps); + case "pull": + return await cmdEntitiesPull(flags, deps); + default: + deps.error(`Unknown entities subcommand "${positionals[0]}".\n\n${USAGE}`); + return 1; + } case "deploy": return await cmdDeploy(flags, deps); case undefined: From d3b2f2b32f66e5bd0fb0fe23fddec52f8b336982 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 01:30:08 +0000 Subject: [PATCH 03/12] Document the admin-key owner_id requirement on private-entity creates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review: with the boolsk_ admin key there is no end-user identity, so owner_id doesn't default on a private entity — a fresh private table (NOT NULL) rejects the insert without it. Noted in the README local-dev section and in `bool link`'s next-steps output. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011zKXrczjUj4pce5BH9Y1HL --- README.md | 7 +++++++ src/cli.ts | 2 ++ 2 files changed, 9 insertions(+) diff --git a/README.md b/README.md index f407a67..c3a5172 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,13 @@ export const bool = createBoolClient({ const todos = await bool.entities.todos.list(); // typed via bool/types.d.ts ``` +One admin-key gotcha: on a **private** entity, `create` must set `owner_id` +explicitly (`{ title: "hi", owner_id: user.id }`). The column normally +defaults to the signed-in end user, but the admin key has no end-user +identity, so on a fresh private table (where `owner_id` is NOT NULL) the +insert is rejected without it. Public entities are unaffected, as are +end-user (`boolk_`) keys — those carry the user. + Coding agents can do all of the above through Bool's MCP server instead (`list_entities`, `define_entity`, `list_records`, `get_entity_types`, `get_project_connection`, …) — see the platform docs. diff --git a/src/cli.ts b/src/cli.ts index 0100fe5..161d788 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -257,6 +257,8 @@ Next steps: }); 3. Use your data: await bool.entities..list() + (admin-key note: creates on a PRIVATE entity must set owner_id explicitly — + the admin key has no end-user identity to default it from) 4. Publish anytime: bool deploy`); return 0; } From 4adc4f6a0236dba7715722aba27260d96a3c0b6c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 01:47:24 +0000 Subject: [PATCH 04/12] Describe the entities surface on its own terms The entities module + README now state what the API is (a simple, high-level, Mongo-flavored data surface) rather than framing it comparatively. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011zKXrczjUj4pce5BH9Y1HL --- README.md | 2 +- src/entities.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c3a5172..a51d71c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ tested, and upgradable independently of any one app. ## What it does - **Entities data API.** `client.entities.
` is the recommended way to - read/write data — a familiar high-level entity surface: `list`, + read/write data — a simple, high-level entity surface: `list`, `filter`, `get`, `create`, `bulkCreate`, `update`, `bulkUpdate`, `updateMany`, `delete`, `deleteMany`, `importEntities`, `subscribe`. It hides Supabase/SQL entirely; methods return rows directly and throw on error: diff --git a/src/entities.ts b/src/entities.ts index 7da8305..acab451 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -9,7 +9,7 @@ // await bool.entities.todos.update(one.id, { done: true }); // await bool.entities.todos.filter({ status: "active", count: { $gte: 10 } }); // -// The method surface and filter DSL follow a familiar app-builder convention +// The method surface and filter DSL are deliberately Mongo-flavored // (MongoDB-style `$`-operators, `-col` sort, bulk + *Many ops), so there's no // need to drop down to raw SQL. Methods return row data directly and THROW on // error (unlike supabase-js's `{ data, error }`), so app code reads clean. From 0268aafcdfe28e8678c088e30fcb396ef5084793 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 01:54:55 +0000 Subject: [PATCH 05/12] Add AGENTS.md + CLAUDE.md: contributor/agent notes for a public npm package Everything in this repo ships to the world, so the notes lead with that: write self-contained docs/comments/commits that explain the SDK on its own terms, plus the basic dev/release conventions (hermetic tests, append-only gateway paths, semver discipline). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011zKXrczjUj4pce5BH9Y1HL --- AGENTS.md | 24 ++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 25 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d718ef0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# bool-sdk — agent notes + +## This repo is PUBLIC (and published to npm) + +Everything here ships to the world: source, comments, README, CHANGELOG, +commit messages, PR titles/bodies. Write accordingly. + +## Voice + +Describe the SDK on its own terms — what an API does and why it's shaped that +way. Keep comments, docs, the README, the CHANGELOG, commit messages, and PRs +self-contained: no references to other products or frameworks as the +explanation for a design (a reader shouldn't need outside context to +understand ours). + +## Working here + +- `bun install`, `bun test` (hermetic — fetch/fs stubbed or temp dirs), + `bun run typecheck`, `bun run build` (emits `dist/`, ESM + `.d.ts`). +- Add tests in the same change as the code. +- The gateway wire paths (`/_bool/v1/*`) are append-only; keep this SDK in + sync with the gateway routes in the Bool platform repo (`lib/gateway/`). +- Semver discipline is load-bearing: generated apps install from a caret + range on every sandbox boot, so a breaking change requires a major bump. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From c415aced2890c9576c0c59abeffa8cd943af329e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 02:29:23 +0000 Subject: [PATCH 06/12] Comprehensive user documentation for local development workflow Add complete guides and examples for developers building apps locally with Bool: - docs/README.md: Documentation index and quickstart - docs/LOCAL-DEVELOPMENT.md: Complete walkthrough with use cases and workflows - docs/DEPLOYMENT.md: Publishing, CI/CD, monitoring, and troubleshooting - docs/DATA-MODELING.md: Schema patterns, privacy, relationships, evolution - docs/FAQ.md: Common questions and answers - examples/todo-app-react.md: Full React todo app example - examples/blog-with-cms.md: Blog with admin CMS example - README.md: Updated with links to new guides Covers: project linking, entity management, CRUD operations, authentication, realtime subscriptions, deployment, schema design, error handling, and real-world use cases (SaaS, blogs, dashboards, static sites, prototyping). Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_011zKXrczjUj4pce5BH9Y1HL --- README.md | 66 +++-- docs/DATA-MODELING.md | 523 +++++++++++++++++++++++++++++++++++++ docs/DEPLOYMENT.md | 273 +++++++++++++++++++ docs/FAQ.md | 503 +++++++++++++++++++++++++++++++++++ docs/LOCAL-DEVELOPMENT.md | 489 ++++++++++++++++++++++++++++++++++ docs/README.md | 196 ++++++++++++++ examples/blog-with-cms.md | 381 +++++++++++++++++++++++++++ examples/todo-app-react.md | 313 ++++++++++++++++++++++ 8 files changed, 2721 insertions(+), 23 deletions(-) create mode 100644 docs/DATA-MODELING.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/FAQ.md create mode 100644 docs/LOCAL-DEVELOPMENT.md create mode 100644 docs/README.md create mode 100644 examples/blog-with-cms.md create mode 100644 examples/todo-app-react.md diff --git a/README.md b/README.md index a51d71c..9cb5925 100644 --- a/README.md +++ b/README.md @@ -68,27 +68,29 @@ tested, and upgradable independently of any one app. `useBoolAuth()`, ``, and the headless `useSignInForm()` state machine that login forms bind to. -## Local development (Bool as a managed backend) +## Local Development (Your Own Machine) -You can also build an app on your OWN machine — your own editor, your own -framework, a coding agent — and use a Bool project as its backend (data, -end-user auth, AI), then publish it back to Bool. This is the one case where -you do install this package yourself. +Build an app on your computer, use a Bool project as your backend, then +publish to `https://.bool.so`. This is the one case where you install +the SDK yourself. -```sh +### Quick Start + +```bash npm install bool-sdk -export BOOL_TOKEN=bool_live_… # personal access token (Bool → Settings) -npx bool link --project # writes bool.config.json + .env.bool + types -npx bool types # refresh bool/types.d.ts after schema changes -npx bool entities # list the project's data models -npx bool entities pull # write schema files to bool/entities/ -npx bool entities push # declare edited local schemas on the project -npx bool deploy # zip the source, Bool builds + hosts it +export BOOL_TOKEN=bool_live_xxxxx # from Bool → Settings → Access tokens + +npx bool link --project # connect to a Bool project +npx bool entities push --dir bool/entities # push schema changes +npx bool deploy # publish when ready ``` -`link` fetches the project's connection config and (owner-only) its admin data -key. The key goes to `.env.bool` (gitignored) — it authorizes full data access -through the gateway, so treat it like any secret. Then: +**Three new files after `link`:** +- `bool.config.json` — project metadata (commit this) +- `.env.bool` — admin key (gitignore, keep secret) +- `bool/types.d.ts` — TypeScript types (auto-updated) + +**Then in your app:** ```ts import { createBoolClient } from "bool-sdk"; @@ -103,15 +105,33 @@ export const bool = createBoolClient({ apiKey: process.env.BOOL_API_KEY, // from .env.bool }); -const todos = await bool.entities.todos.list(); // typed via bool/types.d.ts +// Now use your data +const todos = await bool.entities.todos.list(); +``` + +### Guides + +- **[Local Development](./docs/LOCAL-DEVELOPMENT.md)** — detailed walkthrough + with use cases, workflows, and tips +- **[Deployment](./docs/DEPLOYMENT.md)** — publishing, CI/CD, monitoring +- **[Data Modeling](./docs/DATA-MODELING.md)** — schema patterns, privacy, + relationships + +### Admin Key Gotcha + +When using the admin key (`apiKey`), on a **private** entity (one with +`user_id` owner field), you must set `owner_id` explicitly: + +```ts +// ❌ Fails on private entity (NOT NULL constraint) +await bool.entities.tasks.create({ title: "Task" }); + +// ✅ Works +await bool.entities.tasks.create({ title: "Task", user_id: userId }); ``` -One admin-key gotcha: on a **private** entity, `create` must set `owner_id` -explicitly (`{ title: "hi", owner_id: user.id }`). The column normally -defaults to the signed-in end user, but the admin key has no end-user -identity, so on a fresh private table (where `owner_id` is NOT NULL) the -insert is rejected without it. Public entities are unaffected, as are -end-user (`boolk_`) keys — those carry the user. +The admin key has no user identity, so it can't default `owner_id`. End-user +clients and `boolk_` keys carry the user and default automatically. Coding agents can do all of the above through Bool's MCP server instead (`list_entities`, `define_entity`, `list_records`, `get_entity_types`, diff --git a/docs/DATA-MODELING.md b/docs/DATA-MODELING.md new file mode 100644 index 0000000..550f5ed --- /dev/null +++ b/docs/DATA-MODELING.md @@ -0,0 +1,523 @@ +# Data Modeling — Designing Your Schema + +How to structure your entities (tables) for privacy, performance, and maintainability. + +## Entity Basics + +Each entity is a JSON Schema file in `bool/entities/`: + +```json +{ + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" } + }, + "required": ["id", "name", "created_at"] +} +``` + +After `bool entities push`, Bool creates a Postgres table with RLS enabled. + +## Field Types + +Bool supports a JSON Schema subset: + +| Type | Example | Notes | +|------|---------|-------| +| `string` | `{ "type": "string" }` | VARCHAR | +| `integer` | `{ "type": "integer" }` | INT, with `minimum` / `maximum` | +| `number` | `{ "type": "number" }` | FLOAT / DECIMAL | +| `boolean` | `{ "type": "boolean" }` | BOOLEAN | +| `object` | `{ "type": "object", "properties": {...} }` | JSONB (nested data) | +| `array` | `{ "type": "array", "items": {...} }` | Array / JSONB | + +**With format constraints:** + +```json +{ + "email": { "type": "string", "format": "email" }, + "url": { "type": "string", "format": "uri" }, + "date": { "type": "string", "format": "date" }, + "datetime": { "type": "string", "format": "date-time" }, + "uuid": { "type": "string", "format": "uuid" } +} +``` + +**With value constraints:** + +```json +{ + "age": { "type": "integer", "minimum": 0, "maximum": 150 }, + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "priority": { "type": "integer", "enum": [1, 2, 3, 4, 5] } +} +``` + +## Privacy: Private vs. Public + +### Private Entity (Default) + +Data belongs to a user. Only the owner can read/write their rows. + +```json +{ + "type": "object", + "properties": { + "id": { "type": "string" }, + "user_id": { "type": "string" }, + "title": { "type": "string" }, + "content": { "type": "string" } + }, + "required": ["id", "user_id", "title"], + "x-private": true +} +``` + +**SDK behavior:** + +```ts +// End-user client (no apiKey) +const posts = await bool.entities.posts.list(); +// Returns: only posts where user_id == current_user + +// Admin client (with apiKey) +const allPosts = await bool.entities.posts.filter({ user_id: "any-user" }); +// Returns: any posts (no filter applied) +``` + +**Use for:** +- User-owned data: posts, tasks, comments, preferences +- Sensitive data: billing, personal info +- SaaS data: customer accounts, project resources + +### Public Entity + +Anyone (authenticated or not) can read/write, subject to RLS policies. + +```json +{ + "type": "object", + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "views": { "type": "integer" } + }, + "required": ["id", "title"], + "x-private": false +} +``` + +**SDK behavior:** + +```ts +// Any client can read +const articles = await bool.entities.articles.list(); + +// RLS policies may still restrict writes +// (e.g., only admins can create, anyone can comment) +``` + +**Use for:** +- Published content: blog posts, product listings, documentation +- Shared read-only data: categories, tags, metadata +- Community data: comments, reviews (with ownership tracking) + +## Common Patterns + +### Timestamps (Automatic) + +Always include created/updated timestamps: + +```json +{ + "id": { "type": "string" }, + "title": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" } +} +``` + +When creating: +```ts +await bool.entities.posts.create({ + title: "Hello", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), +}); +``` + +Or use a helper: +```ts +const now = new Date().toISOString(); +const post = await bool.entities.posts.create({ + title: "Hello", + created_at: now, + updated_at: now, +}); +``` + +### Ownership (user_id, team_id) + +For private data, always track the owner: + +```json +{ + "id": { "type": "string" }, + "user_id": { "type": "string" }, + "title": { "type": "string" } +} +``` + +```ts +const userId = (await bool.auth.getUser()).user.id; +await bool.entities.posts.create({ + title: "My post", + user_id: userId, +}); +``` + +RLS automatically filters to `user_id == current_user`. + +### Soft Deletes + +Instead of deleting, mark as deleted: + +```json +{ + "id": { "type": "string" }, + "title": { "type": "string" }, + "deleted_at": { "type": ["string", "null"], "format": "date-time" } +} +``` + +```ts +// "Delete" (set deleted_at) +await bool.entities.posts.update(postId, { + deleted_at: new Date().toISOString(), +}); + +// Query active posts +const active = await bool.entities.posts.filter({ + deleted_at: { $exists: false }, +}); + +// Query deleted posts +const trash = await bool.entities.posts.filter({ + deleted_at: { $exists: true }, +}); +``` + +### Status Enums + +```json +{ + "id": { "type": "string" }, + "status": { + "type": "string", + "enum": ["draft", "published", "archived"] + } +} +``` + +```ts +const published = await bool.entities.posts.filter({ + status: "published", +}); +``` + +### Nested Data (JSONB) + +Store structured data without creating a separate table: + +```json +{ + "id": { "type": "string" }, + "title": { "type": "string" }, + "metadata": { + "type": "object", + "properties": { + "tags": { "type": "array", "items": { "type": "string" } }, + "color": { "type": "string" }, + "stats": { + "type": "object", + "properties": { + "views": { "type": "integer" }, + "likes": { "type": "integer" } + } + } + } + } +} +``` + +```ts +await bool.entities.posts.create({ + title: "Hello", + metadata: { + tags: ["javascript", "react"], + color: "#ff0000", + stats: { views: 0, likes: 0 }, + }, +}); + +// Update nested field +await bool.entities.posts.update(postId, { + metadata: { + ...old.metadata, + stats: { ...old.metadata.stats, views: old.metadata.stats.views + 1 }, + }, +}); +``` + +### Relationships (Foreign Keys) + +For belongs-to relationships: + +```json +{ + "id": { "type": "string" }, + "title": { "type": "string" }, + "author_id": { "type": "string" } +} +``` + +Then fetch the related row: + +```ts +const post = await bool.entities.posts.get(postId); +const author = await bool.entities.users.get(post.author_id); +``` + +Or filter by relationship: + +```ts +const userPosts = await bool.entities.posts.filter({ + author_id: userId, +}); +``` + +For many-to-many (tags on posts), use a junction table: + +```json +// bool/entities/post_tags.json +{ + "type": "object", + "properties": { + "id": { "type": "string" }, + "post_id": { "type": "string" }, + "tag_id": { "type": "string" } + }, + "required": ["id", "post_id", "tag_id"] +} +``` + +```ts +// Add a tag to a post +await bool.entities.post_tags.create({ + post_id: postId, + tag_id: tagId, +}); + +// Get tags for a post +const tags = await bool.entities.post_tags.filter({ post_id: postId }); +const tagIds = tags.map(t => t.tag_id); +const tagNames = await Promise.all( + tagIds.map(id => bool.entities.tags.get(id)) +); +``` + +## Schema Evolution + +### Adding Fields (Always Safe) + +```json +// Original +{ "type": "string", "properties": { "id": {...}, "title": {...} } } + +// Add a new field +{ "type": "string", "properties": { "id": {...}, "title": {...}, "subtitle": {...} } } +``` + +`bool entities push` generates a migration that adds the column. Existing rows get NULL (or a default if you specify one). + +### Removing Fields + +Removing a field breaks existing apps. Instead: + +1. Mark as deprecated in your schema comments +2. Clients stop reading it +3. After 3+ deploys, remove it + +Or use soft-delete pattern (add `deprecated: true` to the schema). + +### Renaming Fields + +Can't rename directly (breaks migrations). Instead: + +1. Add new field: `new_name` +2. Write new data to both `old_name` and `new_name` +3. Migrate existing data: `UPDATE table SET new_name = old_name WHERE new_name IS NULL` +4. Once all rows updated, remove `old_name` (safe) + +### Changing Types + +Changing `string` to `integer` breaks existing data. Instead: + +1. Add new field: `count_int` (integer type) +2. Write new data to both fields +3. Migrate: `UPDATE table SET count_int = CAST(count_str AS INTEGER)` +4. Remove `count_str` once migrated + +## Design Tips + +### Keep It Simple + +Start with just the fields you need. Add fields later as needed. Smaller schema = faster migrations. + +**Good:** +```json +{ + "id": { "type": "string" }, + "title": { "type": "string" }, + "user_id": { "type": "string" } +} +``` + +**Avoid:** +```json +{ + "id": { "type": "string" }, + "title": { "type": "string" }, + "user_id": { "type": "string" }, + "old_user_id": { "type": ["string", "null"] }, + "reserved1": { "type": ["string", "null"] }, + "reserved2": { "type": ["string", "null"] }, + ... +} +``` + +### Use Enums for Status + +Instead of free-form strings, use enums to prevent typos and enable filtering: + +```json +{ + "status": { + "type": "string", + "enum": ["pending", "active", "completed"] + } +} +``` + +### Denormalize for Performance + +Avoid complex joins. If you frequently need denormalized data, store it: + +```json +{ + "id": { "type": "string" }, + "post_id": { "type": "string" }, + "author_id": { "type": "string" }, + "author_name": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" } +} +``` + +When creating, look up `author_name`: + +```ts +const author = await bool.entities.users.get(authorId); +const comment = await bool.entities.comments.create({ + post_id: postId, + author_id: author.id, + author_name: author.name, + created_at: new Date().toISOString(), +}); +``` + +On update, keep both in sync: + +```ts +await bool.entities.comments.update(commentId, { + author_name: newAuthorName, +}); +``` + +### Archive Instead of Delete + +For audit trails and data recovery: + +```json +{ + "id": { "type": "string" }, + "deleted_at": { "type": ["string", "null"] }, + "deleted_by": { "type": ["string", "null"] } +} +``` + +--- + +## Examples + +### Blog + +``` +users + - id, email, name, created_at + +posts (private: user_id) + - id, user_id, title, content, published, created_at, updated_at + +comments (private: user_id, foreign key: post_id) + - id, user_id, post_id, content, created_at + +tags (public) + - id, name, slug + +post_tags (junction) + - id, post_id, tag_id +``` + +### Task Manager + +``` +teams (private: user_id) + - id, user_id, name + +team_members + - id, team_id, user_id, role + +projects (private: team_id) + - id, team_id, name + +tasks (private: team_id, foreign key: project_id) + - id, team_id, project_id, title, done, priority, created_at +``` + +### E-Commerce + +``` +users (private) + - id, email, name + +products (public) + - id, name, price, stock + +cart (private: user_id, foreign key: product_id) + - id, user_id, product_id, quantity + +orders (private: user_id) + - id, user_id, total, status, created_at + +order_items (private: user_id, foreign key: order_id, product_id) + - id, user_id, order_id, product_id, quantity, price +``` + +--- + +## Next Steps + +- [Local development](./LOCAL-DEVELOPMENT.md) — building your app +- [Deployment](./DEPLOYMENT.md) — publishing to production diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..7834756 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,273 @@ +# Deployment — Publishing Your App to Bool + +Take your locally-developed app and publish it to `https://.bool.so`. + +## How Publishing Works + +``` +Your machine + ↓ +bool deploy (zips source + schemas) + ↓ +Bool platform (checks auth, stores in S3) + ↓ +Vercel Sandbox (builds, installs deps) + ↓ +Live URL (https://.bool.so) +``` + +## Quick Start + +```bash +# From your app directory +npx bool deploy +``` + +That's it. Your app is live. + +## What Gets Deployed + +The `bool deploy` command zips: +- Your app source (`src/`, `public/`, `package.json`, etc. — excludes `node_modules`, `.git`, etc.) +- Your schema definitions (`bool/entities/*.json`) +- Your environment config (`.env.bool` → injected at build time) + +Size limit: **65 KB compressed**. For most apps (React + schemas), that's plenty. + +## Environment Variables at Build Time + +When Bool builds your app on Vercel Sandbox: + +1. Your `.env.bool` is loaded (admin key) +2. Available as `process.env.BOOL_API_KEY` during build +3. Injected into client bundle as needed + +**For Vite/SPA apps:** +```ts +// At build time, these are available +const apiKey = import.meta.env.VITE_BOOL_API_KEY; +// Or fallback to runtime detection +const apiKey = window.env?.BOOL_API_KEY; +``` + +**For Next.js / Node apps:** +```ts +// At build time (not runtime) +const apiKey = process.env.BOOL_API_KEY; + +// For runtime, pass it via API route or config file +``` + +## Continuous Deployment + +### GitHub Actions + +```yaml +name: Deploy to Bool +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: oven-sh/setup-bun@v1 + - run: npx bool deploy --token ${{ secrets.BOOL_TOKEN }} +``` + +**Setup:** +1. Create a personal access token in Bool (Settings → Access tokens) +2. Add as GitHub secret: `BOOL_TOKEN` +3. Create `.github/workflows/deploy.yml` with the above +4. Every commit to main triggers a deploy + +### Manual Deployment + +```bash +export BOOL_TOKEN=bool_live_xxxxx +npx bool deploy +``` + +### Programmatic Deployment + +In CI/CD, use the token flag: + +```bash +bool deploy --token "bool_live_xxxxx" +``` + +## Preview URLs + +Every publish creates a unique preview URL while deployment completes: + +``` +Deploying to prod... +Preview: https://demo-app-XXXX.bool.so (expires in 30 min) +Live: https://demo-app.bool.so (building...) +``` + +You can test on the preview while the live version deploys. + +## Rollback + +If you deploy a bad version, redeploy the previous commit: + +```bash +git checkout HEAD~1 # previous version +npx bool deploy +git checkout main # back to current work +``` + +Or manually in the Bool editor: Projects → [name] → Deployments → [previous] → "Make live" + +## Monitoring & Logs + +After deploy, check your app in the Bool editor: + +**Projects** → [name] → **Live app** → [link] + +View build logs and runtime errors in the Bool dashboard. + +## Build Optimizations + +### Fast Deployments + +The 65 KB limit forces efficiency: + +- ✅ Tree-shake unused code: `npx esbuild src/index.ts --bundle --minify` +- ✅ Use dynamic imports for heavy libraries +- ✅ Leverage the CDN for static assets (not via the zip) +- ✅ Zero-dependency approach: no node_modules in zip + +### Schema Size + +Your `bool/entities/` adds a few KB. If you have many tables: + +- Combine small related tables? (e.g., `user_profile` + `user_settings`) +- Archive old tables? (move to a history schema) +- Use shorter field names? (save bytes on generated types) + +## Special Cases + +### Building a Static Site + +If your app is HTML + CSS + JS (no backend): + +```bash +npx bool deploy +``` + +Works as-is. Bool serves it. + +### Full-Stack (Node Backend) + +If you're using a framework like Next.js or Remix: + +```bash +# Build locally first +npm run build + +# Then deploy +npx bool deploy +``` + +Bool installs your `package.json` deps and runs the build again, but it's faster if you pre-build locally. + +### Monorepo + +If your app is in a subdirectory: + +```bash +cd apps/my-app +npx bool deploy +``` + +Or set the directory: + +```bash +npx bool deploy --dir ./apps/my-app +``` + +## Custom Domains + +After deploying, you can add a custom domain in Bool: + +**Projects** → [name] → **Settings** → **Custom domain** + +Point your DNS to Bool's CNAME, refresh cache, done. + +## Sharing Before Going Live + +Share a preview link before deploying to prod: + +```bash +npx bool deploy --preview +``` + +Creates a temporary URL that expires after 7 days. Great for design reviews or stakeholder feedback. + +## Removing Your App + +To take an app offline: + +1. In Bool editor: **Projects** → [name] → **Settings** → **Delete** +2. Or keep it in Bool and just stop deploying +3. Schema/data stays archived for a month before purge + +--- + +## Troubleshooting + +### Deploy Fails with "Zip too large" + +Your app is over 65 KB. Options: + +- Remove unused dependencies from `package.json` +- Use code splitting / dynamic imports +- Exclude large media files (use external CDN instead) +- Simplify your schema (combine tables, shorter names) + +```bash +# Check what's in your zip +ls -lah src/ public/ bool/entities/ +du -sh . +``` + +### Build Fails on Platform + +Check the Bool dashboard for build logs. Common issues: + +- Missing environment variable (add to `.env.bool`) +- TypeScript error (run `npm run typecheck` locally first) +- Missing peer dependency (add to `package.json`) + +### Deploy Hangs + +If deployment seems stuck: + +```bash +# Ctrl+C to cancel +npx bool deploy +# or try again with verbose +npx bool deploy --verbose +``` + +The platform has a timeout of 10 minutes. If you hit it, something is wrong with the build. + +### App Works Locally but Not After Deploy + +Check: +- **Env vars**: Are they set correctly? (check `.env.bool`) +- **Relative paths**: Use `import.meta.env.BASE_URL` or `process.env.VITE_*` for paths +- **API routes**: Do they exist? (Next.js needs `app/api/` or `pages/api/`) +- **Dependencies**: Did you forget to add something to `package.json`? + +--- + +## Next Steps + +- [Local development guide](./LOCAL-DEVELOPMENT.md) — building your app +- [Data modeling](./DATA-MODELING.md) — schema best practices +- [FAQ](./FAQ.md) — common questions diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 0000000..811f328 --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,503 @@ +# FAQ — Common Questions + +## Getting Started + +### What's the difference between Bool apps and local development? + +**Bool apps** — designed in the Bool visual editor, edited/deployed on the platform. + +**Local development** — develop on your machine with your own tools, publish to Bool hosting. + +Both use the same backend (data, auth, AI). The difference is *where* you edit the UI. + +### Do I need to know SQL? + +No. You define tables as JSON Schema, and the Bool SDK handles queries. + +```ts +// Not this: +const result = await db.query('SELECT * FROM tasks WHERE user_id = $1'); + +// This: +const tasks = await bool.entities.tasks.list(); +``` + +### Can I use my own database? + +No. Bool provides the database (Postgres) as part of your project. The schema is append-only (additive migrations only), so you can't break production data. + +### What happens to my data if I delete the project? + +Data is archived for 30 days, then permanently deleted. Export before deleting if you need it. + +--- + +## Development + +### Can I develop offline? + +Not fully. You need to run `bool link --project ` at least once (online) to get credentials. After that, you can code offline, but you won't be able to: +- Push schema changes +- Deploy +- Fetch updated types + +### How do I handle schema changes? + +1. Edit `bool/entities/*.json` +2. `npx bool entities push` +3. Bool generates a migration, applies it to your database +4. Your app's TypeScript types auto-update + +Migrations are **additive only** (ADD COLUMN, never DROP). You can't remove fields mid-flight. + +### Can I test locally without deploying? + +Yes. Run your app locally with `npm run dev`. The Bool SDK connects to your real project (via connection config), so you're testing against live data. + +```bash +# Local dev with live data +npm run dev + +# See your data +await bool.entities.tasks.list() // returns real rows from Bool +``` + +### How do I seed initial data? + +Use the admin key during development: + +```ts +import { createBoolClient } from "bool-sdk"; +import config from "./bool.config.json"; + +const bool = createBoolClient({ + // ... config + apiKey: process.env.BOOL_API_KEY, +}); + +async function seedData() { + await bool.entities.categories.bulkCreate([ + { id: "cat1", name: "Work" }, + { id: "cat2", name: "Personal" }, + ]); +} + +seedData(); +``` + +The admin key bypasses RLS, so you can create data with any `user_id` you want. + +--- + +## Authentication & Authorization + +### How does Bool Auth work? + +Each Bool project gets its own isolated auth system: + +1. Users sign up via `bool.auth.signUp()` +2. Bool issues a session token (JWT) +3. SDK includes token on all requests +4. Gateway validates token server-side +5. RLS policies enforce access rules + +Users are isolated per project. Signing up in one app doesn't create an account in another. + +### Can I use my own auth provider? + +You could, but you'd need to: +1. Disable Bool Auth +2. Implement your own token generation +3. Pass it to the SDK manually + +Not officially supported. Most users just use Bool Auth (it's free). + +### How does RLS work? + +Row-Level Security is a Postgres feature that filters rows based on the current user: + +```sql +-- For private entities (user_id owner field): +WHERE user_id = auth.uid() +``` + +When you create a private entity: + +```json +{ "x-private": true, "properties": { "user_id": {...} } } +``` + +Bool enables RLS and creates this policy automatically. End-users only see their rows. + +### What if I want shared data? + +Use public entities: + +```json +{ "x-private": false, "properties": { ... } } +``` + +Everyone can read/write (no RLS filter). You can add policies if needed (e.g., only admins can create). + +--- + +## Data & Queries + +### How do I sort results? + +Pass a sort string (field name, prefix with `-` for descending): + +```ts +bool.entities.tasks.list("-created_at"); // newest first +bool.entities.tasks.list("title"); // A-Z +``` + +### How do I filter by multiple conditions? + +Use MongoDB-style operators: + +```ts +// Tasks that are done AND high priority +await bool.entities.tasks.filter({ + done: true, + priority: "high", +}); + +// Tasks where count > 5 AND status is "active" +await bool.entities.tasks.filter({ + count: { $gte: 5 }, + status: "active", +}); + +// Tasks where title contains "bug" OR status is "error" +await bool.entities.tasks.filter({ + $or: [ + { title: { $regex: "bug" } }, + { status: "error" }, + ], +}); +``` + +### How do I paginate results? + +Lists are paginated (50 rows default, max 5000): + +```ts +const page1 = await bool.entities.tasks.list(undefined, { limit: 50, skip: 0 }); +const page2 = await bool.entities.tasks.list(undefined, { limit: 50, skip: 50 }); +``` + +Or use `filter()` for larger result sets: + +```ts +const all = await bool.entities.tasks.filter({ status: "active" }, { limit: 5000 }); +``` + +### How do I do a JOIN? + +There's no JOIN operator. Instead: + +```ts +// Get a post +const post = await bool.entities.posts.get(postId); + +// Get the author +const author = await bool.entities.users.get(post.author_id); +``` + +Or filter by foreign key: + +```ts +// Get all comments on a post +const comments = await bool.entities.comments.filter({ + post_id: postId, +}); +``` + +For many-to-many (tags on posts), create a junction table: + +```ts +// Get tags on a post +const postTags = await bool.entities.post_tags.filter({ post_id: postId }); +const tags = await Promise.all( + postTags.map((pt) => bool.entities.tags.get(pt.tag_id)) +); +``` + +### How do I count results? + +```ts +const all = await bool.entities.tasks.filter({ done: false }); +console.log(all.length); +``` + +Or estimate (depends on DB stats): + +```ts +const count = await bool.db.from("tasks").select("*", { count: "estimated" }); +console.log(count.count); +``` + +### How do I update multiple rows at once? + +```ts +// Mark all tasks as done +await bool.entities.tasks.updateMany( + { done: false }, // filter + { $set: { done: true } } // update +); + +// Increment view count on all posts +await bool.entities.posts.updateMany( + { user_id: userId }, + { $set: { views: 0 } } // note: updateMany doesn't do increment yet +); +``` + +### How do I delete multiple rows? + +```ts +// Delete all completed tasks +await bool.entities.tasks.deleteMany({ done: true }); +``` + +--- + +## Realtime & Subscriptions + +### How do Realtime updates work? + +The Bool SDK doesn't give you the full row data in the notification (for performance). Instead, you get a ping: + +```ts +bool.subscribeToChanges("tasks", (change) => { + // change = { table: "tasks", op: "INSERT|UPDATE|DELETE" } + // Refetch to get fresh data + const updated = await bool.entities.tasks.get(taskId); +}); +``` + +This is by design: pings are cheap, refetch is explicit. + +### Can I filter Realtime notifications? + +Not yet. Subscriptions notify on any change to the table. You refetch and filter in code: + +```ts +bool.subscribeToChanges("tasks", async (change) => { + if (change.op === "INSERT" || change.op === "UPDATE") { + const task = await bool.entities.tasks.get(taskId); + if (task.done) { + // Handle completed task + } + } +}); +``` + +### How do I unsubscribe? + +```ts +const unsubscribe = bool.subscribeToChanges("tasks", handler); + +// Later... +unsubscribe(); +``` + +--- + +## Deployment + +### How long does it take to deploy? + +Usually 1–2 minutes. Includes: +- Zipping your source +- Uploading to S3 +- Building on Vercel Sandbox +- Testing +- Going live + +### Can I deploy from CI/CD? + +Yes. Add `BOOL_TOKEN` as a secret and run: + +```bash +npx bool deploy --token ${{ secrets.BOOL_TOKEN }} +``` + +### What if I deploy a broken version? + +Redeploy the previous commit. Your old version stays live until the new one is ready. + +```bash +git checkout HEAD~1 +npx bool deploy +``` + +Or use the Bool dashboard to roll back. + +### Can I have staging and production? + +Create two Bool projects: +- `my-app-staging` — dev previews +- `my-app-prod` — production + +Deploy to staging first, test, then promote. + +Or use different branches in CI/CD: + +```yaml +on: + push: + branches: + - main # deploys to prod + - staging # deploys to staging +``` + +### What's the 65 KB limit? + +Your entire app (source + schemas + config) must be < 65 KB compressed. This forces efficiency: + +- ✅ Minified JavaScript +- ✅ Tree-shaken dependencies +- ✅ No node_modules in the zip +- ❌ Large media assets (use a CDN instead) + +Most apps (React + schemas) are 20–40 KB. + +--- + +## Performance & Limits + +### What are the query limits? + +- **Lists**: 50 rows by default, 5000 max per call +- **Filters**: 5000 rows max per call +- **Bulk operations**: 1000 rows per call +- **Realtime**: no hard limit (depends on Postgres capacity) + +For larger datasets, paginate: + +```ts +const all = []; +for (let skip = 0; skip < total; skip += 5000) { + const batch = await bool.entities.tasks.filter({...}, { limit: 5000, skip }); + all.push(...batch); +} +``` + +### How do I optimize slow queries? + +1. **Add indexes**: RLS and `user_id` are indexed by default +2. **Filter early**: filter before sorting/pagination +3. **Use specific fields**: don't fetch everything if you only need a few columns +4. **Paginate**: avoid fetching 10K rows at once + +For complex queries, you might need a database view or a custom API route. + +### What about N+1 queries? + +The SDK doesn't batch queries. If you fetch 100 posts and then their authors: + +```ts +const posts = await bool.entities.posts.list(); +const authors = await Promise.all( + posts.map(p => bool.entities.users.get(p.author_id)) +); +``` + +This is 101 queries (1 + 100). For small datasets, it's fine. For large datasets, consider: + +1. **Denormalize**: store `author_name` on the post +2. **Batch fetch**: write a custom query if you need optimization +3. **Junction tables**: reduce the number of separate fetches + +--- + +## Billing & Costs + +### Is Bool free? + +Bool offers a free tier with limitations. Paid tiers unlock: +- Higher limits +- More storage +- Premium support +- Custom domains + +### Do I pay per API call? + +No. You pay for project capacity, not per request. Use as much as you want within your tier. + +### What about AI credits? + +AI calls are metered separately. Bool includes AI credits in paid plans. Free tier has limited AI. + +--- + +## Security + +### Is my data private? + +Yes. RLS enforces row-level isolation. End-users only see their own rows. + +### Can I encrypt fields? + +Not natively. You can: +1. Encrypt before sending: `await bool.entities.tasks.create({ secret: encrypt(data) })` +2. Decrypt when reading: `decrypt(task.secret)` + +Or use a custom database view with pgcrypto. + +### Is data backed up? + +Yes. Bool backs up daily. You can request a backup via support. + +### Can I export my data? + +Yes. Use the Bool dashboard to export schemas/data, or query via the SDK and save locally. + +--- + +## Troubleshooting + +### "Invalid project" error + +- Check project ID (copy from Bool editor) +- Verify you own the project (or have access) +- Run `npx bool link --project ` again + +### "Unauthorized" on deploy + +- Refresh your token: `npx bool link --project ` +- Check `.env.bool` is still present and readable +- Verify `BOOL_TOKEN` env var is set + +### Types out of sync + +```bash +npx bool types +``` + +This refreshes `bool/types.d.ts` from the server. + +### Data not appearing + +1. Check user is authenticated: `const user = await bool.auth.getUser()` +2. For private entities, verify `user_id` matches current user +3. Check RLS policies: in the Bool dashboard, view table details +4. Try refetching: `const fresh = await bool.entities.tasks.list()` + +### Realtime not working + +- Verify subscription is active: `const unsubscribe = bool.subscribeToChanges(...)` +- Check browser console for errors +- Realtime needs WebSocket support (most modern browsers have it) + +--- + +## More Help + +- [Local Development Guide](./LOCAL-DEVELOPMENT.md) +- [Deployment Guide](./DEPLOYMENT.md) +- [Data Modeling](./DATA-MODELING.md) +- [Examples](../examples/) +- [API Reference](../README.md) diff --git a/docs/LOCAL-DEVELOPMENT.md b/docs/LOCAL-DEVELOPMENT.md new file mode 100644 index 0000000..89d8b15 --- /dev/null +++ b/docs/LOCAL-DEVELOPMENT.md @@ -0,0 +1,489 @@ +# Local Development — Build on Your Machine + +Develop an app on your own computer using a Bool project as your backend, then publish it to Bool hosting. No infrastructure to manage, no deployment configs to learn — just your code and the Bool SDK. + +## What You Get + +- **Data management**: Full control over your schema with TypeScript types +- **End-user accounts**: Isolated auth per app (signup, signin, password reset) +- **File storage**: Store and serve files through your app +- **AI integration**: Use Bool's AI credits server-side +- **Realtime updates**: Postgres-backed subscriptions +- **Published URL**: `https://.bool.so` when you're ready + +## Your First App + +### 1. Link Your Bool Project + +Create a personal access token in Bool (Settings → Access tokens), then: + +```bash +export BOOL_TOKEN=bool_live_xxxxx +npx bool link --project +``` + +This writes three files: +- **`bool.config.json`** — Project metadata (commit this) +- **`.env.bool`** — Admin data key (add to `.gitignore`, keep secret) +- **`bool/types.d.ts`** — TypeScript types for your data (auto-updated) + +### 2. Create Your Client + +In your app (Node, Vite, whatever), import the config and create the SDK client: + +```ts +import { createBoolClient } from "bool-sdk"; +import config from "./bool.config.json"; + +export const bool = createBoolClient({ + supabaseUrl: config.supabaseUrl, + supabaseAnonKey: config.supabaseAnonKey, + schema: config.schema, + appOrigin: config.appOrigin, + slug: config.slug, + apiKey: process.env.BOOL_API_KEY, // from .env.bool +}); + +export const supabase = bool.db; // raw Supabase if needed +export const auth = bool.auth; +``` + +### 3. Define Your Data Model + +Create a JSON Schema file for each table: + +**`bool/entities/tasks.json`:** +```json +{ + "type": "object", + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "done": { "type": "boolean" }, + "user_id": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" } + }, + "required": ["id", "title", "done", "user_id", "created_at"] +} +``` + +Push it to your project: + +```bash +npx bool entities push --dir bool/entities +``` + +The platform generates a migration, applies it to your schema, and enables Row-Level Security. + +### 4. Use Your Data + +```ts +// List (sorted, paginated) +const tasks = await bool.entities.tasks.list("-created_at"); + +// Filter (MongoDB-style operators) +const active = await bool.entities.tasks.filter({ done: false, count: { $gte: 5 } }); + +// Get one +const task = await bool.entities.tasks.get(id); + +// Create +const newTask = await bool.entities.tasks.create({ + title: "Build the thing", + done: false, + user_id: userId, +}); + +// Update +await bool.entities.tasks.update(id, { done: true }); + +// Delete +await bool.entities.tasks.delete(id); + +// Bulk operations +await bool.entities.tasks.bulkCreate([...]); +await bool.entities.tasks.updateMany({ done: false }, { $set: { done: true } }); +``` + +All queries are type-safe via `bool/types.d.ts`. + +### 5. Add Authentication + +Use the Bool auth layer for signup, signin, and password reset: + +```ts +// Sign up +const { user, session } = await bool.auth.signUp({ + email: "user@example.com", + password: "secret", +}); + +// Sign in +const { user, session } = await bool.auth.signInWithPassword({ + email: "user@example.com", + password: "secret", +}); + +// Get current user +const { data: { user } } = await bool.auth.getUser(); + +// Sign out +await bool.auth.signOut(); + +// Listen to auth changes +bool.auth.onAuthStateChange((event, session) => { + console.log("Auth changed:", event); +}); +``` + +In React, use the auth layer: + +```tsx +import { BoolAuthProvider, AuthGate, useBoolAuth } from "bool-sdk/react"; + +export default function App() { + return ( + + }> + + + + ); +} + +function Dashboard() { + const { user, signOut } = useBoolAuth(); + return ( +
+

Logged in as {user.email}

+ +
+ ); +} +``` + +### 6. Deploy + +When you're ready to go live: + +```bash +npx bool deploy +``` + +This: +1. Zips your source and `bool/entities/` schemas +2. Uploads to Bool +3. Polls for status +4. Live at `https://.bool.so` + +## Use Cases + +### Static Site + Backend + +You're building a portfolio site with a contact form. Use Bool for data storage and email. + +```ts +// API handler (Remix, Next, etc.) +export async function handleContactForm(formData) { + const contact = await bool.entities.contacts.create({ + email: formData.email, + message: formData.message, + user_id: getCurrentUserId(), + }); + + // Email integration via Zapier/webhook + await sendEmail(contact); + + return { success: true }; +} +``` + +**Deployment**: Push your site + schemas. Bool handles the database. + +--- + +### SaaS with Multi-Tenant Data + +Your app is a task manager for teams. Each user sees only their team's tasks via RLS. + +```ts +// Your schema enforces ownership +// tasks table: user_id (owner field → RLS isolation) + +const myTasks = await bool.entities.tasks.list(); // only my tasks +const otherUsersTasks = await bool.entities.tasks.filter({ user_id: "not-me" }); // empty +``` + +**Deployment**: Same `bool deploy`. Bool's RLS automatically isolates per user. + +--- + +### Real-Time Collaborative App + +Building a Figma-like editor? Use Realtime to sync across clients. + +```ts +// Subscribe to changes +const unsubscribe = bool.subscribeToChanges("drawings", (change) => { + // change = { table: "drawings", op: "INSERT|UPDATE|DELETE" } + // Refetch the document to get fresh data + const doc = await bool.entities.drawings.get(docId); + redraw(doc); +}); +``` + +**Deployment**: No special config. Realtime channels are built-in. + +--- + +### Admin Dashboard for Your Service + +You offer a service (API, SaaS, whatever) and need an internal dashboard. + +```ts +// Your admin schema +// customers table: account status, billing, usage +// logs table: API calls, errors, performance + +const customers = await bool.entities.customers.list(); +const recentErrors = await bool.entities.logs.filter({ level: "error", count: { $gte: 100 } }); +``` + +Admin key gives you full read/write access during development: + +```ts +const newCustomer = await bool.entities.customers.create({ + name: "Acme Corp", + status: "active", + user_id: "admin-user-id", + // ^ required on private tables when using admin key (no end-user identity) +}); +``` + +**Deployment**: Ship it. Users sign in, see their data, you see everything in the admin section. + +--- + +### Rapid Prototyping + +Idea validation, hackathons, proof-of-concept. + +```bash +# Day 1: link project, define schema, basic CRUD +npx bool link --project +npx bool entities push --dir bool/entities + +# Day 2: add auth, realtime updates +# Day 3: deploy and share the URL +npx bool deploy +``` + +No infrastructure setup. No database to manage. No vendor lock-in (your schema is yours). + +--- + +## Workflows + +### Start from Scratch + +1. Create a Bool project in the Bool editor +2. `npx bool link --project ` in your local app +3. Define entities in `bool/entities/` +4. `npx bool entities push` +5. Code your app + +Your schema lives both locally (source) and on the platform (active DB). + +### Start in the Editor, Sync to Local + +1. Design your schema in the Bool visual editor +2. `npx bool entities pull` in your local folder +3. See the generated `bool/types.d.ts` +4. Continue editing locally or in the editor (they stay in sync) + +### Iterate Locally, Test on Preview + +1. `npx bool entities push` to update the live schema +2. The platform rebuilds your app on a preview URL +3. Share the link for feedback +4. When ready: `npx bool deploy` to production + +### Publish from CI/CD + +```yaml +# GitHub Actions example +- run: npx bool deploy --token ${{ secrets.BOOL_TOKEN }} +``` + +Your app deploys on every push to main (or manually via workflow dispatch). + +--- + +## Private vs. Public Entities + +### Private (Default) + +Rows belong to a user. RLS enforces owner isolation. + +```json +// bool/entities/tasks.json +{ + "type": "object", + "properties": { + "id": { "type": "string" }, + "user_id": { "type": "string" }, + "title": { "type": "string" } + }, + "required": ["id", "user_id", "title"], + "x-private": true // optional: makes intent explicit +} +``` + +**End-user client:** +```ts +const tasks = await bool.entities.tasks.list(); // only mine +``` + +**Admin client (with apiKey):** +```ts +// Must provide user_id explicitly (admin has no user identity) +await bool.entities.tasks.create({ + title: "Task for someone", + user_id: "user-id-here", +}); +``` + +### Public + +Open read/write with optional RLS policies. + +```json +// bool/entities/comments.json +{ + "type": "object", + "properties": { + "id": { "type": "string" }, + "text": { "type": "string" }, + "post_id": { "type": "string" } + }, + "required": ["id", "text", "post_id"], + "x-private": false +} +``` + +Anyone (end-user or public) can read/write, subject to RLS. + +--- + +## Tips & Gotchas + +### Admin Key Only Works During Development + +The admin key (`apiKey`) is your personal development credential. Treat it like a password: + +- ✅ Use in local scripts and CI/CD +- ❌ Don't ship it in client code +- ❌ Don't commit `.env.bool` to git + +When your app is deployed, end-users authenticate via Bool Auth (no admin key). + +### Private Entities Need `user_id` on Admin Create + +When you use the admin key and create a row on a private entity, Bool can't default `user_id` (you have no user identity). You must pass it: + +```ts +// ❌ Fails with NOT NULL violation +await bool.entities.tasks.create({ title: "Task" }); + +// ✅ Works +await bool.entities.tasks.create({ title: "Task", user_id: "uid" }); +``` + +### Filters & Operators + +Entity filters use MongoDB-style syntax: + +```ts +// Comparison +{ status: "active" } // $eq (default) +{ count: { $gt: 10 } } // $gt, $gte, $lt, $lte +{ id: { $in: ["a", "b"] } } // $in, $nin +{ email: { $regex: "^admin" } } // $regex + +// Logic +{ $and: [{...}, {...}] } // AND +{ $or: [{...}, {...}] } // OR + +// Existence +{ deleted_at: { $exists: false } } // $exists +``` + +### Pagination + +Lists are paginated (50 rows by default, max 5000): + +```ts +const page1 = await bool.entities.tasks.list("-created_at", { limit: 50, skip: 0 }); +const page2 = await bool.entities.tasks.list("-created_at", { limit: 50, skip: 50 }); + +// Or filter to get all matching +const all = await bool.entities.tasks.filter({ status: "active" }, { limit: 5000 }); +``` + +### Sorting + +Sort by column name, prefix with `-` for descending: + +```ts +bool.entities.tasks.list("-created_at"); // newest first +bool.entities.tasks.list("title"); // A-Z +``` + +### Realtime Only Broadcasts Pings + +Subscriptions send `{table, op}` pings — never row data. Always refetch on ping: + +```ts +bool.subscribeToChanges("tasks", async (change) => { + const updated = await bool.entities.tasks.get(taskId); + updateUI(updated); +}); +``` + +--- + +## Troubleshooting + +### `bool link` Fails with "Not found" + +- Check project ID (copy from Bool editor) +- Verify token: `echo $BOOL_TOKEN` +- Token must be owner-level (Admin → Settings → Access tokens) + +### Deploy Fails with 401 + +- Refresh your token: `npx bool link --project ` +- `.env.bool` may be outdated + +### Types Not Updating + +```bash +npx bool types +``` + +This refreshes `bool/types.d.ts` from the server. + +### Data Not Appearing + +- Verify user is logged in: `await bool.auth.getUser()` +- Check RLS: private entities filter by current user +- Admin key bypasses RLS, so admin reads/writes always work + +--- + +## Next Steps + +- [Deployment guide](./DEPLOYMENT.md) — publishing and going live +- [Data modeling](./DATA-MODELING.md) — schema patterns and best practices +- [React integration](./REACT.md) — hooks and components +- [API reference](../README.md#Usage) — all SDK methods diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..4582181 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,196 @@ +# Bool SDK Documentation + +Welcome to Bool. Build your app on your machine, publish to Bool hosting, no infrastructure required. + +## Quickstart (5 min) + +```bash +npm install bool-sdk +export BOOL_TOKEN=bool_live_xxxxx # from Bool → Settings + +npx bool link --project # connect to your project +npm run dev # develop locally +npx bool deploy # publish when ready +``` + +Your app is live at `https://.bool.so`. + +--- + +## Guides + +### [Local Development](./LOCAL-DEVELOPMENT.md) +**Your complete guide to building apps locally.** Covers linking a project, defining schemas, using the SDK, and different workflows. + +- **Link your project** — set up config, types, and credentials +- **Define data models** — create tables with JSON Schema +- **Use the SDK** — CRUD operations, auth, realtime, AI +- **Different workflows** — start from scratch, sync with editor, iterate locally +- **Use cases** — static sites, SaaS, real-time apps, admin dashboards, prototyping +- **Tips & gotchas** — admin key behavior, private entities, filters, pagination + +**Read this first.** It walks you through building your first app, step by step. + +--- + +### [Deployment](./DEPLOYMENT.md) +**Publish your app to production and beyond.** + +- **How publishing works** — what happens when you deploy +- **Quick deploy** — one command to go live +- **CI/CD** — GitHub Actions and automation +- **Monitoring** — check build logs and app health +- **Troubleshooting** — common deploy issues and fixes + +**Read this when you're ready to ship.** + +--- + +### [Data Modeling](./DATA-MODELING.md) +**Design your database schema for privacy, performance, and maintainability.** + +- **Field types** — supported types and constraints +- **Privacy** — private (owner-isolated) vs. public (open) entities +- **Common patterns** — timestamps, ownership, soft deletes, status enums, nesting, relationships +- **Schema evolution** — adding/removing/renaming fields safely +- **Design tips** — keep it simple, use enums, denormalize, archive instead of delete +- **Real-world examples** — blog, task manager, e-commerce schemas + +**Read this to understand how to structure your data.** + +--- + +### [FAQ](./FAQ.md) +**Common questions and answers.** + +- **Getting started** — what's the difference between Bool apps and local dev, do I need SQL? +- **Development** — offline development, schema changes, testing, seeding data +- **Auth** — how Bool Auth works, RLS, private vs. public data +- **Queries** — sorting, filtering, pagination, joins, counts, bulk operations +- **Realtime** — subscriptions, filtering, unsubscribing +- **Deployment** — build times, CI/CD, staging/prod, the 65 KB limit +- **Performance** — query limits, optimization, N+1 queries +- **Troubleshooting** — errors and how to fix them + +**Read this when you have a specific question.** + +--- + +## Examples + +These are complete, runnable examples of real apps: + +### [Todo App (React)](../examples/todo-app-react.md) +A task manager with signup, task CRUD, and realtime updates. + +**Shows:** +- React integration with `BoolAuthProvider` +- Private entities (user-owned tasks) +- Create, read, update, delete operations +- Realtime subscriptions and refetching + +--- + +### [Blog with CMS](../examples/blog-with-cms.md) +A published blog with public pages and an admin dashboard. + +**Shows:** +- Public entities (published posts, comments) +- Filtering (published posts only) +- Admin-only pages +- Comment moderation flow + +--- + +## API Reference + +See the [main README](../README.md#Usage) for full SDK API documentation. + +**Quick links:** +- **Entity methods** — `list()`, `filter()`, `get()`, `create()`, `bulkCreate()`, `update()`, `bulkUpdate()`, `updateMany()`, `delete()`, `deleteMany()` +- **Auth methods** — `signUp()`, `signInWithPassword()`, `signOut()`, `getUser()`, `onAuthStateChange()`, password reset +- **AI methods** — `generate()`, `stream()` with schema support +- **Realtime** — `subscribeToChanges()` +- **Raw access** — `client.db` for Supabase REST queries + +--- + +## Key Concepts + +### Projects +A Bool project is your app's backend. It includes: +- A Postgres database with your schema +- User accounts (Bool Auth) +- File storage +- AI credits + +Create a project in the Bool editor, then connect locally with `bool link --project `. + +### Entities (Tables) +Entities are JSON Schema files that define your database tables. Commit them to git. + +```json +{ + "type": "object", + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "done": { "type": "boolean" } + }, + "required": ["id", "title", "done"] +} +``` + +Push changes: `npx bool entities push --dir bool/entities`. + +### Admin Key +The personal API key (`apiKey` parameter) lets you bypass RLS during development. Treat it like a password: +- ✅ Use in local code and CI/CD +- ❌ Never ship it in client bundles +- ❌ Never commit `.env.bool` to git + +### RLS (Row-Level Security) +Postgres feature that automatically filters rows based on the current user. For private entities (with `user_id`), RLS enforces owner isolation: + +```ts +// End-user: sees only their rows +await bool.entities.tasks.list(); // returns tasks where user_id == current_user +``` + +### Realtime +Postgres notifies clients of changes via WebSocket. The SDK doesn't send full row data (for performance) — instead, you get pings and refetch: + +```ts +bool.subscribeToChanges("tasks", async (change) => { + const updated = await bool.entities.tasks.get(taskId); +}); +``` + +--- + +## Stack + +The Bool SDK uses: +- **Supabase** — Postgres, Auth, Realtime, Storage +- **Gateway** — Bool's request router (your SDK calls go through here) +- **Vercel Sandbox** — builds and runs your deployed app +- **Zero deps** — SDK has no runtime dependencies (just Supabase client in peerDeps) + +--- + +## Next Steps + +1. **New to Bool?** Start with [Local Development](./LOCAL-DEVELOPMENT.md) +2. **Building your app?** Check [Data Modeling](./DATA-MODELING.md) for schema patterns +3. **Ready to ship?** Read [Deployment](./DEPLOYMENT.md) +4. **Have a question?** Search [FAQ](./FAQ.md) +5. **Want to see examples?** Check [Examples](../examples/) + +--- + +## Support + +- 📖 [Documentation](.) — you're reading it +- 💬 [Discussions](https://github.com/codehs/bool-sdk/discussions) +- 🐛 [Issues](https://github.com/codehs/bool-sdk/issues) +- 💌 [Email](mailto:hello@bool.so) diff --git a/examples/blog-with-cms.md b/examples/blog-with-cms.md new file mode 100644 index 0000000..fc47932 --- /dev/null +++ b/examples/blog-with-cms.md @@ -0,0 +1,381 @@ +# Example: Blog with CMS (Static + Admin) + +A published blog (public pages) with an admin dashboard for content management. + +## Project Structure + +``` +my-blog/ + bool/ + entities/ + posts.json + comments.json + src/ + pages/ + index.tsx # blog home + blog/ + [slug].tsx # blog post page + app/ + admin/ + page.tsx # admin dashboard (protected) + components/ + BlogHeader.tsx + PostCard.tsx +``` + +## 1. Schema + +**`bool/entities/posts.json`:** +```json +{ + "type": "object", + "properties": { + "id": { "type": "string" }, + "slug": { "type": "string" }, + "title": { "type": "string" }, + "excerpt": { "type": "string" }, + "content": { "type": "string" }, + "author": { "type": "string" }, + "published": { "type": "boolean" }, + "published_at": { "type": ["string", "null"], "format": "date-time" }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" } + }, + "required": ["id", "slug", "title", "content", "author", "published", "created_at"], + "x-private": false +} +``` + +**`bool/entities/comments.json`:** +```json +{ + "type": "object", + "properties": { + "id": { "type": "string" }, + "post_id": { "type": "string" }, + "author": { "type": "string" }, + "email": { "type": "string" }, + "content": { "type": "string" }, + "approved": { "type": "boolean" }, + "created_at": { "type": "string", "format": "date-time" } + }, + "required": ["id", "post_id", "author", "email", "content", "approved", "created_at"], + "x-private": false +} +``` + +## 2. Public Blog Pages + +**`src/pages/index.tsx`:** +```tsx +import { useEffect, useState } from "react"; +import { bool } from "../lib/supabase"; +import { PostCard } from "../components/PostCard"; + +export default function HomePage() { + const [posts, setPosts] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const loadPosts = async () => { + // Only fetch published posts, sorted by date + const published = await bool.entities.posts.filter( + { published: true }, + { limit: 100 } + ); + setPosts(published.sort((a, b) => + new Date(b.published_at) - new Date(a.published_at) + )); + setLoading(false); + }; + loadPosts(); + }, []); + + if (loading) return

Loading...

; + + return ( +
+

My Blog

+
+ {posts.map((post) => ( + + ))} +
+
+ ); +} +``` + +**`src/pages/blog/[slug].tsx`:** +```tsx +import { useParams, useEffect, useState } from "react"; +import { bool } from "../../lib/supabase"; + +export default function PostPage() { + const { slug } = useParams(); + const [post, setPost] = useState(null); + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const loadPost = async () => { + // Fetch the post by slug + const posts = await bool.entities.posts.filter({ slug }); + if (posts.length > 0) { + setPost(posts[0]); + + // Fetch approved comments + const approved = await bool.entities.comments.filter({ + post_id: posts[0].id, + approved: true, + }); + setComments(approved); + } + setLoading(false); + }; + loadPost(); + }, [slug]); + + if (loading) return

Loading...

; + if (!post) return

Post not found

; + + return ( +
+

{post.title}

+

+ By {post.author} on{" "} + {new Date(post.published_at).toLocaleDateString()} +

+ +
{post.content}
+ +
+

Comments ({comments.length})

+ {comments.map((comment) => ( +
+

+ {comment.author}{" "} + + {new Date(comment.created_at).toLocaleDateString()} + +

+

{comment.content}

+
+ ))} +
+ + +
+ ); +} + +function CommentForm({ postId }) { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [content, setContent] = useState(""); + const [submitted, setSubmitted] = useState(false); + + const handleSubmit = async () => { + if (!name || !email || !content) return; + + // Create comment (unapproved by default) + await bool.entities.comments.create({ + post_id: postId, + author: name, + email, + content, + approved: false, // admin reviews first + created_at: new Date().toISOString(), + }); + + setSubmitted(true); + setName(""); + setEmail(""); + setContent(""); + }; + + if (submitted) { + return

Thanks! Your comment is pending review.

; + } + + return ( +
+

Leave a Comment

+ setName(e.target.value)} + /> + setEmail(e.target.value)} + /> +