From 3b5ff5b333f9468dd28a436d9163d46c4f837153 Mon Sep 17 00:00:00 2001 From: PatrickJS Date: Tue, 2 Jun 2026 19:46:50 -0700 Subject: [PATCH] feat(examples): add local sourceFile web app --- README.md | 53 +++++- db.config.example.mjs | 7 +- docs/configuration.md | 25 ++- docs/integrations.md | 1 - docs/package-api.md | 1 - examples/advanced/db.config.mjs | 1 - examples/basic/db.config.mjs | 1 - examples/computed-fields/db.config.mjs | 1 - examples/content-collections/db.config.mjs | 1 - examples/csv/db.config.mjs | 1 - examples/data-first/db.config.mjs | 1 - examples/diagnostics/db.config.mjs | 1 - examples/hono-auth/db.config.mjs | 1 - examples/local-web-app/README.md | 77 ++++++++ examples/local-web-app/db.config.mjs | 7 + examples/local-web-app/db/appState.json | 5 + examples/local-web-app/example.json | 5 + examples/local-web-app/framework/state.js | 70 +++++++ examples/local-web-app/serve-example.mjs | 20 ++ examples/local-web-app/server/runtime.js | 211 +++++++++++++++++++++ examples/local-web-app/src/app.js | 194 +++++++++++++++++++ examples/local-web-app/src/index.html | 111 +++++++++++ examples/production-json/db.config.mjs | 1 - examples/relations/db.config.mjs | 1 - examples/rest-client/db.config.mjs | 1 - examples/schema-first/db.config.mjs | 1 - examples/schema-manifest/db.config.mjs | 1 - examples/schema-ui/db.config.mjs | 1 - package.json | 3 + test/examples/examples.test.ts | 93 +++++++++ test/runtime/package-api.test.ts | 74 ++++++++ 31 files changed, 942 insertions(+), 29 deletions(-) create mode 100644 examples/local-web-app/README.md create mode 100644 examples/local-web-app/db.config.mjs create mode 100644 examples/local-web-app/db/appState.json create mode 100644 examples/local-web-app/example.json create mode 100644 examples/local-web-app/framework/state.js create mode 100644 examples/local-web-app/serve-example.mjs create mode 100644 examples/local-web-app/server/runtime.js create mode 100644 examples/local-web-app/src/app.js create mode 100644 examples/local-web-app/src/index.html diff --git a/README.md b/README.md index b915cde..dfcb25a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Other useful paths: - [`examples/computed-fields`](./examples/computed-fields): computed field patterns across several schema-backed models. - [`examples/production-json`](./examples/production-json): feature flags and settings in the JSON store behind registered operations. - [`examples/rest-client`](./examples/rest-client): calling @async/db from app or test code. +- [`examples/local-web-app`](./examples/local-web-app): loopback app state saved directly to `db/*.json`. - [`examples/schema-manifest`](./examples/schema-manifest): schema metadata for admin/CMS UI. - [`examples/standard-schema`](./examples/standard-schema): Standard Schema validators with Async DB metadata overlays. - [`examples/hono-auth`](./examples/hono-auth): optional Hono auth and write hooks. @@ -167,6 +168,55 @@ The built-in JSON store is production-appropriate only for file-suitable resourc @async/db is not an auth layer, an ORM, a hosted database service, or a broad JSON Schema compatibility project. For production-facing APIs, put app traffic behind registered operations, app-owned auth/authorization, rate limits, and observability. See [Production JSON Database](./docs/json-production.md), [Resource Graduation And Mixed Stores](./docs/store-graduation.md), and [Prototype To Production REST Guide](./docs/prototype-to-production.md). +## Save Directly To `db/*.json` + +The default `json` store keeps source fixtures unchanged and writes app edits to +the generated mirror under `.db/state`. For small local apps where saved state +should live in the project folder, use the `sourceFile` store: + +```js +import { defineConfig } from '@async/db/config'; + +export default defineConfig({ + stores: { + default: 'sourceFile', + }, +}); +``` + +Now writes to plain JSON resources update `db/.json` directly. +Override individual resources when some data should still use the mirror: + +```js +export default defineConfig({ + stores: { + default: 'sourceFile', + }, + resources: { + importedRows: { store: 'json' }, + }, +}); +``` + +See [`examples/local-web-app`](./examples/local-web-app) for a loopback app that +saves on blur/change, keeps server state canonical, and uses browser storage +only for transient reload recovery. + +For simple local websites, keep the shape boring: + +```txt +db/ saved JSON documents and seed data +src/ browser HTML, CSS, and app code +server/ loopback request handlers and @async/db mounting +framework/ small reload, draft, and DOM helpers +``` + +Run `async-db sync` in that loop even when every resource uses `sourceFile`. +Sync still validates the fixture folder, infers the schema, and writes generated +metadata/types for tools. The difference is that app writes go back to plain +`db/*.json` instead of the `.db/state` mirror, so the project folder contains +the state you want to save, copy, or commit. + ## Add Schema When It Pays For It Data-first fixtures are enough until the shape matters. Inspect what @async/db infers: @@ -389,7 +439,7 @@ routes, see the ## Which Example Should I Start With? -The examples are a learning path. Run any example with `npm run db -- sync --cwd ./examples/` and `npm run db -- serve --cwd ./examples/`, or run `npm run examples` to open one lazy examples index. The examples index binds to `127.0.0.1` by default; use `npm run examples -- --tailscale-serve` when you want Tailscale Serve to proxy that local port over HTTPS for devices in your tailnet. +The examples are a learning path. Run most examples with `npm run db -- sync --cwd ./examples/` and `npm run db -- serve --cwd ./examples/`, or run `npm run examples` to open one lazy examples index. Use `npm run examples` for examples with custom app routes such as `local-web-app` and `schema-ui`. The examples index binds to `127.0.0.1` by default; use `npm run examples -- --tailscale-serve` when you want Tailscale Serve to proxy that local port over HTTPS for devices in your tailnet. | If you want to learn... | Start with | What it shows | | --- | --- | --- | @@ -399,6 +449,7 @@ The examples are a learning path. Run any example with `npm run db -- sync --cwd | Different computed field patterns | [`examples/computed-fields`](./examples/computed-fields) | Shorthand resolvers, `resolveMany`, formatting, and runtime-context lookups | | Contract-first resources | [`examples/schema-first`](./examples/schema-first) | Schema-only resources, empty seed records, committed types | | Calling @async/db from app or test code | [`examples/rest-client`](./examples/rest-client) | `createDbClient`, direct REST calls, REST batching | +| Local app state saved in the project | [`examples/local-web-app`](./examples/local-web-app) | `stores.default: 'sourceFile'`, blur/change saves, transient reload state, custom example runtime | | Related local records | [`examples/relations`](./examples/relations) | Relation metadata, `expand`, and nested `select` | | CSV as the source of truth | [`examples/csv`](./examples/csv) | CSV inference, source hashes, mirror refreshes | | Admin/CMS-style field metadata | [`examples/schema-manifest`](./examples/schema-manifest) | `outputs.schemaManifest` and manifest customization | diff --git a/db.config.example.mjs b/db.config.example.mjs index 20858b0..56244d1 100644 --- a/db.config.example.mjs +++ b/db.config.example.mjs @@ -2,9 +2,6 @@ import { defineConfig } from '@async/db/config'; export default defineConfig({ - // Fixture source folder. Defaults to './db'. - dbDir: './db', - // Generated output locations. Most committed outputs are opt-in. outputs: { stateDir: './.db', @@ -33,8 +30,8 @@ export default defineConfig({ // Runtime stores. The default json store writes app edits to // .db/state/.json while keeping source fixtures unchanged. - // Bind a resource to sourceFile only when supported writebacks should update - // a plain .json source fixture. Optional database stores such as + // Set stores.default to sourceFile when every plain .json resource should + // save directly back into db/.json. Optional database stores such as // @async/db/postgres, @async/db/kv, and @async/db/redis accept injected // clients so the core package stays dependency-light. stores: { diff --git a/docs/configuration.md b/docs/configuration.md index 06c350a..78cf28c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -53,8 +53,6 @@ See [db.config.example.mjs](../db.config.example.mjs) for a commented config wit import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', - outputs: { stateDir: './.db', types: './.db/types/index.d.ts', @@ -160,7 +158,7 @@ Use `dbDir` when fixtures live somewhere other than `./db`: import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', + dbDir: './fixtures', }); ``` @@ -170,18 +168,31 @@ Existing `sourceDir` configs still work; `dbDir` is the shorter fixture-folder n Source fixtures and runtime persistence are separate concerns. By default, source fixtures stay unchanged and app writes go to the generated JSON store under `.db/state`. -Use `resources..store` to bind a resource to a different store: +Use `stores.default` when every resource should use the same runtime store: ```js import { defineConfig } from '@async/db/config'; export default defineConfig({ stores: { - default: 'json', + default: 'sourceFile', + }, +}); +``` + +With that config, writes to plain JSON resources update `db/.json` directly. This is useful for small local web apps where the project folder should contain the saved app state. JSONC and CSV files remain source inputs; they cannot use `sourceFile` as writable state. + +Use `resources..store` to override the default for one resource: + +```js +import { defineConfig } from '@async/db/config'; + +export default defineConfig({ + stores: { + default: 'sourceFile', }, resources: { - users: { store: 'sourceFile' }, - activityEvents: { + importedRows: { store: 'json', indexes: [ { fields: ['observedAt'] }, diff --git a/docs/integrations.md b/docs/integrations.md index ef0bffd..d0b01f5 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -194,7 +194,6 @@ import { createDbHonoApp } from '@async/db/hono'; const app = new Hono(); app.route('/api', await createDbHonoApp({ - dbDir: './db', storage: { kind: 'sqlite', file: './data/app.sqlite', diff --git a/docs/package-api.md b/docs/package-api.md index 5b290b8..bedfd49 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -59,7 +59,6 @@ pnpm db serve import { openDb } from '@async/db'; const db = await openDb({ - dbDir: './db', outputs: { stateDir: './.db', }, diff --git a/examples/advanced/db.config.mjs b/examples/advanced/db.config.mjs index 1d6bf9b..a7b17c4 100644 --- a/examples/advanced/db.config.mjs +++ b/examples/advanced/db.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', outputs: { stateDir: './.db', types: './.db/types/index.d.ts', diff --git a/examples/basic/db.config.mjs b/examples/basic/db.config.mjs index 6c80369..62458ff 100644 --- a/examples/basic/db.config.mjs +++ b/examples/basic/db.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', outputs: { stateDir: './.db', types: './.db/types/index.d.ts', diff --git a/examples/computed-fields/db.config.mjs b/examples/computed-fields/db.config.mjs index b7f7181..d877cba 100644 --- a/examples/computed-fields/db.config.mjs +++ b/examples/computed-fields/db.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', outputs: { stateDir: './.db', types: './.db/types/index.d.ts', diff --git a/examples/content-collections/db.config.mjs b/examples/content-collections/db.config.mjs index 2db3102..85a83d8 100644 --- a/examples/content-collections/db.config.mjs +++ b/examples/content-collections/db.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', outputs: { stateDir: './.db', types: './.db/types/index.d.ts', diff --git a/examples/csv/db.config.mjs b/examples/csv/db.config.mjs index 3e2b677..0a179cf 100644 --- a/examples/csv/db.config.mjs +++ b/examples/csv/db.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', outputs: { stateDir: './.db', types: './.db/types/index.d.ts', diff --git a/examples/data-first/db.config.mjs b/examples/data-first/db.config.mjs index 3e2b677..0a179cf 100644 --- a/examples/data-first/db.config.mjs +++ b/examples/data-first/db.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', outputs: { stateDir: './.db', types: './.db/types/index.d.ts', diff --git a/examples/diagnostics/db.config.mjs b/examples/diagnostics/db.config.mjs index e5ae65e..a4f42e2 100644 --- a/examples/diagnostics/db.config.mjs +++ b/examples/diagnostics/db.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', outputs: { stateDir: './.db', types: './.db/types/index.d.ts', diff --git a/examples/hono-auth/db.config.mjs b/examples/hono-auth/db.config.mjs index c940f25..8e30c52 100644 --- a/examples/hono-auth/db.config.mjs +++ b/examples/hono-auth/db.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', outputs: { stateDir: './.db', types: './.db/types/index.d.ts', diff --git a/examples/local-web-app/README.md b/examples/local-web-app/README.md new file mode 100644 index 0000000..d713424 --- /dev/null +++ b/examples/local-web-app/README.md @@ -0,0 +1,77 @@ +# Local Web App Example + +## What This Teaches + +Use this pattern for small local tools where the Node server is the source of +truth and the app state should be easy to save with the project. The example +writes directly to `db/appState.json` instead of the default `.db/state` mirror. + +## Files To Inspect + +- [db.config.mjs](./db.config.mjs): sets `stores.default` to `sourceFile`. +- [db/appState.json](./db/appState.json): the saved app document. +- [src/app.js](./src/app.js): browser code that saves on blur/change. +- [framework/state.js](./framework/state.js): transient reload-state helpers. +- [server/runtime.js](./server/runtime.js): app routes, version polling endpoint, and `/__db` mounting. +- [serve-example.mjs](./serve-example.mjs): examples launcher hook. + +## Simple Website Structure + +The example keeps the important parts in predictable folders: + +```txt +db/ saved JSON documents and seed data +src/ browser HTML, CSS, and app code +server/ loopback request handlers and @async/db mounting +framework/ small reload, draft, and input-state helpers +``` + +Use this shape when a toy website only needs local server state and a little +browser behavior. `src/` stays focused on the page, `server/` owns the loopback +routes, and `framework/` keeps supporting reload and draft helpers out of the +main app file. + +## Run It + +From the repository root: + +```bash +npm run db -- sync --cwd ./examples/local-web-app +npm run examples +``` + +Open the `local-web-app` link from the examples index. Edit either field, then +move focus out of the field. The app saves the document through the loopback +server into `db/appState.json`. + +## Why Sync With `sourceFile`? + +`npm run db -- sync --cwd ./examples/local-web-app` still matters even though +the app writes directly to `db/appState.json`. Sync validates the fixture folder, +infers the schema, and writes generated metadata/types for the viewer and tools. + +`stores.default: 'sourceFile'` changes where runtime writes land. Instead of +copying app edits into `.db/state/appState.json`, the server writes the plain +JSON source file. That keeps the saved state next to the toy project so it is +easy to inspect, copy, or commit. + +## Features To Notice + +- `stores.default: 'sourceFile'` makes plain JSON resources writable in `db/`. +- `resources..store` can still override the default for individual resources. +- Typing only updates transient browser state; blur/change commits to the server. +- Browser storage is only used to restore scroll position, active field, cursor + position, and unsaved draft text during a reload. +- The app polls `/api/version` and reloads when the served app files change. + +## Cleanup + +This example intentionally edits `db/appState.json` when you use the app. Revert +that file if you want the initial sample text again. + +Generated `.db/` output is ignored by git and can be removed whenever you want a fresh mirror. + +## More Docs + +- [Configuration](../../docs/configuration.md) +- [Server And Viewer](../../docs/server-and-viewer.md) diff --git a/examples/local-web-app/db.config.mjs b/examples/local-web-app/db.config.mjs new file mode 100644 index 0000000..2e4352b --- /dev/null +++ b/examples/local-web-app/db.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from '@async/db/config'; + +export default defineConfig({ + stores: { + default: 'sourceFile', + }, +}); diff --git a/examples/local-web-app/db/appState.json b/examples/local-web-app/db/appState.json new file mode 100644 index 0000000..4492ed1 --- /dev/null +++ b/examples/local-web-app/db/appState.json @@ -0,0 +1,5 @@ +{ + "title": "Local App Notes", + "note": "Edit this note in the local app. It saves to db/appState.json when the field loses focus.", + "updatedAt": "2026-06-03T00:00:00.000Z" +} diff --git a/examples/local-web-app/example.json b/examples/local-web-app/example.json new file mode 100644 index 0000000..15e6393 --- /dev/null +++ b/examples/local-web-app/example.json @@ -0,0 +1,5 @@ +{ + "title": "Local Web App", + "description": "A tiny loopback web app that treats @async/db server state as canonical, saves form edits on blur, and writes directly to db/*.json.", + "tags": ["local-app", "source-file", "live-reload"] +} diff --git a/examples/local-web-app/framework/state.js b/examples/local-web-app/framework/state.js new file mode 100644 index 0000000..d1f3f29 --- /dev/null +++ b/examples/local-web-app/framework/state.js @@ -0,0 +1,70 @@ +export const TRANSIENT_STORAGE_KEY = 'async-db:local-web-app:transient:v1'; + +const editableFields = new Set(['title', 'note']); +const commitEvents = new Set(['blur', 'change']); + +export function normalizeAppState(value = {}) { + const input = isObject(value) ? value : {}; + return { + title: stringValue(input.title, 'Local App Notes'), + note: stringValue(input.note, ''), + updatedAt: stringValue(input.updatedAt, ''), + }; +} + +export function normalizeTransientState(value = {}) { + const input = isObject(value) ? value : {}; + const drafts = isObject(input.drafts) ? input.drafts : {}; + const active = isObject(input.active) ? input.active : {}; + const field = editableFields.has(active.field) ? active.field : null; + + return { + drafts: Object.fromEntries([...editableFields].flatMap((name) => ( + typeof drafts[name] === 'string' ? [[name, drafts[name]]] : [] + ))), + active: field + ? { + field, + selectionStart: numberValue(active.selectionStart, 0), + selectionEnd: numberValue(active.selectionEnd, numberValue(active.selectionStart, 0)), + } + : null, + scrollY: numberValue(input.scrollY, 0), + }; +} + +export function applyTransientState(serverState, transientState) { + const state = normalizeAppState(serverState); + const transient = normalizeTransientState(transientState); + + for (const [field, draft] of Object.entries(transient.drafts)) { + if (editableFields.has(field)) { + state[field] = draft; + } + } + + return { + state, + transient, + }; +} + +export function shouldCommitFieldEvent(eventType, currentValue, lastSavedValue) { + return commitEvents.has(eventType) && String(currentValue ?? '') !== String(lastSavedValue ?? ''); +} + +export function editableFieldNames() { + return [...editableFields]; +} + +function stringValue(value, fallback) { + return typeof value === 'string' ? value : fallback; +} + +function numberValue(value, fallback) { + return Number.isFinite(value) && value >= 0 ? value : fallback; +} + +function isObject(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} diff --git a/examples/local-web-app/serve-example.mjs b/examples/local-web-app/serve-example.mjs new file mode 100644 index 0000000..24bfff1 --- /dev/null +++ b/examples/local-web-app/serve-example.mjs @@ -0,0 +1,20 @@ +import { createLocalWebAppRuntime } from './server/runtime.js'; + +/** @param {{ cwd: string; basePath?: string; url: string }} context */ +export async function createExampleRuntime(context) { + const { cwd, basePath, url, repoRoot } = context; + const runtime = await createLocalWebAppRuntime({ + cwd, + basePath, + repoRoot, + }); + + return { + ...runtime, + viewerUrl: `${url}/__db`, + demoUrl: `${url}/`, + demoLinks: [ + { label: 'App', href: '/' }, + ], + }; +} diff --git a/examples/local-web-app/server/runtime.js b/examples/local-web-app/server/runtime.js new file mode 100644 index 0000000..91d162a --- /dev/null +++ b/examples/local-web-app/server/runtime.js @@ -0,0 +1,211 @@ +import { stat, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { normalizeAppState } from '../framework/state.js'; + +export async function createLocalWebAppRuntime(options) { + const { cwd, basePath = '', repoRoot, skipSync = false } = options; + const { + openDb, + serializeError, + sendJson, + createDbRequestHandler, + createViewerEventHub, + watchSourceDir, + } = await loadAsyncDbRuntime(repoRoot); + const normalizedBasePath = normalizeBasePath(basePath); + const db = await openDb({ + cwd, + allowSourceErrors: true, + syncOnOpen: !skipSync, + }); + + if (skipSync) { + await db.runtime.hydrate(); + } + + const events = createViewerEventHub(); + const dbHandler = createDbRequestHandler(db, { + events, + rootRoutes: true, + apiBase: joinPaths(normalizedBasePath, '/__db'), + dataPath: joinPaths(normalizedBasePath, '/db'), + graphqlPath: joinPaths(normalizedBasePath, '/graphql'), + }); + let watcher; + let closed = false; + + try { + watcher = await watchSourceDir(db, events); + } catch (error) { + events.close(); + await db.close?.(); + throw error; + } + + return { + db, + async handleRequest(request, response) { + try { + const handled = await handleLocalAppRequest(request, response, { + cwd, + db, + basePath: normalizedBasePath, + sendJson, + }); + if (handled) { + return; + } + + await dbHandler(request, response); + } catch (error) { + sendJson(response, error.status ?? 500, serializeError(error, 'LOCAL_APP_SERVER_ERROR')); + } + }, + async close() { + if (closed) { + return; + } + closed = true; + watcher?.close(); + events.close(); + await db.close?.(); + }, + }; +} + +async function loadAsyncDbRuntime(repoRoot) { + const packageRoot = repoRoot ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + const distRoot = path.join(packageRoot, 'dist'); + const [indexModule, errorsModule, restModule, serverModule] = await Promise.all([ + import(pathToFileURL(path.join(distRoot, 'index.js')).href), + import(pathToFileURL(path.join(distRoot, 'errors.js')).href), + import(pathToFileURL(path.join(distRoot, 'rest/handler.js')).href), + import(pathToFileURL(path.join(distRoot, 'server.js')).href), + ]); + + return { + openDb: indexModule.openDb, + serializeError: errorsModule.serializeError, + sendJson: restModule.sendJson, + createDbRequestHandler: serverModule.createDbRequestHandler, + createViewerEventHub: serverModule.createViewerEventHub, + watchSourceDir: serverModule.watchSourceDir, + }; +} + +async function handleLocalAppRequest(request, response, context) { + const host = request.headers.host ?? 'localhost'; + const url = new URL(request.url ?? '/', `http://${host}`); + const pathname = stripBasePath(url.pathname, context.basePath); + if (pathname === null) { + return false; + } + + if (request.method === 'GET' && (pathname === '/' || pathname === '/index.html')) { + const version = await appVersion(context.cwd); + const html = await readFile(path.join(context.cwd, 'src/index.html'), 'utf8'); + sendHtml(response, html + .replaceAll('__BASE_PATH__', context.basePath) + .replaceAll('__APP_VERSION__', version)); + return true; + } + + if (request.method === 'GET' && pathname === '/app.js') { + sendJavaScript(response, await readFile(path.join(context.cwd, 'src/app.js'), 'utf8')); + return true; + } + + if (request.method === 'GET' && pathname === '/framework/state.js') { + sendJavaScript(response, await readFile(path.join(context.cwd, 'framework/state.js'), 'utf8')); + return true; + } + + if (request.method === 'GET' && pathname === '/api/version') { + context.sendJson(response, 200, { version: await appVersion(context.cwd) }); + return true; + } + + if (request.method === 'GET' && pathname === '/api/state') { + context.sendJson(response, 200, { state: normalizeAppState(await context.db.document('appState').get()) }); + return true; + } + + if (request.method === 'PUT' && pathname === '/api/state') { + const body = await readJsonRequest(request); + const state = normalizeAppState(body?.state ?? body); + const saved = await context.db.document('appState').put(state); + context.sendJson(response, 200, { state: saved }); + return true; + } + + return false; +} + +async function appVersion(cwd) { + const files = [ + 'src/index.html', + 'src/app.js', + 'framework/state.js', + ]; + const mtimes = await Promise.all(files.map(async (file) => { + const fileStat = await stat(path.join(cwd, file)); + return fileStat.mtimeMs; + })); + return String(Math.max(...mtimes)); +} + +async function readJsonRequest(request) { + const chunks = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + const text = Buffer.concat(chunks).toString('utf8').trim(); + return text ? JSON.parse(text) : {}; +} + +function sendHtml(response, html) { + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }); + response.end(html); +} + +function sendJavaScript(response, source) { + response.writeHead(200, { + 'content-type': 'text/javascript; charset=utf-8', + 'cache-control': 'no-store', + }); + response.end(source); +} + +function normalizeBasePath(basePath) { + if (!basePath || basePath === '/') { + return ''; + } + return `/${String(basePath).replace(/^\/+|\/+$/gu, '')}`; +} + +function joinPaths(basePath, childPath) { + const normalizedBase = normalizeBasePath(basePath); + const normalizedChild = `/${String(childPath).replace(/^\/+/u, '')}`; + return `${normalizedBase}${normalizedChild}`; +} + +function stripBasePath(pathname, basePath) { + const normalizedBasePath = normalizeBasePath(basePath); + if (!normalizedBasePath) { + return pathname; + } + + if (pathname === normalizedBasePath) { + return '/'; + } + + if (pathname.startsWith(`${normalizedBasePath}/`)) { + return pathname.slice(normalizedBasePath.length) || '/'; + } + + return null; +} diff --git a/examples/local-web-app/src/app.js b/examples/local-web-app/src/app.js new file mode 100644 index 0000000..cb9a247 --- /dev/null +++ b/examples/local-web-app/src/app.js @@ -0,0 +1,194 @@ +import { + TRANSIENT_STORAGE_KEY, + applyTransientState, + editableFieldNames, + normalizeAppState, + shouldCommitFieldEvent, +} from './framework/state.js'; + +const basePath = window.LOCAL_APP_BASE_PATH ?? ''; +const initialVersion = window.LOCAL_APP_VERSION ?? null; +const fields = { + title: document.querySelector('[data-field="title"]'), + note: document.querySelector('[data-field="note"]'), +}; +const status = document.querySelector('[data-status]'); +const savedAt = document.querySelector('[data-saved-at]'); + +let state = normalizeAppState(); +let lastSavedState = normalizeAppState(); +let currentVersion = initialVersion; +const pendingCommits = new Map(); + +boot().catch((error) => { + setStatus(error.message, 'error'); +}); + +async function boot() { + const transient = readTransientState(); + const response = await fetchJson('/api/state'); + const applied = applyTransientState(response.state, transient); + state = applied.state; + lastSavedState = normalizeAppState(response.state); + render(); + bindFields(); + restoreTransientState(applied.transient); + setStatus('Loaded from db/appState.json.', 'ok'); + window.addEventListener('beforeunload', saveTransientState); + window.addEventListener('scroll', saveTransientState, { passive: true }); + window.setInterval(checkForReload, 1000); +} + +function bindFields() { + for (const field of editableFieldNames()) { + const input = fields[field]; + input.addEventListener('input', () => { + state[field] = input.value; + saveTransientState(); + setStatus('Typing locally. Unfocus the field to save.', 'typing'); + }); + input.addEventListener('change', (event) => { + void commitField(field, event.type).catch(handleSaveError); + }); + input.addEventListener('blur', (event) => { + void commitField(field, event.type).catch(handleSaveError); + }); + } +} + +function render() { + for (const field of editableFieldNames()) { + if (document.activeElement !== fields[field]) { + fields[field].value = state[field]; + } + } + savedAt.textContent = state.updatedAt ? `Last saved ${state.updatedAt}` : 'Not saved yet'; +} + +async function commitField(field, eventType) { + const input = fields[field]; + const nextValue = input.value; + if (!shouldCommitFieldEvent(eventType, nextValue, lastSavedState[field])) { + return; + } + if (pendingCommits.get(field) === nextValue) { + return; + } + + pendingCommits.set(field, nextValue); + state = { + ...state, + [field]: nextValue, + updatedAt: new Date().toISOString(), + }; + try { + setStatus('Saving to db/appState.json...', 'saving'); + const response = await fetchJson('/api/state', { + method: 'PUT', + body: JSON.stringify({ state }), + }); + state = normalizeAppState(response.state); + lastSavedState = normalizeAppState(response.state); + clearTransientDraft(field); + render(); + setStatus('Saved to db/appState.json.', 'ok'); + } finally { + pendingCommits.delete(field); + } +} + +async function checkForReload() { + try { + const response = await fetchJson('/api/version'); + if (currentVersion && response.version && response.version !== currentVersion) { + saveTransientState(); + window.location.reload(); + return; + } + currentVersion = response.version; + } catch { + // Keep the editor usable if the local server is restarting. + } +} + +async function fetchJson(path, options = {}) { + const response = await fetch(`${basePath}${path}`, { + ...options, + headers: { + 'content-type': 'application/json; charset=utf-8', + ...(options.headers ?? {}), + }, + }); + const text = await response.text(); + const body = text ? JSON.parse(text) : {}; + if (!response.ok) { + throw new Error(body?.error?.message ?? `Request failed: ${response.status}`); + } + return body; +} + +function saveTransientState() { + const activeEntry = Object.entries(fields).find(([, input]) => input === document.activeElement); + const drafts = {}; + for (const [field, input] of Object.entries(fields)) { + if (input.value !== lastSavedState[field]) { + drafts[field] = input.value; + } + } + + const active = activeEntry + ? { + field: activeEntry[0], + selectionStart: activeEntry[1].selectionStart ?? 0, + selectionEnd: activeEntry[1].selectionEnd ?? activeEntry[1].selectionStart ?? 0, + } + : null; + + window.localStorage.setItem(TRANSIENT_STORAGE_KEY, JSON.stringify({ + drafts, + active, + scrollY: window.scrollY, + })); +} + +function readTransientState() { + try { + return JSON.parse(window.localStorage.getItem(TRANSIENT_STORAGE_KEY) || '{}'); + } catch { + return {}; + } +} + +function restoreTransientState(transient) { + if (transient.scrollY) { + window.scrollTo(0, transient.scrollY); + } + + if (!transient.active?.field) { + return; + } + + const input = fields[transient.active.field]; + input?.focus(); + input?.setSelectionRange?.(transient.active.selectionStart, transient.active.selectionEnd); +} + +function clearTransientDraft(field) { + const transient = readTransientState(); + if (transient?.drafts && typeof transient.drafts === 'object') { + delete transient.drafts[field]; + } + window.localStorage.setItem(TRANSIENT_STORAGE_KEY, JSON.stringify({ + ...transient, + scrollY: window.scrollY, + })); +} + +function setStatus(message, tone) { + status.textContent = message; + status.dataset.tone = tone; +} + +function handleSaveError(error) { + setStatus(error.message, 'error'); +} diff --git a/examples/local-web-app/src/index.html b/examples/local-web-app/src/index.html new file mode 100644 index 0000000..6eacfc3 --- /dev/null +++ b/examples/local-web-app/src/index.html @@ -0,0 +1,111 @@ + + + + + + @async/db Local Web App + + + + +
+
+

@async/db Local Web App

+

This page treats the loopback server as the source of truth and writes app state directly to db/appState.json.

+
+ +
+ + +

Loading...

+

+
+
+ + + diff --git a/examples/production-json/db.config.mjs b/examples/production-json/db.config.mjs index 8fe05f5..b6a28bf 100644 --- a/examples/production-json/db.config.mjs +++ b/examples/production-json/db.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', outputs: { stateDir: './.db', types: './.db/types/index.d.ts', diff --git a/examples/relations/db.config.mjs b/examples/relations/db.config.mjs index 3e2b677..0a179cf 100644 --- a/examples/relations/db.config.mjs +++ b/examples/relations/db.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', outputs: { stateDir: './.db', types: './.db/types/index.d.ts', diff --git a/examples/rest-client/db.config.mjs b/examples/rest-client/db.config.mjs index 3e2b677..0a179cf 100644 --- a/examples/rest-client/db.config.mjs +++ b/examples/rest-client/db.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', outputs: { stateDir: './.db', types: './.db/types/index.d.ts', diff --git a/examples/schema-first/db.config.mjs b/examples/schema-first/db.config.mjs index b7f7181..d877cba 100644 --- a/examples/schema-first/db.config.mjs +++ b/examples/schema-first/db.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', outputs: { stateDir: './.db', types: './.db/types/index.d.ts', diff --git a/examples/schema-manifest/db.config.mjs b/examples/schema-manifest/db.config.mjs index 3d8578e..438a5d9 100644 --- a/examples/schema-manifest/db.config.mjs +++ b/examples/schema-manifest/db.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from '@async/db/config'; export default defineConfig({ - dbDir: './db', outputs: { stateDir: './.db', types: './.db/types/index.d.ts', diff --git a/examples/schema-ui/db.config.mjs b/examples/schema-ui/db.config.mjs index 5c33f32..c0dcdfc 100644 --- a/examples/schema-ui/db.config.mjs +++ b/examples/schema-ui/db.config.mjs @@ -2,7 +2,6 @@ import { defineConfig, mergeManifest } from '@async/db/config'; export default defineConfig({ - dbDir: './db', outputs: { stateDir: './.db', types: './.db/types/index.d.ts', diff --git a/package.json b/package.json index 58299fb..2aa2496 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,9 @@ "examples/*/README.md", "examples/*/example.json", "examples/*/package.json", + "examples/*/serve-example.mjs", + "examples/*/framework/**", + "examples/*/server/**", "examples/*/db/**", "examples/*/db.config.mjs", "examples/*/src/**", diff --git a/test/examples/examples.test.ts b/test/examples/examples.test.ts index 64fc0e5..2dcf87a 100644 --- a/test/examples/examples.test.ts +++ b/test/examples/examples.test.ts @@ -51,6 +51,7 @@ test('examples launcher can discover repo examples and render an index page', as 'diagnostics', 'free-plan-upgrade', 'hono-auth', + 'local-web-app', 'production-json', 'relations', 'rest-client', @@ -83,6 +84,7 @@ test('examples launcher can discover repo examples and render an index page', as assert.match(html, /diagnostics/); assert.match(html, /Free Plan Upgrade/); assert.match(html, /Hono Auth/); + assert.match(html, /Local Web App/); assert.match(html, /Production JSON/); assert.match(html, /REST Client/); assert.match(html, /client/); @@ -438,6 +440,96 @@ test('example runtime resolves schema-ui serve-example hook', async () => { await runtime.close(); }); +test('local web app example saves server state to source JSON and restores transient drafts', async () => { + const cwd = await copyExampleProject('local-web-app'); + const runtime = await createExampleRuntime({ + cwd, + basePath: '/examples/local-web-app', + url: 'http://127.0.0.1:7329/examples/local-web-app', + repoRoot: path.resolve('.'), + }); + + try { + assert.equal(runtime.starterKind, 'custom'); + assert.equal(runtime.demoUrl, 'http://127.0.0.1:7329/examples/local-web-app/'); + + const home = makeResponse(); + await runtime.handleRequest(makeRequest('GET', undefined, '/examples/local-web-app/'), home); + assert.equal(home.statusCode, 200); + assert.match(home.body, /@async\/db Local Web App/); + assert.match(home.body, /window\.LOCAL_APP_BASE_PATH = "\/examples\/local-web-app"/); + + const appScript = makeResponse(); + await runtime.handleRequest(makeRequest('GET', undefined, '/examples/local-web-app/app.js'), appScript); + assert.equal(appScript.statusCode, 200); + assert.match(appScript.body, /\.\/framework\/state\.js/); + + const frameworkScript = makeResponse(); + await runtime.handleRequest(makeRequest('GET', undefined, '/examples/local-web-app/framework/state.js'), frameworkScript); + assert.equal(frameworkScript.statusCode, 200); + assert.match(frameworkScript.body, /TRANSIENT_STORAGE_KEY/); + + const stateResponse = makeResponse(); + await runtime.handleRequest(makeRequest('GET', undefined, '/examples/local-web-app/api/state'), stateResponse); + assert.equal(stateResponse.statusCode, 200); + assert.equal(stateResponse.json().state.title, 'Local App Notes'); + + const savedState = { + title: 'Blur saved title', + note: 'Saved after unfocus', + updatedAt: '2026-06-03T12:00:00.000Z', + }; + const saveResponse = makeResponse(); + await runtime.handleRequest(makeRequest('PUT', { state: savedState }, '/examples/local-web-app/api/state'), saveResponse); + assert.equal(saveResponse.statusCode, 200); + assert.deepEqual(saveResponse.json().state, savedState); + assert.deepEqual(JSON.parse(await readFile(path.join(cwd, 'db/appState.json'), 'utf8')), savedState); + await assert.rejects( + () => readFile(path.join(cwd, '.db/state/appState.json'), 'utf8'), + { code: 'ENOENT' }, + ); + + const version = makeResponse(); + await runtime.handleRequest(makeRequest('GET', undefined, '/examples/local-web-app/api/version'), version); + assert.equal(version.statusCode, 200); + assert.match(version.json().version, /^\d/); + + const helpers = await import(pathToFileURL(path.join(cwd, 'framework/state.js')).href); + assert.equal(helpers.shouldCommitFieldEvent('input', 'draft', 'server'), false); + assert.equal(helpers.shouldCommitFieldEvent('blur', 'draft', 'server'), true); + assert.equal(helpers.shouldCommitFieldEvent('change', 'server', 'server'), false); + assert.deepEqual(helpers.applyTransientState(savedState, { + drafts: { + note: 'Unsaved note during reload', + }, + active: { + field: 'note', + selectionStart: 7, + selectionEnd: 11, + }, + scrollY: 240, + }), { + state: { + ...savedState, + note: 'Unsaved note during reload', + }, + transient: { + drafts: { + note: 'Unsaved note during reload', + }, + active: { + field: 'note', + selectionStart: 7, + selectionEnd: 11, + }, + scrollY: 240, + }, + }); + } finally { + await runtime.close(); + } +}); + test('new onboarding examples sync expected resources', async () => { const expected = { 'hono-auth': ['pages', 'users'], @@ -445,6 +537,7 @@ test('new onboarding examples sync expected resources', async () => { 'computed-fields': ['orders', 'posts', 'products', 'users'], 'cms-json-publish': ['navigation', 'pages'], 'free-plan-upgrade': ['appSettings', 'projects'], + 'local-web-app': ['appState'], 'production-json': ['appSettings', 'featureFlags'], 'rest-client': ['settings', 'users'], relations: ['posts', 'users'], diff --git a/test/runtime/package-api.test.ts b/test/runtime/package-api.test.ts index 0d40476..a82a7d1 100644 --- a/test/runtime/package-api.test.ts +++ b/test/runtime/package-api.test.ts @@ -857,6 +857,80 @@ test('sourceFile store writes plain JSON fixture while json store remains defaul assert.equal(JSON.parse(await readFile(path.join(cwd, '.db/schema.generated.json'), 'utf8')).resources.settings.kind, 'document'); }); +test('stores.default sourceFile writes all plain JSON fixtures directly', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'settings.json', JSON.stringify({ + theme: 'light', + })); + await writeFixture(cwd, 'users.json', JSON.stringify([ + { id: 'u_1', name: 'Ada Lovelace' }, + ])); + await writeConfig(cwd, `export default { + stores: { + default: 'sourceFile' + } + };`); + + const db = await openDb({ cwd }); + await db.document('settings').update({ theme: 'dark' }); + await db.collection('users').create({ id: 'u_2', name: 'Grace Hopper' }); + + assert.deepEqual(JSON.parse(await readFile(path.join(cwd, 'db/settings.json'), 'utf8')), { + theme: 'dark', + }); + assert.deepEqual(JSON.parse(await readFile(path.join(cwd, 'db/users.json'), 'utf8')), [ + { id: 'u_1', name: 'Ada Lovelace' }, + { id: 'u_2', name: 'Grace Hopper' }, + ]); + await assert.rejects( + () => access(path.join(cwd, '.db/state/settings.json')), + { code: 'ENOENT' }, + ); + await assert.rejects( + () => access(path.join(cwd, '.db/state/users.json')), + { code: 'ENOENT' }, + ); +}); + +test('resource store overrides stores.default sourceFile', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'settings.json', JSON.stringify({ + theme: 'light', + })); + await writeFixture(cwd, 'users.json', JSON.stringify([ + { id: 'u_1', name: 'Ada Lovelace' }, + ])); + await writeConfig(cwd, `export default { + stores: { + default: 'sourceFile' + }, + resources: { + users: { + store: 'json' + } + } + };`); + + const db = await openDb({ cwd }); + await db.document('settings').update({ theme: 'dark' }); + await db.collection('users').create({ id: 'u_2', name: 'Grace Hopper' }); + + assert.deepEqual(JSON.parse(await readFile(path.join(cwd, 'db/settings.json'), 'utf8')), { + theme: 'dark', + }); + assert.deepEqual(JSON.parse(await readFile(path.join(cwd, 'db/users.json'), 'utf8')), [ + { id: 'u_1', name: 'Ada Lovelace' }, + ]); + assert.deepEqual(JSON.parse(await readFile(path.join(cwd, '.db/state/users.json'), 'utf8')), [ + { id: 'u_1', name: 'Ada Lovelace' }, + { id: 'u_2', name: 'Grace Hopper' }, + ]); + await assert.rejects( + () => access(path.join(cwd, '.db/state/settings.json')), + { code: 'ENOENT' }, + ); +}); + test('sourceFile store rejects non-JSON source resources with structured diagnostics', async () => { const cases = [ {