@pilotiq/pilotiq 0.21.0 → 0.23.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.
Files changed (112) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +107 -0
  3. package/dist/Pilotiq.d.ts +72 -0
  4. package/dist/Pilotiq.d.ts.map +1 -1
  5. package/dist/Pilotiq.js +145 -0
  6. package/dist/Pilotiq.js.map +1 -1
  7. package/dist/PilotiqServiceProvider.d.ts +2 -0
  8. package/dist/PilotiqServiceProvider.d.ts.map +1 -1
  9. package/dist/PilotiqServiceProvider.js +60 -12
  10. package/dist/PilotiqServiceProvider.js.map +1 -1
  11. package/dist/actions/importFactory.d.ts +5 -0
  12. package/dist/actions/importFactory.d.ts.map +1 -1
  13. package/dist/actions/importFactory.js +20 -10
  14. package/dist/actions/importFactory.js.map +1 -1
  15. package/dist/orm/modelDefaults.d.ts +10 -1
  16. package/dist/orm/modelDefaults.d.ts.map +1 -1
  17. package/dist/orm/modelDefaults.js +7 -2
  18. package/dist/orm/modelDefaults.js.map +1 -1
  19. package/dist/pageData/forms.js +3 -3
  20. package/dist/pageData/forms.js.map +1 -1
  21. package/dist/pageData/misc.js +5 -5
  22. package/dist/pageData/misc.js.map +1 -1
  23. package/dist/pageData/navigation.d.ts.map +1 -1
  24. package/dist/pageData/navigation.js +11 -9
  25. package/dist/pageData/navigation.js.map +1 -1
  26. package/dist/pageData/relationPages.d.ts.map +1 -1
  27. package/dist/pageData/relationPages.js +7 -4
  28. package/dist/pageData/relationPages.js.map +1 -1
  29. package/dist/pageData/resourcePages.js +6 -6
  30. package/dist/pageData/resourcePages.js.map +1 -1
  31. package/dist/plugins/index.d.ts +3 -0
  32. package/dist/plugins/index.d.ts.map +1 -1
  33. package/dist/plugins/index.js +1 -0
  34. package/dist/plugins/index.js.map +1 -1
  35. package/dist/plugins/themeEditor.d.ts +20 -1
  36. package/dist/plugins/themeEditor.d.ts.map +1 -1
  37. package/dist/plugins/themeEditor.js +3 -1
  38. package/dist/plugins/themeEditor.js.map +1 -1
  39. package/dist/react/CollabRoomContext.d.ts +12 -0
  40. package/dist/react/CollabRoomContext.d.ts.map +1 -1
  41. package/dist/react/CollabRoomContext.js.map +1 -1
  42. package/dist/react/FormStateContext.d.ts +10 -0
  43. package/dist/react/FormStateContext.d.ts.map +1 -1
  44. package/dist/react/FormStateContext.js +12 -0
  45. package/dist/react/FormStateContext.js.map +1 -1
  46. package/dist/react/PendingSuggestionApplierRegistry.d.ts +12 -0
  47. package/dist/react/PendingSuggestionApplierRegistry.d.ts.map +1 -1
  48. package/dist/react/PendingSuggestionApplierRegistry.js +21 -1
  49. package/dist/react/PendingSuggestionApplierRegistry.js.map +1 -1
  50. package/dist/react/index.d.ts +2 -1
  51. package/dist/react/index.d.ts.map +1 -1
  52. package/dist/react/index.js +2 -1
  53. package/dist/react/index.js.map +1 -1
  54. package/dist/react/useCollabSeed.d.ts +23 -0
  55. package/dist/react/useCollabSeed.d.ts.map +1 -0
  56. package/dist/react/useCollabSeed.js +67 -0
  57. package/dist/react/useCollabSeed.js.map +1 -0
  58. package/dist/routes/globals.d.ts.map +1 -1
  59. package/dist/routes/globals.js +8 -22
  60. package/dist/routes/globals.js.map +1 -1
  61. package/dist/routes/helpers.d.ts +13 -0
  62. package/dist/routes/helpers.d.ts.map +1 -1
  63. package/dist/routes/helpers.js +25 -8
  64. package/dist/routes/helpers.js.map +1 -1
  65. package/dist/routes/resources.d.ts.map +1 -1
  66. package/dist/routes/resources.js +12 -34
  67. package/dist/routes/resources.js.map +1 -1
  68. package/dist/routes/theme.d.ts +4 -2
  69. package/dist/routes/theme.d.ts.map +1 -1
  70. package/dist/routes/theme.js +27 -26
  71. package/dist/routes/theme.js.map +1 -1
  72. package/dist/routes.d.ts.map +1 -1
  73. package/dist/routes.js +65 -37
  74. package/dist/routes.js.map +1 -1
  75. package/dist/theme/index.d.ts +2 -0
  76. package/dist/theme/index.d.ts.map +1 -1
  77. package/dist/theme/index.js +1 -0
  78. package/dist/theme/index.js.map +1 -1
  79. package/dist/theme/storage.d.ts +86 -0
  80. package/dist/theme/storage.d.ts.map +1 -0
  81. package/dist/theme/storage.js +52 -0
  82. package/dist/theme/storage.js.map +1 -0
  83. package/package.json +1 -1
  84. package/src/Pilotiq.perf.test.ts +252 -0
  85. package/src/Pilotiq.test.ts +4 -0
  86. package/src/Pilotiq.ts +166 -0
  87. package/src/PilotiqServiceProvider.ts +63 -11
  88. package/src/actions/importFactory.ts +31 -10
  89. package/src/orm/modelDefaults.ts +15 -2
  90. package/src/pageData/forms.ts +3 -3
  91. package/src/pageData/misc.ts +5 -5
  92. package/src/pageData/navigation.ts +11 -9
  93. package/src/pageData/relationPages.ts +5 -3
  94. package/src/pageData/resourcePages.ts +6 -6
  95. package/src/plugins/index.ts +7 -0
  96. package/src/plugins/themeEditor.test.ts +36 -0
  97. package/src/plugins/themeEditor.ts +22 -1
  98. package/src/react/CollabRoomContext.ts +12 -0
  99. package/src/react/FormStateContext.tsx +13 -0
  100. package/src/react/PendingSuggestionApplierRegistry.test.ts +97 -0
  101. package/src/react/PendingSuggestionApplierRegistry.ts +19 -1
  102. package/src/react/index.ts +2 -0
  103. package/src/react/useCollabSeed.ts +73 -0
  104. package/src/routes/globals.ts +8 -16
  105. package/src/routes/guard.test.ts +325 -0
  106. package/src/routes/helpers.ts +30 -8
  107. package/src/routes/resources.ts +12 -22
  108. package/src/routes/theme.ts +26 -44
  109. package/src/routes.ts +65 -36
  110. package/src/theme/index.ts +6 -0
  111. package/src/theme/storage.test.ts +126 -0
  112. 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
- // ── Panel-level sibling routes ────────────────────────
218
- // Dashboard, _uploads, _widget, _search, _notifications.
219
- // Pulled out 2026-05-12 (Phase 2 of the routes.ts split).
220
- registerPanelRoutes(router, pilotiq, base)
221
-
222
- // ── Resource routes ───────────────────────────────────
223
- // List / view / create / edit / delete + soft-delete / actions /
224
- // widgets / deferred-table / reorder / per-row editable cells / the
225
- // four form-state companion endpoints / record sub-pages. Each
226
- // Resource also fans out into its registered relation managers
227
- // (depth-1 + depth-2). Pulled out 2026-05-12 (Phase 5 of the
228
- // routes.ts split).
229
- for (const R of cfg.resources) {
230
- registerResourceRoutes(router, pilotiq, R, base, {
231
- reorderable: reorderEnabled.has(R.getSlug()),
232
- editable: editableEnabled.has(R.getSlug()),
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
- // ── Globals (singletons — 2-segment, no /:id) ────────
237
- // Pulled out 2026-05-12 (Phase 3 of the routes.ts split).
238
- for (const G of cfg.globals) {
239
- registerGlobalRoutes(router, pilotiq, G, base)
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
- // ── Custom pages (2-segment, slug route) ──────────────
243
- // Pulled out 2026-05-12 (Phase 3 of the routes.ts split).
244
- for (const PageClass of cfg.pages) {
245
- // The dashboard page lives at `${base}` (panel routes handle it);
246
- // skip it here so we don't register a duplicate `${pageUrl}` route
247
- // or a broken `${base}/` (when `slug = ''`).
248
- if (cfg.dashboardPage === PageClass) continue
249
- registerCustomPageRoutes(router, pilotiq, PageClass, base)
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
- // ── Theme editor ──────────────────────────────────────
253
- // Pulled out 2026-05-12 (Phase 3 of the routes.ts split).
254
- if (cfg.themeEditor) {
255
- registerThemeRoutes(router, pilotiq, base)
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
  }
@@ -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
+ }