@atom.io/template-spa-astro-ssr 0.0.4 → 0.0.6

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.
@@ -0,0 +1,107 @@
1
+ import type { SpaElement } from "@atom.io/spa"
2
+ import { useGetter, useId, useLoadable, useSetter, useStore } from "@atom.io/spa"
3
+ import { resetInStore } from "atom.io/internal"
4
+
5
+ import { api, type Product } from "./api.ts"
6
+ import { productAtoms, productIds, saveStatusAtoms } from "./state.ts"
7
+
8
+ function StockCard(props: { id: number }): SpaElement {
9
+ const store = useStore()
10
+ const label = useId()
11
+ const fallback: Product = {
12
+ id: props.id,
13
+ sku: ``,
14
+ name: `Loading product…`,
15
+ stock: 0,
16
+ revision: 1,
17
+ }
18
+ const product = useLoadable(productAtoms, props.id, fallback)
19
+ const setProduct = useSetter(productAtoms, props.id)
20
+ const status = useGetter(saveStatusAtoms, props.id)
21
+ const setStatus = useSetter(saveStatusAtoms, props.id)
22
+ async function save(): Promise<void> {
23
+ if (status().saving || product().loading || product().error) return
24
+ setStatus({ saving: true, message: `` })
25
+ try {
26
+ const { id, stock, revision } = product().value
27
+ const saved = await api.inventory.setStock.mutate({ id, stock, revision })
28
+ setProduct(saved)
29
+ setStatus({ saving: false, message: `Stock saved` })
30
+ } catch (error) {
31
+ setStatus({
32
+ saving: false,
33
+ message: error instanceof Error ? error.message : `Could not save stock`,
34
+ })
35
+ }
36
+ }
37
+ return (
38
+ <article
39
+ class="product"
40
+ data-product={props.id}
41
+ aria-busy={product().loading || status().saving}
42
+ >
43
+ <header>
44
+ <div>
45
+ <p class="sku">{product().value.sku}</p>
46
+ <h3>{product().value.name}</h3>
47
+ </div>
48
+ <span class="source">
49
+ {product().loading
50
+ ? `Loading…`
51
+ : product().value.stock > 0
52
+ ? `In stock`
53
+ : `Out of stock`}
54
+ </span>
55
+ </header>
56
+ <label for={label}>Units on hand</label>
57
+ <input
58
+ id={label}
59
+ type="number"
60
+ min="0"
61
+ step="1"
62
+ value={product().value.stock}
63
+ disabled={product().loading || status().saving || !!product().error}
64
+ onInput={(event) => {
65
+ const stock = event.currentTarget.valueAsNumber
66
+ if (Number.isSafeInteger(stock) && stock >= 0) {
67
+ setProduct({ ...product().value, stock })
68
+ setStatus({ saving: false, message: `Unsaved count` })
69
+ }
70
+ }}
71
+ />
72
+ <p class="preview" data-preview>
73
+ {product().value.stock} units available
74
+ </p>
75
+ <p role="status">{product().error?.message ?? status().message}</p>
76
+ <footer>
77
+ <button
78
+ disabled={status().saving || product().loading || !!product().error}
79
+ onClick={save}
80
+ >
81
+ {status().saving ? `Saving…` : `Save stock`}
82
+ </button>
83
+ <button
84
+ class="secondary"
85
+ disabled={status().saving || product().loading}
86
+ onClick={() => {
87
+ setStatus({ saving: false, message: `` })
88
+ resetInStore(store, productAtoms, props.id)
89
+ }}
90
+ >
91
+ Reload from database
92
+ </button>
93
+ <output data-revision>{product().value.revision}</output>
94
+ </footer>
95
+ </article>
96
+ )
97
+ }
98
+
99
+ export default function Inventory(props: { id: string }): SpaElement {
100
+ return (
101
+ <div class="product-grid" data-island={props.id}>
102
+ {productIds.map((id) => (
103
+ <StockCard key={id} id={id} />
104
+ ))}
105
+ </div>
106
+ )
107
+ }
package/src/api.ts CHANGED
@@ -3,7 +3,7 @@ import type { inferRouterOutputs } from "@trpc/server"
3
3
 
4
4
  import type { AppRouter } from "./server/router.ts"
5
5
 
6
- export type Draft = inferRouterOutputs<AppRouter>[`drafts`][`get`]
6
+ export type Product = inferRouterOutputs<AppRouter>[`inventory`][`get`]
7
7
  export const api = createTRPCClient<AppRouter>({
8
8
  links: [httpLink({ url: `/api/trpc` })],
9
9
  })
@@ -10,13 +10,12 @@ interface Props {
10
10
  <head>
11
11
  <meta charset="UTF-8" />
12
12
  <meta name="viewport" content="width=device-width" />
13
- <title>{Astro.props.title} · Resumable drafts</title>
13
+ <title>{Astro.props.title} · Stockroom</title>
14
14
  <ClientRouter />
15
15
  </head>
16
16
  <body>
17
17
  <nav>
18
- <a href="/" class="brand">Resumable drafts</a><a href="/about"
19
- >How it works</a>
18
+ <a href="/" class="brand">Stockroom</a><a href="/about">How it works</a>
20
19
  </nav>
21
20
  <main><slot /></main>
22
21
  <footer class="page-footer">Astro + atom.io + SPA + resumability</footer>
@@ -6,20 +6,22 @@ import Page from "../layouts/Page.astro"
6
6
  <p class="eyebrow">One value, two beginnings</p>
7
7
  <h1>One database. Two ways to read it.</h1>
8
8
  <p>
9
- Astro waits for the atom defaults, renders the draft cards with SPA, and
10
- places the results in a JSON sidecar. The browser consumes those values
11
- before hydrating the existing cards.
9
+ Astro waits for the atom defaults, renders the stock cards with SPA, and
10
+ attaches the results to each island’s HTML as seed data. The browser
11
+ consumes those values before hydrating the existing cards.
12
12
  </p>
13
13
  <p>
14
14
  A reset calls the default again. Its server value has already been
15
15
  consumed, so the client closure calls tRPC for a fresh database read.
16
- Editing or resetting a document updates both views of that document.
16
+ Editing, saving, or reloading a product updates both views. Saving persists
17
+ the count and checks its revision so it cannot silently overwrite another
18
+ stock count.
17
19
  </p>
18
20
  <p>
19
21
  Navigation releases the islands while keeping your shared state alive.
20
- Returning renders fresh views of your edits. Refresh explicitly to fetch
21
- new data. SSR and tRPC call the same function, backed by a typed SQL query.
22
+ Returning renders fresh views of your edits. Reload explicitly to fetch new
23
+ data. SSR and tRPC call the same function, backed by a typed SQL query.
22
24
  </p>
23
- <a class="back" href="/">Back to drafts →</a>
25
+ <a class="back" href="/">Back to inventory →</a>
24
26
  </section>
25
27
  </Page>
@@ -1,28 +1,25 @@
1
1
  ---
2
- import Resumption from "resumability/Resumption.astro"
3
- import Drafts from "../Drafts.spa.tsx"
2
+ import Inventory from "../Inventory.tsx"
4
3
  import Page from "../layouts/Page.astro"
5
4
  ---
6
- <Page title="Your drafts">
7
- <Resumption>{() => (
8
- <>
9
- <header class="intro">
10
- <p class="eyebrow">A server head start</p>
11
- <h1>Pick up where the server left off.</h1>
12
- <p>
13
- These notes arrive from SQLite. Edit one, or refresh it from the
14
- database. Both views share the same atom.io state.
15
- </p>
16
- </header>
17
- <section class="workspace" aria-labelledby="work-heading">
18
- <h2 id="work-heading">Your editor <small>Loads immediately</small></h2>
19
- <Drafts id="work" client:load />
20
- </section>
21
- <section class="workspace" aria-labelledby="personal-heading">
22
- <h2 id="personal-heading">
23
- Another view <small>Same shared documents · loads when visible</small>
24
- </h2><Drafts id="personal" client:visible />
25
- </section>
26
- </>
27
- )}</Resumption>
5
+ <Page title="Stock count">
6
+ <header class="intro">
7
+ <p class="eyebrow">Stockroom</p>
8
+ <h1>Count what’s on the shelf.</h1>
9
+ <p>
10
+ Update the count after checking a shelf, then save the new inventory. Both
11
+ views share your counts, including changes you haven’t saved yet.
12
+ </p>
13
+ </header>
14
+ <section class="workspace" aria-labelledby="work-heading">
15
+ <h2 id="work-heading">
16
+ Morning stock count <small>Loads immediately</small>
17
+ </h2>
18
+ <Inventory id="work" client:load />
19
+ </section>
20
+ <section class="workspace" aria-labelledby="personal-heading">
21
+ <h2 id="personal-heading">
22
+ Packing station <small>Same shared inventory · loads when visible</small>
23
+ </h2><Inventory id="personal" client:visible />
24
+ </section>
28
25
  </Page>
@@ -0,0 +1,45 @@
1
+ import { DatabaseSync } from "node:sqlite"
2
+
3
+ import { TRPCError } from "@trpc/server"
4
+
5
+ import readProductQuery from "./read-product.sql"
6
+
7
+ /** SSR and tRPC share the same inferred query result. */
8
+ // oxlint-disable-next-line typescript/explicit-module-boundary-types -- Infer Reylo's SQL result; the type tests verify the transport contract.
9
+ export function readProduct(id: number) {
10
+ const database = new DatabaseSync(`inventory.sqlite`, { readOnly: true })
11
+ try {
12
+ const product = readProductQuery.runSync(database, { id })
13
+ if (!product)
14
+ throw new TRPCError({
15
+ code: `NOT_FOUND`,
16
+ message: `Product ${id} does not exist`,
17
+ })
18
+ return product
19
+ } finally {
20
+ database.close()
21
+ }
22
+ }
23
+
24
+ // oxlint-disable-next-line typescript/explicit-module-boundary-types -- Preserve the query's inferred return type through the mutation.
25
+ export function saveStock(id: number, stock: number, revision: number) {
26
+ const database = new DatabaseSync(`inventory.sqlite`)
27
+ try {
28
+ database.exec(`BEGIN IMMEDIATE`)
29
+ const result = database
30
+ .prepare(
31
+ `UPDATE products SET stock = ?, revision = revision + 1 WHERE id = ? AND revision = ?`,
32
+ )
33
+ .run(stock, id, revision)
34
+ if (!result.changes)
35
+ throw new TRPCError({
36
+ code: `CONFLICT`,
37
+ message: `Stock changed since you loaded it. Reload before saving.`,
38
+ })
39
+ const product = readProductQuery.runSync(database, { id })!
40
+ database.exec(`COMMIT`)
41
+ return product
42
+ } finally {
43
+ database.close()
44
+ }
45
+ }
@@ -0,0 +1 @@
1
+ SELECT id, sku, name, stock, revision FROM products WHERE id = :id;
@@ -1,14 +1,23 @@
1
1
  import { initTRPC } from "@trpc/server"
2
2
  import { z } from "zod"
3
3
 
4
- import { readDraft } from "./drafts.ts"
4
+ import { readProduct, saveStock } from "./inventory.ts"
5
5
 
6
6
  const t = initTRPC.create()
7
7
  export const appRouter = t.router({
8
- drafts: t.router({
8
+ inventory: t.router({
9
9
  get: t.procedure
10
10
  .input(z.object({ id: z.number().int().positive() }))
11
- .query(({ input }) => readDraft(input.id)),
11
+ .query(({ input }) => readProduct(input.id)),
12
+ setStock: t.procedure
13
+ .input(
14
+ z.object({
15
+ id: z.number().int().positive(),
16
+ stock: z.number().int().nonnegative(),
17
+ revision: z.number().int().positive(),
18
+ }),
19
+ )
20
+ .mutation(({ input }) => saveStock(input.id, input.stock, input.revision)),
12
21
  }),
13
22
  })
14
23
  export type AppRouter = typeof appRouter
package/src/state.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { atomFamily, type Loadable } from "atom.io"
2
+ import { resumable } from "resumability"
3
+
4
+ import { api, type Product } from "./api.ts"
5
+
6
+ export const productIds = [1, 2] as const
7
+
8
+ export const productAtoms = atomFamily<Loadable<Product>, number, Error>({
9
+ key: `product`,
10
+ default: (id) =>
11
+ resumable({
12
+ key: [`product`, id],
13
+ server: async ({ signal }) => {
14
+ const { readProduct } = await import(`./server/inventory.ts`)
15
+ signal.throwIfAborted()
16
+ return readProduct(id)
17
+ },
18
+ client: ({ signal }) => api.inventory.get.query({ id }, { signal }),
19
+ }),
20
+ catch: [Error],
21
+ })
22
+
23
+ export const saveStatusAtoms = atomFamily<
24
+ { saving: boolean; message: string },
25
+ number
26
+ >({
27
+ key: `saveStatus`,
28
+ default: { saving: false, message: `` },
29
+ })
package/src/style.css CHANGED
@@ -63,22 +63,19 @@ h2 small {
63
63
  color: #65776b;
64
64
  margin-left: 12px;
65
65
  }
66
- astro-resumption {
67
- display: contents;
68
- }
69
- .draft-grid {
66
+ .product-grid {
70
67
  display: grid;
71
68
  grid-template-columns: repeat(2, minmax(0, 1fr));
72
69
  gap: 20px;
73
70
  }
74
- .draft {
71
+ .product {
75
72
  background: #fffef9;
76
73
  border: 1px solid #d7ded3;
77
74
  border-radius: 16px;
78
75
  padding: 24px;
79
76
  box-shadow: 0 3px 12px #20392f06;
80
77
  }
81
- .draft header {
78
+ .product header {
82
79
  display: flex;
83
80
  align-items: center;
84
81
  justify-content: space-between;
@@ -101,18 +98,16 @@ label {
101
98
  font-weight: 650;
102
99
  margin-bottom: 7px;
103
100
  }
104
- textarea {
101
+ input[type="number"] {
105
102
  width: 100%;
106
- min-height: 100px;
107
103
  padding: 12px;
108
104
  font: inherit;
109
- resize: vertical;
110
105
  color: inherit;
111
106
  border: 1px solid #bdcbbb;
112
107
  border-radius: 7px;
113
108
  background: #fff;
114
109
  }
115
- textarea:focus {
110
+ input[type="number"]:focus {
116
111
  outline: 2px solid #6d9870;
117
112
  outline-offset: 2px;
118
113
  }
@@ -122,7 +117,7 @@ textarea:focus {
122
117
  color: #657569;
123
118
  font-size: 0.85rem;
124
119
  }
125
- .draft footer {
120
+ .product footer {
126
121
  display: flex;
127
122
  flex-wrap: wrap;
128
123
  align-items: center;
@@ -157,14 +152,28 @@ output {
157
152
  margin-block: 18px 60px;
158
153
  }
159
154
  @media (max-width: 650px) {
160
- .draft-grid {
155
+ .product-grid {
161
156
  grid-template-columns: 1fr;
162
157
  }
163
158
  h2 small {
164
159
  display: block;
165
160
  margin: 2px 0 0;
166
161
  }
167
- .draft {
162
+ .product {
168
163
  padding: 20px;
169
164
  }
170
165
  }
166
+
167
+ .sku {
168
+ margin: 0 0 5px;
169
+ font-size: 0.72rem;
170
+ color: #65776b;
171
+ }
172
+ button:disabled {
173
+ opacity: 0.5;
174
+ cursor: wait;
175
+ }
176
+ button.secondary {
177
+ background: #e6eee1;
178
+ color: #22573c;
179
+ }