@pilotiq/pilotiq 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +104 -0
  3. package/dist/Pilotiq.d.ts +72 -0
  4. package/dist/Pilotiq.d.ts.map +1 -1
  5. package/dist/Pilotiq.js +145 -0
  6. package/dist/Pilotiq.js.map +1 -1
  7. package/dist/PilotiqServiceProvider.d.ts +2 -0
  8. package/dist/PilotiqServiceProvider.d.ts.map +1 -1
  9. package/dist/PilotiqServiceProvider.js +60 -12
  10. package/dist/PilotiqServiceProvider.js.map +1 -1
  11. package/dist/actions/importFactory.d.ts +5 -0
  12. package/dist/actions/importFactory.d.ts.map +1 -1
  13. package/dist/actions/importFactory.js +20 -10
  14. package/dist/actions/importFactory.js.map +1 -1
  15. package/dist/orm/modelDefaults.d.ts +10 -1
  16. package/dist/orm/modelDefaults.d.ts.map +1 -1
  17. package/dist/orm/modelDefaults.js +7 -2
  18. package/dist/orm/modelDefaults.js.map +1 -1
  19. package/dist/pageData/forms.js +3 -3
  20. package/dist/pageData/forms.js.map +1 -1
  21. package/dist/pageData/misc.js +5 -5
  22. package/dist/pageData/misc.js.map +1 -1
  23. package/dist/pageData/navigation.d.ts.map +1 -1
  24. package/dist/pageData/navigation.js +11 -9
  25. package/dist/pageData/navigation.js.map +1 -1
  26. package/dist/pageData/relationPages.d.ts.map +1 -1
  27. package/dist/pageData/relationPages.js +7 -4
  28. package/dist/pageData/relationPages.js.map +1 -1
  29. package/dist/pageData/resourcePages.js +6 -6
  30. package/dist/pageData/resourcePages.js.map +1 -1
  31. package/dist/plugins/index.d.ts +3 -0
  32. package/dist/plugins/index.d.ts.map +1 -1
  33. package/dist/plugins/index.js +1 -0
  34. package/dist/plugins/index.js.map +1 -1
  35. package/dist/plugins/themeEditor.d.ts +20 -1
  36. package/dist/plugins/themeEditor.d.ts.map +1 -1
  37. package/dist/plugins/themeEditor.js +3 -1
  38. package/dist/plugins/themeEditor.js.map +1 -1
  39. package/dist/react/CollabRoomContext.d.ts +12 -0
  40. package/dist/react/CollabRoomContext.d.ts.map +1 -1
  41. package/dist/react/CollabRoomContext.js.map +1 -1
  42. package/dist/react/CollabTextRendererRegistry.d.ts +19 -1
  43. package/dist/react/CollabTextRendererRegistry.d.ts.map +1 -1
  44. package/dist/react/CollabTextRendererRegistry.js.map +1 -1
  45. package/dist/react/fields/MarkdownInput.js +1 -1
  46. package/dist/react/fields/MarkdownInput.js.map +1 -1
  47. package/dist/react/fields/TextLikeInput.js +1 -1
  48. package/dist/react/fields/TextLikeInput.js.map +1 -1
  49. package/dist/react/index.d.ts +1 -0
  50. package/dist/react/index.d.ts.map +1 -1
  51. package/dist/react/index.js +1 -0
  52. package/dist/react/index.js.map +1 -1
  53. package/dist/react/useCollabSeed.d.ts +23 -0
  54. package/dist/react/useCollabSeed.d.ts.map +1 -0
  55. package/dist/react/useCollabSeed.js +67 -0
  56. package/dist/react/useCollabSeed.js.map +1 -0
  57. package/dist/routes/globals.d.ts.map +1 -1
  58. package/dist/routes/globals.js +8 -22
  59. package/dist/routes/globals.js.map +1 -1
  60. package/dist/routes/helpers.d.ts +13 -0
  61. package/dist/routes/helpers.d.ts.map +1 -1
  62. package/dist/routes/helpers.js +25 -8
  63. package/dist/routes/helpers.js.map +1 -1
  64. package/dist/routes/resources.d.ts.map +1 -1
  65. package/dist/routes/resources.js +12 -34
  66. package/dist/routes/resources.js.map +1 -1
  67. package/dist/routes/theme.d.ts +4 -2
  68. package/dist/routes/theme.d.ts.map +1 -1
  69. package/dist/routes/theme.js +27 -26
  70. package/dist/routes/theme.js.map +1 -1
  71. package/dist/routes.d.ts.map +1 -1
  72. package/dist/routes.js +65 -37
  73. package/dist/routes.js.map +1 -1
  74. package/dist/theme/index.d.ts +2 -0
  75. package/dist/theme/index.d.ts.map +1 -1
  76. package/dist/theme/index.js +1 -0
  77. package/dist/theme/index.js.map +1 -1
  78. package/dist/theme/storage.d.ts +86 -0
  79. package/dist/theme/storage.d.ts.map +1 -0
  80. package/dist/theme/storage.js +52 -0
  81. package/dist/theme/storage.js.map +1 -0
  82. package/package.json +1 -1
  83. package/src/Pilotiq.perf.test.ts +252 -0
  84. package/src/Pilotiq.test.ts +4 -0
  85. package/src/Pilotiq.ts +166 -0
  86. package/src/PilotiqServiceProvider.ts +63 -11
  87. package/src/actions/importFactory.ts +31 -10
  88. package/src/orm/modelDefaults.ts +15 -2
  89. package/src/pageData/forms.ts +3 -3
  90. package/src/pageData/misc.ts +5 -5
  91. package/src/pageData/navigation.ts +11 -9
  92. package/src/pageData/relationPages.ts +5 -3
  93. package/src/pageData/resourcePages.ts +6 -6
  94. package/src/plugins/index.ts +7 -0
  95. package/src/plugins/themeEditor.test.ts +36 -0
  96. package/src/plugins/themeEditor.ts +22 -1
  97. package/src/react/CollabRoomContext.ts +12 -0
  98. package/src/react/CollabTextRendererRegistry.ts +19 -1
  99. package/src/react/fields/MarkdownInput.tsx +2 -1
  100. package/src/react/fields/TextLikeInput.tsx +2 -1
  101. package/src/react/index.ts +1 -0
  102. package/src/react/useCollabSeed.ts +73 -0
  103. package/src/routes/globals.ts +8 -16
  104. package/src/routes/guard.test.ts +325 -0
  105. package/src/routes/helpers.ts +30 -8
  106. package/src/routes/resources.ts +12 -22
  107. package/src/routes/theme.ts +26 -44
  108. package/src/routes.ts +65 -36
  109. package/src/theme/index.ts +6 -0
  110. package/src/theme/storage.test.ts +126 -0
  111. package/src/theme/storage.ts +106 -0
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/theme/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAC3C,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACpD,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AACnF,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAC7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AACrD,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AACzC,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AACxD,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA;AAEpD,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACnE,YAAY,EACV,WAAW,EACX,SAAS,EACT,QAAQ,EACR,UAAU,EACV,UAAU,EACV,YAAY,EACZ,aAAa,EACb,WAAW,EACX,UAAU,EACV,WAAW,EACX,SAAS,EACT,gBAAgB,GACjB,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/theme/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAC3C,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACpD,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AACnF,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAC7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AACrD,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AACzC,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AACxD,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAA;AACjD,YAAY,EACV,mBAAmB,EACnB,mBAAmB,EACnB,yBAAyB,GAC1B,MAAM,cAAc,CAAA;AAErB,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACnE,YAAY,EACV,WAAW,EACX,SAAS,EACT,QAAQ,EACR,UAAU,EACV,UAAU,EACV,YAAY,EACZ,aAAa,EACb,WAAW,EACX,UAAU,EACV,WAAW,EACX,SAAS,EACT,gBAAgB,GACjB,MAAM,YAAY,CAAA"}
@@ -10,4 +10,5 @@ export { iconMap, resolveIconName } from './icon-map.js';
10
10
  export { colors, BASE_COLOR_NAMES, HUE_NAMES } from './colors.js';
11
11
  export { parseSeedToScale } from './generate-scale.js';
12
12
  export { migrateThemeOverrides } from './migrate.js';
13
+ export { prismaThemeStorage } from './storage.js';
13
14
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/theme/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAC3C,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACpD,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AACnF,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAC7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AACrD,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AACzC,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AACxD,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/theme/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAC3C,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACpD,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AACnF,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAC7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AACrD,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AACzC,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AACxD,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAA"}
@@ -0,0 +1,86 @@
1
+ import type { ThemeConfig } from './types.js';
2
+ /**
3
+ * Adapter that persists a panel's theme overrides — the JSON blob
4
+ * written when a user edits theme settings via the `themeEditor()`
5
+ * plugin and reloaded on next boot.
6
+ *
7
+ * The shipped implementation is `prismaThemeStorage`, which writes to
8
+ * the `panelGlobal` row created by `@rudderjs/orm-prisma`. Apps on a
9
+ * different ORM, key-value store, or filesystem can implement the
10
+ * three methods themselves.
11
+ *
12
+ * Contract:
13
+ *
14
+ * - `load()` returns `null` when no overrides have been persisted yet
15
+ * (fresh install). Throwing surfaces a configuration error to the
16
+ * caller — pilotiq does not swallow.
17
+ * - `save(overrides)` writes the blob verbatim. The next `load()` must
18
+ * return a deep-equal copy. Throwing surfaces to the route handler
19
+ * as a 500.
20
+ * - `clear()` deletes the row. Tolerating "not found" is the adapter's
21
+ * responsibility — `clear()` on an empty store is a no-op.
22
+ */
23
+ export interface ThemeStorageAdapter {
24
+ load(): Promise<Partial<ThemeConfig> | null>;
25
+ save(overrides: Partial<ThemeConfig>): Promise<void>;
26
+ clear(): Promise<void>;
27
+ }
28
+ /**
29
+ * Minimal Prisma surface used by `prismaThemeStorage`. Narrow enough
30
+ * to keep the import surface decoupled from `PrismaClient`'s generated
31
+ * types — apps swap in any client whose `panelGlobal` delegate matches
32
+ * this shape.
33
+ */
34
+ export interface PanelGlobalDelegate {
35
+ panelGlobal: {
36
+ findUnique(args: {
37
+ where: {
38
+ slug: string;
39
+ };
40
+ }): Promise<{
41
+ data: string | object | null;
42
+ } | null>;
43
+ upsert(args: {
44
+ where: {
45
+ slug: string;
46
+ };
47
+ update: {
48
+ data: string;
49
+ };
50
+ create: {
51
+ slug: string;
52
+ data: string;
53
+ };
54
+ }): Promise<unknown>;
55
+ delete(args: {
56
+ where: {
57
+ slug: string;
58
+ };
59
+ }): Promise<unknown>;
60
+ };
61
+ }
62
+ export interface PrismaThemeStorageOptions {
63
+ /** Row key written to `panelGlobal.slug`. Pass per-panel so multiple
64
+ * panels in the same app don't clobber each other. Typically
65
+ * `${panel.name}__theme`. */
66
+ slug: string;
67
+ }
68
+ /**
69
+ * Default storage adapter — writes JSON to the `panelGlobal` row keyed
70
+ * by `opts.slug`. The Prisma delegate is dependency-injected so consumers
71
+ * pick how to resolve it (e.g. `app.make('prisma')`, a direct import, a
72
+ * test stub).
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * import { Pilotiq } from '@pilotiq/pilotiq'
77
+ * import { themeEditor, prismaThemeStorage } from '@pilotiq/pilotiq/plugins'
78
+ *
79
+ * const adminPanel = Pilotiq.make('Admin')
80
+ * .use(themeEditor({
81
+ * storage: prismaThemeStorage(prisma, { slug: 'admin__theme' }),
82
+ * }))
83
+ * ```
84
+ */
85
+ export declare function prismaThemeStorage(prisma: PanelGlobalDelegate, opts: PrismaThemeStorageOptions): ThemeStorageAdapter;
86
+ //# sourceMappingURL=storage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../src/theme/storage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAE7C;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,WAAW,mBAAmB;IAClC,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,GAAG,IAAI,CAAC,CAAA;IAC5C,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACpD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACvB;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,WAAW,EAAE;QACX,UAAU,CAAC,IAAI,EAAE;YAAE,KAAK,EAAE;gBAAE,IAAI,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE,GAAG,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;SAAE,GAAG,IAAI,CAAC,CAAA;QAC/F,MAAM,CAAC,IAAI,EAAE;YACX,KAAK,EAAG;gBAAE,IAAI,EAAE,MAAM,CAAA;aAAE,CAAA;YACxB,MAAM,EAAE;gBAAE,IAAI,EAAE,MAAM,CAAA;aAAE,CAAA;YACxB,MAAM,EAAE;gBAAE,IAAI,EAAE,MAAM,CAAC;gBAAC,IAAI,EAAE,MAAM,CAAA;aAAE,CAAA;SACvC,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;QACpB,MAAM,CAAC,IAAI,EAAE;YAAE,KAAK,EAAE;gBAAE,IAAI,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;KAC5D,CAAA;CACF;AAED,MAAM,WAAW,yBAAyB;IACxC;;kCAE8B;IAC9B,IAAI,EAAE,MAAM,CAAA;CACb;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,mBAAmB,EAC3B,IAAI,EAAI,yBAAyB,GAChC,mBAAmB,CAyBrB"}
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/pilotiq",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "View-based admin panel for RudderJS",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
+ })
@@ -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
+ }