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