@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/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
  /**
@@ -65,6 +65,11 @@ export interface ImportOptions {
65
65
  * exceeded — protects against accidental million-row uploads. Default
66
66
  * `10_000`. */
67
67
  maxRows?: number
68
+ /** Number of rows to process in parallel. The importer chunks the row
69
+ * list into batches of this size and runs each chunk via `Promise.all`.
70
+ * Order within a chunk is non-deterministic; row indices in error
71
+ * messages still match the original CSV/JSON position. Default `10`. */
72
+ concurrency?: number
68
73
  /** Final hook after the import loop. Useful for audit-log writes,
69
74
  * cache invalidation, etc. Async-aware. */
70
75
  onComplete?: (summary: ImportSummary, ctx: ImportContext) => void | Promise<void>
@@ -123,16 +128,21 @@ export async function runImport(
123
128
  ): Promise<ImportSummary> {
124
129
  const summary: ImportSummary = { created: 0, updated: 0, skipped: 0, errors: [] }
125
130
  const upsertBy = opts.upsertBy
131
+ const concurrency = Math.max(1, opts.concurrency ?? 10)
126
132
 
127
- for (let i = 0; i < rows.length; i++) {
128
- const row = rows[i]!
133
+ // Per-row outcome a small value type so chunks can resolve in any
134
+ // order and we still accumulate `summary` in original-row order.
135
+ type RowOutcome =
136
+ | { kind: 'created' }
137
+ | { kind: 'updated' }
138
+ | { kind: 'skipped'; row: number; message: string }
139
+
140
+ async function processRow(row: Record<string, unknown>, i: number): Promise<RowOutcome> {
129
141
  const rowCtx: ImportContext = { ...ctx, rowIndex: i }
130
142
  try {
131
143
  const guard = await opts.validate?.(row, rowCtx)
132
144
  if (typeof guard === 'string' && guard.length > 0) {
133
- summary.skipped++
134
- summary.errors.push({ row: i + 1, message: guard })
135
- continue
145
+ return { kind: 'skipped', row: i + 1, message: guard }
136
146
  }
137
147
 
138
148
  if (mode === 'upsert' && upsertBy) {
@@ -144,8 +154,7 @@ export async function runImport(
144
154
  ? await opts.beforeUpdate(row, existing, rowCtx)
145
155
  : row
146
156
  await M.update(id, payload)
147
- summary.updated++
148
- continue
157
+ return { kind: 'updated' }
149
158
  }
150
159
  }
151
160
 
@@ -153,10 +162,22 @@ export async function runImport(
153
162
  ? await opts.beforeCreate(row, rowCtx)
154
163
  : row
155
164
  await M.create(payload)
156
- summary.created++
165
+ return { kind: 'created' }
157
166
  } catch (err) {
158
- summary.skipped++
159
- summary.errors.push({ row: i + 1, message: err instanceof Error ? err.message : 'unknown' })
167
+ return { kind: 'skipped', row: i + 1, message: err instanceof Error ? err.message : 'unknown' }
168
+ }
169
+ }
170
+
171
+ for (let start = 0; start < rows.length; start += concurrency) {
172
+ const slice = rows.slice(start, start + concurrency)
173
+ const outcomes = await Promise.all(slice.map((row, idx) => processRow(row, start + idx)))
174
+ for (const outcome of outcomes) {
175
+ if (outcome.kind === 'created') summary.created++
176
+ else if (outcome.kind === 'updated') summary.updated++
177
+ else {
178
+ summary.skipped++
179
+ summary.errors.push({ row: outcome.row, message: outcome.message })
180
+ }
160
181
  }
161
182
  }
162
183
 
@@ -50,6 +50,16 @@ export interface ModelQuery {
50
50
  orderBy(column: string, direction?: 'ASC' | 'DESC'): ModelQuery
51
51
  paginate(page: number, perPage?: number): Promise<{ data: unknown[]; total: number }>
52
52
 
53
+ /**
54
+ * Single-row `LIMIT 1` SELECT — Laravel-parity sibling of `paginate`
55
+ * for the "first matching row" case. Optional on the structural shape;
56
+ * the `@rudderjs/orm` `QueryBuilder` ships it, test stubs typically
57
+ * don't. Callers that need it should fall back to
58
+ * `(await q.paginate(1, 1)).data[0]` when absent — see `findRecord` /
59
+ * `loadSingularRecord` / `childBelongsToParent` for the pattern.
60
+ */
61
+ first?(): Promise<unknown | null>
62
+
53
63
  /**
54
64
  * Plan #13 — soft-delete query scopes. Optional on the structural
55
65
  * shape; the `@rudderjs/orm-prisma` QueryBuilder ships them when
@@ -192,7 +202,7 @@ export function modelLoadRecord(R: ResourceLike): LoadRecordHandler {
192
202
  * `Global` subclasses with `static model = M` set get this wired
193
203
  * automatically by `defaultGlobalEditPage`.
194
204
  *
195
- * Default strategy: paginate(1, 1) — i.e. "the first row". Pass
205
+ * Default strategy: `.first()` — i.e. the first matching row. Pass
196
206
  * `findSingular` to switch to a fixed-id lookup
197
207
  * (`(q) => q.where('id', '=', 1)`) or a slug-style lookup
198
208
  * (`(q) => q.where('key', '=', 'site')`).
@@ -208,6 +218,7 @@ export function loadSingularRecord(
208
218
  return async (): Promise<unknown> => {
209
219
  let q = M.query()
210
220
  if (opts?.findSingular) q = opts.findSingular(q)
221
+ if (q.first) return (await q.first()) ?? null
211
222
  const result = await q.paginate(1, 1)
212
223
  const data = (result?.data ?? []) as unknown[]
213
224
  return data[0] ?? null
@@ -234,7 +245,9 @@ export async function findRecord<T = unknown>(
234
245
  const M = R.model
235
246
  if (!M) return undefined
236
247
  const pk = getPrimaryKey(M)
237
- const result = await R.query(ctx).where(pk, '=', id).paginate(1, 1)
248
+ const q = R.query(ctx).where(pk, '=', id)
249
+ if (q.first) return ((await q.first()) ?? undefined) as T | undefined
250
+ const result = await q.paginate(1, 1)
238
251
  const data = (result?.data ?? []) as unknown[]
239
252
  return data[0] as T | undefined
240
253
  }
@@ -58,7 +58,7 @@ async function resolveScopeForm(
58
58
  let baseCtxExtras: Record<string, unknown> = {}
59
59
 
60
60
  if (scope.kind === 'resource-create' || scope.kind === 'resource-edit') {
61
- const R = cfg.resources.find(r => r.getSlug() === scope.slug)
61
+ const R = pilotiq.findResource(scope.slug)
62
62
  if (!R) return null
63
63
  const pages = R.resolvePages()
64
64
  if (scope.kind === 'resource-create') {
@@ -77,14 +77,14 @@ async function resolveScopeForm(
77
77
  }
78
78
  }
79
79
  } else if (scope.kind === 'global-edit') {
80
- const G = cfg.globals.find(g => g.getSlug() === scope.slug)
80
+ const G = pilotiq.findGlobal(scope.slug)
81
81
  if (!G) return null
82
82
  const pages = G.resolvePages()
83
83
  if (!pages.edit) return null
84
84
  PageClass = pages.edit
85
85
  mode = 'edit'
86
86
  } else {
87
- const P = cfg.pages.find(p => p.getSlug() === scope.pageSlug)
87
+ const P = pilotiq.findPage(scope.pageSlug)
88
88
  if (!P) return null
89
89
  PageClass = P
90
90
  // Custom pages don't have a record/edit-mode concept — pass mode
@@ -43,7 +43,7 @@ export async function globalEditData(
43
43
  req?: unknown,
44
44
  ): Promise<Record<string, unknown> | null> {
45
45
  const cfg = pilotiq.getConfig()
46
- const G = cfg.globals.find(g => g.getSlug() === slug)
46
+ const G = pilotiq.findGlobal(slug)
47
47
  if (!G) return null
48
48
  const pages = G.resolvePages()
49
49
  if (!pages.edit) return null
@@ -98,7 +98,7 @@ export async function globalViewData(
98
98
  req?: unknown,
99
99
  ): Promise<Record<string, unknown> | null> {
100
100
  const cfg = pilotiq.getConfig()
101
- const G = cfg.globals.find(g => g.getSlug() === slug)
101
+ const G = pilotiq.findGlobal(slug)
102
102
  if (!G) return null
103
103
  const pages = G.resolvePages()
104
104
  if (!pages.view) return null
@@ -134,7 +134,7 @@ export async function customPageData(
134
134
  req?: unknown,
135
135
  ): Promise<Record<string, unknown> | null> {
136
136
  const cfg = pilotiq.getConfig()
137
- const PageClass = cfg.pages.find(P => P.getSlug() === pageSlug)
137
+ const PageClass = pilotiq.findPage(pageSlug)
138
138
  if (!PageClass) return null
139
139
 
140
140
  const pageUrl = pageBasePath(cfg.path, PageClass)
@@ -237,14 +237,14 @@ export async function widgetData(
237
237
  ctx = uploadCtx(userCtx({ basePath: cfg.path }, user), cfg)
238
238
  elements = await callPageSchema(cfg.dashboardPage, ctx)
239
239
  } else if (scope.kind === 'page') {
240
- const P = cfg.pages.find(p => p.getSlug() === scope.pageSlug)
240
+ const P = pilotiq.findPage(scope.pageSlug)
241
241
  if (!P) return { ok: false, status: 404, error: 'Page not found' }
242
242
  ctx = uploadCtx(userCtx({ basePath: cfg.path }, user), cfg)
243
243
  elements = await callPageSchema(P, ctx)
244
244
  } else {
245
245
  // Resource-scope: re-resolve the list page's schema so widgets from
246
246
  // `Resource.headerSchema()` / `footerSchema()` are reachable.
247
- const R = cfg.resources.find(r => r.getSlug() === scope.slug)
247
+ const R = pilotiq.findResource(scope.slug)
248
248
  if (!R) return { ok: false, status: 404, error: 'Resource not found' }
249
249
  const pages = R.resolvePages()
250
250
  if (!pages.index) return { ok: false, status: 404, error: 'Resource has no list page' }
@@ -627,7 +627,7 @@ export async function buildNavigation(pilotiq: Pilotiq, user: unknown): Promise<
627
627
  const raw: RawNavItem[] = []
628
628
  let idx = 0
629
629
 
630
- const pushBadge: Array<{ item: RawNavItem; handler: () => unknown }> = []
630
+ const pushBadge: Array<{ item: RawNavItem; handler: () => unknown; owner: string }> = []
631
631
 
632
632
  // Plan #10 — pre-evaluate canAccess for every owner in parallel so we
633
633
  // can drop forbidden items before flattening. Failed predicates fail
@@ -672,7 +672,7 @@ export async function buildNavigation(pilotiq: Pilotiq, user: unknown): Promise<
672
672
  if (R.cluster) item.parent = R.cluster.name
673
673
  else if (R.navigationParentItem !== undefined) item.parent = R.navigationParentItem
674
674
  if (R.navigationBadgeColor !== 'default') item.badgeColor = R.navigationBadgeColor
675
- if (R.navigationBadge) pushBadge.push({ item, handler: R.navigationBadge })
675
+ if (R.navigationBadge) pushBadge.push({ item, handler: R.navigationBadge, owner: R.name })
676
676
  raw.push(item)
677
677
  }
678
678
 
@@ -697,7 +697,7 @@ export async function buildNavigation(pilotiq: Pilotiq, user: unknown): Promise<
697
697
  if (G.cluster) item.parent = G.cluster.name
698
698
  else if (G.navigationParentItem !== undefined) item.parent = G.navigationParentItem
699
699
  if (G.navigationBadgeColor !== 'default') item.badgeColor = G.navigationBadgeColor
700
- if (G.navigationBadge) pushBadge.push({ item, handler: G.navigationBadge })
700
+ if (G.navigationBadge) pushBadge.push({ item, handler: G.navigationBadge, owner: G.name })
701
701
  raw.push(item)
702
702
  }
703
703
 
@@ -724,7 +724,7 @@ export async function buildNavigation(pilotiq: Pilotiq, user: unknown): Promise<
724
724
  if (P.cluster && !isDashboard) item.parent = P.cluster.name
725
725
  else if (P.navigationParentItem !== undefined) item.parent = P.navigationParentItem
726
726
  if (P.navigationBadgeColor !== 'default') item.badgeColor = P.navigationBadgeColor
727
- if (P.navigationBadge) pushBadge.push({ item, handler: P.navigationBadge })
727
+ if (P.navigationBadge) pushBadge.push({ item, handler: P.navigationBadge, owner: P.name })
728
728
  raw.push(item)
729
729
  }
730
730
 
@@ -755,15 +755,17 @@ export async function buildNavigation(pilotiq: Pilotiq, user: unknown): Promise<
755
755
  if (C.navigationSort !== undefined) item.sort = C.navigationSort
756
756
  if (C.navigationParentItem !== undefined) item.parent = C.navigationParentItem
757
757
  if (C.navigationBadgeColor !== 'default') item.badgeColor = C.navigationBadgeColor
758
- if (C.navigationBadge) pushBadge.push({ item, handler: C.navigationBadge })
758
+ if (C.navigationBadge) pushBadge.push({ item, handler: C.navigationBadge, owner: C.name })
759
759
  raw.push(item)
760
760
  }
761
761
 
762
- await Promise.all(pushBadge.map(async ({ item, handler }) => {
762
+ await Promise.all(pushBadge.map(async ({ item, handler, owner }) => {
763
763
  try {
764
- const v = await handler()
765
- if (v === undefined || v === null) return
766
- item.badge = String(v)
764
+ const v = await pilotiq.resolveNavigationBadge(owner, user, async () => {
765
+ const raw = await handler()
766
+ return raw === undefined || raw === null ? undefined : String(raw)
767
+ })
768
+ if (v !== undefined) item.badge = v
767
769
  } catch {
768
770
  // Per-badge errors stay silent.
769
771
  }
@@ -193,7 +193,9 @@ async function childBelongsToParent(
193
193
  const q: ModelQuery = (parentModel.relatedQuery
194
194
  ? parentModel.relatedQuery(parent, relationship)
195
195
  : (parent as { related: (n: string) => ModelQuery }).related(relationship))
196
- const result = await q.where(childPk, '=', childId).paginate(1, 1)
196
+ .where(childPk, '=', childId)
197
+ if (q.first) return (await q.first()) !== null
198
+ const result = await q.paginate(1, 1)
197
199
  return result.total > 0
198
200
  } catch {
199
201
  return false
@@ -294,7 +296,7 @@ export async function relationManagerData(
294
296
 
295
297
  const cfg = pilotiq.getConfig()
296
298
 
297
- const R = cfg.resources.find(r => r.getSlug() === scope.slug)
299
+ const R = pilotiq.findResource(scope.slug)
298
300
  if (!R) return null
299
301
 
300
302
  const M = findManager(R, scope.relationship)
@@ -769,7 +771,7 @@ export async function resolveRelationChain(
769
771
  ): Promise<ResolvedChain | { ok: false; status: 403 } | null> {
770
772
  const cfg = pilotiq.getConfig()
771
773
 
772
- const R = cfg.resources.find(r => r.getSlug() === scope.slug)
774
+ const R = pilotiq.findResource(scope.slug)
773
775
  if (!R) return null
774
776
 
775
777
  // Layer 0 — same gates as the depth-1 pipeline.
@@ -142,7 +142,7 @@ export async function resourceIndexData(
142
142
  req?: unknown,
143
143
  ): Promise<Record<string, unknown> | null> {
144
144
  const cfg = pilotiq.getConfig()
145
- const R = cfg.resources.find(r => r.getSlug() === slug)
145
+ const R = pilotiq.findResource(slug)
146
146
  if (!R) return null
147
147
 
148
148
  const pages = R.resolvePages()
@@ -193,7 +193,7 @@ export async function resourceTableData(
193
193
  req?: unknown,
194
194
  ): Promise<{ tables: Record<string, unknown>[] } | null> {
195
195
  const cfg = pilotiq.getConfig()
196
- const R = cfg.resources.find(r => r.getSlug() === slug)
196
+ const R = pilotiq.findResource(slug)
197
197
  if (!R) return null
198
198
 
199
199
  const pages = R.resolvePages()
@@ -321,7 +321,7 @@ export async function resourceCreateData(
321
321
  req?: unknown,
322
322
  ): Promise<Record<string, unknown> | null> {
323
323
  const cfg = pilotiq.getConfig()
324
- const R = cfg.resources.find(r => r.getSlug() === slug)
324
+ const R = pilotiq.findResource(slug)
325
325
  if (!R) return null
326
326
  const pages = R.resolvePages()
327
327
  if (!pages.create) return null
@@ -373,7 +373,7 @@ export async function resourceEditData(
373
373
  req?: unknown,
374
374
  ): Promise<Record<string, unknown> | null> {
375
375
  const cfg = pilotiq.getConfig()
376
- const R = cfg.resources.find(r => r.getSlug() === slug)
376
+ const R = pilotiq.findResource(slug)
377
377
  if (!R) return null
378
378
  const pages = R.resolvePages()
379
379
  if (!pages.edit) return null
@@ -466,7 +466,7 @@ export async function resourceViewData(
466
466
  req?: unknown,
467
467
  ): Promise<Record<string, unknown> | null> {
468
468
  const cfg = pilotiq.getConfig()
469
- const R = cfg.resources.find(r => r.getSlug() === slug)
469
+ const R = pilotiq.findResource(slug)
470
470
  if (!R) return null
471
471
  const pages = R.resolvePages()
472
472
  if (!pages.view) return null
@@ -538,7 +538,7 @@ export async function resourceRecordPageData(
538
538
  req?: unknown,
539
539
  ): Promise<Record<string, unknown> | null | { ok: false; status: 403 }> {
540
540
  const cfg = pilotiq.getConfig()
541
- const R = cfg.resources.find(r => r.getSlug() === slug)
541
+ const R = pilotiq.findResource(slug)
542
542
  if (!R) return null
543
543
  const recordPages = R.getRecordPages()
544
544
  const PageClass = recordPages[subPageSlug]
@@ -1 +1,8 @@
1
1
  export { themeEditor } from './themeEditor.js'
2
+ export type { ThemeEditorOptions } from './themeEditor.js'
3
+ export { prismaThemeStorage } from '../theme/storage.js'
4
+ export type {
5
+ ThemeStorageAdapter,
6
+ PanelGlobalDelegate,
7
+ PrismaThemeStorageOptions,
8
+ } from '../theme/storage.js'