@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 ADDED
@@ -0,0 +1,6 @@
1
+ Copyright (c) 2026 revenexx GmbH. All rights reserved.
2
+
3
+ This software is proprietary and confidential. It is part of revenexx Studio and
4
+ may be used only under the terms of the revenexx Studio license.
5
+
6
+ Full license terms: https://studio.revenexx.design/license
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,5 @@
1
+ <template>
2
+ <NuxtLayout>
3
+ <NuxtPage />
4
+ </NuxtLayout>
5
+ </template>
@@ -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>
@@ -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
+ }