elysia-rack 0.1.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -0
  3. package/dist/Dashboard-xpstr7fr.js +172 -0
  4. package/dist/Panel-fbp3wrsw.js +738 -0
  5. package/dist/index-37x76zdn.js +4 -0
  6. package/dist/index-zc7rgvmm.js +13 -0
  7. package/dist/panel-app.js +1 -0
  8. package/dist/panel-theme.js +1 -0
  9. package/dist/panel.css +1 -0
  10. package/dist/rack/adapter.d.ts +5 -0
  11. package/dist/rack/adapters/drizzle.d.ts +2 -0
  12. package/dist/rack/adapters/index.d.ts +59 -0
  13. package/dist/rack/adapters/prisma.d.ts +2 -0
  14. package/dist/rack/authorization.d.ts +23 -0
  15. package/dist/rack/idempotency.d.ts +9 -0
  16. package/dist/rack/index.d.ts +40 -0
  17. package/dist/rack/index.js +1362 -0
  18. package/dist/rack/models/drizzle.d.ts +4 -0
  19. package/dist/rack/models/index.d.ts +24 -0
  20. package/dist/rack/models/prisma.d.ts +4 -0
  21. package/dist/rack/openapi.d.ts +6 -0
  22. package/dist/rack/page.d.ts +33 -0
  23. package/dist/rack/query.d.ts +22 -0
  24. package/dist/rack/registry.d.ts +20 -0
  25. package/dist/rack/types/authorization.d.ts +16 -0
  26. package/dist/rack/types/idempotency.d.ts +46 -0
  27. package/dist/rack/types/index.d.ts +89 -0
  28. package/dist/rack/types/metadata.d.ts +36 -0
  29. package/dist/rack/types/openapi.d.ts +22 -0
  30. package/dist/rack/types/operations.d.ts +14 -0
  31. package/dist/rack/types/page.d.ts +41 -0
  32. package/dist/rack/types/query.d.ts +30 -0
  33. package/dist/rack/types/validation.d.ts +19 -0
  34. package/dist/react/index.d.ts +64 -0
  35. package/dist/react/index.js +126 -0
  36. package/dist/react/page/Dashboard.d.ts +7 -0
  37. package/dist/react/page/Panel.d.ts +36 -0
  38. package/dist/react/page/client/panel.d.ts +1 -0
  39. package/dist/react/page/client/theme.d.ts +1 -0
  40. package/dist/react/page/components/Breadcrumb.d.ts +7 -0
  41. package/dist/react/page/components/DataTable.d.ts +10 -0
  42. package/dist/react/page/components/Masthead.d.ts +7 -0
  43. package/dist/react/page/components/Pagination.d.ts +10 -0
  44. package/dist/react/page/components/Sidebar.d.ts +16 -0
  45. package/dist/react/page/components/Toolbar.d.ts +7 -0
  46. package/dist/react/page/components/href.d.ts +10 -0
  47. package/dist/react/page/components/panelForm.d.ts +9 -0
  48. package/dist/react/render.d.ts +2 -0
  49. package/dist/react/types.d.ts +16 -0
  50. package/package.json +89 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 notoofly
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,241 @@
1
+ # elysia-rack
2
+
3
+ Model-driven CRUD rack for [Elysia](https://elysiajs.com): declare a resource once, get REST + `QUERY` routes, validation, authorization, query engine, OpenAPI metadata, and a React panel — running on [Bun](https://bun.com) or Node.js 20+.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add elysia-rack elysia react react-dom
9
+ # or
10
+ npm i elysia-rack elysia react react-dom
11
+ ```
12
+
13
+ `elysia`, `react`, and `react-dom` are peer dependencies.
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { Elysia } from "elysia";
19
+ import { rack } from "elysia-rack";
20
+
21
+ const app = new Elysia().use(
22
+ rack("/catalog/products", {
23
+ model: { drizzle: { db, table: products } },
24
+
25
+ validation: {
26
+ create: CreateProductSchema,
27
+ replace: ReplaceProductSchema,
28
+ update: UpdateProductSchema,
29
+ params: ProductParamsSchema,
30
+ query: ProductQuerySchema,
31
+ },
32
+
33
+ authorization: {
34
+ list: "products.view",
35
+ create: "products.create",
36
+ delete: async ({ request }) => canDelete(request),
37
+ },
38
+
39
+ query: {
40
+ searchable: ["name", "sku"],
41
+ filterable: ["status", "categoryId"],
42
+ sortable: ["name", "price", "createdAt"],
43
+ defaultSort: { field: "createdAt", direction: "desc" },
44
+ pagination: { default: 20, max: 100 },
45
+ },
46
+
47
+ operations: { create: true, delete: false },
48
+
49
+ metadata: {
50
+ id: "products",
51
+ label: "Product",
52
+ pluralLabel: "Products",
53
+ group: "Catalog",
54
+ },
55
+
56
+ openapi: { tags: ["Products"] },
57
+ settings: { primaryKey: "id", returning: true },
58
+ }),
59
+ );
60
+
61
+ app.listen(3000);
62
+ ```
63
+
64
+ Generated routes (all gated by `operations`, default on):
65
+
66
+ | Operation | Method | Path |
67
+ |---|---|---|
68
+ | display panel | `GET` | `/` |
69
+ | list data | `QUERY` | `/data` |
70
+ | detail data | `QUERY` | `/data/:id` |
71
+ | create | `POST` | `/` |
72
+ | replace | `PUT` | `/:id` |
73
+ | update | `PATCH` | `/:id` |
74
+ | delete | `DELETE` | `/:id` |
75
+
76
+ `GET /` renders the React panel via `page("/panel")` (customize with the
77
+ `page` option, disable with `page: { enabled: false }`). All data reads go
78
+ through the `QUERY` method under `/data`. The panel UI calls the same
79
+ endpoint live via fetch (progressive enhancement — links and forms still
80
+ work through plain `GET` navigation without JavaScript).
81
+
82
+ ## Panel actions
83
+
84
+ The panel ships create / edit / delete UI driven by the same API:
85
+
86
+ - `+ New` opens a create dialog (fields derived from row columns,
87
+ primary key optional). Submit sends `POST /` with an auto-generated
88
+ `Idempotency-Key`. Tables without known columns fall back to a raw
89
+ JSON body field.
90
+ - Row `Edit` loads the row via `QUERY /data/:id` into a dialog and saves
91
+ with `PATCH /:id`. Empty inputs are skipped (partial update).
92
+ - Row `Delete` confirms natively, then `DELETE /:id`.
93
+ - Checkbox selection enables the bulk bar: `Delete selected` loops
94
+ `DELETE /:id`, and the field applier loops `PATCH /:id` with the
95
+ chosen field/value. `Clear` resets the selection.
96
+
97
+ Action UI follows `operations`: disable `create`/`update`/`delete` and
98
+ the corresponding buttons, dialogs and bulk controls disappear.
99
+
100
+ ## Idempotency (POST)
101
+
102
+ `POST /` requires an idempotency key by default. Send a client-generated
103
+ key per create attempt:
104
+
105
+ ```bash
106
+ curl -X POST localhost:3000/catalog/products \
107
+ -H 'Content-Type: application/json' \
108
+ -H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' \
109
+ -d '{"name":"Keyboard"}'
110
+ ```
111
+
112
+ Replays with the same key return the stored `201` response with an
113
+ `Idempotent-Replayed: true` header instead of re-executing. Missing keys
114
+ are rejected with `400`. Tune via the `idempotency` option:
115
+
116
+ ```ts
117
+ rack("/catalog/products", {
118
+ model: { drizzle: { db, table: products } },
119
+ idempotency: {
120
+ required: false, // allow keyless POST (default true)
121
+ header: "X-Idempotency-Key",
122
+ ttl: 3600, // replay window in seconds (default 86400)
123
+ store: myRedisStore, // shared store for multi-instance deploys
124
+ },
125
+ });
126
+ ```
127
+
128
+ ## ORM adapters
129
+
130
+ `model` selects the adapter. Drizzle needs `drizzle-orm` installed
131
+ (optional peer); Prisma needs no extra dependency — pass any client
132
+ delegate (e.g. `prisma.product`):
133
+
134
+ ```ts
135
+ // Drizzle (Postgres, SQLite, ...)
136
+ import { drizzle } from "drizzle-orm/node-postgres";
137
+ model: { drizzle: { db: drizzle(client), table: products } },
138
+
139
+ // Prisma
140
+ model: { prisma: { client: prisma, model: prisma.product } },
141
+ ```
142
+
143
+ Conventions:
144
+
145
+ - String route ids are coerced to numbers for integer columns
146
+ (e.g. `serial`); anything else passes through. Malformed ids resolve
147
+ to `404`, missing rows to `404`.
148
+ - `searchable` fields use SQL `LIKE` (case-sensitivity follows the DB).
149
+ - `settings.softDelete: true` writes a timestamp to `deletedAt`
150
+ (override with `settings.deletedAtField`) instead of deleting, and
151
+ reads skip soft-deleted rows.
152
+ - `settings.returning: false` omits `data` from mutation responses.
153
+
154
+ ## Dashboard (sidebar, breadcrumb, iframe)
155
+
156
+ Every `rack()` call registers its path, metadata and operations in a
157
+ registry (`listRacks()` / `clearRacks()` from `elysia-rack`). The default
158
+ `/dashboard` page builds a sidebar (grouped by `metadata.group`, ordered
159
+ by `metadata.order`, skipping `hidden`), a breadcrumb
160
+ (Dashboard / Group / Label), and shows the selected resource's `GET`
161
+ panel inside an `<iframe>`:
162
+
163
+ ```ts
164
+ import { page, pages, reactPlugin } from "elysia-rack/react";
165
+
166
+ const app = new Elysia()
167
+ .use(reactPlugin({ pages }))
168
+ .use(rack("/catalog/products", { model, metadata: { ... } }))
169
+ .get("/", ({ query }) =>
170
+ page("/dashboard", {
171
+ name: "My Shop",
172
+ resource: typeof query.resource === "string" ? query.resource : undefined,
173
+ }),
174
+ );
175
+ ```
176
+
177
+ Sidebar links navigate with `?resource=<id>` (full reload, no JavaScript
178
+ needed). Without a selection the first registered resource is shown.
179
+
180
+ React panel rendering via the `./react` subpath:
181
+
182
+ ```ts
183
+ import { page, pages, reactPlugin } from "elysia-rack/react";
184
+
185
+ const app = new Elysia()
186
+ .use(reactPlugin({ pages }))
187
+ .get("/", () => page("/dashboard", { name: "Ada" }));
188
+ ```
189
+
190
+ ## Panel assets (Vite)
191
+
192
+ CSS, Tailwind and browser JavaScript go through Vite (`vite.config.ts`,
193
+ `bun run build:assets`):
194
+
195
+ - `src/react/page/panel.css` — stylesheet entry (Tailwind v4) → `dist/panel.css`
196
+ - `src/react/page/client/theme.ts` — dark/light toggle → `dist/panel-theme.js`
197
+ - `src/react/page/client/panel.ts` — live query + actions → `dist/panel-app.js`
198
+ (imports shared `components/href.ts` and `components/panelForm.ts`)
199
+
200
+ Pages reference `/__rack/panel.css`, `/__rack/panel-theme.js` and
201
+ `/__rack/panel-app.js`, served by `reactPlugin` from `dist/` (run the
202
+ build before `dev`; CI and `prepublishOnly` already chain it).
203
+
204
+ ## Panel theme
205
+
206
+ `src/react/page/panel.css` is the stylesheet entry:
207
+
208
+ - `theme/tokens.css` — full semantic tokens (surface, text, status,
209
+ chart, selection, focus, overlay, navigation, table, form, sidebar),
210
+ shadcn/Tailwind compatible, mapped via `@theme inline`.
211
+ - `theme/dark.css` — `.dark` overrides.
212
+ - `theme/koran.css` — default newspaper theme (warm paper, serif
213
+ headlines). Remove its import for the neutral look.
214
+ - `theme/components.css` — newspaper component classes + base styles.
215
+
216
+ Dark mode flips automatically: stored choice (`localStorage`), else
217
+ `prefers-color-scheme`. The `◐ Theme` button in the masthead toggles it.
218
+ Components must only use semantic utilities (`bg-background`,
219
+ `text-foreground`, `bg-table-row-hover`, …) so themes swap without
220
+ touching React code.
221
+
222
+ ## Release (maintainer)
223
+
224
+ 1. One-time setup: npmjs.com → package → Settings → Trusted Publisher → GitHub Actions (`notoofly/elysia-rack`, workflow `publish.yml`).
225
+ 2. Bump `version` in `package.json`.
226
+ 3. Create a GitHub Release with tag `v<version>` (e.g. `v0.1.0`).
227
+ 4. The `publish` workflow runs automatically: install, typecheck, asset build, test, build, tag-vs-version check, `npm publish --provenance`.
228
+
229
+ ## Scripts
230
+
231
+ ```bash
232
+ bun install # install dependencies
233
+ bun run dev # run src/playground.ts
234
+ bun test # unit tests (bun:test)
235
+ bun run typecheck # tsc --noEmit
236
+ bun run build # dist/ (js via bun build + .d.ts via tsc)
237
+ ```
238
+
239
+ ## License
240
+
241
+ MIT © notoofly
@@ -0,0 +1,172 @@
1
+ import {
2
+ listRacks
3
+ } from "./index-zc7rgvmm.js";
4
+ import"./index-37x76zdn.js";
5
+
6
+ // src/react/page/components/Breadcrumb.tsx
7
+ import { jsxDEV } from "react/jsx-dev-runtime";
8
+ function Breadcrumb({ trail }) {
9
+ return /* @__PURE__ */ jsxDEV("nav", {
10
+ "aria-label": "Breadcrumb",
11
+ className: "pb-3 text-sm",
12
+ children: /* @__PURE__ */ jsxDEV("ol", {
13
+ className: "flex flex-wrap items-center gap-1.5",
14
+ children: trail.map((item, i) => {
15
+ const last = i === trail.length - 1;
16
+ return /* @__PURE__ */ jsxDEV("li", {
17
+ className: "flex items-center gap-1.5",
18
+ children: [
19
+ i > 0 ? /* @__PURE__ */ jsxDEV("span", {
20
+ "aria-hidden": "true",
21
+ className: "text-text-muted",
22
+ children: "/"
23
+ }, undefined, false, undefined, this) : null,
24
+ item.href && !last ? /* @__PURE__ */ jsxDEV("a", {
25
+ href: item.href,
26
+ className: "text-text-muted hover:text-foreground",
27
+ children: item.label
28
+ }, undefined, false, undefined, this) : /* @__PURE__ */ jsxDEV("span", {
29
+ "aria-current": last ? "page" : undefined,
30
+ className: last ? "text-foreground font-semibold" : "text-text-muted",
31
+ children: item.label
32
+ }, undefined, false, undefined, this)
33
+ ]
34
+ }, `${item.label}-${i}`, true, undefined, this);
35
+ })
36
+ }, undefined, false, undefined, this)
37
+ }, undefined, false, undefined, this);
38
+ }
39
+
40
+ // src/react/page/components/Sidebar.tsx
41
+ import { jsxDEV as jsxDEV2 } from "react/jsx-dev-runtime";
42
+ function Sidebar({ title, groups }) {
43
+ return /* @__PURE__ */ jsxDEV2("aside", {
44
+ className: "bg-sidebar text-sidebar-foreground border-sidebar-border min-h-0 w-64 shrink-0 overflow-y-auto rounded-lg border",
45
+ children: [
46
+ /* @__PURE__ */ jsxDEV2("div", {
47
+ className: "border-sidebar-border border-b px-4 py-3",
48
+ children: /* @__PURE__ */ jsxDEV2("p", {
49
+ className: "font-koran text-xl font-black tracking-tight",
50
+ children: title
51
+ }, undefined, false, undefined, this)
52
+ }, undefined, false, undefined, this),
53
+ /* @__PURE__ */ jsxDEV2("nav", {
54
+ className: "flex flex-col gap-4 p-3",
55
+ children: groups.map((group) => /* @__PURE__ */ jsxDEV2("section", {
56
+ children: /* @__PURE__ */ jsxDEV2("details", {
57
+ className: "group",
58
+ open: group.items.some((item) => item.active) || undefined,
59
+ children: [
60
+ /* @__PURE__ */ jsxDEV2("summary", {
61
+ className: "font-koran border-sidebar-border flex cursor-pointer list-none items-center justify-between border-b px-2 pb-1 text-base font-bold tracking-tight [&::-webkit-details-marker]:hidden",
62
+ children: [
63
+ /* @__PURE__ */ jsxDEV2("span", {
64
+ children: group.name
65
+ }, undefined, false, undefined, this),
66
+ /* @__PURE__ */ jsxDEV2("span", {
67
+ "aria-hidden": "true",
68
+ className: "text-text-muted text-xs transition group-open:rotate-180",
69
+ children: "▾"
70
+ }, undefined, false, undefined, this)
71
+ ]
72
+ }, undefined, true, undefined, this),
73
+ /* @__PURE__ */ jsxDEV2("ul", {
74
+ className: "border-sidebar-border mt-1 ml-2 flex flex-col gap-0.5 border-l py-1 pl-2",
75
+ children: group.items.map((item) => /* @__PURE__ */ jsxDEV2("li", {
76
+ children: /* @__PURE__ */ jsxDEV2("a", {
77
+ href: item.href,
78
+ "aria-current": item.active ? "page" : undefined,
79
+ className: item.active ? "bg-sidebar-primary text-sidebar-primary-foreground flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-semibold" : "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
80
+ children: [
81
+ item.icon ? /* @__PURE__ */ jsxDEV2("span", {
82
+ "aria-hidden": "true",
83
+ children: item.icon
84
+ }, undefined, false, undefined, this) : null,
85
+ /* @__PURE__ */ jsxDEV2("span", {
86
+ children: item.label
87
+ }, undefined, false, undefined, this)
88
+ ]
89
+ }, undefined, true, undefined, this)
90
+ }, item.id, false, undefined, this))
91
+ }, undefined, false, undefined, this)
92
+ ]
93
+ }, undefined, true, undefined, this)
94
+ }, group.name, false, undefined, this))
95
+ }, undefined, false, undefined, this)
96
+ ]
97
+ }, undefined, true, undefined, this);
98
+ }
99
+
100
+ // src/react/page/Dashboard.tsx
101
+ import { jsxDEV as jsxDEV3 } from "react/jsx-dev-runtime";
102
+ function itemLabel(r) {
103
+ return r.metadata.pluralLabel ?? r.metadata.label ?? r.metadata.id;
104
+ }
105
+ function buildGroups(racks, selectedId) {
106
+ const byGroup = new Map;
107
+ for (const r of racks) {
108
+ if (r.metadata.hidden)
109
+ continue;
110
+ const name = r.metadata.group ?? "General";
111
+ let group = byGroup.get(name);
112
+ if (!group) {
113
+ group = { name, items: [] };
114
+ byGroup.set(name, group);
115
+ }
116
+ group.items.push({
117
+ id: r.metadata.id,
118
+ label: itemLabel(r),
119
+ icon: r.metadata.icon,
120
+ href: `?resource=${encodeURIComponent(r.metadata.id)}`,
121
+ active: r.metadata.id === selectedId
122
+ });
123
+ }
124
+ const groups = [...byGroup.values()].sort((a, b) => a.name.localeCompare(b.name));
125
+ for (const group of groups) {
126
+ const orderOf = (id) => racks.find((r) => r.metadata.id === id)?.metadata.order ?? 0;
127
+ group.items.sort((a, b) => orderOf(a.id) - orderOf(b.id) || a.label.localeCompare(b.label));
128
+ }
129
+ return groups;
130
+ }
131
+ async function Dashboard(props) {
132
+ const racks = (props.racks ?? listRacks()).filter((r) => !r.metadata.hidden);
133
+ const selected = racks.find((r) => r.metadata.id === props.resource) ?? racks[0];
134
+ const groups = buildGroups(racks, selected?.metadata.id);
135
+ const trail = [
136
+ { label: "Dashboard", href: "?" },
137
+ ...selected?.metadata.group ? [{ label: selected.metadata.group }] : [],
138
+ ...selected ? [{ label: itemLabel(selected) }] : []
139
+ ];
140
+ return /* @__PURE__ */ jsxDEV3("div", {
141
+ className: "koran-paper font-koran-body text-foreground flex flex-col min-h-screen",
142
+ children: /* @__PURE__ */ jsxDEV3("div", {
143
+ className: "flex min-h-0 flex-1 gap-6 p-4 sm:p-6 h-full",
144
+ children: [
145
+ /* @__PURE__ */ jsxDEV3(Sidebar, {
146
+ title: props.name ?? "Panel",
147
+ groups
148
+ }, undefined, false, undefined, this),
149
+ /* @__PURE__ */ jsxDEV3("main", {
150
+ className: "flex min-w-0 flex-1 flex-col",
151
+ children: [
152
+ /* @__PURE__ */ jsxDEV3(Breadcrumb, {
153
+ trail
154
+ }, undefined, false, undefined, this),
155
+ selected ? /* @__PURE__ */ jsxDEV3("iframe", {
156
+ src: selected.path,
157
+ title: itemLabel(selected),
158
+ sandbox: "allow-scripts allow-same-origin allow-forms",
159
+ className: "bg-card min-h-0 w-full flex-1 rounded-lg"
160
+ }, undefined, false, undefined, this) : /* @__PURE__ */ jsxDEV3("p", {
161
+ className: "text-text-muted border-border rounded-lg border px-4 py-10 text-center",
162
+ children: "No resources registered. Mount a resource with rack() first."
163
+ }, undefined, false, undefined, this)
164
+ ]
165
+ }, undefined, true, undefined, this)
166
+ ]
167
+ }, undefined, true, undefined, this)
168
+ }, undefined, false, undefined, this);
169
+ }
170
+ export {
171
+ Dashboard as default
172
+ };