@pilotiq/pilotiq 0.20.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 (111) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +104 -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/CollabTextRendererRegistry.d.ts +19 -1
  43. package/dist/react/CollabTextRendererRegistry.d.ts.map +1 -1
  44. package/dist/react/CollabTextRendererRegistry.js.map +1 -1
  45. package/dist/react/fields/MarkdownInput.js +1 -1
  46. package/dist/react/fields/MarkdownInput.js.map +1 -1
  47. package/dist/react/fields/TextLikeInput.js +1 -1
  48. package/dist/react/fields/TextLikeInput.js.map +1 -1
  49. package/dist/react/index.d.ts +1 -0
  50. package/dist/react/index.d.ts.map +1 -1
  51. package/dist/react/index.js +1 -0
  52. package/dist/react/index.js.map +1 -1
  53. package/dist/react/useCollabSeed.d.ts +23 -0
  54. package/dist/react/useCollabSeed.d.ts.map +1 -0
  55. package/dist/react/useCollabSeed.js +67 -0
  56. package/dist/react/useCollabSeed.js.map +1 -0
  57. package/dist/routes/globals.d.ts.map +1 -1
  58. package/dist/routes/globals.js +8 -22
  59. package/dist/routes/globals.js.map +1 -1
  60. package/dist/routes/helpers.d.ts +13 -0
  61. package/dist/routes/helpers.d.ts.map +1 -1
  62. package/dist/routes/helpers.js +25 -8
  63. package/dist/routes/helpers.js.map +1 -1
  64. package/dist/routes/resources.d.ts.map +1 -1
  65. package/dist/routes/resources.js +12 -34
  66. package/dist/routes/resources.js.map +1 -1
  67. package/dist/routes/theme.d.ts +4 -2
  68. package/dist/routes/theme.d.ts.map +1 -1
  69. package/dist/routes/theme.js +27 -26
  70. package/dist/routes/theme.js.map +1 -1
  71. package/dist/routes.d.ts.map +1 -1
  72. package/dist/routes.js +65 -37
  73. package/dist/routes.js.map +1 -1
  74. package/dist/theme/index.d.ts +2 -0
  75. package/dist/theme/index.d.ts.map +1 -1
  76. package/dist/theme/index.js +1 -0
  77. package/dist/theme/index.js.map +1 -1
  78. package/dist/theme/storage.d.ts +86 -0
  79. package/dist/theme/storage.d.ts.map +1 -0
  80. package/dist/theme/storage.js +52 -0
  81. package/dist/theme/storage.js.map +1 -0
  82. package/package.json +1 -1
  83. package/src/Pilotiq.perf.test.ts +252 -0
  84. package/src/Pilotiq.test.ts +4 -0
  85. package/src/Pilotiq.ts +166 -0
  86. package/src/PilotiqServiceProvider.ts +63 -11
  87. package/src/actions/importFactory.ts +31 -10
  88. package/src/orm/modelDefaults.ts +15 -2
  89. package/src/pageData/forms.ts +3 -3
  90. package/src/pageData/misc.ts +5 -5
  91. package/src/pageData/navigation.ts +11 -9
  92. package/src/pageData/relationPages.ts +5 -3
  93. package/src/pageData/resourcePages.ts +6 -6
  94. package/src/plugins/index.ts +7 -0
  95. package/src/plugins/themeEditor.test.ts +36 -0
  96. package/src/plugins/themeEditor.ts +22 -1
  97. package/src/react/CollabRoomContext.ts +12 -0
  98. package/src/react/CollabTextRendererRegistry.ts +19 -1
  99. package/src/react/fields/MarkdownInput.tsx +2 -1
  100. package/src/react/fields/TextLikeInput.tsx +2 -1
  101. package/src/react/index.ts +1 -0
  102. package/src/react/useCollabSeed.ts +73 -0
  103. package/src/routes/globals.ts +8 -16
  104. package/src/routes/guard.test.ts +325 -0
  105. package/src/routes/helpers.ts +30 -8
  106. package/src/routes/resources.ts +12 -22
  107. package/src/routes/theme.ts +26 -44
  108. package/src/routes.ts +65 -36
  109. package/src/theme/index.ts +6 -0
  110. package/src/theme/storage.test.ts +126 -0
  111. package/src/theme/storage.ts +106 -0
@@ -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'
@@ -0,0 +1,36 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { Pilotiq } from '../Pilotiq.js'
4
+ import { themeEditor } from './themeEditor.js'
5
+ import type { ThemeStorageAdapter } from '../theme/storage.js'
6
+
7
+ function makeStubAdapter(): ThemeStorageAdapter {
8
+ return {
9
+ async load() { return null },
10
+ async save() { /* noop */ },
11
+ async clear() { /* noop */ },
12
+ }
13
+ }
14
+
15
+ describe('themeEditor() plugin', () => {
16
+ it('bare themeEditor() leaves the storage slot undefined (boot resolves it)', () => {
17
+ const panel = Pilotiq.make('admin').use(themeEditor())
18
+ assert.equal(panel.getConfig().themeEditor, true)
19
+ assert.equal(panel.getThemeStorage(), undefined)
20
+ })
21
+
22
+ it('themeEditor({ storage }) stamps the adapter onto the panel', () => {
23
+ const adapter = makeStubAdapter()
24
+ const panel = Pilotiq.make('admin').use(themeEditor({ storage: adapter }))
25
+ assert.equal(panel.getThemeStorage(), adapter)
26
+ })
27
+
28
+ it('themeEditor() can be called on multiple panels without leaking adapters', () => {
29
+ const a = makeStubAdapter()
30
+ const b = makeStubAdapter()
31
+ const panelA = Pilotiq.make('admin').use(themeEditor({ storage: a }))
32
+ const panelB = Pilotiq.make('billing').use(themeEditor({ storage: b }))
33
+ assert.equal(panelA.getThemeStorage(), a)
34
+ assert.equal(panelB.getThemeStorage(), b)
35
+ })
36
+ })
@@ -1,4 +1,24 @@
1
1
  import type { PilotiqPlugin } from '../Pilotiq.js'
2
+ import type { ThemeStorageAdapter } from '../theme/storage.js'
3
+
4
+ export interface ThemeEditorOptions {
5
+ /**
6
+ * Override persistence adapter. Defaults to the implicit Prisma
7
+ * fallback (auto-resolved from `app.make('prisma')` against the
8
+ * `panelGlobal` row) — that fallback is deprecated; pass an explicit
9
+ * adapter to opt out and silence the deprecation warning.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import { prismaThemeStorage, themeEditor } from '@pilotiq/pilotiq/plugins'
14
+ *
15
+ * .use(themeEditor({
16
+ * storage: prismaThemeStorage(prisma, { slug: 'admin__theme' }),
17
+ * }))
18
+ * ```
19
+ */
20
+ storage?: ThemeStorageAdapter
21
+ }
2
22
 
3
23
  /**
4
24
  * Theme editor plugin — adds an interactive theme customization page
@@ -14,11 +34,12 @@ import type { PilotiqPlugin } from '../Pilotiq.js'
14
34
  * .use(themeEditor())
15
35
  * ```
16
36
  */
17
- export function themeEditor(): PilotiqPlugin {
37
+ export function themeEditor(opts: ThemeEditorOptions = {}): PilotiqPlugin {
18
38
  return {
19
39
  name: 'theme-editor',
20
40
  register(panel) {
21
41
  panel.enableThemeEditor()
42
+ if (opts.storage) panel._setThemeStorage(opts.storage)
22
43
  },
23
44
  }
24
45
  }
@@ -22,6 +22,18 @@ export interface CollabRoom {
22
22
  ydoc: unknown
23
23
  /** `WebsocketProvider` instance. Opaque to pilotiq core. */
24
24
  provider: unknown
25
+ /**
26
+ * Resolves on the provider's first sync. Present when the room is
27
+ * wired through `@rudderjs/sync/react`'s `CollabRoomManager` (which is
28
+ * what `@pilotiq-pro/collab@>=0.2`'s `<RecordCollabRoom>` does);
29
+ * absent for legacy / hand-rolled providers. `useCollabSeed` gates
30
+ * its seed callback on this Promise — adapters that need a
31
+ * "fragment-empty?" check after first sync should consume the hook
32
+ * rather than calling `onProviderSynced` themselves.
33
+ */
34
+ synced?: Promise<void>
35
+ /** IndexedDB persistence handle, when the room wraps `y-indexeddb`. Opaque. */
36
+ persistence?: unknown
25
37
  /** Presence info for cursors / avatars. Forwarded to the extension factory. */
26
38
  user?: {
27
39
  name?: string
@@ -24,8 +24,26 @@ import type { ComponentType } from 'react'
24
24
  * keeps the seam narrow: handler callbacks + DOM chrome only.
25
25
  */
26
26
  export interface CollabTextRendererProps {
27
- /** Field name — drives the `Y.XmlFragment` selector inside the collab adapter. */
27
+ /**
28
+ * Field name — drives the FormData hidden input AND the routing key
29
+ * for AI suggestion delivery (chip widget, applier registry). For
30
+ * Repeater / Builder row leaves this is the dotted positional path
31
+ * (`items.0.title`).
32
+ */
28
33
  name: string
34
+ /**
35
+ * Collab-stable identifier for the `Y.XmlFragment` selector. When
36
+ * present, the renderer binds its collab fragment under this key
37
+ * instead of `name`. Row leaves pass a row-id-anchored composite
38
+ * (`items.<rowId>.title`) so the fragment survives reorders even
39
+ * as the dotted positional `name` shifts. Top-level fields omit
40
+ * it — `name` is stable on its own.
41
+ *
42
+ * AI suggestion routing continues to use `name` regardless. Tool
43
+ * calls reference fields by their positional FormData name, not
44
+ * the collab-stable composite.
45
+ */
46
+ fragmentKey?: string
29
47
  /** `true` for textarea-like (multiple paragraphs); `false` for input-like. */
30
48
  multiline: boolean
31
49
  /**
@@ -463,7 +463,8 @@ function MarkdownCollabInput({
463
463
  <div style={wrapperStyle} className="overflow-auto">
464
464
  <input type="hidden" name={hiddenInputName} value={text} />
465
465
  <Renderer
466
- name={fragmentKey}
466
+ name={hiddenInputName}
467
+ {...(fragmentKey !== hiddenInputName ? { fragmentKey } : {})}
467
468
  multiline={true}
468
469
  defaultValue={initial}
469
470
  {...(placeholder !== undefined ? { placeholder } : {})}
@@ -233,7 +233,8 @@ function CollabTextField({
233
233
  <>
234
234
  <input type="hidden" name={hiddenInputName} value={text} />
235
235
  <Renderer
236
- name={fragmentKey}
236
+ name={hiddenInputName}
237
+ {...(fragmentKey !== hiddenInputName ? { fragmentKey } : {})}
237
238
  multiline={multiline}
238
239
  defaultValue={defaultValue}
239
240
  {...(placeholder !== undefined ? { placeholder } : {})}
@@ -39,6 +39,7 @@ export {
39
39
  type CollabRoom,
40
40
  type SyncedProviderLike,
41
41
  } from './CollabRoomContext.js'
42
+ export { useCollabSeed } from './useCollabSeed.js'
42
43
  export {
43
44
  registerCollabExtensions,
44
45
  getCollabExtensions,