@revenexx/studio-commerce-devkit 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.
- package/LICENSE +6 -0
- package/README.md +104 -0
- package/app/app.vue +5 -0
- package/app/layouts/default.vue +18 -0
- package/app/pages/index.vue +13 -0
- package/bin/preview.mjs +33 -0
- package/nuxt.config.ts +105 -0
- package/package.json +37 -0
- package/server/routes/api/v1/tenants/[slug]/apps.get.ts +7 -0
- package/server/routes/pg/[entity].ts +58 -0
- package/server/utils/commerce.ts +244 -0
package/LICENSE
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Commerce Studio — standalone dev host (app building)
|
|
2
|
+
|
|
3
|
+
Run the **Commerce Studio UI on its own** to build apps: point it at your
|
|
4
|
+
`../apps/` and it renders each app's `cockpit.json` **with realistic mock
|
|
5
|
+
data** — no platform, no backend, no auth. Edit the manifest, reload, iterate.
|
|
6
|
+
|
|
7
|
+
## What this is
|
|
8
|
+
|
|
9
|
+
A thin Nuxt host around the publishable
|
|
10
|
+
[`@revenexx/studio-commerce`](../../packages/studio-commerce) module — the same
|
|
11
|
+
manifest-driven AppRenderer the cockpit runs. The renderer is unchanged; all the
|
|
12
|
+
"local" magic is two **Nitro shims** in this host that speak the cockpit's exact
|
|
13
|
+
wire contracts:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
browser ──https──► commerce-studio.rvnxx.test (this Nuxt host)
|
|
17
|
+
useInstalledApps → GET /api/v1/tenants/:slug/apps ─┐
|
|
18
|
+
usePostgrest → /pg/{vendor}__{app}__{entity} ─┤ served locally
|
|
19
|
+
▼
|
|
20
|
+
server/utils/commerce.ts: reads ../apps/<app>/{manifest,cockpit,schema}.json
|
|
21
|
+
+ an in-memory store seeded with fake rows
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- **Console shim** (`server/routes/api/v1/tenants/[slug]/apps.get.ts`) assembles
|
|
25
|
+
`InstalledAppRecord[]` from disk **per request** — so editing a `cockpit.json`
|
|
26
|
+
and reloading reflects immediately.
|
|
27
|
+
- **PostgREST shim** (`server/routes/pg/[entity].ts`) serves list/get/create/
|
|
28
|
+
update/delete over an in-memory store built generically from each
|
|
29
|
+
`schema.json`, seeded with plausible fake rows (column types, `CHECK` enums,
|
|
30
|
+
FK `references`), with a `Content-Range` total.
|
|
31
|
+
- Media picker + Baseline-IO are **stubbed** (standalone has no Storage/IO
|
|
32
|
+
service); everything else renders and round-trips against the mock.
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cd revenexx-cockpit
|
|
38
|
+
export GITHUB_TOKEN=ghp_xxx # read:packages (for @revenexx/studio*)
|
|
39
|
+
docker compose -f apps/commerce-studio-dev/docker-compose.dev.yml up
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Open **https://commerce-studio.rvnxx.test** → redirects to `/commerce`. The
|
|
43
|
+
sidebar lists every app under `../apps/`; open one (e.g. **inventories** or
|
|
44
|
+
**products**) and its list/detail/form/widget views render from `cockpit.json`
|
|
45
|
+
with mock rows. Create/edit persist in-memory for the session.
|
|
46
|
+
|
|
47
|
+
**Host mode (no Docker):**
|
|
48
|
+
```bash
|
|
49
|
+
export APPS_DIR=/absolute/path/to/playground/apps # optional; default is ../../../apps
|
|
50
|
+
pnpm --filter commerce-studio-dev dev # http://localhost:3000
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## The app-building loop
|
|
54
|
+
|
|
55
|
+
1. Edit your app under `../apps/<app>/`:
|
|
56
|
+
- `cockpit.json` — navigation / list / detail / form / widget views (the UI).
|
|
57
|
+
- `schema.json` — entities + columns (drives the mock data + Data Model page).
|
|
58
|
+
2. Reload the studio → the manifest is re-read and re-rendered. No rebuild.
|
|
59
|
+
3. Iterate. When happy, the same `cockpit.json` renders identically in the real
|
|
60
|
+
cockpit (this host runs the exact same module).
|
|
61
|
+
|
|
62
|
+
## Config (`nuxt.config.ts` runtimeConfig.public)
|
|
63
|
+
|
|
64
|
+
| Key | Default | Purpose |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `consoleApi` | `''` (same-origin) | `useInstalledApps` → the Console shim |
|
|
67
|
+
| `postgrestApi` | `/pg` | `usePostgrest` → the PostgREST shim |
|
|
68
|
+
| `devToken` / `devTenant` | `dev` | Standalone platform-provider (any non-empty token unlocks the shims) |
|
|
69
|
+
| `APPS_DIR` (env) | `../../../apps` (host) / `/apps` (compose) | Where apps are read from |
|
|
70
|
+
|
|
71
|
+
## Troubleshooting
|
|
72
|
+
|
|
73
|
+
| Symptom | Fix |
|
|
74
|
+
|---|---|
|
|
75
|
+
| Empty sidebar / no apps | `APPS_DIR` wrong, or apps have no `cockpit.json`. Container mounts `../../../apps` → `/apps`. |
|
|
76
|
+
| List views empty | The entity has no `schema.json` columns, or a `CHECK`/reference the generator couldn't seed — check `server/utils/commerce.ts`. |
|
|
77
|
+
| A media field is inert | By design (standalone stub) — the real Storage picker is cockpit-only. |
|
|
78
|
+
| Import/Export missing on a view | By design — Baseline-IO is stubbed standalone (`useCommerceIo` returns null). |
|
|
79
|
+
|
|
80
|
+
## Use it from your own app repo (published: `@revenexx/studio-commerce-devkit`)
|
|
81
|
+
|
|
82
|
+
Once the studio packages are published to GitHub Packages, an app author works
|
|
83
|
+
entirely inside their app repo — no cockpit checkout:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
cd apps/products # your app repo (has cockpit.json)
|
|
87
|
+
pnpm add -D @revenexx/studio-commerce-devkit
|
|
88
|
+
pnpm commerce-studio # → http://localhost:3030 → /commerce
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The `commerce-studio` bin boots this preview app (from `node_modules`) with
|
|
92
|
+
`APPS_DIR` = your cwd, so it renders **your** app from `cockpit.json` with mock
|
|
93
|
+
data. Preview the whole set instead: `APPS_DIR=/path/to/apps pnpm commerce-studio`.
|
|
94
|
+
Requires the `@revenexx` registry in `.npmrc` (+ a `read:packages` token), same
|
|
95
|
+
as any `@revenexx/*` install. Build output goes to `.commerce-studio/` in your
|
|
96
|
+
app (gitignore it).
|
|
97
|
+
|
|
98
|
+
## Notes
|
|
99
|
+
|
|
100
|
+
- Mock data is in-memory and regenerated on server restart; it's for *seeing the
|
|
101
|
+
UI*, not persistence. A real local Postgres/PostgREST (from `schema.json` DDL)
|
|
102
|
+
is a deferred option if you need real constraints/RLS.
|
|
103
|
+
- This same package doubles as the in-workspace dev host (the `docker compose`
|
|
104
|
+
above) and the published `@revenexx/studio-commerce-devkit` app-dev tool.
|
package/app/app.vue
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Minimal standalone shell — no cockpit IconRail/TopBar. Gives the commerce
|
|
4
|
+
* pages' `<CommerceLayout>` (its own sidebar shelf) a full-viewport height
|
|
5
|
+
* context. A thin top strip stands in for the cockpit chrome.
|
|
6
|
+
*/
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<div class="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
|
|
11
|
+
<header class="flex h-9 shrink-0 items-center gap-2 px-3 text-mono text-muted-foreground">
|
|
12
|
+
Commerce Studio · standalone app-building
|
|
13
|
+
</header>
|
|
14
|
+
<div class="min-h-0 flex-1">
|
|
15
|
+
<slot />
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The Commerce Studio lives at /commerce (registered by the module). An index
|
|
3
|
+
// page also enables Nuxt's router so the module's extendPages routes mount.
|
|
4
|
+
onMounted(() => {
|
|
5
|
+
navigateTo('/commerce')
|
|
6
|
+
})
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<div class="grid h-full place-items-center text-muted-foreground">
|
|
11
|
+
Loading Commerce Studio…
|
|
12
|
+
</div>
|
|
13
|
+
</template>
|
package/bin/preview.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `commerce-studio` — boot the Commerce Studio preview against your app.
|
|
4
|
+
*
|
|
5
|
+
* Run from an app repo (where `cockpit.json` lives) after adding
|
|
6
|
+
* `@revenexx/studio-commerce-devkit` as a devDependency:
|
|
7
|
+
*
|
|
8
|
+
* pnpm add -D @revenexx/studio-commerce-devkit
|
|
9
|
+
* pnpm commerce-studio # → http://localhost:3030 → /commerce
|
|
10
|
+
*
|
|
11
|
+
* It runs this package's Nuxt app (from node_modules) with APPS_DIR pointed at
|
|
12
|
+
* your current directory, so the studio renders your app from disk with mock
|
|
13
|
+
* data — no platform, no backend. Preview several apps by pointing APPS_DIR at
|
|
14
|
+
* the apps root: `APPS_DIR=/path/to/apps pnpm commerce-studio`.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from 'node:child_process'
|
|
17
|
+
import { dirname, resolve } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
|
|
20
|
+
const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
21
|
+
const cwd = process.cwd()
|
|
22
|
+
|
|
23
|
+
const env = {
|
|
24
|
+
...process.env,
|
|
25
|
+
// Your app repo (or the apps root) — the studio reads manifests from here.
|
|
26
|
+
APPS_DIR: process.env.APPS_DIR || cwd,
|
|
27
|
+
// Keep Nuxt's build output out of node_modules, in your project.
|
|
28
|
+
NUXT_STUDIO_BUILDDIR: process.env.NUXT_STUDIO_BUILDDIR || resolve(cwd, '.commerce-studio'),
|
|
29
|
+
}
|
|
30
|
+
const port = process.env.PORT || '3030'
|
|
31
|
+
|
|
32
|
+
const res = spawnSync('npx', ['nuxi', 'dev', pkgDir, '--port', port], { stdio: 'inherit', env })
|
|
33
|
+
process.exit(res.status ?? 1)
|
package/nuxt.config.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { addTemplate } from '@nuxt/kit'
|
|
5
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Standalone dev host for the Commerce Studio (app building).
|
|
9
|
+
*
|
|
10
|
+
* Extends `@revenexx/studio-shared` (shell primitives + standalone platform
|
|
11
|
+
* provider) and loads `@revenexx/studio-commerce` — the same AppRenderer the
|
|
12
|
+
* cockpit runs. It renders any app under `APPS_DIR` from that app's
|
|
13
|
+
* `cockpit.json`, with data served by two local Nitro shims:
|
|
14
|
+
* - `GET /api/v1/tenants/:slug/apps` → InstalledAppRecord[] assembled from
|
|
15
|
+
* each `APPS_DIR/<app>/{manifest,cockpit,schema}.json`
|
|
16
|
+
* - `/pg/{entity}` → PostgREST-compatible mock store seeded from schema.json
|
|
17
|
+
* No platform, no CORS (same-origin), no auth (dev token). Edit a cockpit.json
|
|
18
|
+
* and reload — the manifest is read per request.
|
|
19
|
+
*
|
|
20
|
+
* Everything resolves by PACKAGE NAME (not monorepo-relative paths), so this
|
|
21
|
+
* works both in-repo and when installed from npm as
|
|
22
|
+
* `@revenexx/studio-commerce-devkit`.
|
|
23
|
+
*/
|
|
24
|
+
// Locate a package's install directory on the filesystem (walk up node_modules
|
|
25
|
+
// from this config). Avoids `require.resolve`, whose exports/conditions the
|
|
26
|
+
// studio packages don't expose for CJS or for `./package.json`. Works in the
|
|
27
|
+
// monorepo and when installed from npm (pnpm symlinks are followed).
|
|
28
|
+
const configDir = dirname(fileURLToPath(import.meta.url))
|
|
29
|
+
function pkgDir(name: string): string {
|
|
30
|
+
let base = configDir
|
|
31
|
+
for (;;) {
|
|
32
|
+
const candidate = join(base, 'node_modules', name)
|
|
33
|
+
if (existsSync(join(candidate, 'package.json'))) return candidate
|
|
34
|
+
const parent = dirname(base)
|
|
35
|
+
if (parent === base) throw new Error(`Cannot locate ${name} in any node_modules`)
|
|
36
|
+
base = parent
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Absolute source dirs the Tailwind JIT must scan (design system + shell
|
|
41
|
+
// primitives + the studio-commerce module runtime), resolved from wherever the
|
|
42
|
+
// packages are installed — no fragile relative `@source` paths.
|
|
43
|
+
const studioTailwindSources = [
|
|
44
|
+
join(pkgDir('@revenexx/studio'), 'dist'),
|
|
45
|
+
join(pkgDir('@revenexx/studio-shared'), 'components'),
|
|
46
|
+
join(pkgDir('@revenexx/studio-commerce'), 'dist', 'runtime'),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
export default defineNuxtConfig({
|
|
50
|
+
ssr: false,
|
|
51
|
+
// When run via the `commerce-studio` bin from an app repo, keep Nuxt's build
|
|
52
|
+
// output in the app's cwd instead of node_modules.
|
|
53
|
+
...(process.env.NUXT_STUDIO_BUILDDIR ? { buildDir: process.env.NUXT_STUDIO_BUILDDIR } : {}),
|
|
54
|
+
extends: ['@revenexx/studio-shared'],
|
|
55
|
+
modules: [
|
|
56
|
+
'@solar-icons/nuxt',
|
|
57
|
+
'@revenexx/studio-commerce',
|
|
58
|
+
// Generate the Tailwind entry with absolute @source paths (see above) and
|
|
59
|
+
// register it as the app's stylesheet.
|
|
60
|
+
(_options, nuxt) => {
|
|
61
|
+
const tpl = addTemplate({
|
|
62
|
+
filename: 'studio-tailwind.css',
|
|
63
|
+
write: true,
|
|
64
|
+
getContents: () => [
|
|
65
|
+
'@import "tailwindcss";',
|
|
66
|
+
'@import "tw-animate-css";',
|
|
67
|
+
'@import "@revenexx/studio/tokens";',
|
|
68
|
+
'@import "@revenexx/studio/fonts";',
|
|
69
|
+
...studioTailwindSources.map(s => `@source "${s}";`),
|
|
70
|
+
'@custom-variant dark (&:is(.dark *));',
|
|
71
|
+
'@theme inline { --font-sans: "Flink Neue Condensed", ui-sans-serif, system-ui, sans-serif; }',
|
|
72
|
+
'@layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } }',
|
|
73
|
+
'@utility text-mono { font-family: "Flink Neue Condensed", system-ui, sans-serif; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; font-size: 0.6875rem; line-height: 1.15; }',
|
|
74
|
+
].join('\n'),
|
|
75
|
+
})
|
|
76
|
+
nuxt.options.css.push(tpl.dst)
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
vite: {
|
|
80
|
+
plugins: [tailwindcss()],
|
|
81
|
+
server: {
|
|
82
|
+
allowedHosts: ['commerce-studio.rvnxx.test', 'localhost'],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
solarIcons: {
|
|
86
|
+
namePrefix: 'Solar',
|
|
87
|
+
autoImport: true,
|
|
88
|
+
provider: true,
|
|
89
|
+
color: 'currentColor',
|
|
90
|
+
size: 18,
|
|
91
|
+
weight: 'BoldDuotone',
|
|
92
|
+
},
|
|
93
|
+
runtimeConfig: {
|
|
94
|
+
public: {
|
|
95
|
+
// Same-origin local shims (served by this host's server/routes):
|
|
96
|
+
consoleApi: '',
|
|
97
|
+
postgrestApi: '/pg',
|
|
98
|
+
// Standalone platform provider (any non-empty token unlocks the shims):
|
|
99
|
+
devToken: 'dev',
|
|
100
|
+
devTenant: 'dev',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
devtools: { enabled: true },
|
|
104
|
+
compatibilityDate: '2025-01-01',
|
|
105
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@revenexx/studio-commerce-devkit",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Preview any revenexx app locally in the Commerce Studio: renders the app's cockpit.json with mock data, no platform. Add as a devDependency and run `commerce-studio` from your app repo.",
|
|
6
|
+
"bin": {
|
|
7
|
+
"commerce-studio": "./bin/preview.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"nuxt.config.ts",
|
|
11
|
+
"app",
|
|
12
|
+
"server",
|
|
13
|
+
"bin",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"registry": "https://registry.npmjs.org/",
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@revenexx/studio": "^0.1.2",
|
|
22
|
+
"@solar-icons/nuxt": "^1.2.0",
|
|
23
|
+
"@tailwindcss/vite": "^4.1.18",
|
|
24
|
+
"nuxt": "^4.2.2",
|
|
25
|
+
"tailwindcss": "^4.1.18",
|
|
26
|
+
"tw-animate-css": "^1.4.0",
|
|
27
|
+
"vue": "^3.5.26",
|
|
28
|
+
"vue-router": "^4.6.4",
|
|
29
|
+
"@revenexx/studio-commerce": "0.1.1",
|
|
30
|
+
"@revenexx/studio-shared": "0.1.1"
|
|
31
|
+
},
|
|
32
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
33
|
+
"scripts": {
|
|
34
|
+
"dev": "nuxt dev",
|
|
35
|
+
"build": "nuxt build"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Console shim: the cockpit's `useInstalledApps` calls
|
|
2
|
+
// `GET {consoleApi}/api/v1/tenants/{slug}/apps`. We assemble the same
|
|
3
|
+
// InstalledAppRecord[] from the apps on disk, per request (edit a manifest +
|
|
4
|
+
// reload → reflected).
|
|
5
|
+
export default defineEventHandler(() => {
|
|
6
|
+
return { data: loadApps() }
|
|
7
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PostgREST shim over the in-memory mock store. The cockpit's `usePostgrest`
|
|
5
|
+
* hits `{postgrestApi}/{qualified_entity}?…`; here `postgrestApi=/pg`, so this
|
|
6
|
+
* catch-all serves list/get/create/update/delete with a Content-Range total —
|
|
7
|
+
* the same wire contract Baseline's PostgREST provides. No auth is enforced.
|
|
8
|
+
*/
|
|
9
|
+
export default defineEventHandler(async (event) => {
|
|
10
|
+
const entity = getRouterParam(event, 'entity') || ''
|
|
11
|
+
const query = getQuery(event) as Record<string, any>
|
|
12
|
+
const method = event.method
|
|
13
|
+
|
|
14
|
+
const def = pgResolve(entity)
|
|
15
|
+
if (!def) {
|
|
16
|
+
setResponseStatus(event, 404)
|
|
17
|
+
return { message: `Unknown entity ${entity}` }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// id filter (`?id=eq.<id>`) drives get/update/delete.
|
|
21
|
+
const idFilter = typeof query[def.pk] === 'string' && query[def.pk].startsWith('eq.')
|
|
22
|
+
? String(query[def.pk]).slice(3)
|
|
23
|
+
: null
|
|
24
|
+
|
|
25
|
+
if (method === 'GET') {
|
|
26
|
+
const { rows, total } = pgQuery(entity, query)
|
|
27
|
+
const offset = query.offset ? Number(query.offset) : 0
|
|
28
|
+
setResponseHeader(event, 'Content-Range', rows.length ? `${offset}-${offset + rows.length - 1}/${total}` : `*/${total}`)
|
|
29
|
+
return rows
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (method === 'POST') {
|
|
33
|
+
const body = (await readBody(event)) as Record<string, any>
|
|
34
|
+
const row = { ...body }
|
|
35
|
+
if (!row[def.pk]) row[def.pk] = randomUUID()
|
|
36
|
+
def.rows.unshift(row)
|
|
37
|
+
setResponseStatus(event, 201)
|
|
38
|
+
return [row]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (method === 'PATCH') {
|
|
42
|
+
const body = (await readBody(event)) as Record<string, any>
|
|
43
|
+
const idx = def.rows.findIndex(r => String(r[def.pk]) === idFilter)
|
|
44
|
+
if (idx < 0) { setResponseStatus(event, 404); return [] }
|
|
45
|
+
def.rows[idx] = { ...def.rows[idx], ...body }
|
|
46
|
+
return [def.rows[idx]]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (method === 'DELETE') {
|
|
50
|
+
const idx = def.rows.findIndex(r => String(r[def.pk]) === idFilter)
|
|
51
|
+
if (idx >= 0) def.rows.splice(idx, 1)
|
|
52
|
+
setResponseStatus(event, 204)
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setResponseStatus(event, 405)
|
|
57
|
+
return { message: 'Method not allowed' }
|
|
58
|
+
})
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-building dev backend — reads apps from disk and serves a mock data plane
|
|
3
|
+
* that speaks the same shapes the cockpit's Console + PostgREST clients expect.
|
|
4
|
+
*
|
|
5
|
+
* - `loadApps()` assembles `InstalledAppRecord[]` from each app's manifest,
|
|
6
|
+
* cockpit and schema JSON under APPS_DIR (per call → edit + reload reflects).
|
|
7
|
+
* - `getStore()` builds an in-memory store keyed by the qualified entity name
|
|
8
|
+
* `{vendor}__{app}__{entity}`, seeded with plausible fake rows derived from
|
|
9
|
+
* each `schema.json`. It's PostgREST-compatible: list/get/create/update/delete
|
|
10
|
+
* + select/order/limit/offset/filter translation + a Content-Range total.
|
|
11
|
+
*/
|
|
12
|
+
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { randomUUID } from 'node:crypto'
|
|
15
|
+
|
|
16
|
+
export function appsDir(): string {
|
|
17
|
+
// Container: set APPS_DIR=/apps (mounted). Host `pnpm dev`: cwd is this
|
|
18
|
+
// package dir, so the sibling apps repo is three levels up
|
|
19
|
+
// (commerce-studio-dev → apps → revenexx-cockpit → playground/apps).
|
|
20
|
+
return process.env.APPS_DIR || join(process.cwd(), '..', '..', '..', 'apps')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface RawApp {
|
|
24
|
+
folder: string
|
|
25
|
+
manifest: Record<string, any>
|
|
26
|
+
cockpit: Record<string, any>
|
|
27
|
+
schema: { entities?: Record<string, any> }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readJson(path: string): Record<string, any> | null {
|
|
31
|
+
try {
|
|
32
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readOneApp(base: string, folder: string): RawApp | null {
|
|
40
|
+
const cockpit = readJson(join(base, 'cockpit.json'))
|
|
41
|
+
if (!cockpit) return null // not a renderable app
|
|
42
|
+
return {
|
|
43
|
+
folder,
|
|
44
|
+
manifest: readJson(join(base, 'manifest.json')) ?? {},
|
|
45
|
+
cockpit,
|
|
46
|
+
schema: (readJson(join(base, 'schema.json')) as any) ?? { entities: {} },
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Apps on disk with a cockpit.json. `APPS_DIR` may point at either the apps
|
|
52
|
+
* ROOT (multi-app: iterate subdirs) or a SINGLE app dir (has cockpit.json
|
|
53
|
+
* directly — the `pnpm add -D @revenexx/studio-commerce-devkit` case, run
|
|
54
|
+
* from inside your app repo).
|
|
55
|
+
*/
|
|
56
|
+
export function readApps(): RawApp[] {
|
|
57
|
+
const dir = appsDir()
|
|
58
|
+
// Single-app: APPS_DIR is itself an app.
|
|
59
|
+
const self = readOneApp(dir, dir.split('/').filter(Boolean).pop() || 'app')
|
|
60
|
+
if (self) return [self]
|
|
61
|
+
// Multi-app: iterate subdirs.
|
|
62
|
+
const out: RawApp[] = []
|
|
63
|
+
for (const folder of readdirSync(dir)) {
|
|
64
|
+
const base = join(dir, folder)
|
|
65
|
+
if (folder.startsWith('.') || !statSync(base).isDirectory()) continue
|
|
66
|
+
const app = readOneApp(base, folder)
|
|
67
|
+
if (app) out.push(app)
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Assemble the InstalledAppRecord[] the cockpit's useInstalledApps expects. */
|
|
73
|
+
export function loadApps(): unknown[] {
|
|
74
|
+
const now = new Date().toISOString()
|
|
75
|
+
return readApps().map((a) => {
|
|
76
|
+
const vendor = a.manifest.vendor ?? 'revenexx'
|
|
77
|
+
return {
|
|
78
|
+
tenant_slug: 'dev',
|
|
79
|
+
app: {
|
|
80
|
+
owner_tenant_slug: vendor,
|
|
81
|
+
name: a.manifest.name ?? a.folder,
|
|
82
|
+
title: a.manifest.title ?? a.folder,
|
|
83
|
+
vendor,
|
|
84
|
+
icon: a.manifest.icon ?? null,
|
|
85
|
+
kind: 'app',
|
|
86
|
+
app_type: a.manifest.type ?? 'public',
|
|
87
|
+
latest_version: a.manifest.version ?? null,
|
|
88
|
+
cockpit: a.cockpit,
|
|
89
|
+
settings_schema: null,
|
|
90
|
+
data_schema: { tables: a.schema.entities ?? {} },
|
|
91
|
+
analytics: null,
|
|
92
|
+
permissions: a.manifest.permissions ?? [],
|
|
93
|
+
},
|
|
94
|
+
installed_at: now,
|
|
95
|
+
uninstalled_at: null,
|
|
96
|
+
state: 'installed',
|
|
97
|
+
provisioning_error: null,
|
|
98
|
+
is_active: true,
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Mock data store ─────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
interface EntityDef {
|
|
106
|
+
qualified: string
|
|
107
|
+
pk: string
|
|
108
|
+
columns: Record<string, any>
|
|
109
|
+
refs: { col: string, entity: string }[] // qualified ref entity
|
|
110
|
+
rows: Record<string, any>[]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function qualify(vendor: string, app: string, entity: string): string {
|
|
114
|
+
return `${vendor.replace(/-/g, '_')}__${app.replace(/-/g, '_')}__${entity}`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function enumFromCheck(check?: string): string[] | null {
|
|
118
|
+
if (!check) return null
|
|
119
|
+
const m = check.match(/in\s*\(([^)]*)\)/i)
|
|
120
|
+
if (!m) return null
|
|
121
|
+
return m[1]!.split(',').map(s => s.trim().replace(/^'|'$/g, '')).filter(Boolean)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function fakeValue(colName: string, col: any, i: number): unknown {
|
|
125
|
+
const type = String(col.type || 'text').toLowerCase()
|
|
126
|
+
const options = enumFromCheck(col.check)
|
|
127
|
+
if (options?.length) return options[i % options.length]
|
|
128
|
+
if (col.pk || type.includes('uuid')) return randomUUID()
|
|
129
|
+
if (/(bool)/.test(type)) return i % 2 === 0
|
|
130
|
+
if (/(int|numeric|decimal|real|double|serial)/.test(type)) return Math.round(Math.random() * 1000) / (type.includes('numeric') ? 100 : 1)
|
|
131
|
+
if (/(timestamp|date|time)/.test(type)) return new Date(Date.now() - i * 86400000).toISOString()
|
|
132
|
+
if (/(json|jsonb)/.test(type)) return {}
|
|
133
|
+
// text-ish: use a readable value based on the column name
|
|
134
|
+
if (colName === 'name' || colName === 'title' || colName === 'label') return `${colName} ${i + 1}`
|
|
135
|
+
if (colName === 'code' || colName === 'sku' || colName === 'slug') return `${colName}-${i + 1}`
|
|
136
|
+
if (colName.endsWith('status') || colName === 'state') return ['active', 'draft', 'archived'][i % 3]
|
|
137
|
+
return `${colName} ${i + 1}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let _store: Map<string, EntityDef> | null = null
|
|
141
|
+
|
|
142
|
+
/** Build (once) the qualified-entity store with fake rows, in FK-safe order. */
|
|
143
|
+
export function getStore(rowsPerEntity = 8): Map<string, EntityDef> {
|
|
144
|
+
if (_store) return _store
|
|
145
|
+
const store = new Map<string, EntityDef>()
|
|
146
|
+
|
|
147
|
+
// 1. Register every entity across all apps under its qualified name.
|
|
148
|
+
for (const a of readApps()) {
|
|
149
|
+
const vendor = a.manifest.vendor ?? 'revenexx'
|
|
150
|
+
const app = a.manifest.name ?? a.folder
|
|
151
|
+
for (const [entity, def] of Object.entries(a.schema.entities ?? {})) {
|
|
152
|
+
const columns = (def as any).columns ?? {}
|
|
153
|
+
const pk = Object.keys(columns).find(c => columns[c]?.pk) ?? 'id'
|
|
154
|
+
const refs = Object.entries(columns)
|
|
155
|
+
.filter(([, c]: any) => c?.references?.entity)
|
|
156
|
+
.map(([col, c]: any) => ({ col, entity: qualify(vendor, app, c.references.entity) }))
|
|
157
|
+
store.set(qualify(vendor, app, entity), { qualified: qualify(vendor, app, entity), pk, columns, refs, rows: [] })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 2. Seed rows FK-safe: an entity is ready once its referenced entities are seeded.
|
|
162
|
+
const pending = new Set(store.keys())
|
|
163
|
+
let guard = 0
|
|
164
|
+
while (pending.size && guard++ < 20) {
|
|
165
|
+
for (const key of [...pending]) {
|
|
166
|
+
const def = store.get(key)!
|
|
167
|
+
const deps = def.refs.filter(r => store.has(r.entity) && r.entity !== key)
|
|
168
|
+
if (deps.some(r => pending.has(r.entity))) continue // wait for parents
|
|
169
|
+
for (let i = 0; i < rowsPerEntity; i++) {
|
|
170
|
+
const row: Record<string, any> = {}
|
|
171
|
+
for (const [colName, col] of Object.entries(def.columns)) {
|
|
172
|
+
const ref = def.refs.find(r => r.col === colName)
|
|
173
|
+
if (ref && store.get(ref.entity)?.rows.length) {
|
|
174
|
+
const parents = store.get(ref.entity)!.rows
|
|
175
|
+
row[colName] = parents[i % parents.length]![store.get(ref.entity)!.pk]
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
row[colName] = fakeValue(colName, col, i)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
def.rows.push(row)
|
|
182
|
+
}
|
|
183
|
+
pending.delete(key)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
_store = store
|
|
187
|
+
return store
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── PostgREST querystring → in-memory query ──────────────────────────────────
|
|
191
|
+
|
|
192
|
+
export interface PgResult { rows: Record<string, any>[], total: number }
|
|
193
|
+
|
|
194
|
+
export function pgQuery(entity: string, query: Record<string, any>): PgResult {
|
|
195
|
+
const store = getStore()
|
|
196
|
+
// ListView appends `_scoped` for market-scoped views; fall back to the base.
|
|
197
|
+
const def = store.get(entity) ?? store.get(entity.replace(/_scoped$/, ''))
|
|
198
|
+
if (!def) return { rows: [], total: 0 }
|
|
199
|
+
let rows = [...def.rows]
|
|
200
|
+
|
|
201
|
+
for (const [key, raw] of Object.entries(query)) {
|
|
202
|
+
if (['select', 'order', 'limit', 'offset'].includes(key)) continue
|
|
203
|
+
const val = String(raw)
|
|
204
|
+
const [op, ...rest] = val.split('.')
|
|
205
|
+
const arg = rest.join('.')
|
|
206
|
+
rows = rows.filter((r) => {
|
|
207
|
+
const cell = r[key]
|
|
208
|
+
switch (op) {
|
|
209
|
+
case 'eq': return String(cell) === arg
|
|
210
|
+
case 'neq': return String(cell) !== arg
|
|
211
|
+
case 'ilike': return String(cell ?? '').toLowerCase().includes(arg.replace(/\*/g, '').toLowerCase())
|
|
212
|
+
case 'like': return String(cell ?? '').includes(arg.replace(/\*/g, ''))
|
|
213
|
+
case 'in': return arg.replace(/^\(|\)$/g, '').split(',').includes(String(cell))
|
|
214
|
+
case 'gt': return Number(cell) > Number(arg)
|
|
215
|
+
case 'gte': return Number(cell) >= Number(arg)
|
|
216
|
+
case 'lt': return Number(cell) < Number(arg)
|
|
217
|
+
case 'lte': return Number(cell) <= Number(arg)
|
|
218
|
+
case 'is': return arg === 'null' ? cell == null : cell === (arg === 'true')
|
|
219
|
+
default: return true
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (query.order) {
|
|
225
|
+
const [col, dir] = String(query.order).split('.')
|
|
226
|
+
rows.sort((a, b) => (a[col!] > b[col!] ? 1 : a[col!] < b[col!] ? -1 : 0) * (dir === 'desc' ? -1 : 1))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const total = rows.length
|
|
230
|
+
const offset = query.offset ? Number(query.offset) : 0
|
|
231
|
+
const limit = query.limit ? Number(query.limit) : undefined
|
|
232
|
+
rows = rows.slice(offset, limit != null ? offset + limit : undefined)
|
|
233
|
+
|
|
234
|
+
if (query.select && query.select !== '*') {
|
|
235
|
+
const cols = String(query.select).split(',').map(s => s.trim())
|
|
236
|
+
rows = rows.map(r => Object.fromEntries(cols.map(c => [c, r[c]])))
|
|
237
|
+
}
|
|
238
|
+
return { rows, total }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function pgResolve(entity: string): EntityDef | undefined {
|
|
242
|
+
const store = getStore()
|
|
243
|
+
return store.get(entity) ?? store.get(entity.replace(/_scoped$/, ''))
|
|
244
|
+
}
|