create-tinyjoin 0.0.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,11 +17,13 @@ Your app should look something like this:
17
17
  - Task list with add/complete/delete
18
18
  - Single TinyJoin database with one `todos` table
19
19
  - Demonstrates basic CRUD operations in SQL
20
+ - Automatic multi-tab access for saved data
21
+ - Offline production app loading after the first online visit
20
22
  - Perfect starter example
21
23
 
22
24
  It shares its markup and styling with the equivalent
23
25
  [create-tinybase](https://github.com/tinyplex/create-tinybase) vanilla
24
- TypeScript starter, apart from the TinyJoin accent color.
26
+ TypeScript starter, with the TinyJoin accent color and system fonts.
25
27
 
26
28
  For agents and CI, every option can be supplied non-interactively:
27
29
 
@@ -45,6 +47,7 @@ The generated project is a single Vite application at its root (`.ts` below
45
47
  becomes `.js` when JavaScript is chosen):
46
48
 
47
49
  - `index.html` loads the app and defines its theme variables.
50
+ - `vite.config.ts` enables production offline loading through `tinyjoinOffline()`.
48
51
  - `src/index.ts` bootstraps the app on load.
49
52
  - `src/app.ts` builds the app shell, showing a spinner until the database opens.
50
53
  - `src/database.ts` opens the database, defines the `todos` table, and adds the
@@ -63,6 +66,20 @@ the JavaScript language is chosen, and `tinycreate` blanks the types during
63
66
  post-processing. A JavaScript project also drops `tsconfig.json`, the
64
67
  `typescript` devDependency, and the `tsc --noEmit` step from its build script.
65
68
 
69
+ ## Offline builds
70
+
71
+ Generated applications use `tinyjoinOffline()` from `tinyjoin/vite`. The
72
+ production build includes the application and complete database runtime in
73
+ its offline copy, including files loaded only when persistent storage opens.
74
+ There are no remote font requests. Development remains a normal Vite session;
75
+ use `npm run build` and `npm run preview` to test offline behavior.
76
+
77
+ New application versions wait for open tabs to close before activating. This
78
+ keeps existing pages on their matching build; it does not migrate database
79
+ schemas. Applications with their own service worker can use the plugin's
80
+ manifest mode. Offline loading and saved local data do not synchronize with
81
+ a server or another device.
82
+
66
83
  ## Development
67
84
 
68
85
  ```sh
@@ -80,7 +97,8 @@ vocabulary to `cspell.json` rather than disabling the check.
80
97
  modes, against the snapshots in `test/__snapshots__`. Run
81
98
  `npx vitest run test/cli.test.ts -u` to accept intentional template changes.
82
99
  `npm run test:generated` installs and builds all four combinations, and
83
- `npm run test:e2e` then drives the two saved apps in Chromium.
100
+ `npm run test:e2e` then drives the two saved apps in Chromium and tests offline
101
+ production loading for all four combinations.
84
102
 
85
103
  Run the local generator from this repository's parent directory with:
86
104
 
package/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import{existsSync as g}from"node:fs";import{dirname as m,join as r}from"node:path";import{fileURLToPath as d}from"node:url";import{createCLI as f,detectPackageManager as y}from"tinycreate";const h=r(m(d(import.meta.url)),"templates"),a=process.argv.slice(2),c=[{title:"TypeScript",value:"typescript"},{title:"JavaScript",value:"javascript"}],p=[{title:"Save data across reloads (recommended)",value:"opfs"},{title:"Start fresh each time",value:"memory"}],v={command:"npm create tinyjoin@latest --",nonInteractiveFlag:"--non-interactive",options:{projectName:{type:"string",required:!0},language:{values:c.map(({value:t})=>t),required:!0,default:"typescript"},storage:{values:p.map(({value:t})=>t),required:!0,default:"opfs"},installAndRun:{values:[!0,!1],required:!0,recommendedForAgents:!1}}};(a.includes("--help")||a.includes("-h"))&&(console.log(`create-tinyjoin
2
+ import{existsSync as g}from"node:fs";import{dirname as m,join as a}from"node:path";import{fileURLToPath as d}from"node:url";import{createCLI as f,detectPackageManager as y}from"tinycreate";const h=a(m(d(import.meta.url)),"templates"),r=process.argv.slice(2),c=[{title:"TypeScript",value:"typescript"},{title:"JavaScript",value:"javascript"}],p=[{title:"Save data across reloads (recommended)",value:"opfs"},{title:"Start fresh each time",value:"memory"}],v={command:"npm create tinyjoin@latest --",nonInteractiveFlag:"--non-interactive",options:{projectName:{type:"string",required:!0},language:{values:c.map(({value:t})=>t),required:!0,default:"typescript"},storage:{values:p.map(({value:t})=>t),required:!0,default:"opfs"},installAndRun:{values:[!0,!1],required:!0,recommendedForAgents:!1}}};(r.includes("--help")||r.includes("-h"))&&(console.log(`create-tinyjoin
3
3
 
4
4
  Interactively scaffold a TinyJoin application:
5
5
  npm create tinyjoin@latest
@@ -11,5 +11,5 @@ Run non-interactively:
11
11
 
12
12
  Agent and automation commands:
13
13
  --list-options Print the current option catalog as JSON
14
- --help Show this help`),process.exit(0)),a.includes("--list-options")&&(console.log(JSON.stringify(v,null,2)),process.exit(0));const j={welcomeMessage:`\u{1F389} Welcome to TinyJoin!
15
- `,questions:[{type:"text",name:"projectName",message:"Project name:",initial:"my-tinyjoin-app",validate:l},{type:"select",name:"language",message:"Language:",choices:[...c],initial:0},{type:"select",name:"storage",message:"Todo data:",choices:[...p],initial:0},{type:"confirm",name:"installAndRun",message:"Install dependencies and start the app?",initial:!0}],createContext:t=>{const n=String(t.projectName??"").trim(),o=l(n);if(o!==!0)throw new TypeError(o);const e=u(t.language===0?"typescript":t.language??"typescript","language",["typescript","javascript"]),s=u(t.storage===0?"opfs":t.storage??"opfs","storage",["opfs","memory"]),i=e==="typescript";return{projectName:n,installAndRun:t.installAndRun===!0||t.installAndRun==="true",language:e,typescript:i,javascript:!i,scriptExt:i?"ts":"js",ext:i?"ts":"js",storage:s,usesOpfs:s==="opfs",storageName:b(n),tinyjoinDependency:process.env.CREATE_TINYJOIN_DEPENDENCY??"^0.0.5"}},getFiles:t=>{const{ext:n,typescript:o}=t,e=i=>({template:`client/src/${i}.ts.hbs`,output:`src/${i}.${n}`,prettier:!0,transpile:!o}),s=i=>({template:`client/src/${i}.css.hbs`,output:`src/${i}.css`,prettier:!0});return[{template:"README.md.hbs",output:"README.md",prettier:!0},{template:"AGENTS.md.hbs",output:"AGENTS.md",prettier:!0},{template:"client/package.json.hbs",output:"package.json",prettier:!0},{template:"client/.gitignore.hbs",output:".gitignore"},{template:"client/index.html.hbs",output:"index.html",prettier:!0},...o?[{template:"client/tsconfig.json.hbs",output:"tsconfig.json",prettier:!0}]:[],{template:"client/public/favicon.svg",output:"public/favicon.svg"},o?{template:"client/public/ts.svg",output:"public/ts.svg"}:{template:"client/public/js.svg",output:"public/js.svg"},e("index"),e("app"),e("topBar"),s("topBar"),e("title"),s("title"),e("info"),s("info"),e("loading"),s("loading"),e("button"),s("button"),e("input"),s("input"),e("database"),e("todoInput"),s("todoInput"),e("todoList"),s("todoList"),e("todoItem"),s("todoItem")]},templateRoot:h,installCommand:"{pm} install",devCommand:"{pm} run dev",workingDirectory:".",onSuccess:t=>{const n=y();console.log("Next steps:"),console.log(` cd ${t}`),console.log(` ${n} install`),console.log(` ${n} run dev`)}};f(j).catch(t=>{console.error(t instanceof Error?t.message:String(t)),process.exit(1)});function l(t){return!/^[a-z0-9][a-z0-9._-]*$/i.test(t)||t==="."||t===".."?"Use letters, numbers, dots, dashes, or underscores without path separators.":g(r(process.cwd(),t))?`Directory "${t}" already exists. Choose a different name.`:!0}function u(t,n,o){const e=typeof t=="string"?t:"";if(!o.includes(e))throw new TypeError(`${x(n)} must be one of: ${o.join(", ")}.`);return e}function b(t){const n=t.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"app",o="tinyjoin-",e="-db-v1";return`${o}${n.slice(0,64-o.length-e.length)}${e}`}function x(t){return`${t.charAt(0).toUpperCase()}${t.slice(1)}`}
14
+ --help Show this help`),process.exit(0)),r.includes("--list-options")&&(console.log(JSON.stringify(v,null,2)),process.exit(0));const j={welcomeMessage:`\u{1F389} Welcome to TinyJoin!
15
+ `,questions:[{type:"text",name:"projectName",message:"Project name:",initial:"my-tinyjoin-app",validate:l},{type:"select",name:"language",message:"Language:",choices:[...c],initial:0},{type:"select",name:"storage",message:"Todo data:",choices:[...p],initial:0},{type:"confirm",name:"installAndRun",message:"Install dependencies and start the app?",initial:!0}],createContext:t=>{const n=String(t.projectName??"").trim(),o=l(n);if(o!==!0)throw new TypeError(o);const e=u(t.language===0?"typescript":t.language??"typescript","language",["typescript","javascript"]),s=u(t.storage===0?"opfs":t.storage??"opfs","storage",["opfs","memory"]),i=e==="typescript";return{projectName:n,installAndRun:t.installAndRun===!0||t.installAndRun==="true",language:e,typescript:i,javascript:!i,scriptExt:i?"ts":"js",ext:i?"ts":"js",storage:s,usesOpfs:s==="opfs",storageName:b(n),tinyjoinDependency:process.env.CREATE_TINYJOIN_DEPENDENCY??"^0.2.0"}},getFiles:t=>{const{ext:n,typescript:o}=t,e=i=>({template:`client/src/${i}.ts.hbs`,output:`src/${i}.${n}`,prettier:!0,transpile:!o}),s=i=>({template:`client/src/${i}.css.hbs`,output:`src/${i}.css`,prettier:!0});return[{template:"README.md.hbs",output:"README.md",prettier:!0},{template:"AGENTS.md.hbs",output:"AGENTS.md",prettier:!0},{template:"client/package.json.hbs",output:"package.json",prettier:!0},{template:"client/.gitignore.hbs",output:".gitignore"},{template:"client/index.html.hbs",output:"index.html",prettier:!0},{template:"client/vite.config.ts.hbs",output:`vite.config.${n}`,prettier:!0,transpile:!o},...o?[{template:"client/tsconfig.json.hbs",output:"tsconfig.json",prettier:!0}]:[],{template:"client/public/favicon.svg",output:"public/favicon.svg"},o?{template:"client/public/ts.svg",output:"public/ts.svg"}:{template:"client/public/js.svg",output:"public/js.svg"},e("index"),e("app"),e("topBar"),s("topBar"),e("title"),s("title"),e("info"),s("info"),e("loading"),s("loading"),e("error"),s("error"),e("button"),s("button"),e("input"),s("input"),e("database"),e("todoInput"),s("todoInput"),e("todoList"),s("todoList"),e("todoItem"),s("todoItem")]},templateRoot:h,installCommand:"{pm} install",devCommand:"{pm} run dev",workingDirectory:".",onSuccess:t=>{const n=y();console.log("Next steps:"),console.log(` cd ${t}`),console.log(` ${n} install`),console.log(` ${n} run dev`)}};f(j).catch(t=>{console.error(t instanceof Error?t.message:String(t)),process.exit(1)});function l(t){return!/^[a-z0-9][a-z0-9._-]*$/i.test(t)||t==="."||t===".."?"Use letters, numbers, dots, dashes, or underscores without path separators.":g(a(process.cwd(),t))?`Directory "${t}" already exists. Choose a different name.`:!0}function u(t,n,o){const e=typeof t=="string"?t:"";if(!o.includes(e))throw new TypeError(`${x(n)} must be one of: ${o.join(", ")}.`);return e}function b(t){const n=t.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"app",o="tinyjoin-",e="-db-v2";return`${o}${n.slice(0,64-o.length-e.length)}${e}`}function x(t){return`${t.charAt(0).toUpperCase()}${t.slice(1)}`}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-tinyjoin",
3
- "version": "0.0.6",
3
+ "version": "0.2.0",
4
4
  "author": "jamesgpearce",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,12 +12,18 @@ lifecycle while adapting the application to the user's requirements.
12
12
  - **Persistence**: {{#if usesOpfs}}saved in this browser across reloads{{else}}kept for the current visit only{{/if}}
13
13
 
14
14
  The database client is opened once in `src/database.{{ext}}` and shared through
15
- the `databaseReady` promise. Keep that single client: opening a second one for
16
- the same data is rejected.
15
+ the `databaseReady` promise. Reuse it within the application.{{#if usesOpfs}}
16
+ Tabs share the saved database automatically; keep its name stable.{{/if}}
17
17
 
18
- The schema is created before the first read, and the starter rows are only
19
- inserted when the table is empty. Preserve that ordering so a reload does not
20
- duplicate or overwrite existing data.
18
+ One atomic script creates the schema and inserts starter rows. Only
19
+ `TABLE_ALREADY_EXISTS` is ignored, so concurrent first visits cannot seed
20
+ twice and an existing empty table stays empty. Preserve this behavior.
21
+
22
+ Await writes, preserve drafts on failure, and keep errors visible. Retry at
23
+ startup reloads the page to create a fresh client. Do not automatically replay
24
+ a failed write: a storage failure can leave its commit outcome uncertain.
25
+ If the browser restores a cached page after teardown, the `pageshow` handler
26
+ reloads it so the app creates a fresh client instead of reusing a closed one.
21
27
 
22
28
  ## Key Files
23
29
 
@@ -25,12 +31,20 @@ duplicate or overwrite existing data.
25
31
  - `src/database.{{ext}}` opens the database and defines the `todos` table.
26
32
  - `src/app.{{ext}}` contains the main application UI.
27
33
  - `src/todoList.{{ext}}` reads the `todos` table and subscribes to its changes.
34
+ - `vite.config.{{ext}}` enables offline production builds with `tinyjoinOffline()`.
35
+
36
+ The production app caches its complete build after an online visit. Keep the
37
+ offline plugin configured when changing build output. It waits for open tabs
38
+ to close before activating an update; do not force updates into running tabs.
39
+ For an existing service worker, use the plugin's manifest mode and integrate
40
+ its assets there instead of adding a competing registration. Offline caching
41
+ does not migrate a saved schema or synchronize data to a server.
28
42
 
29
43
  ## Working Method
30
44
 
31
45
  1. Read `README.md` and `src/database.{{ext}}` before making changes.
32
46
  2. Keep application data in TinyJoin rather than duplicating it in UI state.
33
- 3. Use the `sql` tagged template or `$1` parameter arrays for application
47
+ 3. Use `$1` parameter arrays for application
34
48
  values. Do not concatenate user input into SQL.
35
49
  4. Generate stable row identifiers in application code.
36
50
  5. Keep the schema safe to run again when the app reloads.
@@ -48,5 +62,9 @@ npm run build
48
62
 
49
63
  Change data in the running app, reload the page, and confirm the change
50
64
  {{#if usesOpfs}}remains{{else}}resets, as this starter was generated without persistence{{/if}}.
65
+ For offline verification, use `npm run preview`, wait for the browser's offline
66
+ copy to finish installing, close the page, disconnect the network, and reopen
67
+ the same URL. Verify loading, edits, and reloads; the development server does
68
+ not install offline support.
51
69
 
52
70
  Current TinyJoin documentation: https://tinyjoin.org/llms.txt
@@ -25,6 +25,7 @@ This project is a single web application, built with Vite and
25
25
  ### Key Files
26
26
 
27
27
  - **`index.html`**: The main HTML file that loads your app
28
+ - **`vite.config.{{ext}}`**: Production build and offline support
28
29
  - **`src/index.{{ext}}`**: Entry point that bootstraps the app
29
30
  - **`src/app.{{ext}}`**: Main application logic
30
31
  - **`src/database.{{ext}}`**: TinyJoin database and `todos` table setup
@@ -42,12 +43,47 @@ to the `todos` table so that every change re-renders the list.
42
43
 
43
44
  {{#if usesOpfs}}
44
45
  **Persistence** is enabled. Your data is automatically saved and restored when
45
- you reload the page.
46
+ you reload the page. Tabs share the same saved database, and changes appear
47
+ in every open tab.
48
+
49
+ The browser can clear or evict saved data, so keep a separate copy of anything
50
+ irreplaceable. Deleting all todos leaves the list empty after a reload; the
51
+ example rows are added only when the table is first created.
46
52
  {{else}}
47
53
  **Persistence** is not enabled. Your todos are kept for the current visit and
48
54
  start fresh after a reload.
49
55
  {{/if}}
50
56
 
57
+ Startup failures replace the loading spinner with a message and **Retry**.
58
+ Failed changes show an error; a failed addition keeps the text you entered.
59
+ Writes are not automatically retried.
60
+
61
+ ## Offline Use
62
+
63
+ Build and preview the app to try offline support:
64
+
65
+ ```sh
66
+ npm run build
67
+ npm run preview
68
+ ```
69
+
70
+ The production app downloads a local copy on its first online visit. Once
71
+ that download finishes, you can close it, reopen it without a connection,
72
+ and keep using your todos.{{#unless usesOpfs}} Your selected data mode still
73
+ starts with a fresh todo list each visit.{{/unless}} The development server
74
+ does not install an offline copy.
75
+
76
+ Deploy the contents of `dist/` to an HTTPS host. Offline loading is set up by
77
+ `tinyjoinOffline()` in `vite.config.{{ext}}`; no extra application code is needed.
78
+ New versions wait until all open tabs close before taking over, so a working
79
+ tab keeps its matching application files. A new deployment does not migrate
80
+ saved database schemas. Offline use does not synchronize data with a server
81
+ or another device.
82
+
83
+ If you later add your own service worker, use the plugin's manifest mode to
84
+ integrate its cached files with that worker instead of registering two for
85
+ the same application. See the [offline guide](https://tinyjoin.org/guides/offline/).
86
+
51
87
  ## Learn More
52
88
 
53
89
  - [TinyJoin Documentation](https://tinyjoin.org)
@@ -4,9 +4,6 @@
4
4
  <head>
5
5
  <meta charset="UTF-8" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <link rel="preconnect" href="https://fonts.googleapis.com" />
8
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
- <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;800&display=swap" />
10
7
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
11
8
  <title>TinyJoin Todos</title>
12
9
  <style>
@@ -18,7 +15,7 @@
18
15
  user-select: none;
19
16
  margin: 0;
20
17
  padding: 0;
21
- font-family: 'Inter', sans-serif;
18
+ font-family: system-ui, sans-serif;
22
19
  }
23
20
 
24
21
  #root {
@@ -18,6 +18,6 @@
18
18
  {{#if typescript}}
19
19
  "typescript": "^7.0.2",
20
20
  {{/if}}
21
- "vite": "^8.2.1"
21
+ "vite": "^8.3.0"
22
22
  }
23
23
  }
@@ -3,6 +3,8 @@ import {createTopBar} from './topBar';
3
3
  import {showLoading, hideLoading} from './loading';
4
4
  import {createTodoInput} from './todoInput';
5
5
  import {createTodoList} from './todoList';
6
+ import {createButton} from './button';
7
+ import {createError, showError} from './error';
6
8
 
7
9
  export const app = async (root: HTMLElement) => {
8
10
  const appElement = document.createElement('div');
@@ -12,17 +14,24 @@ export const app = async (root: HTMLElement) => {
12
14
  // Show loading spinner
13
15
  const loadingDiv = showLoading(appElement);
14
16
 
15
- // Wait for the database to be ready
16
- const database = await databaseReady;
17
+ try {
18
+ const database = await databaseReady;
19
+ const todoInputContainer = createTodoInput(database);
20
+ appElement.appendChild(todoInputContainer);
17
21
 
18
- // Remove loading spinner
19
- hideLoading(loadingDiv);
22
+ const input = todoInputContainer.querySelector('input')!;
23
+ input.focus();
20
24
 
21
- const todoInputContainer = createTodoInput(database);
22
- appElement.appendChild(todoInputContainer);
23
-
24
- const input = todoInputContainer.querySelector('input')!;
25
- input.focus();
26
-
27
- appElement.appendChild(createTodoList(database));
25
+ appElement.appendChild(createTodoList(database));
26
+ } catch (error) {
27
+ const message = createError();
28
+ message.id = 'startupError';
29
+ showError(message, error, 'Could not open your todos.');
30
+ appElement.append(
31
+ message,
32
+ createButton('Retry', () => location.reload(), 'primary'),
33
+ );
34
+ } finally {
35
+ hideLoading(loadingDiv);
36
+ }
28
37
  };
@@ -14,6 +14,11 @@ button:hover {
14
14
  border-color: #7c3aed;
15
15
  }
16
16
 
17
+ button:disabled {
18
+ cursor: wait;
19
+ opacity: 0.6;
20
+ }
21
+
17
22
  button.primary {
18
23
  background: #7c3aed;
19
24
  border: none;
@@ -1,4 +1,4 @@
1
- import {create, type Client} from 'tinyjoin';
1
+ import {create, ClientError, type Client} from 'tinyjoin';
2
2
 
3
3
  export type TodoRow = {
4
4
  id: string;
@@ -18,39 +18,49 @@ export const DATA_DIR = 'memory://';
18
18
  export const createTodosDatabase = async (): Promise<TodosDatabase> => {
19
19
  const database = await create(DATA_DIR);
20
20
 
21
- // Create the table, and add the starter todos, only on the first visit.
22
- if (!(await hasTodosTable(database))) {
21
+ try {
22
+ await initializeTodos(database);
23
+ return database;
24
+ } catch (error) {
25
+ await database.close().catch(() => undefined);
26
+ throw error;
27
+ }
28
+ };
29
+
30
+ const initializeTodos = async (database: TodosDatabase): Promise<void> => {
31
+ try {
32
+ // One atomic script creates and seeds the table. Concurrent tabs can race
33
+ // to initialize it, but only the tab that creates the table adds examples.
23
34
  await database.exec(`
24
- CREATE TABLE IF NOT EXISTS todos (
35
+ CREATE TABLE todos (
25
36
  id TEXT PRIMARY KEY,
26
37
  text TEXT NOT NULL,
27
38
  completed BOOLEAN NOT NULL DEFAULT false,
28
39
  created INTEGER NOT NULL DEFAULT 0
29
- )
40
+ );
41
+ INSERT INTO todos (id, text, completed, created)
42
+ VALUES ('1', 'Learn TinyJoin', false, 1), ('2', 'Build an app', false, 2)
30
43
  `);
31
- await database.query(
32
- `INSERT INTO todos (id, text, completed, created)
33
- VALUES ($1, $2, $3, $4), ($5, $6, $7, $8)`,
34
- ['1', 'Learn TinyJoin', false, 1, '2', 'Build an app', false, 2],
35
- );
36
- }
37
-
38
- return database;
39
- };
40
-
41
- const hasTodosTable = async (database: TodosDatabase): Promise<boolean> => {
42
- try {
43
- await database.query('SELECT id FROM todos LIMIT 1');
44
- return true;
45
- } catch {
46
- return false;
44
+ } catch (error) {
45
+ if (!(error instanceof ClientError && error.code === 'TABLE_ALREADY_EXISTS')) {
46
+ throw error;
47
+ }
47
48
  }
48
49
  };
49
50
 
50
51
  export const databaseReady = createTodosDatabase();
52
+ // Startup can reject before the page's load event. app() presents the failure.
53
+ void databaseReady.catch(() => undefined);
51
54
 
52
55
  addEventListener(
53
56
  'pagehide',
54
- () => void databaseReady.then((database) => database.close()),
57
+ () => void databaseReady.then((database) => database.close()).catch(() => undefined),
55
58
  {once: true},
56
59
  );
60
+
61
+ // A restored page must not reuse the client closed during pagehide.
62
+ addEventListener('pageshow', (event) => {
63
+ if (event.persisted) {
64
+ location.reload();
65
+ }
66
+ });
@@ -0,0 +1,6 @@
1
+ .error {
2
+ color: #fca5a5;
3
+ line-height: 1.5;
4
+ margin: 0 0 1rem;
5
+ user-select: text;
6
+ }
@@ -0,0 +1,39 @@
1
+ import {ClientError} from 'tinyjoin';
2
+ import './error.css';
3
+
4
+ export const createError = (): HTMLParagraphElement => {
5
+ const element = document.createElement('p');
6
+ element.className = 'error';
7
+ element.setAttribute('role', 'alert');
8
+ element.hidden = true;
9
+ return element;
10
+ };
11
+
12
+ export const clearError = (element: HTMLElement): void => {
13
+ element.hidden = true;
14
+ element.textContent = '';
15
+ };
16
+
17
+ export const showError = (
18
+ element: HTMLElement,
19
+ error: unknown,
20
+ fallback: string,
21
+ ): void => {
22
+ let message = error instanceof Error ? `${fallback} ${error.message}` : fallback;
23
+ if (error instanceof ClientError) {
24
+ if (error.code === 'STORAGE_LOCKED') {
25
+ message = 'The saved database is busy. Close older versions of this app, then select Retry.';
26
+ } else if ([
27
+ 'RECOVERY_REQUIRED',
28
+ 'STORAGE_COMMIT_OUTCOME_UNKNOWN',
29
+ 'STORAGE_ENGINE_POISONED',
30
+ 'WORKER_ERROR',
31
+ 'WORKER_MESSAGE_ERROR',
32
+ 'WORKER_TERMINATED',
33
+ ].includes(error.code)) {
34
+ message = 'The change may have been saved. Reload this page and check your todos before trying again.';
35
+ }
36
+ }
37
+ element.textContent = message;
38
+ element.hidden = false;
39
+ };
@@ -3,5 +3,5 @@ import {app} from './app';
3
3
  const root = document.getElementById('root')!;
4
4
 
5
5
  addEventListener('load', () => {
6
- app(root);
6
+ void app(root);
7
7
  });
@@ -1,9 +1,16 @@
1
1
  #todoInput {
2
2
  display: flex;
3
+ flex-wrap: wrap;
3
4
  gap: 0.5rem;
4
5
  margin-bottom: 1.5rem;
5
6
  }
6
7
 
7
8
  #todoInput input {
8
9
  flex: 1;
10
+ min-width: 0;
11
+ }
12
+
13
+ #todoInput .error {
14
+ flex-basis: 100%;
15
+ margin: 0;
9
16
  }
@@ -2,35 +2,49 @@ import './todoInput.css';
2
2
  import {type TodosDatabase} from './database';
3
3
  import {createButton} from './button';
4
4
  import {createInput} from './input';
5
+ import {createError, clearError, showError} from './error';
5
6
 
6
7
  export const createTodoInput = (database: TodosDatabase): HTMLFormElement => {
7
8
  const container = document.createElement('form');
8
9
  container.id = 'todoInput';
9
10
 
10
11
  const input = createInput('What needs to be done?');
12
+ const addButton = createButton('Add', null, 'primary', 'submit');
13
+ const error = createError();
14
+ let pending = false;
11
15
 
12
- const addTodo = () => {
16
+ const addTodo = async () => {
13
17
  const text = input.value.trim();
14
- if (text) {
15
- void database.query(
18
+ if (!text || pending) {
19
+ return;
20
+ }
21
+ pending = true;
22
+ input.disabled = true;
23
+ addButton.disabled = true;
24
+ clearError(error);
25
+ try {
26
+ await database.query(
16
27
  `INSERT INTO todos (id, text, completed, created)
17
28
  VALUES ($1, $2, $3, $4)`,
18
29
  [crypto.randomUUID(), text, false, Date.now()],
19
30
  );
20
31
  input.value = '';
32
+ } catch (failure) {
33
+ showError(error, failure, 'Could not add this todo.');
34
+ } finally {
35
+ pending = false;
36
+ input.disabled = false;
37
+ addButton.disabled = false;
21
38
  input.focus();
22
39
  }
23
40
  };
24
41
 
25
42
  container.addEventListener('submit', (event) => {
26
43
  event.preventDefault();
27
- addTodo();
44
+ void addTodo();
28
45
  });
29
46
 
30
- const addButton = createButton('Add', null, 'primary', 'submit');
31
-
32
- container.appendChild(input);
33
- container.appendChild(addButton);
47
+ container.append(input, addButton, error);
34
48
 
35
49
  return container;
36
50
  };
@@ -5,7 +5,7 @@ export const createTodoItem = (
5
5
  id: string,
6
6
  text: string,
7
7
  completed: boolean,
8
- onToggle: () => void,
8
+ onToggle: (checkbox: HTMLInputElement) => void,
9
9
  onDelete: () => void,
10
10
  ): HTMLDivElement => {
11
11
  const item = document.createElement('div');
@@ -15,7 +15,7 @@ export const createTodoItem = (
15
15
  checkbox.type = 'checkbox';
16
16
  checkbox.checked = completed;
17
17
  checkbox.id = `todo-${id}`;
18
- checkbox.addEventListener('change', onToggle);
18
+ checkbox.addEventListener('change', () => onToggle(checkbox));
19
19
 
20
20
  const label = document.createElement('label');
21
21
  label.textContent = text;
@@ -1,38 +1,89 @@
1
1
  import './todoList.css';
2
2
  import {type TodosDatabase, type TodoRow} from './database';
3
3
  import {createTodoItem} from './todoItem';
4
+ import {createError, clearError, showError} from './error';
4
5
 
5
6
  export const createTodoList = (database: TodosDatabase): HTMLDivElement => {
7
+ const container = document.createElement('div');
8
+ const error = createError();
6
9
  const list = document.createElement('div');
7
10
  list.id = 'todoList';
11
+ container.append(error, list);
12
+ let pending = false;
13
+ let mutationFailed = false;
14
+ let renderGeneration = 0;
15
+
16
+ const setDisabled = (disabled: boolean) => {
17
+ list.querySelectorAll<HTMLInputElement | HTMLButtonElement>('input, button')
18
+ .forEach((control) => { control.disabled = disabled; });
19
+ };
20
+
21
+ const mutate = async (
22
+ operation: () => Promise<unknown>,
23
+ restore?: () => void,
24
+ ) => {
25
+ if (pending) {
26
+ return;
27
+ }
28
+ pending = true;
29
+ mutationFailed = false;
30
+ setDisabled(true);
31
+ clearError(error);
32
+ try {
33
+ await operation();
34
+ } catch (failure) {
35
+ restore?.();
36
+ mutationFailed = true;
37
+ showError(error, failure, 'Could not save this change.');
38
+ } finally {
39
+ // Refresh reads only: a rejected write may already have committed.
40
+ await render();
41
+ pending = false;
42
+ setDisabled(false);
43
+ }
44
+ };
8
45
 
9
46
  const render = async () => {
10
- const {rows} = await database.query<TodoRow>(
11
- 'SELECT id, text, completed, created FROM todos ORDER BY created, id',
12
- );
47
+ const generation = ++renderGeneration;
48
+ try {
49
+ const {rows} = await database.query<TodoRow>(
50
+ 'SELECT id, text, completed, created FROM todos ORDER BY created, id',
51
+ );
52
+ if (generation !== renderGeneration) {
53
+ return;
54
+ }
55
+ if (!mutationFailed) {
56
+ clearError(error);
57
+ }
13
58
 
14
- list.innerHTML = '';
15
- rows.forEach((todo) => {
16
- const item = createTodoItem(
59
+ const items = rows.map((todo) => createTodoItem(
17
60
  todo.id,
18
61
  todo.text,
19
62
  todo.completed,
20
- () => {
21
- void database.query('UPDATE todos SET completed = $1 WHERE id = $2', [
22
- !todo.completed,
23
- todo.id,
24
- ]);
63
+ (checkbox) => {
64
+ void mutate(
65
+ () => database.query('UPDATE todos SET completed = $1 WHERE id = $2', [
66
+ !todo.completed,
67
+ todo.id,
68
+ ]),
69
+ () => { checkbox.checked = todo.completed; },
70
+ );
25
71
  },
26
72
  () => {
27
- void database.query('DELETE FROM todos WHERE id = $1', [todo.id]);
73
+ void mutate(() => database.query('DELETE FROM todos WHERE id = $1', [todo.id]));
28
74
  },
29
- );
30
- list.appendChild(item);
31
- });
75
+ ));
76
+ list.replaceChildren(...items);
77
+ setDisabled(pending);
78
+ } catch (failure) {
79
+ if (generation === renderGeneration && !mutationFailed) {
80
+ showError(error, failure, 'Could not refresh your todos. Reload to try again.');
81
+ }
82
+ }
32
83
  };
33
84
 
34
85
  database.subscribe({tables: ['todos']}, () => void render());
35
86
  void render();
36
87
 
37
- return list;
88
+ return container;
38
89
  };
@@ -0,0 +1,6 @@
1
+ import {tinyjoinOffline} from 'tinyjoin/vite';
2
+ import {defineConfig} from 'vite';
3
+
4
+ export default defineConfig({
5
+ plugins: [tinyjoinOffline()],
6
+ });