create-softeneers-app 0.1.0 → 0.2.1
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.html +56 -0
- package/README.md +16 -8
- package/dist/args.js +33 -4
- package/dist/args.js.map +1 -1
- package/dist/fragments.js +127 -0
- package/dist/fragments.js.map +1 -0
- package/dist/index.js +18 -7
- package/dist/index.js.map +1 -1
- package/dist/prompts.js +27 -1
- package/dist/prompts.js.map +1 -1
- package/dist/templates.js +33 -8
- package/dist/templates.js.map +1 -1
- package/package.json +2 -2
- package/templates/express-api/.env.example +13 -0
- package/templates/express-api/README.md +77 -0
- package/templates/express-api/docker-compose.yml +15 -0
- package/templates/express-api/package.json +36 -0
- package/templates/express-api/softeneers.template.json +17 -0
- package/templates/express-api/src/auth/auth.ts +11 -0
- package/templates/express-api/src/cars/demo.ts +10 -0
- package/templates/express-api/src/cars/routes.ts +58 -0
- package/templates/express-api/src/cars/store.ts +150 -0
- package/templates/express-api/src/cars/types.ts +8 -0
- package/templates/express-api/src/cars/validate.ts +16 -0
- package/templates/express-api/src/db.ts +13 -0
- package/templates/express-api/src/env.ts +23 -0
- package/templates/express-api/src/index.ts +37 -0
- package/templates/express-api/src/scripts/migrate.ts +13 -0
- package/templates/express-api/src/scripts/seed.ts +20 -0
- package/templates/express-api/test/validate.test.ts +25 -0
- package/templates/express-api/tsconfig.json +14 -0
- package/templates/hono-api/.env.example +13 -0
- package/templates/hono-api/README.md +77 -0
- package/templates/hono-api/docker-compose.yml +15 -0
- package/templates/hono-api/package.json +34 -0
- package/templates/hono-api/softeneers.template.json +17 -0
- package/templates/hono-api/src/auth/auth.ts +11 -0
- package/templates/hono-api/src/cars/demo.ts +10 -0
- package/templates/hono-api/src/cars/routes.ts +43 -0
- package/templates/hono-api/src/cars/store.ts +150 -0
- package/templates/hono-api/src/cars/types.ts +8 -0
- package/templates/hono-api/src/cars/validate.ts +16 -0
- package/templates/hono-api/src/db.ts +13 -0
- package/templates/hono-api/src/env.ts +23 -0
- package/templates/hono-api/src/index.ts +26 -0
- package/templates/hono-api/src/scripts/migrate.ts +13 -0
- package/templates/hono-api/src/scripts/seed.ts +20 -0
- package/templates/hono-api/test/validate.test.ts +25 -0
- package/templates/hono-api/tsconfig.json +14 -0
- package/templates/minimal/.env.example +2 -0
- package/templates/minimal/README.md +33 -0
- package/templates/minimal/package.json +22 -0
- package/templates/minimal/src/index.ts +20 -0
- package/templates/minimal/test/greet.test.ts +12 -0
- package/templates/minimal/tsconfig.json +15 -0
- package/templates/tanstack-start/.env.example +11 -0
- package/templates/tanstack-start/README.md +74 -0
- package/templates/tanstack-start/docker-compose.yml +15 -0
- package/templates/tanstack-start/package.json +56 -0
- package/templates/tanstack-start/public/favicon.ico +0 -0
- package/templates/tanstack-start/public/logo192.png +0 -0
- package/templates/tanstack-start/public/logo512.png +0 -0
- package/templates/tanstack-start/public/manifest.json +25 -0
- package/templates/tanstack-start/public/robots.txt +3 -0
- package/templates/tanstack-start/softeneers.template.json +17 -0
- package/templates/tanstack-start/src/cars/types.ts +8 -0
- package/templates/tanstack-start/src/cars/validate.ts +16 -0
- package/templates/tanstack-start/src/router.tsx +19 -0
- package/templates/tanstack-start/src/routes/__root.tsx +54 -0
- package/templates/tanstack-start/src/routes/api/auth/$.ts +14 -0
- package/templates/tanstack-start/src/routes/cars.tsx +87 -0
- package/templates/tanstack-start/src/routes/index.tsx +20 -0
- package/templates/tanstack-start/src/server/auth.ts +11 -0
- package/templates/tanstack-start/src/server/cars.ts +27 -0
- package/templates/tanstack-start/src/server/db.ts +12 -0
- package/templates/tanstack-start/src/server/demo.ts +9 -0
- package/templates/tanstack-start/src/server/env.ts +22 -0
- package/templates/tanstack-start/src/server/scripts/migrate.ts +13 -0
- package/templates/tanstack-start/src/server/scripts/seed.ts +20 -0
- package/templates/tanstack-start/src/server/store.ts +132 -0
- package/templates/tanstack-start/src/styles.css +17 -0
- package/templates/tanstack-start/tsconfig.json +28 -0
- package/templates/tanstack-start/tsr.config.json +3 -0
- package/templates/tanstack-start/vite.config.ts +14 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"toggles": { "db": false, "auth": false, "docker": false },
|
|
3
|
+
"fragments": {
|
|
4
|
+
"db": {
|
|
5
|
+
"removePaths": ["src/server/db.ts", "src/server/scripts", "docker-compose.yml"],
|
|
6
|
+
"removeDeps": ["@softeneers/db", "mysql2"],
|
|
7
|
+
"removeScripts": ["db:migrate", "db:seed", "db:reset"]
|
|
8
|
+
},
|
|
9
|
+
"auth": {
|
|
10
|
+
"removePaths": ["src/server/auth.ts", "src/routes/api"],
|
|
11
|
+
"removeDeps": ["@softeneers/auth"]
|
|
12
|
+
},
|
|
13
|
+
"docker": {
|
|
14
|
+
"removePaths": ["docker-compose.yml"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { NewCar } from './types'
|
|
2
|
+
|
|
3
|
+
/** Thrown when a submitted car is invalid. */
|
|
4
|
+
export class ValidationError extends Error {}
|
|
5
|
+
|
|
6
|
+
/** Validate and normalize untrusted input into a NewCar. Runs on the server. */
|
|
7
|
+
export function parseNewCar(input: unknown): NewCar {
|
|
8
|
+
const b = (input ?? {}) as Record<string, unknown>
|
|
9
|
+
const brand = typeof b.brand === 'string' ? b.brand.trim() : ''
|
|
10
|
+
const model = typeof b.model === 'string' ? b.model.trim() : ''
|
|
11
|
+
const year = Number(b.year)
|
|
12
|
+
if (!brand || !model || !Number.isInteger(year)) {
|
|
13
|
+
throw new ValidationError('brand (string), model (string) and year (integer) are required.')
|
|
14
|
+
}
|
|
15
|
+
return { brand, model, year }
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
|
2
|
+
import { routeTree } from './routeTree.gen'
|
|
3
|
+
|
|
4
|
+
export function getRouter() {
|
|
5
|
+
const router = createTanStackRouter({
|
|
6
|
+
routeTree,
|
|
7
|
+
scrollRestoration: true,
|
|
8
|
+
defaultPreload: 'intent',
|
|
9
|
+
defaultPreloadStaleTime: 0,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
return router
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
declare module '@tanstack/react-router' {
|
|
16
|
+
interface Register {
|
|
17
|
+
router: ReturnType<typeof getRouter>
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
|
2
|
+
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
|
3
|
+
import { TanStackDevtools } from '@tanstack/react-devtools'
|
|
4
|
+
|
|
5
|
+
import appCss from '../styles.css?url'
|
|
6
|
+
|
|
7
|
+
export const Route = createRootRoute({
|
|
8
|
+
head: () => ({
|
|
9
|
+
meta: [
|
|
10
|
+
{
|
|
11
|
+
charSet: 'utf-8',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'viewport',
|
|
15
|
+
content: 'width=device-width, initial-scale=1',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
title: 'TanStack Start Starter',
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
links: [
|
|
22
|
+
{
|
|
23
|
+
rel: 'stylesheet',
|
|
24
|
+
href: appCss,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
}),
|
|
28
|
+
shellComponent: RootDocument,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
function RootDocument({ children }: { children: React.ReactNode }) {
|
|
32
|
+
return (
|
|
33
|
+
<html lang="en">
|
|
34
|
+
<head>
|
|
35
|
+
<HeadContent />
|
|
36
|
+
</head>
|
|
37
|
+
<body>
|
|
38
|
+
{children}
|
|
39
|
+
<TanStackDevtools
|
|
40
|
+
config={{
|
|
41
|
+
position: 'bottom-right',
|
|
42
|
+
}}
|
|
43
|
+
plugins={[
|
|
44
|
+
{
|
|
45
|
+
name: 'Tanstack Router',
|
|
46
|
+
render: <TanStackRouterDevtoolsPanel />,
|
|
47
|
+
},
|
|
48
|
+
]}
|
|
49
|
+
/>
|
|
50
|
+
<Scripts />
|
|
51
|
+
</body>
|
|
52
|
+
</html>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
|
|
3
|
+
import { auth } from '../../../server/auth'
|
|
4
|
+
|
|
5
|
+
// Catch-all server route: better-auth speaks the Fetch API, so hand it the raw
|
|
6
|
+
// Request and return its Response. Handles every /api/auth/* endpoint.
|
|
7
|
+
export const Route = createFileRoute('/api/auth/$')({
|
|
8
|
+
server: {
|
|
9
|
+
handlers: {
|
|
10
|
+
GET: ({ request }) => auth.handler(request),
|
|
11
|
+
POST: ({ request }) => auth.handler(request),
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { createCar, listCars, removeCar } from '../server/cars'
|
|
5
|
+
|
|
6
|
+
export const Route = createFileRoute('/cars')({
|
|
7
|
+
component: CarsPage,
|
|
8
|
+
loader: async () => listCars(),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
function CarsPage() {
|
|
12
|
+
const cars = Route.useLoaderData()
|
|
13
|
+
const router = useRouter()
|
|
14
|
+
const [brand, setBrand] = useState('')
|
|
15
|
+
const [model, setModel] = useState('')
|
|
16
|
+
const [year, setYear] = useState('')
|
|
17
|
+
const [error, setError] = useState('')
|
|
18
|
+
|
|
19
|
+
async function add(event: React.FormEvent) {
|
|
20
|
+
event.preventDefault()
|
|
21
|
+
setError('')
|
|
22
|
+
try {
|
|
23
|
+
await createCar({ data: { brand, model, year: Number(year) } })
|
|
24
|
+
setBrand('')
|
|
25
|
+
setModel('')
|
|
26
|
+
setYear('')
|
|
27
|
+
await router.invalidate()
|
|
28
|
+
} catch {
|
|
29
|
+
setError('Please provide a brand, model and a valid year.')
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function del(id: number) {
|
|
34
|
+
await removeCar({ data: id })
|
|
35
|
+
await router.invalidate()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="mx-auto max-w-2xl p-8">
|
|
40
|
+
<h1 className="text-3xl font-bold">Cars</h1>
|
|
41
|
+
<p className="mt-1 text-gray-500">A full CRUD example backed by a server function.</p>
|
|
42
|
+
|
|
43
|
+
<form onSubmit={add} className="mt-6 flex flex-wrap gap-2">
|
|
44
|
+
<input
|
|
45
|
+
className="flex-1 rounded border border-gray-300 px-3 py-2"
|
|
46
|
+
placeholder="Brand"
|
|
47
|
+
value={brand}
|
|
48
|
+
onChange={(e) => setBrand(e.target.value)}
|
|
49
|
+
/>
|
|
50
|
+
<input
|
|
51
|
+
className="flex-1 rounded border border-gray-300 px-3 py-2"
|
|
52
|
+
placeholder="Model"
|
|
53
|
+
value={model}
|
|
54
|
+
onChange={(e) => setModel(e.target.value)}
|
|
55
|
+
/>
|
|
56
|
+
<input
|
|
57
|
+
className="w-24 rounded border border-gray-300 px-3 py-2"
|
|
58
|
+
placeholder="Year"
|
|
59
|
+
value={year}
|
|
60
|
+
onChange={(e) => setYear(e.target.value)}
|
|
61
|
+
/>
|
|
62
|
+
<button className="rounded bg-black px-4 py-2 font-medium text-white" type="submit">
|
|
63
|
+
Add
|
|
64
|
+
</button>
|
|
65
|
+
</form>
|
|
66
|
+
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
|
|
67
|
+
|
|
68
|
+
<ul className="mt-6 divide-y divide-gray-200">
|
|
69
|
+
{cars.length === 0 && <li className="py-3 text-gray-500">No cars yet — add one above.</li>}
|
|
70
|
+
{cars.map((car) => (
|
|
71
|
+
<li key={car.id} className="flex items-center justify-between py-3">
|
|
72
|
+
<span>
|
|
73
|
+
{car.brand} {car.model} <span className="text-gray-400">({car.year})</span>
|
|
74
|
+
</span>
|
|
75
|
+
<button
|
|
76
|
+
className="text-sm text-red-600 hover:underline"
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={() => del(car.id)}
|
|
79
|
+
>
|
|
80
|
+
Delete
|
|
81
|
+
</button>
|
|
82
|
+
</li>
|
|
83
|
+
))}
|
|
84
|
+
</ul>
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Link, createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
|
|
3
|
+
export const Route = createFileRoute('/')({ component: Home })
|
|
4
|
+
|
|
5
|
+
function Home() {
|
|
6
|
+
return (
|
|
7
|
+
<div className="mx-auto max-w-2xl p-8">
|
|
8
|
+
<h1 className="text-4xl font-bold">Welcome to TanStack Start</h1>
|
|
9
|
+
<p className="mt-4 text-lg text-gray-600">
|
|
10
|
+
Generated by create-softeneers-app. A fullstack React app with type-safe server functions.
|
|
11
|
+
</p>
|
|
12
|
+
<Link
|
|
13
|
+
to="/cars"
|
|
14
|
+
className="mt-6 inline-block rounded bg-black px-5 py-2.5 font-medium text-white"
|
|
15
|
+
>
|
|
16
|
+
Open the cars CRUD demo →
|
|
17
|
+
</Link>
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createAuth } from '@softeneers/auth'
|
|
2
|
+
|
|
3
|
+
import { env } from './env'
|
|
4
|
+
|
|
5
|
+
// Email + password auth via better-auth (@softeneers/auth). With no `database`
|
|
6
|
+
// configured better-auth uses an in-memory store — fine for local dev; point it
|
|
7
|
+
// at your DB for persistence. Mounted at /api/auth/* in routes/api/auth/$.ts.
|
|
8
|
+
export const auth = createAuth({
|
|
9
|
+
secret: env.AUTH_SECRET,
|
|
10
|
+
baseURL: env.AUTH_BASE_URL,
|
|
11
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
2
|
+
|
|
3
|
+
import { parseNewCar } from '../cars/validate'
|
|
4
|
+
import { carStore } from './store'
|
|
5
|
+
|
|
6
|
+
// Server functions: type-safe RPC the client calls directly. Their bodies (and
|
|
7
|
+
// the store/db imports above) run only on the server and are stripped from the
|
|
8
|
+
// client bundle.
|
|
9
|
+
export const listCars = createServerFn({ method: 'GET' }).handler(async () => {
|
|
10
|
+
return carStore.list()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export const createCar = createServerFn({ method: 'POST' })
|
|
14
|
+
.validator((data: unknown) => parseNewCar(data))
|
|
15
|
+
.handler(async ({ data }) => {
|
|
16
|
+
return carStore.create(data)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export const removeCar = createServerFn({ method: 'POST' })
|
|
20
|
+
.validator((data: unknown) => {
|
|
21
|
+
const id = Number(data)
|
|
22
|
+
if (!Number.isInteger(id)) throw new Error('A numeric car id is required.')
|
|
23
|
+
return id
|
|
24
|
+
})
|
|
25
|
+
.handler(async ({ data }) => {
|
|
26
|
+
return carStore.remove(data)
|
|
27
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createDb } from '@softeneers/db'
|
|
2
|
+
|
|
3
|
+
import { env } from './env'
|
|
4
|
+
|
|
5
|
+
// Configured (not-yet-connected) Sequelize instance. Server-only.
|
|
6
|
+
export const sequelize = createDb({
|
|
7
|
+
host: env.DB_HOST,
|
|
8
|
+
port: env.DB_PORT,
|
|
9
|
+
database: env.DB_NAME,
|
|
10
|
+
username: env.DB_USER,
|
|
11
|
+
password: env.DB_PASSWORD,
|
|
12
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NewCar } from '../cars/types'
|
|
2
|
+
|
|
3
|
+
// Demo garage inventory seeded on first run so `npm run dev` shows a working
|
|
4
|
+
// CRUD demo immediately — into MySQL when reachable, else the in-memory store.
|
|
5
|
+
export const DEMO_CARS: NewCar[] = [
|
|
6
|
+
{ brand: 'Toyota', model: 'Corolla', year: 2021 },
|
|
7
|
+
{ brand: 'Tesla', model: 'Model 3', year: 2023 },
|
|
8
|
+
{ brand: 'Ford', model: 'Mustang', year: 1969 },
|
|
9
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import 'dotenv/config'
|
|
2
|
+
|
|
3
|
+
import { createEnv, z } from '@softeneers/env'
|
|
4
|
+
|
|
5
|
+
// Server-only validated environment. Variables for a feature exist only when
|
|
6
|
+
// that toggle is enabled at generation time.
|
|
7
|
+
export const env = createEnv({
|
|
8
|
+
schema: {
|
|
9
|
+
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
|
10
|
+
// #if db
|
|
11
|
+
DB_HOST: z.string().default('127.0.0.1'),
|
|
12
|
+
DB_PORT: z.coerce.number().default(3306),
|
|
13
|
+
DB_NAME: z.string().default('app_dev'),
|
|
14
|
+
DB_USER: z.string().default('root'),
|
|
15
|
+
DB_PASSWORD: z.string().default(''),
|
|
16
|
+
// #endif
|
|
17
|
+
// #if auth
|
|
18
|
+
AUTH_SECRET: z.string().min(16).default('dev-secret-change-me-to-a-long-random-string'),
|
|
19
|
+
AUTH_BASE_URL: z.string().default('http://localhost:3000'),
|
|
20
|
+
// #endif
|
|
21
|
+
},
|
|
22
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import 'dotenv/config'
|
|
2
|
+
|
|
3
|
+
import { assertConnection } from '@softeneers/db'
|
|
4
|
+
|
|
5
|
+
import { sequelize } from '../db'
|
|
6
|
+
import '../store' // importing the store registers the Car model
|
|
7
|
+
|
|
8
|
+
const reset = process.argv.includes('--reset')
|
|
9
|
+
|
|
10
|
+
await assertConnection(sequelize)
|
|
11
|
+
await sequelize.sync({ force: reset })
|
|
12
|
+
console.log(reset ? 'Database reset — tables recreated.' : 'Database synced — tables ensured.')
|
|
13
|
+
await sequelize.close()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import 'dotenv/config'
|
|
2
|
+
|
|
3
|
+
import { assertConnection } from '@softeneers/db'
|
|
4
|
+
|
|
5
|
+
import { DEMO_CARS } from '../demo'
|
|
6
|
+
import { sequelize } from '../db'
|
|
7
|
+
import { CarModel } from '../store'
|
|
8
|
+
|
|
9
|
+
await assertConnection(sequelize)
|
|
10
|
+
await sequelize.sync()
|
|
11
|
+
|
|
12
|
+
const count = await CarModel.count()
|
|
13
|
+
if (count > 0) {
|
|
14
|
+
console.log(`Skipped seeding — ${count} cars already present.`)
|
|
15
|
+
} else {
|
|
16
|
+
await CarModel.bulkCreate([...DEMO_CARS])
|
|
17
|
+
console.log(`Seeded ${DEMO_CARS.length} cars.`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
await sequelize.close()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Car, NewCar } from '../cars/types'
|
|
2
|
+
import { DEMO_CARS } from './demo'
|
|
3
|
+
|
|
4
|
+
// Server-only data layer behind the cars server functions. With `db` on it
|
|
5
|
+
// persists to MySQL (Sequelize via @softeneers/db) and **falls back to an
|
|
6
|
+
// in-memory store if the database is unreachable**, so `npm run dev` always
|
|
7
|
+
// yields a working, pre-seeded demo. With `db` off it is always in-memory.
|
|
8
|
+
// Never bundled into the client — only reached from server functions.
|
|
9
|
+
export interface CarStore {
|
|
10
|
+
list(): Promise<Array<Car>>
|
|
11
|
+
get(id: number): Promise<Car | null>
|
|
12
|
+
create(input: NewCar): Promise<Car>
|
|
13
|
+
remove(id: number): Promise<boolean>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// In-memory backend (the default, and the fallback when no database is reachable).
|
|
17
|
+
function createMemoryStore(): CarStore {
|
|
18
|
+
let nextId = 1
|
|
19
|
+
const cars = new Map<number, Car>()
|
|
20
|
+
for (const car of DEMO_CARS) {
|
|
21
|
+
const id = nextId++
|
|
22
|
+
cars.set(id, { id, ...car })
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
async list() {
|
|
26
|
+
return [...cars.values()].sort((a, b) => a.id - b.id)
|
|
27
|
+
},
|
|
28
|
+
async get(id) {
|
|
29
|
+
return cars.get(id) ?? null
|
|
30
|
+
},
|
|
31
|
+
async create(input) {
|
|
32
|
+
const car: Car = { id: nextId++, ...input }
|
|
33
|
+
cars.set(car.id, car)
|
|
34
|
+
return car
|
|
35
|
+
},
|
|
36
|
+
async remove(id) {
|
|
37
|
+
return cars.delete(id)
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// #if db
|
|
43
|
+
import { DataTypes, Model, assertConnection } from '@softeneers/db'
|
|
44
|
+
|
|
45
|
+
import { sequelize } from './db'
|
|
46
|
+
|
|
47
|
+
class CarModel extends Model {
|
|
48
|
+
declare id: number
|
|
49
|
+
declare brand: string
|
|
50
|
+
declare model: string
|
|
51
|
+
declare year: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
CarModel.init(
|
|
55
|
+
{
|
|
56
|
+
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
|
57
|
+
brand: { type: DataTypes.STRING, allowNull: false },
|
|
58
|
+
model: { type: DataTypes.STRING, allowNull: false },
|
|
59
|
+
year: { type: DataTypes.INTEGER, allowNull: false },
|
|
60
|
+
},
|
|
61
|
+
{ sequelize, modelName: 'Car', tableName: 'cars' },
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
export { CarModel }
|
|
65
|
+
|
|
66
|
+
const toCar = (m: CarModel): Car => ({ id: m.id, brand: m.brand, model: m.model, year: m.year })
|
|
67
|
+
|
|
68
|
+
function createDbStore(): CarStore {
|
|
69
|
+
return {
|
|
70
|
+
async list() {
|
|
71
|
+
return (await CarModel.findAll({ order: [['id', 'ASC']] })).map(toCar)
|
|
72
|
+
},
|
|
73
|
+
async get(id) {
|
|
74
|
+
const m = await CarModel.findByPk(id)
|
|
75
|
+
return m ? toCar(m) : null
|
|
76
|
+
},
|
|
77
|
+
async create(input) {
|
|
78
|
+
return toCar(await CarModel.create(input))
|
|
79
|
+
},
|
|
80
|
+
async remove(id) {
|
|
81
|
+
const m = await CarModel.findByPk(id)
|
|
82
|
+
if (!m) return false
|
|
83
|
+
await m.destroy()
|
|
84
|
+
return true
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Resolve the backend once, lazily, on first use: try MySQL (create tables +
|
|
90
|
+
// seed if empty); on any connection error, fall back to the in-memory store.
|
|
91
|
+
let backend: Promise<CarStore> | null = null
|
|
92
|
+
function resolveBackend(): Promise<CarStore> {
|
|
93
|
+
if (!backend) {
|
|
94
|
+
backend = (async () => {
|
|
95
|
+
try {
|
|
96
|
+
await assertConnection(sequelize)
|
|
97
|
+
await sequelize.sync()
|
|
98
|
+
const db = createDbStore()
|
|
99
|
+
if ((await db.list()).length === 0) {
|
|
100
|
+
for (const car of DEMO_CARS) await db.create(car)
|
|
101
|
+
}
|
|
102
|
+
console.log('Data store: MySQL')
|
|
103
|
+
return db
|
|
104
|
+
} catch {
|
|
105
|
+
console.warn(
|
|
106
|
+
'Data store: in-memory — database unreachable. Run `docker compose up -d` (and `npm run db:migrate && npm run db:seed`) for MySQL.',
|
|
107
|
+
)
|
|
108
|
+
return createMemoryStore()
|
|
109
|
+
}
|
|
110
|
+
})()
|
|
111
|
+
}
|
|
112
|
+
return backend
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const carStore: CarStore = {
|
|
116
|
+
async list() {
|
|
117
|
+
return (await resolveBackend()).list()
|
|
118
|
+
},
|
|
119
|
+
async get(id) {
|
|
120
|
+
return (await resolveBackend()).get(id)
|
|
121
|
+
},
|
|
122
|
+
async create(input) {
|
|
123
|
+
return (await resolveBackend()).create(input)
|
|
124
|
+
},
|
|
125
|
+
async remove(id) {
|
|
126
|
+
return (await resolveBackend()).remove(id)
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
// #endif
|
|
130
|
+
// #if !db
|
|
131
|
+
export const carStore: CarStore = createMemoryStore()
|
|
132
|
+
// #endif
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"include": ["**/*.ts", "**/*.tsx"],
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"jsx": "react-jsx",
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"paths": {
|
|
8
|
+
"#/*": ["./src/*"],
|
|
9
|
+
"@/*": ["./src/*"]
|
|
10
|
+
},
|
|
11
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
12
|
+
"types": ["vite/client"],
|
|
13
|
+
|
|
14
|
+
/* Bundler mode */
|
|
15
|
+
"moduleResolution": "bundler",
|
|
16
|
+
"allowImportingTsExtensions": true,
|
|
17
|
+
"verbatimModuleSyntax": true,
|
|
18
|
+
"noEmit": true,
|
|
19
|
+
|
|
20
|
+
/* Linting */
|
|
21
|
+
"skipLibCheck": true,
|
|
22
|
+
"strict": true,
|
|
23
|
+
"noUnusedLocals": true,
|
|
24
|
+
"noUnusedParameters": true,
|
|
25
|
+
"noFallthroughCasesInSwitch": true,
|
|
26
|
+
"noUncheckedSideEffectImports": true
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import { devtools } from '@tanstack/devtools-vite'
|
|
3
|
+
|
|
4
|
+
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
|
5
|
+
|
|
6
|
+
import viteReact from '@vitejs/plugin-react'
|
|
7
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
8
|
+
|
|
9
|
+
const config = defineConfig({
|
|
10
|
+
resolve: { tsconfigPaths: true },
|
|
11
|
+
plugins: [devtools(), tailwindcss(), tanstackStart(), viteReact()],
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export default config
|