@raclettejs/workbench 0.1.35 → 0.1.36-canary.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 (38) hide show
  1. package/CHANGELOG.md +10 -2
  2. package/config/compositions.js +18 -0
  3. package/config/interactionLinks.js +19 -0
  4. package/i18n/de-DE.json +15 -1
  5. package/i18n/en-EU.json +52 -1
  6. package/i18n/sk-SK.json +15 -1
  7. package/package.json +3 -4
  8. package/packages.json +3 -1
  9. package/plugins/pacifico__compositions/frontend/components/compositionConfiguration/CompositionConfiguration.vue +96 -14
  10. package/plugins/pacifico__compositions/frontend/components/compositionConfiguration/CompositionIntegration.vue +29 -1
  11. package/plugins/pacifico__compositions/frontend/components/compositionConfiguration/widgetsLayout/WidgetRenderer.vue +1 -6
  12. package/plugins/pacifico__compositions/frontend/composables/prefillInteractionLinkFromComposition.ts +54 -0
  13. package/plugins/pacifico__compositions/frontend/widgets/CompositionCreateWidget.vue +145 -9
  14. package/plugins/pacifico__compositions/frontend/widgets/CompositionEditWidget.vue +11 -2
  15. package/plugins/pacifico__compositions/frontend/widgets/CompositionListWidget.vue +25 -5
  16. package/plugins/pacifico__core/frontend/generated-config.ts +22 -0
  17. package/plugins/pacifico__interactionLinks/frontend/components/InteractionLinkConfiguration.vue +3 -65
  18. package/plugins/pacifico__plugins/frontend/widgets/PluginListWidget.vue +20 -3
  19. package/plugins/raclette__cache/backend/helpers/cacheEntryBuilder.ts +94 -0
  20. package/plugins/raclette__cache/backend/helpers/cacheEntrySanitizer.ts +86 -0
  21. package/plugins/raclette__cache/backend/helpers/valkeyExplorer.ts +89 -0
  22. package/plugins/raclette__cache/backend/index.ts +21 -0
  23. package/plugins/raclette__cache/backend/routes/route.cache.cacheEntries.get.ts +118 -0
  24. package/plugins/raclette__cache/backend/routes/route.cache.cacheEntry.get.ts +91 -0
  25. package/plugins/raclette__cache/backend/routes.ts +8 -0
  26. package/plugins/raclette__cache/frontend/components/TableColumnConfigDialog.vue +95 -0
  27. package/plugins/raclette__cache/frontend/composables/useTableColumnConfig.ts +66 -0
  28. package/plugins/raclette__cache/frontend/generated-config.ts +33 -0
  29. package/plugins/raclette__cache/frontend/utils/cacheValueTableRows.ts +340 -0
  30. package/plugins/raclette__cache/frontend/widgets/CacheListWidget.vue +742 -0
  31. package/plugins/raclette__cache/raclette.plugin.ts +8 -0
  32. package/services/frontend/src/app/components/dynamicForm/DynamicForm.vue +3 -1
  33. package/services/frontend/src/app/components/interactionLink/InteractionLinkBehaviorForm.vue +99 -0
  34. package/services/frontend/src/app/components/stepNavigator/StepNavigator.vue +175 -24
  35. package/services/frontend/src/app/components/stepNavigator/StepNavigatorTypes.ts +18 -0
  36. package/services/frontend/src/app/composables/useInteractionLinkBehaviorFields.ts +83 -0
  37. package/services/frontend/src/app/composables/useRacletteUserIsAdmin.ts +25 -0
  38. package/services/frontend/src/orchestrator/assets/styles/themes/light.ts +1 -1
@@ -1,29 +1,57 @@
1
1
  <template>
2
2
  <CompositionConfiguration
3
+ ref="configurationRef"
3
4
  v-model="newComposition"
5
+ v-model:interaction-link="draftInteractionLink"
4
6
  :disabled="isLoading"
5
- @save-and-finish="onSubmit(true)"
6
- @save="onSubmit"
7
+ @custom-action="onCustomAction"
7
8
  />
8
9
  </template>
9
10
 
10
11
  <script setup lang="ts">
11
- import { CompositionCreate } from "@raclettejs/core"
12
- import { ref } from "vue"
12
+ import { CompositionCreate, InteractionLinkCreate } from "@raclettejs/core"
13
+ import { computed, ref } from "vue"
13
14
  import CompositionConfiguration from "../components/compositionConfiguration/CompositionConfiguration.vue"
14
15
  import {
15
16
  usePluginApi,
16
17
  useRouteState,
17
18
  } from "@raclettejs/core/orchestrator/composables"
19
+ import { DEFAULT_SLOT_NAME } from "@raclettejs/core/orchestrator/router/routeParserHelper"
20
+ import { prefillInteractionLinkFromComposition } from "../composables/prefillInteractionLinkFromComposition"
18
21
 
19
- const props = defineProps<{
22
+ defineProps<{
20
23
  uuid: string
21
24
  }>()
22
25
 
23
26
  const { $data } = usePluginApi("raclette__core")
24
27
  const { triggerInteractionLinkById } = useRouteState()
25
28
 
26
- const { execute, isLoading, error } = $data.composition.create()
29
+ const configurationRef = ref<InstanceType<typeof CompositionConfiguration>>()
30
+
31
+ const {
32
+ execute: executeCreate,
33
+ isLoading: isCreating,
34
+ error: createError,
35
+ data: createResultData,
36
+ } = $data.composition.create({
37
+ options: { notify: false },
38
+ })
39
+ const {
40
+ execute: executeUpdate,
41
+ isLoading: isUpdating,
42
+ error: updateError,
43
+ } = $data.composition.update({
44
+ options: { notify: false },
45
+ })
46
+ const {
47
+ execute: executeInteractionLinkCreate,
48
+ isLoading: isCreatingInteractionLink,
49
+ error: interactionLinkCreateError,
50
+ } = $data.interactionLink.create()
51
+
52
+ const isLoading = computed(
53
+ () => isCreating.value || isUpdating.value || isCreatingInteractionLink.value,
54
+ )
27
55
 
28
56
  const newComposition = ref<CompositionCreate>({
29
57
  title: "",
@@ -39,14 +67,122 @@ const newComposition = ref<CompositionCreate>({
39
67
  widgetsLayout: [[]],
40
68
  })
41
69
 
70
+ const createdCompositionId = ref<string | null>(null)
71
+
72
+ const draftInteractionLink = ref<InteractionLinkCreate>({
73
+ composition: "",
74
+ description: "",
75
+ slotType: DEFAULT_SLOT_NAME,
76
+ triggers: [],
77
+ tags: [],
78
+ queryParameters: [],
79
+ isLandingPage: false,
80
+ })
81
+
42
82
  const navigateBack = () =>
43
83
  triggerInteractionLinkById("compositionListInteractionLink")
44
84
 
45
- const onSubmit = async (finish = false) => {
46
- await execute(newComposition.value)
85
+ type CompositionWriteResult = {
86
+ result?: Array<{ _id?: string }>
87
+ response?: {
88
+ data?: {
89
+ body?: {
90
+ items?: Array<{ _id?: string }>
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ const getCreatedId = (executeResult: unknown): string | undefined => {
97
+ if (!executeResult || typeof executeResult !== "object") {
98
+ return undefined
99
+ }
100
+
101
+ const payload = executeResult as CompositionWriteResult
102
+ const fromHandler = payload.result?.[0]?._id
103
+ if (fromHandler) {
104
+ return fromHandler
105
+ }
106
+
107
+ return payload.response?.data?.body?.items?.[0]?._id
108
+ }
109
+
110
+ const getCreatedIdFromStore = (): string | undefined => {
111
+ if (!createResultData.value) {
112
+ return undefined
113
+ }
114
+
115
+ const items = Object.values(createResultData.value) as Array<{ _id?: string }>
116
+ return items[0]?._id
117
+ }
47
118
 
48
- if (!error.value && finish) {
119
+ const persistComposition = async (): Promise<boolean> => {
120
+ if (createdCompositionId.value) {
121
+ await executeUpdate({
122
+ ...newComposition.value,
123
+ _id: createdCompositionId.value,
124
+ })
125
+
126
+ if (updateError.value) {
127
+ return false
128
+ }
129
+ } else {
130
+ const result = await executeCreate(newComposition.value)
131
+
132
+ if (createError.value) {
133
+ return false
134
+ }
135
+
136
+ const createdId = getCreatedId(result) ?? getCreatedIdFromStore()
137
+
138
+ if (!createdId) {
139
+ return false
140
+ }
141
+
142
+ createdCompositionId.value = createdId
143
+ }
144
+
145
+ draftInteractionLink.value.composition = createdCompositionId.value
146
+ prefillInteractionLinkFromComposition(
147
+ draftInteractionLink.value,
148
+ newComposition.value,
149
+ )
150
+
151
+ return true
152
+ }
153
+
154
+ const onCustomAction = async (actionId: string) => {
155
+ if (actionId === "save-and-continue") {
156
+ const saved = await persistComposition()
157
+
158
+ if (saved) {
159
+ configurationRef.value?.goToStep(3, { skipValidation: true })
160
+ }
161
+
162
+ return
163
+ }
164
+
165
+ if (actionId === "finish-without-il") {
49
166
  navigateBack()
167
+ return
168
+ }
169
+
170
+ if (actionId === "save-il") {
171
+ if (!createdCompositionId.value) {
172
+ const saved = await persistComposition()
173
+
174
+ if (!saved) {
175
+ return
176
+ }
177
+ }
178
+
179
+ draftInteractionLink.value.composition = createdCompositionId.value!
180
+
181
+ await executeInteractionLinkCreate(draftInteractionLink.value)
182
+
183
+ if (!interactionLinkCreateError.value) {
184
+ navigateBack()
185
+ }
50
186
  }
51
187
  }
52
188
  </script>
@@ -47,7 +47,11 @@ const { data } = $data.composition.get({
47
47
  },
48
48
  })
49
49
 
50
- const { execute: executeUpdate, isLoading } = $data.composition.update()
50
+ const {
51
+ execute: executeUpdate,
52
+ isLoading,
53
+ error: updateError,
54
+ } = $data.composition.update()
51
55
  const editableComposition = ref<Composition>()
52
56
 
53
57
  const composition = computed<Composition | undefined>(() => {
@@ -61,6 +65,11 @@ const composition = computed<Composition | undefined>(() => {
61
65
  const navigateBack = () =>
62
66
  triggerInteractionLinkById("compositionListInteractionLink")
63
67
 
64
- const onSubmit = async (finish = false) =>
68
+ const onSubmit = async (finish = false) => {
65
69
  await executeUpdate(toRaw(editableComposition.value))
70
+
71
+ if (finish && !updateError.value) {
72
+ navigateBack()
73
+ }
74
+ }
66
75
  </script>
@@ -16,7 +16,11 @@
16
16
  prepend-icon="mdi-plus"
17
17
  variant="flat"
18
18
  color="success"
19
- :text="$t('core.baseDataTable.createDataButton', { dataName: $t('workbench.compositionList.dataName') })"
19
+ :text="
20
+ $t('core.baseDataTable.createDataButton', {
21
+ dataName: $t('workbench.compositionList.dataName'),
22
+ })
23
+ "
20
24
  @click="goToCreate('compositionCreateInteractionLink')"
21
25
  />
22
26
  </template>
@@ -94,7 +98,11 @@
94
98
  </template>
95
99
 
96
100
  <script setup lang="ts">
97
- import { usePluginApi } from "@raclettejs/core/orchestrator/composables"
101
+ import {
102
+ usePluginApi,
103
+ useRouteState,
104
+ } from "@raclettejs/core/orchestrator/composables"
105
+ import { DEFAULT_SLOT_NAME } from "@raclettejs/core/orchestrator/router/routeParserHelper"
98
106
  import { computed, watch, ref } from "vue"
99
107
  import type { Composition } from "@raclettejs/core"
100
108
  import { formatDistance } from "date-fns"
@@ -153,6 +161,16 @@ const props = defineProps<{
153
161
  }>()
154
162
 
155
163
  const { $data, $socket, $user } = usePluginApi("raclette__core")
164
+ const { compositionSlots } = useRouteState()
165
+
166
+ const COMPOSITION_LIST_PATH = "composition-list"
167
+
168
+ const isCompositionListActive = () =>
169
+ compositionSlots.value.some(
170
+ (slot) =>
171
+ slot.slotName === DEFAULT_SLOT_NAME &&
172
+ slot.slotValue === COMPOSITION_LIST_PATH,
173
+ )
156
174
  const {
157
175
  data: compositions,
158
176
  execute,
@@ -291,9 +309,11 @@ const restore = (_id: string) => {
291
309
  const handleRowClick = (item: Composition) =>
292
310
  goToEditById("compositionEditInteractionLink", item._id)
293
311
 
294
- $socket.on("compositionCreated", () =>
295
- execute({ isDeleted: showDeleted.value }),
296
- )
312
+ $socket.on("compositionCreated", () => {
313
+ if (isCompositionListActive()) {
314
+ execute({ isDeleted: showDeleted.value })
315
+ }
316
+ })
297
317
  watch(
298
318
  showDeleted,
299
319
  () => {
@@ -0,0 +1,22 @@
1
+ // Auto-generated frontend configuration for plugin: pacifico__core
2
+ // This file is generated from backend routes - do not edit manually
3
+ // Entity mapping: {}
4
+
5
+ export default {
6
+ pluginName: "core",
7
+ author: "pacifico",
8
+ pluginKey: "pacifico__core",
9
+ routePrefix: "/pacifico__core",
10
+ pluginPath: "/app/src/appPlugins/pacifico__core",
11
+ data: {
12
+ "init-project": {
13
+ type: "init-project",
14
+ operations: {
15
+ create: {
16
+ target: "/pacifico__core/init-project",
17
+ method: "post"
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }
@@ -33,11 +33,7 @@ import { useI18n } from "vue-i18n"
33
33
  import { computed, useTemplateRef } from "vue"
34
34
  import { Step } from "@app/components/stepNavigator/StepNavigatorTypes"
35
35
  import StepNavigator from "@app/components/stepNavigator/StepNavigator.vue"
36
- import {
37
- DEFAULT_SLOT_NAME,
38
- DEFAULT_INLINE_SLOT_NAME,
39
- DEFAULT_MODAL_SLOT_NAME,
40
- } from "@raclettejs/core/orchestrator/router/routeParserHelper"
36
+ import { useInteractionLinkBehaviorFields } from "@app/composables/useInteractionLinkBehaviorFields"
41
37
 
42
38
  defineProps<{ disabled?: boolean; isEditing?: boolean }>()
43
39
 
@@ -53,6 +49,7 @@ const interactionLink = defineModel<
53
49
  const dynamicFormRef = useTemplateRef("dynamicFormRef")
54
50
 
55
51
  const { t } = useI18n()
52
+ const { behaviorFields } = useInteractionLinkBehaviorFields()
56
53
 
57
54
  const inputFields = computed<
58
55
  DynamicFormItem<InteractionLinkCreate | InteractionLinkUpdate>[]
@@ -68,66 +65,7 @@ const inputFields = computed<
68
65
  prependIcon: "file-document",
69
66
  validation: { required: true },
70
67
  },
71
- {
72
- title: t("workbench.interactionLinkConfiguration.description.title"),
73
- description: t(
74
- "workbench.interactionLinkConfiguration.description.description",
75
- ),
76
- inputType: "textarea",
77
- field: "description",
78
- prependIcon: "text",
79
- },
80
- {
81
- title: t("workbench.interactionLinkConfiguration.tags.title"),
82
- description: t("workbench.interactionLinkConfiguration.tags.description"),
83
- inputType: "tags",
84
- field: "tags",
85
- prependIcon: "tag-multiple",
86
- },
87
- {
88
- divider: t("workbench.interactionLinkConfiguration.dividers.behavior"),
89
- },
90
- {
91
- title: t("workbench.interactionLinkConfiguration.slotType.title"),
92
- description: t(
93
- "workbench.interactionLinkConfiguration.slotType.description",
94
- ),
95
- inputType: "select",
96
- field: "slotType",
97
- selectItems: [
98
- DEFAULT_SLOT_NAME,
99
- DEFAULT_MODAL_SLOT_NAME,
100
- DEFAULT_INLINE_SLOT_NAME,
101
- ],
102
- prependIcon: "dock-window",
103
- validation: {
104
- required: true,
105
- },
106
- },
107
- {
108
- title: t("workbench.interactionLinkConfiguration.triggers.title"),
109
- description: t(
110
- "workbench.interactionLinkConfiguration.triggers.description",
111
- ),
112
- inputType: "triggers",
113
- field: "triggers",
114
- },
115
- {
116
- title: t("workbench.interactionLinkConfiguration.queryParameters.title"),
117
- description: t(
118
- "workbench.interactionLinkConfiguration.queryParameters.description",
119
- ),
120
- inputType: "list",
121
- field: "queryParameters",
122
- },
123
- {
124
- title: t("workbench.interactionLinkConfiguration.isLandingPage.title"),
125
- description: t(
126
- "workbench.interactionLinkConfiguration.isLandingPage.description",
127
- ),
128
- inputType: "switch",
129
- field: "isLandingPage",
130
- },
68
+ ...behaviorFields.value,
131
69
  ])
132
70
 
133
71
  const validateForm = (): boolean => {
@@ -7,8 +7,21 @@
7
7
  :show-filters="true"
8
8
  :show-actions-column="false"
9
9
  :row-click-handler="onRowClick"
10
+ :is-row-clickable="isPluginRowClickable"
10
11
  :show-export="false"
11
- />
12
+ >
13
+ <template #item.name="{ item }">
14
+ <span class="tw:flex tw:items-center tw:gap-2">
15
+ <v-icon
16
+ v-if="isPluginRowClickable(item)"
17
+ icon="mdi-pencil"
18
+ size="small"
19
+ class="tw:opacity-70"
20
+ />
21
+ {{ item.name }}
22
+ </span>
23
+ </template>
24
+ </BaseDataTable>
12
25
  </div>
13
26
  </template>
14
27
 
@@ -48,10 +61,14 @@ const headers = computed<
48
61
  },
49
62
  ])
50
63
 
64
+ const isPluginRowClickable = (item: PluginMetadata) =>
65
+ Boolean(item.pluginKey && hasPluginSettingsWidget(item.pluginKey))
66
+
51
67
  const onRowClick = (item: PluginMetadata) => {
52
- if (item.pluginKey && hasPluginSettingsWidget(item.pluginKey)) {
68
+ const { pluginKey } = item
69
+ if (pluginKey && isPluginRowClickable(item)) {
53
70
  triggerInteractionLinkById("pluginDetailInteractionLink", {
54
- pluginKey: item.pluginKey,
71
+ pluginKey,
55
72
  })
56
73
  }
57
74
  }
@@ -0,0 +1,94 @@
1
+ import { keyGroup } from "./valkeyExplorer"
2
+ import { sanitizeCacheEntryFields } from "./cacheEntrySanitizer"
3
+
4
+ /** Max string length stored per row (larger values are truncated for transport). */
5
+ export const MAX_VALUE_LENGTH = 16_384
6
+
7
+ export type CacheEntrySource = {
8
+ key: string
9
+ keyGroup: string
10
+ value: string
11
+ valuePreview?: string
12
+ truncated: boolean
13
+ valueParsed?: unknown
14
+ ttl: number
15
+ ttlLabel: string
16
+ }
17
+
18
+ const truncateValue = (
19
+ raw: string,
20
+ ): { value: string; valuePreview?: string; truncated: boolean } => {
21
+ if (raw.length <= MAX_VALUE_LENGTH) {
22
+ return { value: raw, truncated: false }
23
+ }
24
+
25
+ return {
26
+ value: raw.slice(0, MAX_VALUE_LENGTH),
27
+ valuePreview: `${raw.slice(0, 512)}…`,
28
+ truncated: true,
29
+ }
30
+ }
31
+
32
+ const formatTtlLabel = (ttl: number): string => {
33
+ if (ttl === -1) return "no expiry"
34
+ if (ttl === -2) return "missing"
35
+ return `${ttl}s`
36
+ }
37
+
38
+ export type BuildCacheEntrySourceOptions = {
39
+ /** Parse the full raw value for `valueParsed` even when the transport string is truncated. */
40
+ attachFullParsed?: boolean
41
+ }
42
+
43
+ export const buildCacheEntrySource = (
44
+ key: string,
45
+ rawValue: string,
46
+ ttl: number,
47
+ options: BuildCacheEntrySourceOptions = {},
48
+ ): CacheEntrySource => {
49
+ const { attachFullParsed = false } = options
50
+ const truncatedFields = truncateValue(rawValue ?? "")
51
+ const sanitized = sanitizeCacheEntryFields(truncatedFields)
52
+
53
+ let valueParsed = sanitized.valueParsed
54
+
55
+ if (attachFullParsed && truncatedFields.truncated) {
56
+ const fullSanitized = sanitizeCacheEntryFields({
57
+ value: rawValue ?? "",
58
+ truncated: false,
59
+ })
60
+ if (fullSanitized.valueParsed !== undefined) {
61
+ valueParsed = fullSanitized.valueParsed
62
+ }
63
+ }
64
+
65
+ return {
66
+ key,
67
+ keyGroup: keyGroup(key),
68
+ value: sanitized.value,
69
+ valuePreview: sanitized.valuePreview,
70
+ truncated: sanitized.truncated,
71
+ valueParsed,
72
+ ttl,
73
+ ttlLabel: formatTtlLabel(ttl),
74
+ }
75
+ }
76
+
77
+ export const toPayloadItems = (entries: CacheEntrySource[]) => {
78
+ const now = new Date().toISOString()
79
+
80
+ return entries.map((entry) => ({
81
+ _id: entry.key,
82
+ _type: "cacheEntry",
83
+ _offline: 0,
84
+ _owner: "",
85
+ _tags: [] as string[],
86
+ _displayName: entry.key,
87
+ _completion: "",
88
+ _project: "",
89
+ _updatedAt: now,
90
+ _createdAt: now,
91
+ _deleted: false,
92
+ _source: entry,
93
+ }))
94
+ }
@@ -0,0 +1,86 @@
1
+ export const HIDE_SECRETS = true
2
+
3
+ export const SECRET_KEY_PATTERNS: RegExp[] = [
4
+ /password/i,
5
+ /secret/i,
6
+ /token/i,
7
+ /api[_-]?key/i,
8
+ /credential/i,
9
+ /private[_-]?key/i,
10
+ /access[_-]?token/i,
11
+ /refresh[_-]?token/i,
12
+ /authorization/i,
13
+ /bearer/i,
14
+ ]
15
+
16
+ const REDACTED_VALUE = "******"
17
+ const PREVIEW_LENGTH = 512
18
+
19
+ export const isSecretKey = (key: string): boolean =>
20
+ SECRET_KEY_PATTERNS.some((pattern) => pattern.test(key))
21
+
22
+ export const redactJsonValue = (value: unknown): unknown => {
23
+ if (Array.isArray(value)) {
24
+ return value.map((item) => redactJsonValue(item))
25
+ }
26
+
27
+ if (value !== null && typeof value === "object") {
28
+ const redacted: Record<string, unknown> = {}
29
+
30
+ for (const [key, nestedValue] of Object.entries(
31
+ value as Record<string, unknown>,
32
+ )) {
33
+ redacted[key] = isSecretKey(key)
34
+ ? REDACTED_VALUE
35
+ : redactJsonValue(nestedValue)
36
+ }
37
+
38
+ return redacted
39
+ }
40
+
41
+ return value
42
+ }
43
+
44
+ type SanitizeInput = {
45
+ value: string
46
+ valuePreview?: string
47
+ truncated: boolean
48
+ }
49
+
50
+ type SanitizeResult = {
51
+ value: string
52
+ valuePreview?: string
53
+ truncated: boolean
54
+ valueParsed?: unknown
55
+ }
56
+
57
+ const buildPreview = (value: string): string =>
58
+ `${value.slice(0, PREVIEW_LENGTH)}…`
59
+
60
+ export const sanitizeCacheEntryFields = ({
61
+ value,
62
+ valuePreview,
63
+ truncated,
64
+ }: SanitizeInput): SanitizeResult => {
65
+ let parsed: unknown
66
+
67
+ try {
68
+ parsed = JSON.parse(value)
69
+ } catch {
70
+ return { value, valuePreview, truncated }
71
+ }
72
+
73
+ if (!HIDE_SECRETS) {
74
+ return { value, valuePreview, truncated, valueParsed: parsed }
75
+ }
76
+
77
+ const valueParsed = redactJsonValue(parsed)
78
+ const redactedValue = JSON.stringify(valueParsed)
79
+
80
+ return {
81
+ value: redactedValue,
82
+ valuePreview: truncated ? buildPreview(redactedValue) : valuePreview,
83
+ truncated,
84
+ valueParsed,
85
+ }
86
+ }
@@ -0,0 +1,89 @@
1
+ import { Redis } from "iovalkey"
2
+
3
+ export type NormalizedPattern = {
4
+ match: string
5
+ listAll: boolean
6
+ }
7
+
8
+ /** Explorer defaults to DB 0; raclette.config often sets cache db 1 for the app client. */
9
+ export const DEFAULT_EXPLORER_DB = 0
10
+
11
+ export const parseExplorerDb = (queryDb?: number): number => {
12
+ if (typeof queryDb === "number" && !Number.isNaN(queryDb) && queryDb >= 0) {
13
+ return Math.floor(queryDb)
14
+ }
15
+ return DEFAULT_EXPLORER_DB
16
+ }
17
+
18
+ export const buildRedisOptions = (db: number) => {
19
+ const raw = process.env.CACHE_URL || "redis://cache:6379"
20
+ const normalized = raw
21
+ .replace(/^valkey:/, "redis:")
22
+ .replace(/^raclette-cache:/, "redis:")
23
+
24
+ try {
25
+ const url = new URL(normalized)
26
+ return {
27
+ host: url.hostname || "cache",
28
+ port: url.port ? Number(url.port) : 6379,
29
+ db,
30
+ maxRetriesPerRequest: 1,
31
+ }
32
+ } catch {
33
+ return { host: "cache", port: 6379, db, maxRetriesPerRequest: 1 }
34
+ }
35
+ }
36
+
37
+ export const withExplorerClient = async <T>(
38
+ db: number,
39
+ fn: (client: Redis) => Promise<T>,
40
+ ): Promise<T> => {
41
+ const client = new Redis(buildRedisOptions(db))
42
+ try {
43
+ return await fn(client)
44
+ } finally {
45
+ await client.quit()
46
+ }
47
+ }
48
+
49
+ /** SCAN avoids blocking KEYS; dedupes results across cursor pages. */
50
+ export const scanKeys = async (
51
+ client: Redis,
52
+ pattern: string,
53
+ ): Promise<string[]> => {
54
+ const keys = new Set<string>()
55
+ let cursor = "0"
56
+
57
+ do {
58
+ const [next, batch] = await client.scan(
59
+ cursor,
60
+ "MATCH",
61
+ pattern,
62
+ "COUNT",
63
+ 500,
64
+ )
65
+ cursor = next
66
+ for (const key of batch) {
67
+ keys.add(key)
68
+ }
69
+ } while (cursor !== "0")
70
+
71
+ return [...keys]
72
+ }
73
+
74
+ export const normalizePattern = (pattern?: string): NormalizedPattern => {
75
+ const trimmed = pattern?.trim() ?? ""
76
+ if (!trimmed || trimmed === "*") {
77
+ return { match: "*", listAll: true }
78
+ }
79
+ return { match: trimmed, listAll: false }
80
+ }
81
+
82
+ /** Group label from key prefix (segment before first `:` or `.`). */
83
+ export const keyGroup = (key: string): string => {
84
+ const colon = key.indexOf(":")
85
+ if (colon > 0) return key.slice(0, colon)
86
+ const dot = key.indexOf(".")
87
+ if (dot > 0) return key.slice(0, dot)
88
+ return "(ungrouped)"
89
+ }