@pilotiq/pilotiq 0.21.0 → 0.22.0
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/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +88 -0
- package/dist/Pilotiq.d.ts +72 -0
- package/dist/Pilotiq.d.ts.map +1 -1
- package/dist/Pilotiq.js +145 -0
- package/dist/Pilotiq.js.map +1 -1
- package/dist/PilotiqServiceProvider.d.ts +2 -0
- package/dist/PilotiqServiceProvider.d.ts.map +1 -1
- package/dist/PilotiqServiceProvider.js +60 -12
- package/dist/PilotiqServiceProvider.js.map +1 -1
- package/dist/actions/importFactory.d.ts +5 -0
- package/dist/actions/importFactory.d.ts.map +1 -1
- package/dist/actions/importFactory.js +20 -10
- package/dist/actions/importFactory.js.map +1 -1
- package/dist/orm/modelDefaults.d.ts +10 -1
- package/dist/orm/modelDefaults.d.ts.map +1 -1
- package/dist/orm/modelDefaults.js +7 -2
- package/dist/orm/modelDefaults.js.map +1 -1
- package/dist/pageData/forms.js +3 -3
- package/dist/pageData/forms.js.map +1 -1
- package/dist/pageData/misc.js +5 -5
- package/dist/pageData/misc.js.map +1 -1
- package/dist/pageData/navigation.d.ts.map +1 -1
- package/dist/pageData/navigation.js +11 -9
- package/dist/pageData/navigation.js.map +1 -1
- package/dist/pageData/relationPages.d.ts.map +1 -1
- package/dist/pageData/relationPages.js +7 -4
- package/dist/pageData/relationPages.js.map +1 -1
- package/dist/pageData/resourcePages.js +6 -6
- package/dist/pageData/resourcePages.js.map +1 -1
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +1 -0
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/themeEditor.d.ts +20 -1
- package/dist/plugins/themeEditor.d.ts.map +1 -1
- package/dist/plugins/themeEditor.js +3 -1
- package/dist/plugins/themeEditor.js.map +1 -1
- package/dist/react/CollabRoomContext.d.ts +12 -0
- package/dist/react/CollabRoomContext.d.ts.map +1 -1
- package/dist/react/CollabRoomContext.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/useCollabSeed.d.ts +23 -0
- package/dist/react/useCollabSeed.d.ts.map +1 -0
- package/dist/react/useCollabSeed.js +67 -0
- package/dist/react/useCollabSeed.js.map +1 -0
- package/dist/routes/globals.d.ts.map +1 -1
- package/dist/routes/globals.js +8 -22
- package/dist/routes/globals.js.map +1 -1
- package/dist/routes/helpers.d.ts +13 -0
- package/dist/routes/helpers.d.ts.map +1 -1
- package/dist/routes/helpers.js +25 -8
- package/dist/routes/helpers.js.map +1 -1
- package/dist/routes/resources.d.ts.map +1 -1
- package/dist/routes/resources.js +12 -34
- package/dist/routes/resources.js.map +1 -1
- package/dist/routes/theme.d.ts +4 -2
- package/dist/routes/theme.d.ts.map +1 -1
- package/dist/routes/theme.js +27 -26
- package/dist/routes/theme.js.map +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +65 -37
- package/dist/routes.js.map +1 -1
- package/dist/theme/index.d.ts +2 -0
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +1 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/storage.d.ts +86 -0
- package/dist/theme/storage.d.ts.map +1 -0
- package/dist/theme/storage.js +52 -0
- package/dist/theme/storage.js.map +1 -0
- package/package.json +1 -1
- package/src/Pilotiq.perf.test.ts +252 -0
- package/src/Pilotiq.test.ts +4 -0
- package/src/Pilotiq.ts +166 -0
- package/src/PilotiqServiceProvider.ts +63 -11
- package/src/actions/importFactory.ts +31 -10
- package/src/orm/modelDefaults.ts +15 -2
- package/src/pageData/forms.ts +3 -3
- package/src/pageData/misc.ts +5 -5
- package/src/pageData/navigation.ts +11 -9
- package/src/pageData/relationPages.ts +5 -3
- package/src/pageData/resourcePages.ts +6 -6
- package/src/plugins/index.ts +7 -0
- package/src/plugins/themeEditor.test.ts +36 -0
- package/src/plugins/themeEditor.ts +22 -1
- package/src/react/CollabRoomContext.ts +12 -0
- package/src/react/index.ts +1 -0
- package/src/react/useCollabSeed.ts +73 -0
- package/src/routes/globals.ts +8 -16
- package/src/routes/guard.test.ts +325 -0
- package/src/routes/helpers.ts +30 -8
- package/src/routes/resources.ts +12 -22
- package/src/routes/theme.ts +26 -44
- package/src/routes.ts +65 -36
- package/src/theme/index.ts +6 -0
- package/src/theme/storage.test.ts +126 -0
- package/src/theme/storage.ts +106 -0
package/src/routes.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Router } from '@rudderjs/router'
|
|
2
|
+
import type { MiddlewareHandler } from '@rudderjs/contracts'
|
|
2
3
|
import type { Pilotiq } from './Pilotiq.js'
|
|
3
4
|
import { Form } from './elements/Form.js'
|
|
4
5
|
import { dispatchFormSubmit, findForms, selectForm } from './elements/dispatchForm.js'
|
|
@@ -6,6 +7,7 @@ import { RESERVED_RELATIONSHIP_TOKENS } from './RelationManager.js'
|
|
|
6
7
|
import { Table } from './elements/Table.js'
|
|
7
8
|
import { Column } from './Column.js'
|
|
8
9
|
import type { ClusterClass } from './Cluster.js'
|
|
10
|
+
import { wantsJson } from './routes/helpers.js'
|
|
9
11
|
|
|
10
12
|
// `routes.ts` is split into a directory of focused modules under
|
|
11
13
|
// `./routes/`. This file is the orchestrator — boot-time validation
|
|
@@ -214,46 +216,68 @@ export function registerPilotiqRoutes(
|
|
|
214
216
|
}
|
|
215
217
|
}
|
|
216
218
|
|
|
217
|
-
// ──
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
219
|
+
// ── `Pilotiq.guard()` — panel-wide 401 layer ──────────
|
|
220
|
+
// Documented as the unauthenticated-request gate, but until 2026-05-21
|
|
221
|
+
// only `_uploads` consulted it — every other route relied on
|
|
222
|
+
// `cfg.user` returning null + `R.canX(user, …)` defaulting to true,
|
|
223
|
+
// so an app that wired `guard(req => Auth.check())` but shipped any
|
|
224
|
+
// Resource without `canAccess` ended up with an unauthenticated,
|
|
225
|
+
// fully-readable admin panel. Wrap every core panel route in one
|
|
226
|
+
// group so the guard runs in front of every handler.
|
|
227
|
+
const guardMiddleware: MiddlewareHandler = async (req, res, next) => {
|
|
228
|
+
if (cfg.guard) {
|
|
229
|
+
const allowed = await cfg.guard(req)
|
|
230
|
+
if (!allowed) {
|
|
231
|
+
res.status(401)
|
|
232
|
+
if (wantsJson(req)) return res.json({ ok: false, error: 'Unauthorized' })
|
|
233
|
+
return res.send('Unauthorized')
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return next()
|
|
234
237
|
}
|
|
235
238
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
239
|
+
router.group({ middleware: [guardMiddleware] }, () => {
|
|
240
|
+
// ── Panel-level sibling routes ────────────────────────
|
|
241
|
+
// Dashboard, _uploads, _widget, _search, _notifications.
|
|
242
|
+
// Pulled out 2026-05-12 (Phase 2 of the routes.ts split).
|
|
243
|
+
registerPanelRoutes(router, pilotiq, base)
|
|
241
244
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
245
|
+
// ── Resource routes ───────────────────────────────────
|
|
246
|
+
// List / view / create / edit / delete + soft-delete / actions /
|
|
247
|
+
// widgets / deferred-table / reorder / per-row editable cells / the
|
|
248
|
+
// four form-state companion endpoints / record sub-pages. Each
|
|
249
|
+
// Resource also fans out into its registered relation managers
|
|
250
|
+
// (depth-1 + depth-2). Pulled out 2026-05-12 (Phase 5 of the
|
|
251
|
+
// routes.ts split).
|
|
252
|
+
for (const R of cfg.resources) {
|
|
253
|
+
registerResourceRoutes(router, pilotiq, R, base, {
|
|
254
|
+
reorderable: reorderEnabled.has(R.getSlug()),
|
|
255
|
+
editable: editableEnabled.has(R.getSlug()),
|
|
256
|
+
})
|
|
257
|
+
}
|
|
251
258
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
259
|
+
// ── Globals (singletons — 2-segment, no /:id) ────────
|
|
260
|
+
// Pulled out 2026-05-12 (Phase 3 of the routes.ts split).
|
|
261
|
+
for (const G of cfg.globals) {
|
|
262
|
+
registerGlobalRoutes(router, pilotiq, G, base)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Custom pages (2-segment, slug route) ──────────────
|
|
266
|
+
// Pulled out 2026-05-12 (Phase 3 of the routes.ts split).
|
|
267
|
+
for (const PageClass of cfg.pages) {
|
|
268
|
+
// The dashboard page lives at `${base}` (panel routes handle it);
|
|
269
|
+
// skip it here so we don't register a duplicate `${pageUrl}` route
|
|
270
|
+
// or a broken `${base}/` (when `slug = ''`).
|
|
271
|
+
if (cfg.dashboardPage === PageClass) continue
|
|
272
|
+
registerCustomPageRoutes(router, pilotiq, PageClass, base)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Theme editor ──────────────────────────────────────
|
|
276
|
+
// Pulled out 2026-05-12 (Phase 3 of the routes.ts split).
|
|
277
|
+
if (cfg.themeEditor) {
|
|
278
|
+
registerThemeRoutes(router, pilotiq, base)
|
|
279
|
+
}
|
|
280
|
+
})
|
|
257
281
|
|
|
258
282
|
// Plugin route hook — runs AFTER all core routes register so plugins
|
|
259
283
|
// can mount their own HTTP surface alongside the panel's. Order
|
|
@@ -263,6 +287,11 @@ export function registerPilotiqRoutes(
|
|
|
263
287
|
// `_notifications`) is the recommended shape. Failures inside a
|
|
264
288
|
// plugin's hook propagate — boot order is "register all core, then
|
|
265
289
|
// each plugin in order"; a throw on hook N stops hooks N+1..N+M.
|
|
290
|
+
//
|
|
291
|
+
// Plugin routes mount OUTSIDE the guard group — plugins own their
|
|
292
|
+
// own auth posture (e.g. public webhooks, custom auth handshakes).
|
|
293
|
+
// Plugin authors that want the panel guard should consult
|
|
294
|
+
// `cfg.guard` themselves at the top of their handlers.
|
|
266
295
|
for (const plugin of pilotiq.getPlugins()) {
|
|
267
296
|
plugin.registerRoutes?.(router, pilotiq)
|
|
268
297
|
}
|
package/src/theme/index.ts
CHANGED
|
@@ -10,6 +10,12 @@ export { iconMap, resolveIconName } from './icon-map.js'
|
|
|
10
10
|
export { colors, BASE_COLOR_NAMES, HUE_NAMES } from './colors.js'
|
|
11
11
|
export { parseSeedToScale } from './generate-scale.js'
|
|
12
12
|
export { migrateThemeOverrides } from './migrate.js'
|
|
13
|
+
export { prismaThemeStorage } from './storage.js'
|
|
14
|
+
export type {
|
|
15
|
+
ThemeStorageAdapter,
|
|
16
|
+
PanelGlobalDelegate,
|
|
17
|
+
PrismaThemeStorageOptions,
|
|
18
|
+
} from './storage.js'
|
|
13
19
|
|
|
14
20
|
export type { ColorName, ColorScale, ColorStep } from './colors.js'
|
|
15
21
|
export type {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { prismaThemeStorage } from './storage.js'
|
|
4
|
+
import type { PanelGlobalDelegate } from './storage.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-test stub for the prisma panelGlobal delegate. Captures the args
|
|
8
|
+
* each method was called with so tests can assert the exact wire shape
|
|
9
|
+
* we send Prisma (slug, JSON-encoded data, etc).
|
|
10
|
+
*/
|
|
11
|
+
interface PrismaStub extends PanelGlobalDelegate {
|
|
12
|
+
rows: Map<string, { data: string | object | null }>
|
|
13
|
+
calls: { method: string; args: unknown }[]
|
|
14
|
+
/** When set, the next call to this method throws this error. */
|
|
15
|
+
throwOnce?: { method: 'findUnique' | 'upsert' | 'delete'; error: unknown }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeStub(initial: Record<string, unknown> = {}): PrismaStub {
|
|
19
|
+
const rows = new Map<string, { data: string | object | null }>()
|
|
20
|
+
for (const [slug, data] of Object.entries(initial)) {
|
|
21
|
+
rows.set(slug, { data: typeof data === 'string' ? data : JSON.stringify(data) })
|
|
22
|
+
}
|
|
23
|
+
const calls: { method: string; args: unknown }[] = []
|
|
24
|
+
const stub: PrismaStub = {
|
|
25
|
+
rows,
|
|
26
|
+
calls,
|
|
27
|
+
panelGlobal: {
|
|
28
|
+
async findUnique(args) {
|
|
29
|
+
calls.push({ method: 'findUnique', args })
|
|
30
|
+
if (stub.throwOnce?.method === 'findUnique') {
|
|
31
|
+
const e = stub.throwOnce.error; delete stub.throwOnce; throw e
|
|
32
|
+
}
|
|
33
|
+
return rows.get(args.where.slug) ?? null
|
|
34
|
+
},
|
|
35
|
+
async upsert(args) {
|
|
36
|
+
calls.push({ method: 'upsert', args })
|
|
37
|
+
if (stub.throwOnce?.method === 'upsert') {
|
|
38
|
+
const e = stub.throwOnce.error; delete stub.throwOnce; throw e
|
|
39
|
+
}
|
|
40
|
+
rows.set(args.where.slug, { data: args.update.data })
|
|
41
|
+
return undefined
|
|
42
|
+
},
|
|
43
|
+
async delete(args) {
|
|
44
|
+
calls.push({ method: 'delete', args })
|
|
45
|
+
if (stub.throwOnce?.method === 'delete') {
|
|
46
|
+
const e = stub.throwOnce.error; delete stub.throwOnce; throw e
|
|
47
|
+
}
|
|
48
|
+
if (!rows.has(args.where.slug)) {
|
|
49
|
+
const e: Error & { code?: string } = new Error('Record not found')
|
|
50
|
+
e.code = 'P2025'
|
|
51
|
+
throw e
|
|
52
|
+
}
|
|
53
|
+
rows.delete(args.where.slug)
|
|
54
|
+
return undefined
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
return stub
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('prismaThemeStorage', () => {
|
|
62
|
+
let prisma: PrismaStub
|
|
63
|
+
|
|
64
|
+
beforeEach(() => { prisma = makeStub() })
|
|
65
|
+
|
|
66
|
+
it('load() returns null when no row exists', async () => {
|
|
67
|
+
const storage = prismaThemeStorage(prisma, { slug: 'admin__theme' })
|
|
68
|
+
assert.equal(await storage.load(), null)
|
|
69
|
+
assert.deepEqual(prisma.calls, [{ method: 'findUnique', args: { where: { slug: 'admin__theme' } } }])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('load() parses JSON-string data', async () => {
|
|
73
|
+
prisma.rows.set('admin__theme', { data: JSON.stringify({ preset: 'nova' }) })
|
|
74
|
+
const storage = prismaThemeStorage(prisma, { slug: 'admin__theme' })
|
|
75
|
+
assert.deepEqual(await storage.load(), { preset: 'nova' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('load() passes through pre-parsed object data', async () => {
|
|
79
|
+
prisma.rows.set('admin__theme', { data: { preset: 'maia' } })
|
|
80
|
+
const storage = prismaThemeStorage(prisma, { slug: 'admin__theme' })
|
|
81
|
+
assert.deepEqual(await storage.load(), { preset: 'maia' })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('save() JSON-encodes the overrides via upsert', async () => {
|
|
85
|
+
const storage = prismaThemeStorage(prisma, { slug: 'admin__theme' })
|
|
86
|
+
await storage.save({ preset: 'lyra', radius: 'medium' })
|
|
87
|
+
const stored = prisma.rows.get('admin__theme')
|
|
88
|
+
assert.ok(stored)
|
|
89
|
+
assert.equal(typeof stored.data, 'string')
|
|
90
|
+
assert.deepEqual(JSON.parse(stored.data as string), { preset: 'lyra', radius: 'medium' })
|
|
91
|
+
const upsertCall = prisma.calls.find(c => c.method === 'upsert')
|
|
92
|
+
assert.ok(upsertCall, 'expected upsert call')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('clear() deletes the row', async () => {
|
|
96
|
+
prisma.rows.set('admin__theme', { data: '{}' })
|
|
97
|
+
const storage = prismaThemeStorage(prisma, { slug: 'admin__theme' })
|
|
98
|
+
await storage.clear()
|
|
99
|
+
assert.equal(prisma.rows.has('admin__theme'), false)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('clear() tolerates "row not found" (P2025)', async () => {
|
|
103
|
+
const storage = prismaThemeStorage(prisma, { slug: 'admin__theme' })
|
|
104
|
+
// Row does not exist — stub throws P2025 — clear() must not propagate.
|
|
105
|
+
await storage.clear()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('clear() rethrows non-P2025 errors', async () => {
|
|
109
|
+
prisma.rows.set('admin__theme', { data: '{}' })
|
|
110
|
+
prisma.throwOnce = { method: 'delete', error: new Error('connection lost') }
|
|
111
|
+
const storage = prismaThemeStorage(prisma, { slug: 'admin__theme' })
|
|
112
|
+
await assert.rejects(() => storage.clear(), /connection lost/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('save() bubbles non-P2025 errors', async () => {
|
|
116
|
+
prisma.throwOnce = { method: 'upsert', error: new Error('connection lost') }
|
|
117
|
+
const storage = prismaThemeStorage(prisma, { slug: 'admin__theme' })
|
|
118
|
+
await assert.rejects(() => storage.save({ preset: 'nova' }), /connection lost/)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('load() bubbles errors (callers swallow if they want back-compat)', async () => {
|
|
122
|
+
prisma.throwOnce = { method: 'findUnique', error: new Error('connection lost') }
|
|
123
|
+
const storage = prismaThemeStorage(prisma, { slug: 'admin__theme' })
|
|
124
|
+
await assert.rejects(() => storage.load(), /connection lost/)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ThemeConfig } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adapter that persists a panel's theme overrides — the JSON blob
|
|
5
|
+
* written when a user edits theme settings via the `themeEditor()`
|
|
6
|
+
* plugin and reloaded on next boot.
|
|
7
|
+
*
|
|
8
|
+
* The shipped implementation is `prismaThemeStorage`, which writes to
|
|
9
|
+
* the `panelGlobal` row created by `@rudderjs/orm-prisma`. Apps on a
|
|
10
|
+
* different ORM, key-value store, or filesystem can implement the
|
|
11
|
+
* three methods themselves.
|
|
12
|
+
*
|
|
13
|
+
* Contract:
|
|
14
|
+
*
|
|
15
|
+
* - `load()` returns `null` when no overrides have been persisted yet
|
|
16
|
+
* (fresh install). Throwing surfaces a configuration error to the
|
|
17
|
+
* caller — pilotiq does not swallow.
|
|
18
|
+
* - `save(overrides)` writes the blob verbatim. The next `load()` must
|
|
19
|
+
* return a deep-equal copy. Throwing surfaces to the route handler
|
|
20
|
+
* as a 500.
|
|
21
|
+
* - `clear()` deletes the row. Tolerating "not found" is the adapter's
|
|
22
|
+
* responsibility — `clear()` on an empty store is a no-op.
|
|
23
|
+
*/
|
|
24
|
+
export interface ThemeStorageAdapter {
|
|
25
|
+
load(): Promise<Partial<ThemeConfig> | null>
|
|
26
|
+
save(overrides: Partial<ThemeConfig>): Promise<void>
|
|
27
|
+
clear(): Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Minimal Prisma surface used by `prismaThemeStorage`. Narrow enough
|
|
32
|
+
* to keep the import surface decoupled from `PrismaClient`'s generated
|
|
33
|
+
* types — apps swap in any client whose `panelGlobal` delegate matches
|
|
34
|
+
* this shape.
|
|
35
|
+
*/
|
|
36
|
+
export interface PanelGlobalDelegate {
|
|
37
|
+
panelGlobal: {
|
|
38
|
+
findUnique(args: { where: { slug: string } }): Promise<{ data: string | object | null } | null>
|
|
39
|
+
upsert(args: {
|
|
40
|
+
where: { slug: string }
|
|
41
|
+
update: { data: string }
|
|
42
|
+
create: { slug: string; data: string }
|
|
43
|
+
}): Promise<unknown>
|
|
44
|
+
delete(args: { where: { slug: string } }): Promise<unknown>
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PrismaThemeStorageOptions {
|
|
49
|
+
/** Row key written to `panelGlobal.slug`. Pass per-panel so multiple
|
|
50
|
+
* panels in the same app don't clobber each other. Typically
|
|
51
|
+
* `${panel.name}__theme`. */
|
|
52
|
+
slug: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Default storage adapter — writes JSON to the `panelGlobal` row keyed
|
|
57
|
+
* by `opts.slug`. The Prisma delegate is dependency-injected so consumers
|
|
58
|
+
* pick how to resolve it (e.g. `app.make('prisma')`, a direct import, a
|
|
59
|
+
* test stub).
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* import { Pilotiq } from '@pilotiq/pilotiq'
|
|
64
|
+
* import { themeEditor, prismaThemeStorage } from '@pilotiq/pilotiq/plugins'
|
|
65
|
+
*
|
|
66
|
+
* const adminPanel = Pilotiq.make('Admin')
|
|
67
|
+
* .use(themeEditor({
|
|
68
|
+
* storage: prismaThemeStorage(prisma, { slug: 'admin__theme' }),
|
|
69
|
+
* }))
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export function prismaThemeStorage(
|
|
73
|
+
prisma: PanelGlobalDelegate,
|
|
74
|
+
opts: PrismaThemeStorageOptions,
|
|
75
|
+
): ThemeStorageAdapter {
|
|
76
|
+
const { slug } = opts
|
|
77
|
+
return {
|
|
78
|
+
async load() {
|
|
79
|
+
const row = await prisma.panelGlobal.findUnique({ where: { slug } })
|
|
80
|
+
if (!row?.data) return null
|
|
81
|
+
const raw = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
|
|
82
|
+
return raw as Partial<ThemeConfig>
|
|
83
|
+
},
|
|
84
|
+
async save(overrides) {
|
|
85
|
+
const data = JSON.stringify(overrides)
|
|
86
|
+
await prisma.panelGlobal.upsert({
|
|
87
|
+
where: { slug },
|
|
88
|
+
update: { data },
|
|
89
|
+
create: { slug, data },
|
|
90
|
+
})
|
|
91
|
+
},
|
|
92
|
+
async clear() {
|
|
93
|
+
try {
|
|
94
|
+
await prisma.panelGlobal.delete({ where: { slug } })
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (!isRecordNotFound(e)) throw e
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isRecordNotFound(e: unknown): boolean {
|
|
103
|
+
return typeof e === 'object'
|
|
104
|
+
&& e !== null
|
|
105
|
+
&& (e as { code?: string }).code === 'P2025'
|
|
106
|
+
}
|