@brust/create-app 2.0.0-alpha.1 → 2.0.0-alpha.3
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/package.json +1 -1
- package/src/dep.ts +2 -1
- package/templates/blank/README.md.tmpl +2 -1
- package/templates/blank/_gitignore +1 -0
- package/templates/blank/brust.toml +1 -1
- package/templates/blank/package.json.tmpl +5 -2
- package/templates/blank/wrangler.jsonc.tmpl +9 -0
- package/templates/pokedex/README.md.tmpl +16 -1
- package/templates/pokedex/_gitignore +2 -0
- package/templates/pokedex/actions.ts +5 -5
- package/templates/pokedex/brust.toml +21 -1
- package/templates/pokedex/lib/cloudflare.d.ts +24 -0
- package/templates/pokedex/lib/loaders.ts +2 -2
- package/templates/pokedex/lib/team-db.d1.ts +47 -0
- package/templates/pokedex/lib/team-db.sqlite.ts +49 -0
- package/templates/pokedex/lib/team-db.ts +7 -46
- package/templates/pokedex/migrations/0001_team.sql +2 -0
- package/templates/pokedex/package.json.tmpl +6 -1
- package/templates/pokedex/wrangler.jsonc +9 -0
package/package.json
CHANGED
package/src/dep.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { dirname, join, relative, resolve } from 'node:path'
|
|
4
4
|
|
|
5
|
-
/** = scripts/release-
|
|
5
|
+
/** = scripts/release-files.ts PLATS. */
|
|
6
6
|
export const PLATS = ['darwin-x64', 'darwin-arm64', 'linux-x64-gnu', 'linux-arm64-gnu', 'linux-x64-musl', 'linux-arm64-musl'] as const
|
|
7
7
|
|
|
8
8
|
export interface BrustDep {
|
|
@@ -44,6 +44,7 @@ export function resolveBrustDep(o: { explicit?: string; targetDir: string; pkgRo
|
|
|
44
44
|
if (inWorkspace(root, o.targetDir)) return { kind: 'workspace', spec: 'workspace:*' }
|
|
45
45
|
// `file:` alone fails: core's own workspace:* deps do not resolve outside the workspace globs, so override them.
|
|
46
46
|
const overrides: Record<string, string> = { '@brust/runtime-dom': `file:${root}/packages/runtime-dom` }
|
|
47
|
+
overrides['@brust/core-cf'] = `file:${root}/npm/core-cf`
|
|
47
48
|
for (const p of PLATS) overrides[`@brust/native-${p}`] = `file:${root}/npm/${p}`
|
|
48
49
|
return { kind: 'file', spec: `file:${root}/packages/brust`, overrides }
|
|
49
50
|
}
|
|
@@ -9,7 +9,7 @@ bun install
|
|
|
9
9
|
bun run dev # brust build routes.tsx && brust start -> http://127.0.0.1:1337
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
Scripts: `dev` (build then start), `build`, `start`, `typecheck
|
|
12
|
+
Scripts: `dev` (build then start), `build`, `start`, `typecheck`, `dev:cf` (build for Cloudflare Workers and run `wrangler dev`), `deploy:cf` (build and `wrangler deploy`).
|
|
13
13
|
|
|
14
14
|
## Layout
|
|
15
15
|
|
|
@@ -18,6 +18,7 @@ Scripts: `dev` (build then start), `build`, `start`, `typecheck`.
|
|
|
18
18
|
- `components/Counter.tsx` — a `useState` counter; the compiler makes it a native component (no React in the browser).
|
|
19
19
|
- `actions.ts` — an Elysia 2 app on `brust()`, served by Rust under `/_brust/action` (enabled by `[actions]` in `brust.toml`).
|
|
20
20
|
- `public/` — static files, served at `/public/...`.
|
|
21
|
+
- `wrangler.jsonc` — your Cloudflare bindings (D1, KV, vars); brust owns `main`, `assets` and `compatibility_date`.
|
|
21
22
|
|
|
22
23
|
## The hello action
|
|
23
24
|
|
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
address = "127.0.0.1"
|
|
3
3
|
port = 1337
|
|
4
4
|
|
|
5
|
-
# Elysia actions (docs/actions.md). Defaults: entry = "actions.ts", prefix = "/_brust/action", max_body_bytes =
|
|
5
|
+
# Elysia actions (docs/actions.md). Defaults: entry = "actions.ts", prefix = "/_brust/action", max_body_bytes = 1048576, max_response_bytes = 8388608
|
|
6
6
|
[actions]
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
"dev": "brust build routes.tsx && brust start",
|
|
8
8
|
"build": "brust build routes.tsx",
|
|
9
9
|
"start": "brust start",
|
|
10
|
-
"typecheck": "bun check"
|
|
10
|
+
"typecheck": "bun check",
|
|
11
|
+
"dev:cf": "brust dev routes.tsx --target cloudflare",
|
|
12
|
+
"deploy:cf": "brust build routes.tsx --target cloudflare && wrangler deploy -c dist/cf/wrangler.jsonc"
|
|
11
13
|
},
|
|
12
14
|
"dependencies": {
|
|
13
15
|
"@brust/core": __BRUST_DEP__,
|
|
@@ -19,6 +21,7 @@
|
|
|
19
21
|
"devDependencies": {
|
|
20
22
|
"@types/bun": "^1.4.0",
|
|
21
23
|
"@types/react": "^19.2.0",
|
|
22
|
-
"typescript": "^7.0.2"
|
|
24
|
+
"typescript": "^7.0.2",
|
|
25
|
+
"wrangler": "^4.149.0"
|
|
23
26
|
}
|
|
24
27
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Your Cloudflare config for `bun run dev:cf` / `bun run deploy:cf`.
|
|
2
|
+
// `brust build --target cloudflare` merges this file into dist/cf/wrangler.jsonc and owns these keys:
|
|
3
|
+
// main, assets (the ASSETS binding), compatibility_date, no_bundle, build, rules, site.
|
|
4
|
+
{
|
|
5
|
+
"name": "__PROJECT_NAME__"
|
|
6
|
+
// "d1_databases": [{ "binding": "DB", "database_name": "__PROJECT_NAME__", "database_id": "<id>" }],
|
|
7
|
+
// "kv_namespaces": [{ "binding": "KV", "id": "<id>" }],
|
|
8
|
+
// "vars": { "GREETING": "hello" }
|
|
9
|
+
}
|
|
@@ -26,10 +26,25 @@ at most 6 members and a seventh add answers `409` (`TeamFull`). The team store (
|
|
|
26
26
|
|
|
27
27
|
- `routes.tsx` — `/`, `/pokedex`, `/pokemon/{name}` (L1 60 s, `?nocache` bypass), `/type-chart` (L1 1 h), `/unified`, and a
|
|
28
28
|
root-level `*` 404 outside the layout. Pages that show the team carry the cache tag `team`.
|
|
29
|
-
- `lib/` — loaders over the snapshot, `team-db.ts` (sqlite, server-only), `team-client.ts` + `api.ts` (the typed actions client).
|
|
29
|
+
- `lib/` — loaders over the snapshot, `team-db.ts` (the team interface: sqlite natively, D1 on Cloudflare via `[cloudflare.alias]`; server-only), `team-client.ts` + `api.ts` (the typed actions client).
|
|
30
30
|
- `actions.ts` — the Elysia 2 team app on `brust()`; `stores/` — `team` and `unified` (`defineStore`).
|
|
31
31
|
- `components/`, `pages/` — plain React functions.
|
|
32
32
|
|
|
33
|
+
## Cloudflare Workers (preview)
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bun run cf:migrate:local # create the local D1 table (state in .wrangler/)
|
|
37
|
+
bun run dev:cf # build for Workers and run `wrangler dev` (rebuilds on change)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`brust build --target cloudflare` swaps `lib/team-db.ts` for the D1 version (`lib/team-db.d1.ts`) through `[cloudflare.alias]`
|
|
41
|
+
in `brust.toml`; your bindings live in `wrangler.jsonc`. To deploy: `wrangler d1 create __PROJECT_NAME__-team`, paste the
|
|
42
|
+
`database_id` into the **app-root** `wrangler.jsonc` (not `dist/cf/`, which is regenerated), `bun run cf:migrate:remote`,
|
|
43
|
+
then `bun run deploy:cf`.
|
|
44
|
+
|
|
45
|
+
Preview note: a team change refreshes cached team pages only in the isolate that served the action; other isolates catch up
|
|
46
|
+
within the route `ttl` (60 s for `/pokemon/*`, 1 h for `/type-chart`) until the cross-isolate invalidation lands.
|
|
47
|
+
|
|
33
48
|
## Regenerating the stylesheet (never hand-edit `public/app.css`)
|
|
34
49
|
|
|
35
50
|
```bash
|
|
@@ -13,21 +13,21 @@ export interface TeamDeps {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function teamApp({ db, invalidate }: TeamDeps = { db: teamDb, invalidate: cache.invalidate }) {
|
|
16
|
-
const reply = () => ({ team: db().list(), max: MAX_TEAM })
|
|
16
|
+
const reply = async () => ({ team: await db().list(), max: MAX_TEAM })
|
|
17
17
|
// R5: cached pages that render the team carry the tag `team`; drop them after a real change.
|
|
18
18
|
const changed = () => invalidate({ tags: ['team'] })
|
|
19
19
|
return new Elysia({ adapter: brust() })
|
|
20
20
|
.get('/team', () => reply())
|
|
21
|
-
.post('/team', { body: t.Object({ id: t.Integer({ minimum: 1 }) }) }, ({ body }) => {
|
|
21
|
+
.post('/team', { body: t.Object({ id: t.Integer({ minimum: 1 }) }) }, async ({ body }) => {
|
|
22
22
|
const p = pokemonById(body.id)
|
|
23
23
|
if (!p) return problem(404, { title: 'UnknownPokemon', detail: `no Pokémon #${body.id}` })
|
|
24
|
-
const r = db().add(p)
|
|
24
|
+
const r = await db().add(p)
|
|
25
25
|
if (r === 'full') return problem(409, { title: 'TeamFull', detail: `the team already has ${MAX_TEAM} members`, max: MAX_TEAM })
|
|
26
26
|
if (r === 'added') changed()
|
|
27
27
|
return reply()
|
|
28
28
|
})
|
|
29
|
-
.delete('/team/:id', { params: t.Object({ id: t.Integer({ minimum: 1 }) }) }, ({ params }) => {
|
|
30
|
-
if (db().remove(params.id)) changed()
|
|
29
|
+
.delete('/team/:id', { params: t.Object({ id: t.Integer({ minimum: 1 }) }) }, async ({ params }) => {
|
|
30
|
+
if (await db().remove(params.id)) changed()
|
|
31
31
|
return reply()
|
|
32
32
|
})
|
|
33
33
|
}
|
|
@@ -3,6 +3,26 @@ address = "127.0.0.1"
|
|
|
3
3
|
port = 1337
|
|
4
4
|
|
|
5
5
|
[build]
|
|
6
|
-
server_only = ["lib/team-db
|
|
6
|
+
server_only = ["lib/team-db"]
|
|
7
7
|
|
|
8
8
|
[actions]
|
|
9
|
+
|
|
10
|
+
# `brust build --target cloudflare` swaps the sqlite roster for the D1 one (the whole facade module).
|
|
11
|
+
[cloudflare.alias]
|
|
12
|
+
"lib/team-db.ts" = "lib/team-db.d1.ts"
|
|
13
|
+
|
|
14
|
+
# A shared second cache tier (needs `bun add @brust/cache-redis`; see the brust docs, "Redis / Dragonfly"):
|
|
15
|
+
# [cache]
|
|
16
|
+
# adapter = "redis" # or "dragonfly"
|
|
17
|
+
# [cache.redis]
|
|
18
|
+
# url = "env:REDIS_URL"
|
|
19
|
+
|
|
20
|
+
# Or, on Cloudflare Workers: a shared second cache tier (KV bodies + a Durable Object invalidation broadcast). Off by default.
|
|
21
|
+
# Needs: bun add @brust/cache-cloudflare, plus the KV and Durable Object bindings in wrangler.jsonc (see the comment there).
|
|
22
|
+
# [cache]
|
|
23
|
+
# adapter = "cloudflare-kv"
|
|
24
|
+
# broadcast = "cloudflare-do"
|
|
25
|
+
# epoch_interval_ms = 5000
|
|
26
|
+
#
|
|
27
|
+
# [cache.cloudflare]
|
|
28
|
+
# kv_min_ttl_s = 300
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Ambient types for the Cloudflare Workers surface this example uses (lib/team-db.d1.ts): just the subset of D1 that
|
|
2
|
+
// is called, so `bun check` needs no @cloudflare/workers-types (whose global Request/Response clash with lib DOM).
|
|
3
|
+
interface D1Meta {
|
|
4
|
+
changes: number
|
|
5
|
+
last_row_id: number
|
|
6
|
+
}
|
|
7
|
+
interface D1Result<T = unknown> {
|
|
8
|
+
success: boolean
|
|
9
|
+
results: T[]
|
|
10
|
+
meta: D1Meta
|
|
11
|
+
}
|
|
12
|
+
interface D1PreparedStatement {
|
|
13
|
+
bind(...values: unknown[]): D1PreparedStatement
|
|
14
|
+
all<T = Record<string, unknown>>(): Promise<D1Result<T>>
|
|
15
|
+
first<T = Record<string, unknown>>(): Promise<T | null>
|
|
16
|
+
run(): Promise<D1Result>
|
|
17
|
+
}
|
|
18
|
+
interface D1Database {
|
|
19
|
+
prepare(query: string): D1PreparedStatement
|
|
20
|
+
batch<T = unknown>(statements: D1PreparedStatement[]): Promise<D1Result<T>[]>
|
|
21
|
+
}
|
|
22
|
+
declare module 'cloudflare:workers' {
|
|
23
|
+
export const env: { TEAM_DB?: D1Database }
|
|
24
|
+
}
|
|
@@ -9,8 +9,8 @@ import type { BrowseData, DetailData, HomeData, TypeChartCellVM, TypeChartData,
|
|
|
9
9
|
|
|
10
10
|
const FEATURED = [{ id: 1, name: 'bulbasaur' }, { id: 4, name: 'charmander' }, { id: 7, name: 'squirtle' }, { id: 25, name: 'pikachu' }, { id: 39, name: 'jigglypuff' }, { id: 94, name: 'gengar' }, { id: 143, name: 'snorlax' }, { id: 150, name: 'mewtwo' }]
|
|
11
11
|
/** Layout loader: seeds the request's store scope, so every layout page paints the DB roster (D4/E7). */
|
|
12
|
-
export function layoutLoader(): void {
|
|
13
|
-
team.members.set(teamDb().list())
|
|
12
|
+
export async function layoutLoader(): Promise<void> {
|
|
13
|
+
team.members.set(await teamDb().list())
|
|
14
14
|
team.max.set(MAX_TEAM)
|
|
15
15
|
}
|
|
16
16
|
const NAV = [{ href: '/', label: 'Home' }, { href: '/pokedex', label: 'Pokédex' }, { href: '/type-chart', label: 'Type chart' }, { href: '/unified', label: 'Unified' }]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// examples/pokedex/lib/team-db.d1.ts — the TeamDb on Cloudflare D1. `brust.toml` [cloudflare.alias] swaps lib/team-db.ts for
|
|
2
|
+
// this file in `brust build --target cloudflare`. The binding is read per call (never at import: the build smoke-imports the
|
|
3
|
+
// bundle with an empty env) and nothing is cached across requests.
|
|
4
|
+
import { env } from 'cloudflare:workers'
|
|
5
|
+
import { MAX_TEAM } from './team'
|
|
6
|
+
import type { AddResult, TeamDb } from './team-db'
|
|
7
|
+
import type { TeamMember } from './types'
|
|
8
|
+
|
|
9
|
+
export type { AddResult, TeamDb }
|
|
10
|
+
|
|
11
|
+
interface Row { id: number; name: string; display_name: string; num: string; types: string; artwork: string }
|
|
12
|
+
|
|
13
|
+
const LIST = 'SELECT id, name, display_name, num, types, artwork FROM team ORDER BY added_at, rowid'
|
|
14
|
+
// ONE statement decides the cap: the count check and the insert cannot be split across awaits on D1 (no transaction handle),
|
|
15
|
+
// so the insert is conditional. added_at is a clock that never repeats, as in the sqlite twin.
|
|
16
|
+
const INSERT =
|
|
17
|
+
'INSERT INTO team (id, name, display_name, num, types, artwork, added_at) ' +
|
|
18
|
+
'SELECT ?1, ?2, ?3, ?4, ?5, ?6, MAX(?7, (SELECT COALESCE(MAX(added_at), 0) + 1 FROM team)) ' +
|
|
19
|
+
'WHERE (SELECT COUNT(*) FROM team) < ?8 ON CONFLICT(id) DO NOTHING'
|
|
20
|
+
const HAS = 'SELECT 1 AS one FROM team WHERE id = ?1'
|
|
21
|
+
|
|
22
|
+
export function openTeamDb(d1: D1Database): TeamDb {
|
|
23
|
+
return {
|
|
24
|
+
async list() {
|
|
25
|
+
const { results } = await d1.prepare(LIST).all<Row>()
|
|
26
|
+
return results.map((r) => ({ id: r.id, name: r.name, displayName: r.display_name, types: JSON.parse(r.types) as string[], artwork: r.artwork, num: r.num }))
|
|
27
|
+
},
|
|
28
|
+
async add(m: TeamMember): Promise<AddResult> {
|
|
29
|
+
// One batch = one transaction: added (1 row) | present (the id exists, even when full) | full.
|
|
30
|
+
const [ins, has] = await d1.batch([
|
|
31
|
+
d1.prepare(INSERT).bind(m.id, m.name, m.displayName, m.num, JSON.stringify(m.types), m.artwork, Date.now(), MAX_TEAM),
|
|
32
|
+
d1.prepare(HAS).bind(m.id),
|
|
33
|
+
])
|
|
34
|
+
if (ins!.meta.changes === 1) return 'added'
|
|
35
|
+
return has!.results.length > 0 ? 'present' : 'full'
|
|
36
|
+
},
|
|
37
|
+
async remove(id: number) {
|
|
38
|
+
return (await d1.prepare('DELETE FROM team WHERE id = ?1').bind(id).run()).meta.changes > 0
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function teamDb(): TeamDb {
|
|
44
|
+
const d1 = env.TEAM_DB
|
|
45
|
+
if (!d1) throw new Error('TEAM_DB D1 binding missing: add it to wrangler.jsonc (see README)')
|
|
46
|
+
return openTeamDb(d1)
|
|
47
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// examples/pokedex/lib/team-db.sqlite.ts — the native TeamDb (server-only via brust.toml [build] server_only = lib/team-db).
|
|
2
|
+
// The team is ONE shared roster, as in 0.1.x, but persisted: every worker thread opens the same file (WAL), so workers never
|
|
3
|
+
// diverge (M3-F E7). Methods return Promises so the interface matches D1 (lib/team-db.d1.ts); the work itself stays synchronous.
|
|
4
|
+
import { Database } from 'bun:sqlite'
|
|
5
|
+
import { mkdirSync } from 'node:fs'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
import { MAX_TEAM } from './team'
|
|
8
|
+
import type { AddResult, TeamDb } from './team-db'
|
|
9
|
+
import type { TeamMember } from './types'
|
|
10
|
+
|
|
11
|
+
export type SqliteTeamDb = TeamDb & { close(): void }
|
|
12
|
+
|
|
13
|
+
export const TEAM_DDL =
|
|
14
|
+
'CREATE TABLE IF NOT EXISTS team (id INTEGER PRIMARY KEY, name TEXT NOT NULL, display_name TEXT NOT NULL, num TEXT NOT NULL, types TEXT NOT NULL, artwork TEXT NOT NULL, added_at INTEGER NOT NULL)'
|
|
15
|
+
|
|
16
|
+
interface Row { id: number; name: string; display_name: string; num: string; types: string; artwork: string }
|
|
17
|
+
|
|
18
|
+
export function openTeamDb(file: string): SqliteTeamDb {
|
|
19
|
+
if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true })
|
|
20
|
+
const db = new Database(file, { create: true })
|
|
21
|
+
db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000')
|
|
22
|
+
db.exec(TEAM_DDL)
|
|
23
|
+
const listQ = db.query<Row, []>('SELECT id, name, display_name, num, types, artwork FROM team ORDER BY added_at, rowid')
|
|
24
|
+
const hasQ = db.query<{ one: number }, [number]>('SELECT 1 AS one FROM team WHERE id = ?')
|
|
25
|
+
const countQ = db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM team')
|
|
26
|
+
const insertQ = db.query('INSERT INTO team (id, name, display_name, num, types, artwork, added_at) VALUES (?, ?, ?, ?, ?, ?, MAX(?, (SELECT COALESCE(MAX(added_at), 0) + 1 FROM team)))')
|
|
27
|
+
const deleteQ = db.query('DELETE FROM team WHERE id = ?')
|
|
28
|
+
// added_at is a clock that never repeats (MAX(now, last + 1)): rowid IS the Pokémon id, so it cannot break same-ms ties.
|
|
29
|
+
// count check + insert under ONE write lock (.immediate): two workers adding at five members cannot both succeed.
|
|
30
|
+
const addTx = db.transaction((m: TeamMember): AddResult => {
|
|
31
|
+
if (hasQ.get(m.id)) return 'present'
|
|
32
|
+
if ((countQ.get()?.n ?? 0) >= MAX_TEAM) return 'full'
|
|
33
|
+
insertQ.run(m.id, m.name, m.displayName, m.num, JSON.stringify(m.types), m.artwork, Date.now())
|
|
34
|
+
return 'added'
|
|
35
|
+
})
|
|
36
|
+
return {
|
|
37
|
+
list: async () => listQ.all().map((r) => ({ id: r.id, name: r.name, displayName: r.display_name, types: JSON.parse(r.types) as string[], artwork: r.artwork, num: r.num })),
|
|
38
|
+
add: async (m) => addTx.immediate(m),
|
|
39
|
+
remove: async (id) => deleteQ.run(id).changes > 0,
|
|
40
|
+
close: () => db.close(),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let shared: SqliteTeamDb | null = null
|
|
45
|
+
export const teamDbPath = (): string => process.env.POKEDEX_TEAM_DB || join(process.cwd(), 'data/team.sqlite')
|
|
46
|
+
/** Opened lazily on first use: `brust build` imports routes.tsx, and building must not create the file. */
|
|
47
|
+
export function teamDb(): SqliteTeamDb {
|
|
48
|
+
return (shared ??= openTeamDb(teamDbPath()))
|
|
49
|
+
}
|
|
@@ -1,52 +1,13 @@
|
|
|
1
|
-
// examples/pokedex/lib/team-db.ts —
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
import { mkdirSync } from 'node:fs'
|
|
5
|
-
import { dirname, join } from 'node:path'
|
|
6
|
-
import { MAX_TEAM } from './team'
|
|
1
|
+
// examples/pokedex/lib/team-db.ts — the TeamDb interface (async: D1 has no sync API) and the native default.
|
|
2
|
+
// `brust build --target cloudflare` swaps this whole module for lib/team-db.d1.ts via brust.toml [cloudflare.alias],
|
|
3
|
+
// so app imports (`./lib/team-db`) never change. Server-only (brust.toml [build] server_only = lib/team-db).
|
|
7
4
|
import type { TeamMember } from './types'
|
|
8
5
|
|
|
9
6
|
export type AddResult = 'added' | 'present' | 'full'
|
|
10
7
|
export interface TeamDb {
|
|
11
|
-
list(): TeamMember[]
|
|
12
|
-
add(m: TeamMember): AddResult
|
|
13
|
-
remove(id: number): boolean
|
|
14
|
-
close(): void
|
|
8
|
+
list(): Promise<TeamMember[]>
|
|
9
|
+
add(m: TeamMember): Promise<AddResult>
|
|
10
|
+
remove(id: number): Promise<boolean>
|
|
15
11
|
}
|
|
16
12
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
export function openTeamDb(file: string): TeamDb {
|
|
20
|
-
if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true })
|
|
21
|
-
const db = new Database(file, { create: true })
|
|
22
|
-
db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000')
|
|
23
|
-
db.exec(
|
|
24
|
-
'CREATE TABLE IF NOT EXISTS team (id INTEGER PRIMARY KEY, name TEXT NOT NULL, display_name TEXT NOT NULL, num TEXT NOT NULL, types TEXT NOT NULL, artwork TEXT NOT NULL, added_at INTEGER NOT NULL)',
|
|
25
|
-
)
|
|
26
|
-
const listQ = db.query<Row, []>('SELECT id, name, display_name, num, types, artwork FROM team ORDER BY added_at, rowid')
|
|
27
|
-
const hasQ = db.query<{ one: number }, [number]>('SELECT 1 AS one FROM team WHERE id = ?')
|
|
28
|
-
const countQ = db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM team')
|
|
29
|
-
const insertQ = db.query('INSERT INTO team (id, name, display_name, num, types, artwork, added_at) VALUES (?, ?, ?, ?, ?, ?, MAX(?, (SELECT COALESCE(MAX(added_at), 0) + 1 FROM team)))')
|
|
30
|
-
const deleteQ = db.query('DELETE FROM team WHERE id = ?')
|
|
31
|
-
// added_at is a clock that never repeats (MAX(now, last + 1)): rowid IS the Pokémon id, so it cannot break same-ms ties.
|
|
32
|
-
// count check + insert under ONE write lock (.immediate): two workers adding at five members cannot both succeed.
|
|
33
|
-
const addTx = db.transaction((m: TeamMember): AddResult => {
|
|
34
|
-
if (hasQ.get(m.id)) return 'present'
|
|
35
|
-
if ((countQ.get()?.n ?? 0) >= MAX_TEAM) return 'full'
|
|
36
|
-
insertQ.run(m.id, m.name, m.displayName, m.num, JSON.stringify(m.types), m.artwork, Date.now())
|
|
37
|
-
return 'added'
|
|
38
|
-
})
|
|
39
|
-
return {
|
|
40
|
-
list: () => listQ.all().map((r) => ({ id: r.id, name: r.name, displayName: r.display_name, types: JSON.parse(r.types) as string[], artwork: r.artwork, num: r.num })),
|
|
41
|
-
add: (m) => addTx.immediate(m),
|
|
42
|
-
remove: (id) => deleteQ.run(id).changes > 0,
|
|
43
|
-
close: () => db.close(),
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
let shared: TeamDb | null = null
|
|
48
|
-
export const teamDbPath = (): string => process.env.POKEDEX_TEAM_DB || join(process.cwd(), 'data/team.sqlite')
|
|
49
|
-
/** Opened lazily on first use: `brust build` imports routes.tsx, and building must not create the file. */
|
|
50
|
-
export function teamDb(): TeamDb {
|
|
51
|
-
return (shared ??= openTeamDb(teamDbPath()))
|
|
52
|
-
}
|
|
13
|
+
export { openTeamDb, type SqliteTeamDb, teamDb, teamDbPath } from './team-db.sqlite'
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "brust build routes.tsx && brust start",
|
|
8
8
|
"build": "brust build routes.tsx",
|
|
9
|
+
"dev:cf": "wrangler d1 migrations apply TEAM_DB --local --persist-to .wrangler/state && brust dev routes.tsx --target cloudflare",
|
|
10
|
+
"deploy:cf": "brust build routes.tsx --target cloudflare && wrangler deploy -c dist/cf/wrangler.jsonc",
|
|
11
|
+
"cf:migrate:local": "wrangler d1 migrations apply TEAM_DB --local --persist-to .wrangler/state",
|
|
12
|
+
"cf:migrate:remote": "wrangler d1 migrations apply TEAM_DB --remote",
|
|
9
13
|
"start": "brust start",
|
|
10
14
|
"snapshot": "bun scripts/snapshot.ts",
|
|
11
15
|
"typecheck": "bun check"
|
|
@@ -20,6 +24,7 @@
|
|
|
20
24
|
"devDependencies": {
|
|
21
25
|
"@types/bun": "^1.4.0",
|
|
22
26
|
"@types/react": "^19.2.0",
|
|
23
|
-
"typescript": "^7.0.2"
|
|
27
|
+
"typescript": "^7.0.2",
|
|
28
|
+
"wrangler": "^4.149.0"
|
|
24
29
|
}
|
|
25
30
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Bindings for `brust build --target cloudflare` (merged into dist/cf/wrangler.jsonc).
|
|
2
|
+
// brust owns main, assets and compatibility_date. Add the database_id from `wrangler d1 create pokedex-team` before deploy.
|
|
3
|
+
{
|
|
4
|
+
// To enable the shared cache tier (brust.toml [cache]), add these beside d1_databases:
|
|
5
|
+
// "kv_namespaces": [{ "binding": "BRUST_CACHE", "id": "<wrangler kv namespace create brust-cache>" }],
|
|
6
|
+
// "durable_objects": { "bindings": [{ "name": "BRUST_CACHE_DO", "class_name": "BrustCacheDO" }] },
|
|
7
|
+
// "migrations": [{ "tag": "brust-cache-v1", "new_sqlite_classes": ["BrustCacheDO"] }],
|
|
8
|
+
"d1_databases": [{ "binding": "TEAM_DB", "database_name": "pokedex-team", "migrations_dir": "migrations" }]
|
|
9
|
+
}
|