@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.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +107 -0
- package/dist/Pilotiq.d.ts +72 -0
- package/dist/Pilotiq.d.ts.map +1 -1
- package/dist/Pilotiq.js +145 -0
- package/dist/Pilotiq.js.map +1 -1
- package/dist/PilotiqServiceProvider.d.ts +2 -0
- package/dist/PilotiqServiceProvider.d.ts.map +1 -1
- package/dist/PilotiqServiceProvider.js +60 -12
- package/dist/PilotiqServiceProvider.js.map +1 -1
- package/dist/actions/importFactory.d.ts +5 -0
- package/dist/actions/importFactory.d.ts.map +1 -1
- package/dist/actions/importFactory.js +20 -10
- package/dist/actions/importFactory.js.map +1 -1
- package/dist/orm/modelDefaults.d.ts +10 -1
- package/dist/orm/modelDefaults.d.ts.map +1 -1
- package/dist/orm/modelDefaults.js +7 -2
- package/dist/orm/modelDefaults.js.map +1 -1
- package/dist/pageData/forms.js +3 -3
- package/dist/pageData/forms.js.map +1 -1
- package/dist/pageData/misc.js +5 -5
- package/dist/pageData/misc.js.map +1 -1
- package/dist/pageData/navigation.d.ts.map +1 -1
- package/dist/pageData/navigation.js +11 -9
- package/dist/pageData/navigation.js.map +1 -1
- package/dist/pageData/relationPages.d.ts.map +1 -1
- package/dist/pageData/relationPages.js +7 -4
- package/dist/pageData/relationPages.js.map +1 -1
- package/dist/pageData/resourcePages.js +6 -6
- package/dist/pageData/resourcePages.js.map +1 -1
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +1 -0
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/themeEditor.d.ts +20 -1
- package/dist/plugins/themeEditor.d.ts.map +1 -1
- package/dist/plugins/themeEditor.js +3 -1
- package/dist/plugins/themeEditor.js.map +1 -1
- package/dist/react/CollabRoomContext.d.ts +12 -0
- package/dist/react/CollabRoomContext.d.ts.map +1 -1
- package/dist/react/CollabRoomContext.js.map +1 -1
- package/dist/react/FormStateContext.d.ts +10 -0
- package/dist/react/FormStateContext.d.ts.map +1 -1
- package/dist/react/FormStateContext.js +12 -0
- package/dist/react/FormStateContext.js.map +1 -1
- package/dist/react/PendingSuggestionApplierRegistry.d.ts +12 -0
- package/dist/react/PendingSuggestionApplierRegistry.d.ts.map +1 -1
- package/dist/react/PendingSuggestionApplierRegistry.js +21 -1
- package/dist/react/PendingSuggestionApplierRegistry.js.map +1 -1
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/useCollabSeed.d.ts +23 -0
- package/dist/react/useCollabSeed.d.ts.map +1 -0
- package/dist/react/useCollabSeed.js +67 -0
- package/dist/react/useCollabSeed.js.map +1 -0
- package/dist/routes/globals.d.ts.map +1 -1
- package/dist/routes/globals.js +8 -22
- package/dist/routes/globals.js.map +1 -1
- package/dist/routes/helpers.d.ts +13 -0
- package/dist/routes/helpers.d.ts.map +1 -1
- package/dist/routes/helpers.js +25 -8
- package/dist/routes/helpers.js.map +1 -1
- package/dist/routes/resources.d.ts.map +1 -1
- package/dist/routes/resources.js +12 -34
- package/dist/routes/resources.js.map +1 -1
- package/dist/routes/theme.d.ts +4 -2
- package/dist/routes/theme.d.ts.map +1 -1
- package/dist/routes/theme.js +27 -26
- package/dist/routes/theme.js.map +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +65 -37
- package/dist/routes.js.map +1 -1
- package/dist/theme/index.d.ts +2 -0
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +1 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/storage.d.ts +86 -0
- package/dist/theme/storage.d.ts.map +1 -0
- package/dist/theme/storage.js +52 -0
- package/dist/theme/storage.js.map +1 -0
- package/package.json +1 -1
- package/src/Pilotiq.perf.test.ts +252 -0
- package/src/Pilotiq.test.ts +4 -0
- package/src/Pilotiq.ts +166 -0
- package/src/PilotiqServiceProvider.ts +63 -11
- package/src/actions/importFactory.ts +31 -10
- package/src/orm/modelDefaults.ts +15 -2
- package/src/pageData/forms.ts +3 -3
- package/src/pageData/misc.ts +5 -5
- package/src/pageData/navigation.ts +11 -9
- package/src/pageData/relationPages.ts +5 -3
- package/src/pageData/resourcePages.ts +6 -6
- package/src/plugins/index.ts +7 -0
- package/src/plugins/themeEditor.test.ts +36 -0
- package/src/plugins/themeEditor.ts +22 -1
- package/src/react/CollabRoomContext.ts +12 -0
- package/src/react/FormStateContext.tsx +13 -0
- package/src/react/PendingSuggestionApplierRegistry.test.ts +97 -0
- package/src/react/PendingSuggestionApplierRegistry.ts +19 -1
- package/src/react/index.ts +2 -0
- package/src/react/useCollabSeed.ts +73 -0
- package/src/routes/globals.ts +8 -16
- package/src/routes/guard.test.ts +325 -0
- package/src/routes/helpers.ts +30 -8
- package/src/routes/resources.ts +12 -22
- package/src/routes/theme.ts +26 -44
- package/src/routes.ts +65 -36
- package/src/theme/index.ts +6 -0
- package/src/theme/storage.test.ts +126 -0
- 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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
+
return { kind: 'created' }
|
|
157
166
|
} catch (err) {
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
package/src/orm/modelDefaults.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
}
|
package/src/pageData/forms.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
package/src/pageData/misc.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
541
|
+
const R = pilotiq.findResource(slug)
|
|
542
542
|
if (!R) return null
|
|
543
543
|
const recordPages = R.getRecordPages()
|
|
544
544
|
const PageClass = recordPages[subPageSlug]
|
package/src/plugins/index.ts
CHANGED
|
@@ -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'
|