@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.
Files changed (101) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +88 -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/index.d.ts +1 -0
  43. package/dist/react/index.d.ts.map +1 -1
  44. package/dist/react/index.js +1 -0
  45. package/dist/react/index.js.map +1 -1
  46. package/dist/react/useCollabSeed.d.ts +23 -0
  47. package/dist/react/useCollabSeed.d.ts.map +1 -0
  48. package/dist/react/useCollabSeed.js +67 -0
  49. package/dist/react/useCollabSeed.js.map +1 -0
  50. package/dist/routes/globals.d.ts.map +1 -1
  51. package/dist/routes/globals.js +8 -22
  52. package/dist/routes/globals.js.map +1 -1
  53. package/dist/routes/helpers.d.ts +13 -0
  54. package/dist/routes/helpers.d.ts.map +1 -1
  55. package/dist/routes/helpers.js +25 -8
  56. package/dist/routes/helpers.js.map +1 -1
  57. package/dist/routes/resources.d.ts.map +1 -1
  58. package/dist/routes/resources.js +12 -34
  59. package/dist/routes/resources.js.map +1 -1
  60. package/dist/routes/theme.d.ts +4 -2
  61. package/dist/routes/theme.d.ts.map +1 -1
  62. package/dist/routes/theme.js +27 -26
  63. package/dist/routes/theme.js.map +1 -1
  64. package/dist/routes.d.ts.map +1 -1
  65. package/dist/routes.js +65 -37
  66. package/dist/routes.js.map +1 -1
  67. package/dist/theme/index.d.ts +2 -0
  68. package/dist/theme/index.d.ts.map +1 -1
  69. package/dist/theme/index.js +1 -0
  70. package/dist/theme/index.js.map +1 -1
  71. package/dist/theme/storage.d.ts +86 -0
  72. package/dist/theme/storage.d.ts.map +1 -0
  73. package/dist/theme/storage.js +52 -0
  74. package/dist/theme/storage.js.map +1 -0
  75. package/package.json +1 -1
  76. package/src/Pilotiq.perf.test.ts +252 -0
  77. package/src/Pilotiq.test.ts +4 -0
  78. package/src/Pilotiq.ts +166 -0
  79. package/src/PilotiqServiceProvider.ts +63 -11
  80. package/src/actions/importFactory.ts +31 -10
  81. package/src/orm/modelDefaults.ts +15 -2
  82. package/src/pageData/forms.ts +3 -3
  83. package/src/pageData/misc.ts +5 -5
  84. package/src/pageData/navigation.ts +11 -9
  85. package/src/pageData/relationPages.ts +5 -3
  86. package/src/pageData/resourcePages.ts +6 -6
  87. package/src/plugins/index.ts +7 -0
  88. package/src/plugins/themeEditor.test.ts +36 -0
  89. package/src/plugins/themeEditor.ts +22 -1
  90. package/src/react/CollabRoomContext.ts +12 -0
  91. package/src/react/index.ts +1 -0
  92. package/src/react/useCollabSeed.ts +73 -0
  93. package/src/routes/globals.ts +8 -16
  94. package/src/routes/guard.test.ts +325 -0
  95. package/src/routes/helpers.ts +30 -8
  96. package/src/routes/resources.ts +12 -22
  97. package/src/routes/theme.ts +26 -44
  98. package/src/routes.ts +65 -36
  99. package/src/theme/index.ts +6 -0
  100. package/src/theme/storage.test.ts +126 -0
  101. package/src/theme/storage.ts +106 -0
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Default storage adapter — writes JSON to the `panelGlobal` row keyed
3
+ * by `opts.slug`. The Prisma delegate is dependency-injected so consumers
4
+ * pick how to resolve it (e.g. `app.make('prisma')`, a direct import, a
5
+ * test stub).
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { Pilotiq } from '@pilotiq/pilotiq'
10
+ * import { themeEditor, prismaThemeStorage } from '@pilotiq/pilotiq/plugins'
11
+ *
12
+ * const adminPanel = Pilotiq.make('Admin')
13
+ * .use(themeEditor({
14
+ * storage: prismaThemeStorage(prisma, { slug: 'admin__theme' }),
15
+ * }))
16
+ * ```
17
+ */
18
+ export function prismaThemeStorage(prisma, opts) {
19
+ const { slug } = opts;
20
+ return {
21
+ async load() {
22
+ const row = await prisma.panelGlobal.findUnique({ where: { slug } });
23
+ if (!row?.data)
24
+ return null;
25
+ const raw = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
26
+ return raw;
27
+ },
28
+ async save(overrides) {
29
+ const data = JSON.stringify(overrides);
30
+ await prisma.panelGlobal.upsert({
31
+ where: { slug },
32
+ update: { data },
33
+ create: { slug, data },
34
+ });
35
+ },
36
+ async clear() {
37
+ try {
38
+ await prisma.panelGlobal.delete({ where: { slug } });
39
+ }
40
+ catch (e) {
41
+ if (!isRecordNotFound(e))
42
+ throw e;
43
+ }
44
+ },
45
+ };
46
+ }
47
+ function isRecordNotFound(e) {
48
+ return typeof e === 'object'
49
+ && e !== null
50
+ && e.code === 'P2025';
51
+ }
52
+ //# sourceMappingURL=storage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storage.js","sourceRoot":"","sources":["../../src/theme/storage.ts"],"names":[],"mappings":"AAsDA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,kBAAkB,CAChC,MAA2B,EAC3B,IAAiC;IAEjC,MAAM,EAAE,IAAI,EAAE,GAAG,IAAI,CAAA;IACrB,OAAO;QACL,KAAK,CAAC,IAAI;YACR,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,EAAE,CAAC,CAAA;YACpE,IAAI,CAAC,GAAG,EAAE,IAAI;gBAAE,OAAO,IAAI,CAAA;YAC3B,MAAM,GAAG,GAAG,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAA;YAC1E,OAAO,GAA2B,CAAA;QACpC,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,SAAS;YAClB,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,CAAA;YACtC,MAAM,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC;gBAC9B,KAAK,EAAG,EAAE,IAAI,EAAE;gBAChB,MAAM,EAAE,EAAE,IAAI,EAAE;gBAChB,MAAM,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE;aACvB,CAAC,CAAA;QACJ,CAAC;QACD,KAAK,CAAC,KAAK;YACT,IAAI,CAAC;gBACH,MAAM,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,EAAE,CAAC,CAAA;YACtD,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,IAAI,CAAC,gBAAgB,CAAC,CAAC,CAAC;oBAAE,MAAM,CAAC,CAAA;YACnC,CAAC;QACH,CAAC;KACF,CAAA;AACH,CAAC;AAED,SAAS,gBAAgB,CAAC,CAAU;IAClC,OAAO,OAAO,CAAC,KAAK,QAAQ;WACvB,CAAC,KAAK,IAAI;WACT,CAAuB,CAAC,IAAI,KAAK,OAAO,CAAA;AAChD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/pilotiq",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "View-based admin panel for RudderJS",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Phase 5 perf sweep — covers the four hot-path changes that landed
3
+ * 2026-05-22:
4
+ *
5
+ * - 5b Per-user navigation-badge TTL cache (`Pilotiq.navigationBadgeTtl`)
6
+ * - 5c Map-based slug lookup (`Pilotiq.findResource/findGlobal/findPage`)
7
+ * - 5a Chunked import (`importFactory.runImport` honors `concurrency`)
8
+ *
9
+ * 5d (`policyGate`) is exercised indirectly by the existing routes /
10
+ * authorization tests — its contract is identical to the prior
11
+ * serial pair, just parallelized; no behavior change to assert here.
12
+ */
13
+ import { describe, it } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+
16
+ import { Pilotiq } from './Pilotiq.js'
17
+ import { Resource } from './Resource.js'
18
+ import { Global } from './Global.js'
19
+ import { Page } from './Page.js'
20
+ import { runImport } from './actions/importFactory.js'
21
+
22
+ // ─── Fixtures ─────────────────────────────────────────────────
23
+
24
+ class Articles extends Resource {
25
+ static override slug = 'articles'
26
+ static override label = 'Articles'
27
+ }
28
+ class Comments extends Resource {
29
+ static override slug = 'comments'
30
+ static override label = 'Comments'
31
+ }
32
+ class Settings extends Global {
33
+ static override slug = 'settings'
34
+ static override label = 'Settings'
35
+ }
36
+ class Branding extends Global {
37
+ static override slug = 'branding'
38
+ static override label = 'Branding'
39
+ }
40
+ class Reports extends Page {
41
+ static override slug = 'reports'
42
+ static override label = 'Reports'
43
+ }
44
+ class Health extends Page {
45
+ static override slug = 'health'
46
+ static override label = 'Health'
47
+ }
48
+
49
+ // ─── 5c — Map-based slug lookup ───────────────────────────────
50
+
51
+ describe('Pilotiq.find{Resource,Global,Page}() — Plan 5c', () => {
52
+ it('returns the matching class by slug', () => {
53
+ const p = Pilotiq.make('admin')
54
+ .resources([Articles, Comments])
55
+ .globals([Settings, Branding])
56
+ .pages([Reports, Health])
57
+ assert.equal(p.findResource('articles'), Articles)
58
+ assert.equal(p.findResource('comments'), Comments)
59
+ assert.equal(p.findGlobal('settings'), Settings)
60
+ assert.equal(p.findGlobal('branding'), Branding)
61
+ assert.equal(p.findPage('reports'), Reports)
62
+ assert.equal(p.findPage('health'), Health)
63
+ })
64
+
65
+ it('returns undefined for unknown slugs', () => {
66
+ const p = Pilotiq.make('admin').resources([Articles])
67
+ assert.equal(p.findResource('nope'), undefined)
68
+ assert.equal(p.findGlobal('nope'), undefined)
69
+ assert.equal(p.findPage('nope'), undefined)
70
+ })
71
+
72
+ it('invalidates the cache when .resources() is reassigned', () => {
73
+ const p = Pilotiq.make('admin').resources([Articles])
74
+ assert.equal(p.findResource('articles'), Articles)
75
+ assert.equal(p.findResource('comments'), undefined)
76
+ p.resources([Articles, Comments])
77
+ assert.equal(p.findResource('comments'), Comments)
78
+ })
79
+
80
+ it('invalidates the page cache when .pages() is reassigned', () => {
81
+ const p = Pilotiq.make('admin').pages([Reports])
82
+ assert.equal(p.findPage('reports'), Reports)
83
+ assert.equal(p.findPage('health'), undefined)
84
+ p.pages([Reports, Health])
85
+ assert.equal(p.findPage('health'), Health)
86
+ })
87
+
88
+ it('invalidates the page cache when .dashboard()/.profile() auto-append', () => {
89
+ class Dash extends Page {
90
+ static override slug = 'dash'
91
+ static override label = 'Dashboard'
92
+ }
93
+ const p = Pilotiq.make('admin')
94
+ assert.equal(p.findPage('dash'), undefined)
95
+ p.dashboard(Dash)
96
+ assert.equal(p.findPage('dash'), Dash)
97
+ })
98
+ })
99
+
100
+ // ─── 5b — Navigation badge TTL cache ──────────────────────────
101
+
102
+ describe('Pilotiq.navigationBadgeTtl() + resolveNavigationBadge() — Plan 5b', () => {
103
+ it('default TTL is 30s', () => {
104
+ const p = Pilotiq.make('admin')
105
+ assert.equal(p.getNavigationBadgeTtl(), 30_000)
106
+ })
107
+
108
+ it('navigationBadgeTtl(ms) overrides; clamps negatives to 0', () => {
109
+ const p = Pilotiq.make('admin').navigationBadgeTtl(5_000)
110
+ assert.equal(p.getNavigationBadgeTtl(), 5_000)
111
+ p.navigationBadgeTtl(-1)
112
+ assert.equal(p.getNavigationBadgeTtl(), 0)
113
+ })
114
+
115
+ it('navigationBadgeTtl(null) restores the default', () => {
116
+ const p = Pilotiq.make('admin').navigationBadgeTtl(1_000)
117
+ p.navigationBadgeTtl(null)
118
+ assert.equal(p.getNavigationBadgeTtl(), 30_000)
119
+ })
120
+
121
+ it('resolveNavigationBadge caches within TTL, busts on user change', async () => {
122
+ const p = Pilotiq.make('admin')
123
+ let calls = 0
124
+ const resolver = async () => { calls++; return String(calls) }
125
+
126
+ // First call: miss → resolver fires → returns '1'.
127
+ assert.equal(await p.resolveNavigationBadge('Articles', { id: 1 }, resolver), '1')
128
+ assert.equal(calls, 1)
129
+ // Same user + owner: hit → no new call → still '1'.
130
+ assert.equal(await p.resolveNavigationBadge('Articles', { id: 1 }, resolver), '1')
131
+ assert.equal(calls, 1)
132
+ // Different user: miss → resolver fires again.
133
+ assert.equal(await p.resolveNavigationBadge('Articles', { id: 2 }, resolver), '2')
134
+ assert.equal(calls, 2)
135
+ // Different owner, same user: separate cache slot.
136
+ assert.equal(await p.resolveNavigationBadge('Comments', { id: 1 }, resolver), '3')
137
+ assert.equal(calls, 3)
138
+ })
139
+
140
+ it('TTL of 0 disables caching entirely', async () => {
141
+ const p = Pilotiq.make('admin').navigationBadgeTtl(0)
142
+ let calls = 0
143
+ const resolver = async () => { calls++; return 'x' }
144
+ await p.resolveNavigationBadge('Articles', { id: 1 }, resolver)
145
+ await p.resolveNavigationBadge('Articles', { id: 1 }, resolver)
146
+ assert.equal(calls, 2)
147
+ })
148
+
149
+ it('caches undefined results (no need to keep re-resolving "no badge")', async () => {
150
+ const p = Pilotiq.make('admin')
151
+ let calls = 0
152
+ const resolver = async () => { calls++; return undefined }
153
+ assert.equal(await p.resolveNavigationBadge('Articles', null, resolver), undefined)
154
+ assert.equal(await p.resolveNavigationBadge('Articles', null, resolver), undefined)
155
+ assert.equal(calls, 1)
156
+ })
157
+
158
+ it('navigationBadgeTtl(ms) clears the cache', async () => {
159
+ const p = Pilotiq.make('admin')
160
+ let calls = 0
161
+ const resolver = async () => { calls++; return 'x' }
162
+ await p.resolveNavigationBadge('A', null, resolver)
163
+ assert.equal(calls, 1)
164
+ p.navigationBadgeTtl(60_000)
165
+ await p.resolveNavigationBadge('A', null, resolver)
166
+ assert.equal(calls, 2)
167
+ })
168
+
169
+ it('anonymous users share one cache slot', async () => {
170
+ const p = Pilotiq.make('admin')
171
+ let calls = 0
172
+ const resolver = async () => { calls++; return 'x' }
173
+ await p.resolveNavigationBadge('A', null, resolver)
174
+ await p.resolveNavigationBadge('A', undefined, resolver)
175
+ assert.equal(calls, 1)
176
+ })
177
+
178
+ it('falls back to JSON.stringify when user has no .id', async () => {
179
+ const p = Pilotiq.make('admin')
180
+ let calls = 0
181
+ const resolver = async () => { calls++; return 'x' }
182
+ await p.resolveNavigationBadge('A', { role: 'editor' }, resolver)
183
+ await p.resolveNavigationBadge('A', { role: 'editor' }, resolver)
184
+ assert.equal(calls, 1) // same JSON shape → cache hit
185
+ await p.resolveNavigationBadge('A', { role: 'admin' }, resolver)
186
+ assert.equal(calls, 2) // different JSON → miss
187
+ })
188
+ })
189
+
190
+ // ─── 5a — Chunked importFactory.runImport ─────────────────────
191
+
192
+ describe('importFactory.runImport — Plan 5a chunking', () => {
193
+ it('runs rows in chunks of `concurrency` and aggregates counts', async () => {
194
+ const created: string[] = []
195
+ let maxInFlight = 0
196
+ let inFlight = 0
197
+ const M = {
198
+ async create(row: { id: string }) {
199
+ inFlight++; if (inFlight > maxInFlight) maxInFlight = inFlight
200
+ await new Promise(r => setTimeout(r, 5))
201
+ inFlight--
202
+ created.push(row.id)
203
+ },
204
+ // unused for create-mode tests but the type wants them present
205
+ query() { return { where() { return { paginate: async () => ({ data: [] }) } } } },
206
+ async update() {},
207
+ }
208
+ const rows = Array.from({ length: 25 }, (_, i) => ({ id: `r${i}` }))
209
+ const summary = await runImport(rows, M, 'create', { concurrency: 5 }, { request: undefined })
210
+ assert.equal(summary.created, 25)
211
+ assert.equal(summary.errors.length, 0)
212
+ // With concurrency=5 we should see at least 4 in-flight at peak.
213
+ assert.ok(maxInFlight >= 4, `expected >=4 concurrent, saw ${maxInFlight}`)
214
+ // Never exceed the cap.
215
+ assert.ok(maxInFlight <= 5, `expected <=5 concurrent, saw ${maxInFlight}`)
216
+ })
217
+
218
+ it('preserves original-row indices in error messages despite chunking', async () => {
219
+ const M = {
220
+ async create(row: { id: string }) {
221
+ if (row.id === 'r2') throw new Error('boom')
222
+ },
223
+ query() { return { where() { return { paginate: async () => ({ data: [] }) } } } },
224
+ async update() {},
225
+ }
226
+ const rows = [{ id: 'r0' }, { id: 'r1' }, { id: 'r2' }, { id: 'r3' }]
227
+ const summary = await runImport(rows, M, 'create', { concurrency: 4 }, { request: undefined })
228
+ assert.equal(summary.created, 3)
229
+ assert.equal(summary.skipped, 1)
230
+ assert.equal(summary.errors.length, 1)
231
+ assert.equal(summary.errors[0]?.row, 3) // 1-based, original index 2 → row 3
232
+ assert.match(summary.errors[0]?.message ?? '', /boom/)
233
+ })
234
+
235
+ it('defaults to concurrency 10 when unset', async () => {
236
+ let maxInFlight = 0
237
+ let inFlight = 0
238
+ const M = {
239
+ async create() {
240
+ inFlight++; if (inFlight > maxInFlight) maxInFlight = inFlight
241
+ await new Promise(r => setTimeout(r, 3))
242
+ inFlight--
243
+ },
244
+ query() { return { where() { return { paginate: async () => ({ data: [] }) } } } },
245
+ async update() {},
246
+ }
247
+ const rows = Array.from({ length: 30 }, () => ({}))
248
+ await runImport(rows, M, 'create', {}, { request: undefined })
249
+ assert.ok(maxInFlight <= 10, `expected <=10 concurrent, saw ${maxInFlight}`)
250
+ assert.ok(maxInFlight >= 5, `expected >=5 concurrent under default, saw ${maxInFlight}`)
251
+ })
252
+ })
@@ -62,6 +62,10 @@ function makeStubRouter(): Router & { _calls: Array<{ method: string; path: stri
62
62
  put: (path: string) => noop(path),
63
63
  delete: (path: string) => noop(path),
64
64
  patch: (path: string) => noop(path),
65
+ // `router.group(opts, fn)` runs `fn()` synchronously inside its
66
+ // scope. Stub mirrors that — `Pilotiq.guard()` middleware doesn't
67
+ // touch the stub, only `fn()` matters.
68
+ group: (_opts: unknown, fn: () => void) => { fn() },
65
69
  _calls: calls,
66
70
  }
67
71
  return stub
package/src/Pilotiq.ts CHANGED
@@ -6,6 +6,7 @@ import type { ClusterClass } from './Cluster.js'
6
6
  import type { Page } from './Page.js'
7
7
  import type { SchemaDefinition } from './schema/resolveSchema.js'
8
8
  import type { ThemeConfig } from './theme/types.js'
9
+ import type { ThemeStorageAdapter } from './theme/storage.js'
9
10
  import type { UploadAdapter } from './uploads/UploadAdapter.js'
10
11
  import type { UserMenuItem } from './UserMenuItem.js'
11
12
  import type { NavigationBadgeColor } from './Resource.js'
@@ -228,6 +229,16 @@ export interface PilotiqConfig {
228
229
  profilePage?: typeof Page
229
230
  theme?: ThemeConfig
230
231
  themeEditor?: boolean
232
+ /**
233
+ * Theme override persistence adapter — wired via
234
+ * `themeEditor({ storage })`. Reads/writes the JSON blob the editor
235
+ * page produces. Without this, the service provider falls back to
236
+ * the implicit Prisma adapter (auto-resolved via
237
+ * `app.make('prisma')`) for back-compat — that fallback is
238
+ * deprecated and will be removed in a future minor; pass `storage`
239
+ * explicitly.
240
+ */
241
+ themeStorage?: ThemeStorageAdapter
231
242
  guard?: (req: unknown) => boolean | Promise<boolean>
232
243
  user?: UserResolver
233
244
  uploads?: UploadConfig
@@ -317,6 +328,11 @@ export interface PilotiqConfig {
317
328
  aiSuggestionsMode?: 'auto' | 'review'
318
329
  /** @internal Runtime theme overrides from DB. */
319
330
  _themeOverrides?: Partial<ThemeConfig>
331
+ /**
332
+ * TTL (milliseconds) for the per-user navigation badge cache. Set to
333
+ * `0` (or `null` via the builder) to disable caching. Default 30000.
334
+ */
335
+ navigationBadgeTtlMs?: number
320
336
  }
321
337
 
322
338
  /**
@@ -369,6 +385,21 @@ export interface ComponentSlots {
369
385
  export class Pilotiq {
370
386
  private config: PilotiqConfig
371
387
  private installedPlugins: PilotiqPlugin[] = []
388
+ /** Lazy slug-indexed caches. Built on first lookup; invalidated when
389
+ * the underlying setter mutates the matching array. Resources /
390
+ * globals / pages are looked up by slug 16+ times per request across
391
+ * the page-data builders — the linear `Array.find` adds up around 50+
392
+ * resources. */
393
+ private _resourceBySlug?: Map<string, ResourceClass>
394
+ private _globalBySlug?: Map<string, GlobalClass>
395
+ private _pageBySlug?: Map<string, typeof Page>
396
+ /**
397
+ * Per-user navigation badge cache. Keyed by `${ownerName}|${userKey}`
398
+ * — `userKey` derived from `user.id` (or the primitive user / JSON
399
+ * fallback / `''` for anon). Each entry expires after
400
+ * `getNavigationBadgeTtl()` ms.
401
+ */
402
+ private _navigationBadgeCache: Map<string, { value: string | undefined; expires: number }> = new Map()
372
403
 
373
404
  private constructor(name: string) {
374
405
  this.config = {
@@ -399,16 +430,19 @@ export class Pilotiq {
399
430
 
400
431
  resources(r: ResourceClass[]): this {
401
432
  this.config.resources = r
433
+ delete this._resourceBySlug
402
434
  return this
403
435
  }
404
436
 
405
437
  globals(g: GlobalClass[]): this {
406
438
  this.config.globals = g
439
+ delete this._globalBySlug
407
440
  return this
408
441
  }
409
442
 
410
443
  pages(p: (typeof Page)[]): this {
411
444
  this.config.pages = p
445
+ delete this._pageBySlug
412
446
  return this
413
447
  }
414
448
 
@@ -453,6 +487,7 @@ export class Pilotiq {
453
487
  this.config.dashboardPage = P
454
488
  if (!this.config.pages.includes(P)) {
455
489
  this.config.pages = [...this.config.pages, P]
490
+ delete this._pageBySlug
456
491
  }
457
492
  return this
458
493
  }
@@ -476,6 +511,7 @@ export class Pilotiq {
476
511
  this.config.profilePage = P
477
512
  if (!this.config.pages.includes(P)) {
478
513
  this.config.pages = [...this.config.pages, P]
514
+ delete this._pageBySlug
479
515
  }
480
516
  return this
481
517
  }
@@ -961,6 +997,24 @@ export class Pilotiq {
961
997
  this.config.themeEditor = true
962
998
  }
963
999
 
1000
+ /** @internal — assign the storage adapter resolved by the
1001
+ * `themeEditor({ storage })` plugin OR by the service provider's
1002
+ * back-compat Prisma fallback. Both writers funnel through this
1003
+ * setter so the route handlers consume a single slot. */
1004
+ _setThemeStorage(adapter: ThemeStorageAdapter | undefined): void {
1005
+ if (adapter === undefined) {
1006
+ delete this.config.themeStorage
1007
+ } else {
1008
+ this.config.themeStorage = adapter
1009
+ }
1010
+ }
1011
+
1012
+ /** @internal — the active theme storage adapter (explicit or the
1013
+ * boot-time Prisma fallback). Routes read from here. */
1014
+ getThemeStorage(): ThemeStorageAdapter | undefined {
1015
+ return this.config.themeStorage
1016
+ }
1017
+
964
1018
  /** @internal */
965
1019
  setThemeOverrides(overrides: Partial<ThemeConfig> | undefined): void {
966
1020
  if (overrides === undefined) {
@@ -980,6 +1034,93 @@ export class Pilotiq {
980
1034
  return { ...base, ...overrides }
981
1035
  }
982
1036
 
1037
+ /**
1038
+ * Slug-indexed lookup for resources. O(1) replacement for
1039
+ * `cfg.resources.find(r => r.getSlug() === slug)`. Built lazily on
1040
+ * first call; invalidated when `.resources([…])` is reassigned.
1041
+ */
1042
+ findResource(slug: string): ResourceClass | undefined {
1043
+ if (!this._resourceBySlug) {
1044
+ this._resourceBySlug = new Map(this.config.resources.map(r => [r.getSlug(), r]))
1045
+ }
1046
+ return this._resourceBySlug.get(slug)
1047
+ }
1048
+
1049
+ /** Slug-indexed lookup for globals. See `findResource`. */
1050
+ findGlobal(slug: string): GlobalClass | undefined {
1051
+ if (!this._globalBySlug) {
1052
+ this._globalBySlug = new Map(this.config.globals.map(g => [g.getSlug(), g]))
1053
+ }
1054
+ return this._globalBySlug.get(slug)
1055
+ }
1056
+
1057
+ /** Slug-indexed lookup for pages. See `findResource`. */
1058
+ findPage(slug: string): typeof Page | undefined {
1059
+ if (!this._pageBySlug) {
1060
+ this._pageBySlug = new Map(this.config.pages.map(p => [p.getSlug(), p]))
1061
+ }
1062
+ return this._pageBySlug.get(slug)
1063
+ }
1064
+
1065
+ /**
1066
+ * TTL (milliseconds) for the per-user navigation badge cache. Badges
1067
+ * resolve once per `(owner, userIdentity)` pair and serve from the
1068
+ * in-memory cache until the TTL elapses; the cache covers the
1069
+ * common case where a panel with N resources each running
1070
+ * `Model.count()` for a sidebar badge would otherwise issue N queries
1071
+ * on every page nav.
1072
+ *
1073
+ * Pass `0` (or `null`) to disable caching entirely. Default 30000.
1074
+ */
1075
+ navigationBadgeTtl(ms: number | null): this {
1076
+ if (ms === null) {
1077
+ delete this.config.navigationBadgeTtlMs
1078
+ } else {
1079
+ this.config.navigationBadgeTtlMs = Math.max(0, ms)
1080
+ }
1081
+ // Bust on reconfigure so the new TTL doesn't reuse stale slots.
1082
+ this._navigationBadgeCache.clear()
1083
+ return this
1084
+ }
1085
+
1086
+ /** @internal — resolved TTL in milliseconds. Default 30s. `0`
1087
+ * disables caching (each request re-resolves). */
1088
+ getNavigationBadgeTtl(): number {
1089
+ return this.config.navigationBadgeTtlMs ?? 30_000
1090
+ }
1091
+
1092
+ /** @internal — cache key for one (owner, user) pair. */
1093
+ navigationBadgeCacheKey(ownerName: string, user: unknown): string {
1094
+ return `${ownerName}|${userIdentityKey(user)}`
1095
+ }
1096
+
1097
+ /** @internal — read-through cache for a single owner's badge value.
1098
+ * Caller supplies the resolver; cache wraps it with the configured
1099
+ * TTL. When TTL is 0 the resolver is invoked unconditionally and
1100
+ * nothing is stored. */
1101
+ async resolveNavigationBadge(
1102
+ ownerName: string,
1103
+ user: unknown,
1104
+ resolver: () => Promise<string | undefined>,
1105
+ ): Promise<string | undefined> {
1106
+ const ttl = this.getNavigationBadgeTtl()
1107
+ if (ttl <= 0) return resolver()
1108
+
1109
+ const key = this.navigationBadgeCacheKey(ownerName, user)
1110
+ const now = Date.now()
1111
+ const hit = this._navigationBadgeCache.get(key)
1112
+ if (hit && hit.expires > now) return hit.value
1113
+
1114
+ const value = await resolver()
1115
+ this._navigationBadgeCache.set(key, { value, expires: now + ttl })
1116
+ return value
1117
+ }
1118
+
1119
+ /** @internal — test seam; clears the per-user badge cache. */
1120
+ _clearNavigationBadgeCache(): void {
1121
+ this._navigationBadgeCache.clear()
1122
+ }
1123
+
983
1124
  /** @internal */
984
1125
  getConfig(): Readonly<PilotiqConfig> {
985
1126
  return this.config
@@ -990,3 +1131,28 @@ export class Pilotiq {
990
1131
  return this.installedPlugins
991
1132
  }
992
1133
  }
1134
+
1135
+ /**
1136
+ * Stable cache key derived from a user object. Pilotiq treats the user
1137
+ * as opaque, so we sniff the common shapes:
1138
+ *
1139
+ * 1. `null` / `undefined` — anonymous request; everyone shares one slot.
1140
+ * 2. Primitive (string / number / bigint / boolean) — stringify directly.
1141
+ * 3. Object with `id` — `String(user.id)` (the 99% case for app-supplied users).
1142
+ * 4. Other objects — `JSON.stringify` as a last resort; falls back to a
1143
+ * sentinel if stringify throws (cycles).
1144
+ *
1145
+ * Two distinct users with the same `id` collide, but that's the same
1146
+ * collision the rest of the framework already trusts.
1147
+ */
1148
+ function userIdentityKey(user: unknown): string {
1149
+ if (user === null || user === undefined) return ''
1150
+ const t = typeof user
1151
+ if (t === 'string' || t === 'number' || t === 'bigint' || t === 'boolean') return String(user)
1152
+ if (t === 'object') {
1153
+ const u = user as { id?: unknown }
1154
+ if (u.id !== undefined && u.id !== null) return String(u.id)
1155
+ try { return JSON.stringify(user) } catch { return '__opaque__' }
1156
+ }
1157
+ return '__opaque__'
1158
+ }
@@ -3,9 +3,14 @@ import type { Application } from '@rudderjs/core'
3
3
  import type { Pilotiq } from './Pilotiq.js'
4
4
  import { PilotiqRegistry } from './PilotiqRegistry.js'
5
5
  import { registerPilotiqRoutes } from './routes.js'
6
+ import { migrateThemeOverrides } from './theme/migrate.js'
7
+ import { prismaThemeStorage } from './theme/storage.js'
8
+ import type { PanelGlobalDelegate, ThemeStorageAdapter } from './theme/storage.js'
6
9
 
7
10
  // ─── Service Provider ─────────────────────────────────────
8
11
 
12
+ const autoFallbackWarned = new Set<string>()
13
+
9
14
  class PilotiqServiceProvider extends ServiceProvider {
10
15
  private panels: Pilotiq[]
11
16
 
@@ -26,25 +31,72 @@ class PilotiqServiceProvider extends ServiceProvider {
26
31
  router: Parameters<typeof registerPilotiqRoutes>[0]
27
32
  }
28
33
 
29
- // Load saved theme overrides from DB for panels with themeEditor enabled
30
34
  for (const panel of PilotiqRegistry.all()) {
31
35
  if (panel.getConfig().themeEditor) {
32
- try {
33
- const prisma = this.app.make('prisma') as any
34
- const slug = `${panel.getConfig().name}__theme`
35
- const row = await prisma.panelGlobal.findUnique({ where: { slug } })
36
- if (row?.data) {
37
- const raw = typeof row.data === 'string' ? JSON.parse(row.data as string) : row.data
38
- const { migrateThemeOverrides } = await import('./theme/migrate.js')
39
- panel.setThemeOverrides(migrateThemeOverrides(raw))
40
- }
41
- } catch { /* no DB or no table — use code defaults */ }
36
+ await loadThemeOverrides(this.app, panel)
42
37
  }
43
38
  registerPilotiqRoutes(router, panel)
44
39
  }
45
40
  }
46
41
  }
47
42
 
43
+ /**
44
+ * Resolve the panel's theme storage adapter and hydrate any persisted
45
+ * overrides onto the panel.
46
+ *
47
+ * - Explicit `themeEditor({ storage })`: errors bubble (the user opted
48
+ * in, misconfiguration should surface loudly).
49
+ * - Implicit Prisma fallback: errors swallowed for back-compat with a
50
+ * one-time deprecation warning. Removing this branch is the breaking
51
+ * change scheduled for the next minor.
52
+ */
53
+ async function loadThemeOverrides(app: Application, panel: Pilotiq): Promise<void> {
54
+ const adapter = resolveThemeStorage(app, panel)
55
+ if (!adapter) return
56
+
57
+ const isExplicit = panel.getConfig().themeStorage === adapter
58
+ if (!isExplicit) panel._setThemeStorage(adapter)
59
+
60
+ try {
61
+ const overrides = await adapter.load()
62
+ if (overrides) panel.setThemeOverrides(migrateThemeOverrides(overrides))
63
+ } catch (e) {
64
+ if (isExplicit) throw e
65
+ // Implicit fallback: swallow connection / schema errors. Removed
66
+ // alongside the auto-fallback branch in a future minor.
67
+ }
68
+ }
69
+
70
+ function resolveThemeStorage(app: Application, panel: Pilotiq): ThemeStorageAdapter | null {
71
+ const explicit = panel.getConfig().themeStorage
72
+ if (explicit) return explicit
73
+
74
+ let prisma: PanelGlobalDelegate | null
75
+ try {
76
+ prisma = app.make('prisma') as PanelGlobalDelegate
77
+ } catch {
78
+ return null
79
+ }
80
+ if (!prisma || typeof prisma.panelGlobal?.findUnique !== 'function') return null
81
+
82
+ const panelName = panel.getConfig().name
83
+ if (!autoFallbackWarned.has(panelName)) {
84
+ autoFallbackWarned.add(panelName)
85
+ console.warn(
86
+ `[pilotiq] themeEditor() on panel "${panelName}" is using the implicit ` +
87
+ `Prisma fallback for theme persistence. Pass storage explicitly — ` +
88
+ `themeEditor({ storage: prismaThemeStorage(prisma, { slug: '${panelName}__theme' }) }) — ` +
89
+ `the implicit fallback is deprecated and will be removed in a future minor.`,
90
+ )
91
+ }
92
+ return prismaThemeStorage(prisma, { slug: `${panelName}__theme` })
93
+ }
94
+
95
+ /** @internal — test seam; resets the "deprecation already warned" memo. */
96
+ export function _resetThemeFallbackWarned(): void {
97
+ autoFallbackWarned.clear()
98
+ }
99
+
48
100
  // ─── Factory ──────────────────────────────────────────────
49
101
 
50
102
  /**