@atom.io/template-spa-astro-ssr 0.0.3 → 0.0.5
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/.reylo.yaml +1 -1
- package/README.md +102 -88
- package/README.mdx +20 -25
- package/_gitignore +1 -1
- package/exhibits/PrivateInventory.tsx +8 -0
- package/exhibits/inventory-page.astro +5 -0
- package/exhibits/private-inventory-page.astro +5 -0
- package/package.json +13 -13
- package/schema.sql +5 -3
- package/scripts/seed.ts +4 -4
- package/src/Inventory.tsx +107 -0
- package/src/api.ts +1 -1
- package/src/layouts/Page.astro +2 -3
- package/src/pages/about.astro +9 -7
- package/src/pages/index.astro +21 -24
- package/src/server/inventory.ts +45 -0
- package/src/server/read-product.sql +1 -0
- package/src/server/router.ts +12 -3
- package/src/state.ts +29 -0
- package/src/style.css +22 -13
- package/tests/browser.mjs +185 -66
- package/tests/types.ts +14 -10
- package/tsconfig.json +1 -0
- package/src/Drafts.spa.tsx +0 -62
- package/src/model.ts +0 -21
- package/src/prepare-drafts.ts +0 -22
- package/src/server/drafts.ts +0 -17
- package/src/server/read-draft.sql +0 -1
|
@@ -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
|
|
6
|
+
export type Product = inferRouterOutputs<AppRouter>[`inventory`][`get`]
|
|
7
7
|
export const api = createTRPCClient<AppRouter>({
|
|
8
8
|
links: [httpLink({ url: `/api/trpc` })],
|
|
9
9
|
})
|
package/src/layouts/Page.astro
CHANGED
|
@@ -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} ·
|
|
13
|
+
<title>{Astro.props.title} · Stockroom</title>
|
|
14
14
|
<ClientRouter />
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<nav>
|
|
18
|
-
<a href="/" class="brand">
|
|
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>
|
package/src/pages/about.astro
CHANGED
|
@@ -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
|
|
10
|
-
|
|
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
|
|
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.
|
|
21
|
-
|
|
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
|
|
25
|
+
<a class="back" href="/">Back to inventory →</a>
|
|
24
26
|
</section>
|
|
25
27
|
</Page>
|
package/src/pages/index.astro
CHANGED
|
@@ -1,28 +1,25 @@
|
|
|
1
1
|
---
|
|
2
|
-
import
|
|
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="
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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;
|
package/src/server/router.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { initTRPC } from "@trpc/server"
|
|
2
2
|
import { z } from "zod"
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { readProduct, saveStock } from "./inventory.ts"
|
|
5
5
|
|
|
6
6
|
const t = initTRPC.create()
|
|
7
7
|
export const appRouter = t.router({
|
|
8
|
-
|
|
8
|
+
inventory: t.router({
|
|
9
9
|
get: t.procedure
|
|
10
10
|
.input(z.object({ id: z.number().int().positive() }))
|
|
11
|
-
.query(({ input }) =>
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
+
}
|