@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
@@ -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
@@ -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,
@@ -0,0 +1,73 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { CollabRoom } from './CollabRoomContext.js'
3
+
4
+ const SEED_ORIGIN = 'pilotiq-collab-seed'
5
+
6
+ /**
7
+ * Run `seedFn` exactly once after the collab room's first sync.
8
+ *
9
+ * Mirrors `@rudderjs/sync/react`'s `useCollabSeed` shape — same purpose,
10
+ * reimplemented here so pilotiq core stays free of any hard runtime dep
11
+ * on Yjs. The `room` parameter is pilotiq's opaque `CollabRoom`, so the
12
+ * seed callback receives `doc: unknown` and is responsible for its own
13
+ * share-type lookup (`doc.getXmlFragment(key)` for Tiptap consumers,
14
+ * `doc.getText(key)` for CodeMirror, etc.) and its own emptiness check.
15
+ *
16
+ * Returns `true` once the seed callback has run (or was skipped because
17
+ * the room has no `.synced` Promise — i.e. a legacy non-framework
18
+ * provider), so consumers can gate editor mount / placeholder swap.
19
+ *
20
+ * The hook wraps `seedFn` in `doc.transact(..., 'pilotiq-collab-seed')`
21
+ * so downstream observers can filter the synthetic write. Two peers
22
+ * mounting against a brand-new record may both see "empty" and both
23
+ * seed — same race window as the legacy `onProviderSynced` path; the
24
+ * fix is server-side seed handoff, deferred.
25
+ */
26
+ export function useCollabSeed(
27
+ room: CollabRoom | null,
28
+ key: string,
29
+ seedFn: (doc: unknown) => void,
30
+ ): boolean {
31
+ const [seeded, setSeeded] = useState(false)
32
+ const seedFnRef = useRef(seedFn)
33
+ seedFnRef.current = seedFn
34
+
35
+ useEffect(() => {
36
+ if (!room) {
37
+ setSeeded(false)
38
+ return
39
+ }
40
+ // Legacy rooms without `.synced` — the room owner is on the hook
41
+ // for whatever first-sync gating they used to do via
42
+ // `onProviderSynced`. Mark seeded immediately so the consumer
43
+ // can mount without a placeholder. No seedFn runs.
44
+ const syncedPromise = room.synced
45
+ if (!syncedPromise) {
46
+ setSeeded(true)
47
+ return
48
+ }
49
+
50
+ let cancelled = false
51
+ syncedPromise.then(() => {
52
+ if (cancelled) return
53
+ try {
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ const ydoc = room.ydoc as any
56
+ if (ydoc && typeof ydoc.transact === 'function') {
57
+ ydoc.transact(() => seedFnRef.current(ydoc), SEED_ORIGIN)
58
+ } else {
59
+ seedFnRef.current(ydoc)
60
+ }
61
+ } catch { /* ignore — seed is best-effort */ }
62
+ setSeeded(true)
63
+ }).catch(() => {
64
+ // synced rejects when the room is torn down before first sync.
65
+ // Don't surface as a seed failure — the room is gone.
66
+ if (!cancelled) setSeeded(false)
67
+ })
68
+
69
+ return () => { cancelled = true }
70
+ }, [room, key])
71
+
72
+ return seeded
73
+ }
@@ -15,8 +15,7 @@ import {
15
15
  normalizeRedirect,
16
16
  splitMeta,
17
17
  forbidden,
18
- checkPolicy,
19
- policyAccess,
18
+ policyGate,
20
19
  handleFormState,
21
20
  handleFormWizard,
22
21
  handleFormCreateOption,
@@ -49,8 +48,7 @@ export function registerGlobalRoutes(
49
48
  // POST ${editUrl}/_form/:formId/state
50
49
  router.post(`${editUrl}/_form/:formId/state`, async (req, res) => {
51
50
  const user = await pilotiq.resolveUser(req)
52
- if (!await policyAccess(G, user)) return forbidden(res, true)
53
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
51
+ if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, true)
54
52
  const formId = req.params['formId']!
55
53
  return handleFormState(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
56
54
  })
@@ -58,8 +56,7 @@ export function registerGlobalRoutes(
58
56
  // Plan #8 wizard step-validate endpoint for the global's edit form.
59
57
  router.post(`${editUrl}/_form/:formId/wizard`, async (req, res) => {
60
58
  const user = await pilotiq.resolveUser(req)
61
- if (!await policyAccess(G, user)) return forbidden(res, true)
62
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
59
+ if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, true)
63
60
  const formId = req.params['formId']!
64
61
  return handleFormWizard(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
65
62
  })
@@ -67,8 +64,7 @@ export function registerGlobalRoutes(
67
64
  // Async-mention endpoint for the global's edit form.
68
65
  router.post(`${editUrl}/_form/:formId/mentions`, async (req, res) => {
69
66
  const user = await pilotiq.resolveUser(req)
70
- if (!await policyAccess(G, user)) return forbidden(res, true)
71
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
67
+ if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, true)
72
68
  const formId = req.params['formId']!
73
69
  return handleFormMentions(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
74
70
  })
@@ -76,8 +72,7 @@ export function registerGlobalRoutes(
76
72
  // SelectField inline-create modal endpoint for the global's edit form.
77
73
  router.post(`${editUrl}/_form/:formId/create-option/:fieldName`, async (req, res) => {
78
74
  const user = await pilotiq.resolveUser(req)
79
- if (!await policyAccess(G, user)) return forbidden(res, true)
80
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
75
+ if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, true)
81
76
  const formId = req.params['formId']!
82
77
  const fieldName = req.params['fieldName']!
83
78
  return handleFormCreateOption(req, res, pilotiq, { kind: 'global-edit', slug }, formId, fieldName)
@@ -85,11 +80,10 @@ export function registerGlobalRoutes(
85
80
 
86
81
  router.get(editUrl, async (req, res) => {
87
82
  const user = await pilotiq.resolveUser(req)
88
- if (!await policyAccess(G, user)) return forbidden(res, wantsJson(req))
89
83
  // Globals carry their record on the singleton form's `loadRecord`;
90
84
  // we don't pre-load here — pass a stub so canEdit's signature is
91
85
  // honored, and let user code decide whether to consult it.
92
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, wantsJson(req))
86
+ if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, wantsJson(req))
93
87
  const data = await globalEditData(pilotiq, slug, undefined, req)
94
88
  return view('pilotiq.slug', data ?? {})
95
89
  })
@@ -100,8 +94,7 @@ export function registerGlobalRoutes(
100
94
  const json = wantsJson(req)
101
95
 
102
96
  const user = await pilotiq.resolveUser(req)
103
- if (!await policyAccess(G, user)) return forbidden(res, json)
104
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, json)
97
+ if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, json)
105
98
 
106
99
  const ctx: SchemaContext = { mode: 'edit', basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
107
100
  const elements = await callPageSchema(PageClass, ctx)
@@ -147,8 +140,7 @@ export function registerGlobalRoutes(
147
140
  if (pages.view) {
148
141
  router.get(`${editUrl}/view`, async (req, res) => {
149
142
  const user = await pilotiq.resolveUser(req)
150
- if (!await policyAccess(G, user)) return forbidden(res, wantsJson(req))
151
- if (!await checkPolicy(() => G.canView(user, undefined))) return forbidden(res, wantsJson(req))
143
+ if (!await policyGate(G, user, () => G.canView(user, undefined))) return forbidden(res, wantsJson(req))
152
144
  const data = await globalViewData(pilotiq, slug, req)
153
145
  return view('pilotiq.resource-view', data ?? {})
154
146
  })