@antelopejs/dms-database 0.0.1

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 (164) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +53 -0
  3. package/dist/automation/index.d.ts +2 -0
  4. package/dist/automation/index.js +13 -0
  5. package/dist/automation/index.js.map +1 -0
  6. package/dist/automation/tableWatch.d.ts +17 -0
  7. package/dist/automation/tableWatch.js +189 -0
  8. package/dist/automation/tableWatch.js.map +1 -0
  9. package/dist/config.d.ts +7 -0
  10. package/dist/config.js +12 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/db/index.d.ts +2 -0
  13. package/dist/db/index.js +19 -0
  14. package/dist/db/index.js.map +1 -0
  15. package/dist/db/models/diagram_layouts.model.d.ts +25 -0
  16. package/dist/db/models/diagram_layouts.model.js +29 -0
  17. package/dist/db/models/diagram_layouts.model.js.map +1 -0
  18. package/dist/db/models/diagram_notes.model.d.ts +23 -0
  19. package/dist/db/models/diagram_notes.model.js +15 -0
  20. package/dist/db/models/diagram_notes.model.js.map +1 -0
  21. package/dist/db/models/index.d.ts +4 -0
  22. package/dist/db/models/index.js +21 -0
  23. package/dist/db/models/index.js.map +1 -0
  24. package/dist/db/models/query_history.model.d.ts +25 -0
  25. package/dist/db/models/query_history.model.js +33 -0
  26. package/dist/db/models/query_history.model.js.map +1 -0
  27. package/dist/db/models/saved_queries.model.d.ts +24 -0
  28. package/dist/db/models/saved_queries.model.js +21 -0
  29. package/dist/db/models/saved_queries.model.js.map +1 -0
  30. package/dist/db/tables/diagram_layouts.table.d.ts +7 -0
  31. package/dist/db/tables/diagram_layouts.table.js +35 -0
  32. package/dist/db/tables/diagram_layouts.table.js.map +1 -0
  33. package/dist/db/tables/diagram_notes.table.d.ts +13 -0
  34. package/dist/db/tables/diagram_notes.table.js +59 -0
  35. package/dist/db/tables/diagram_notes.table.js.map +1 -0
  36. package/dist/db/tables/index.d.ts +4 -0
  37. package/dist/db/tables/index.js +21 -0
  38. package/dist/db/tables/index.js.map +1 -0
  39. package/dist/db/tables/query_history.table.d.ts +11 -0
  40. package/dist/db/tables/query_history.table.js +54 -0
  41. package/dist/db/tables/query_history.table.js.map +1 -0
  42. package/dist/db/tables/saved_queries.table.d.ts +12 -0
  43. package/dist/db/tables/saved_queries.table.js +58 -0
  44. package/dist/db/tables/saved_queries.table.js.map +1 -0
  45. package/dist/index.d.ts +8 -0
  46. package/dist/index.js +56 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/pages/data.d.ts +9 -0
  49. package/dist/pages/data.js +29 -0
  50. package/dist/pages/data.js.map +1 -0
  51. package/dist/pages/index.d.ts +4 -0
  52. package/dist/pages/index.js +21 -0
  53. package/dist/pages/index.js.map +1 -0
  54. package/dist/pages/overview.d.ts +9 -0
  55. package/dist/pages/overview.js +29 -0
  56. package/dist/pages/overview.js.map +1 -0
  57. package/dist/pages/query.d.ts +9 -0
  58. package/dist/pages/query.js +29 -0
  59. package/dist/pages/query.js.map +1 -0
  60. package/dist/pages/schemas.d.ts +9 -0
  61. package/dist/pages/schemas.js +29 -0
  62. package/dist/pages/schemas.js.map +1 -0
  63. package/dist/routes/data.d.ts +25 -0
  64. package/dist/routes/data.js +324 -0
  65. package/dist/routes/data.js.map +1 -0
  66. package/dist/routes/diagram.d.ts +47 -0
  67. package/dist/routes/diagram.js +200 -0
  68. package/dist/routes/diagram.js.map +1 -0
  69. package/dist/routes/health.d.ts +6 -0
  70. package/dist/routes/health.js +35 -0
  71. package/dist/routes/health.js.map +1 -0
  72. package/dist/routes/index.d.ts +6 -0
  73. package/dist/routes/index.js +23 -0
  74. package/dist/routes/index.js.map +1 -0
  75. package/dist/routes/query.d.ts +26 -0
  76. package/dist/routes/query.js +245 -0
  77. package/dist/routes/query.js.map +1 -0
  78. package/dist/routes/schemas.d.ts +8 -0
  79. package/dist/routes/schemas.js +35 -0
  80. package/dist/routes/schemas.js.map +1 -0
  81. package/dist/routes/tables.d.ts +23 -0
  82. package/dist/routes/tables.js +94 -0
  83. package/dist/routes/tables.js.map +1 -0
  84. package/dist/service/fieldDescriptor.d.ts +66 -0
  85. package/dist/service/fieldDescriptor.js +156 -0
  86. package/dist/service/fieldDescriptor.js.map +1 -0
  87. package/dist/service/health.d.ts +20 -0
  88. package/dist/service/health.js +58 -0
  89. package/dist/service/health.js.map +1 -0
  90. package/dist/service/introspect.d.ts +6 -0
  91. package/dist/service/introspect.js +312 -0
  92. package/dist/service/introspect.js.map +1 -0
  93. package/dist/service/schemaLabels.d.ts +7 -0
  94. package/dist/service/schemaLabels.js +39 -0
  95. package/dist/service/schemaLabels.js.map +1 -0
  96. package/dist/service/schemaRegistry.d.ts +2 -0
  97. package/dist/service/schemaRegistry.js +8 -0
  98. package/dist/service/schemaRegistry.js.map +1 -0
  99. package/dist/service/stagedSerialization.d.ts +6 -0
  100. package/dist/service/stagedSerialization.js +54 -0
  101. package/dist/service/stagedSerialization.js.map +1 -0
  102. package/dist/service/types.d.ts +67 -0
  103. package/dist/service/types.js +3 -0
  104. package/dist/service/types.js.map +1 -0
  105. package/dist/types/constants.d.ts +3 -0
  106. package/dist/types/constants.js +7 -0
  107. package/dist/types/constants.js.map +1 -0
  108. package/dist/types/responses.d.ts +14 -0
  109. package/dist/types/responses.js +3 -0
  110. package/dist/types/responses.js.map +1 -0
  111. package/dist/utils/query.d.ts +17 -0
  112. package/dist/utils/query.js +116 -0
  113. package/dist/utils/query.js.map +1 -0
  114. package/dist/utils/requestValidation.d.ts +2 -0
  115. package/dist/utils/requestValidation.js +14 -0
  116. package/dist/utils/requestValidation.js.map +1 -0
  117. package/frontend-vue/.editorconfig +12 -0
  118. package/frontend-vue/.prettierrc +11 -0
  119. package/frontend-vue/app/components/DataBrowserSidebar.vue +254 -0
  120. package/frontend-vue/app/components/DataBrowserTabBar.vue +166 -0
  121. package/frontend-vue/app/components/DataGrid.vue +735 -0
  122. package/frontend-vue/app/components/DataGridCell.vue +147 -0
  123. package/frontend-vue/app/components/DataGridHeaderCell.vue +175 -0
  124. package/frontend-vue/app/components/DiagramNoteNode.vue +100 -0
  125. package/frontend-vue/app/components/DiagramRelationEdge.vue +62 -0
  126. package/frontend-vue/app/components/DiagramStubNode.vue +37 -0
  127. package/frontend-vue/app/components/DiagramTableNode.vue +94 -0
  128. package/frontend-vue/app/components/JsonCellPanel.vue +93 -0
  129. package/frontend-vue/app/components/QueryBuilderPanel.vue +501 -0
  130. package/frontend-vue/app/components/QueryFavoritesPanel.vue +127 -0
  131. package/frontend-vue/app/components/QueryHistoryPanel.vue +134 -0
  132. package/frontend-vue/app/components/QueryResultPanel.vue +204 -0
  133. package/frontend-vue/app/components/SchemaDiagram.vue +1150 -0
  134. package/frontend-vue/app/components/SchemaTableInspector.vue +278 -0
  135. package/frontend-vue/app/composables/useDataBrowserGrid.ts +162 -0
  136. package/frontend-vue/app/composables/useDataBrowserTabs.ts +255 -0
  137. package/frontend-vue/app/composables/useDatabaseHealth.ts +50 -0
  138. package/frontend-vue/app/composables/useDatabaseSchemas.ts +92 -0
  139. package/frontend-vue/app/composables/useDiagramPersistence.ts +99 -0
  140. package/frontend-vue/app/composables/useQueryRuntime.ts +118 -0
  141. package/frontend-vue/app/composables/useQueryStore.ts +117 -0
  142. package/frontend-vue/app/composables/useTableViewRowClick.ts +22 -0
  143. package/frontend-vue/app/custom-pages/database/data.vue +42 -0
  144. package/frontend-vue/app/custom-pages/database/overview.vue +367 -0
  145. package/frontend-vue/app/custom-pages/database/query.vue +432 -0
  146. package/frontend-vue/app/custom-pages/database/schemas.vue +216 -0
  147. package/frontend-vue/app/utils/databaseFieldModifiers.ts +33 -0
  148. package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +224 -0
  149. package/frontend-vue/app/utils/dmsTableView.ts +83 -0
  150. package/frontend-vue/app/utils/relativeTime.ts +15 -0
  151. package/frontend-vue/app/utils/stagedSerialization.ts +91 -0
  152. package/frontend-vue/dms.frontend.ts +38 -0
  153. package/frontend-vue/eslint.config.mjs +16 -0
  154. package/frontend-vue/i18n/locales/database-en-GB.json +239 -0
  155. package/frontend-vue/i18n/locales/database-fr-FR.json +239 -0
  156. package/frontend-vue/package.json +42 -0
  157. package/frontend-vue/pnpm-lock.yaml +5819 -0
  158. package/frontend-vue/pnpm-workspace.yaml +2 -0
  159. package/frontend-vue/tests/persistence.test.ts +70 -0
  160. package/frontend-vue/tests/queryRuntime.test.ts +53 -0
  161. package/frontend-vue/tests/registration.test.ts +33 -0
  162. package/frontend-vue/tsconfig.json +13 -0
  163. package/frontend-vue/vitest.config.ts +5 -0
  164. package/package.json +78 -0
@@ -0,0 +1,278 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ FieldDescriptor,
4
+ TableSummary,
5
+ } from "../composables/useDatabaseSchemas";
6
+ import { inferFieldTypeIcon, isPrimaryKey } from "../utils/databaseFieldTypeIcons";
7
+
8
+ // Body panel for the shared dms drawer (useDrawer). The drawer owns the chrome
9
+ // (themed surface, header with the table name + subtitle, close button); this
10
+ // component only renders the inspector content: columns / indexes / preview
11
+ // tabs and the footer. It is opened from schemas.vue via `useDrawer().open()`.
12
+
13
+ // Descriptor kinds that admit null/undefined on their own: an explicit
14
+ // null/undefined, or an untyped any/unknown.
15
+ const NULLISH_KINDS = new Set<FieldDescriptor["kind"]>([
16
+ "null",
17
+ "undefined",
18
+ "any",
19
+ "unknown",
20
+ ]);
21
+
22
+ // A field is nullable when its descriptor admits null/undefined — directly, as
23
+ // an untyped any/unknown, or as a member of a union. Anything else is required.
24
+ function isNullable(descriptor: FieldDescriptor): boolean {
25
+ if (NULLISH_KINDS.has(descriptor.kind)) return true;
26
+ if (descriptor.kind === "union") {
27
+ return descriptor.members.some(
28
+ (member) => member.kind === "null" || member.kind === "undefined",
29
+ );
30
+ }
31
+ return false;
32
+ }
33
+
34
+ interface Props {
35
+ schemaId: string | null;
36
+ table: TableSummary | null;
37
+ // Injected by the shared DynamicDrawer; unused here (read-only panel).
38
+ containerId?: string;
39
+ }
40
+
41
+ const props = defineProps<Props>();
42
+
43
+ // The shared DynamicDrawer dismisses the drawer when the body emits `success`.
44
+ const emit = defineEmits<{ success: [] }>();
45
+
46
+ const { t } = useI18n();
47
+ const router = useDmsRouter();
48
+ const { $authFetch } = useAuthFetch();
49
+
50
+ const DATA_PATH = "/modules/database/data";
51
+ const BROWSE_LIST = "/api/database/browse/list";
52
+ const FILTER_MODE_IS = "is";
53
+
54
+ type Tab = "columns" | "indexes" | "preview";
55
+ const tab = ref<Tab>("columns");
56
+
57
+ const tabItems = computed(() => [
58
+ { label: t("dms_database.schemas.inspector.columns"), value: "columns" as const, icon: "i-ph-layout" },
59
+ { label: t("dms_database.schemas.inspector.indexes"), value: "indexes" as const, icon: "i-ph-key" },
60
+ { label: t("dms_database.schemas.inspector.preview"), value: "preview" as const, icon: "i-ph-table" },
61
+ ]);
62
+
63
+ const fieldEntries = computed(() =>
64
+ Object.entries(props.table?.fields ?? {}).map(([name, descriptor]) => {
65
+ const meta = inferFieldTypeIcon(descriptor, name);
66
+ const pk = isPrimaryKey(name, props.table?.indexes ?? {});
67
+ // Primary keys are never nullable; otherwise read it off the descriptor.
68
+ const nullable = pk ? false : isNullable(descriptor);
69
+ return { name, meta, pk, nullable };
70
+ }),
71
+ );
72
+
73
+ const indexEntries = computed(() =>
74
+ Object.entries(props.table?.indexes ?? {}).map(([name, def]) => ({
75
+ name,
76
+ fields: def.fields ?? [],
77
+ multi: Boolean(def.multi),
78
+ })),
79
+ );
80
+
81
+ // --- preview (single sample row) ---
82
+ const previewRow = ref<Record<string, unknown> | null>(null);
83
+ const previewLoading = ref(false);
84
+ const previewError = ref(false);
85
+ const previewLoadedFor = ref<string | null>(null);
86
+
87
+ async function loadPreview() {
88
+ if (!props.schemaId || !props.table) return;
89
+ const key = `${props.schemaId}::${props.table.name}`;
90
+ if (previewLoadedFor.value === key) return;
91
+ previewLoading.value = true;
92
+ previewError.value = false;
93
+ try {
94
+ const res = await $authFetch<{ results: Record<string, unknown>[] }>(
95
+ BROWSE_LIST,
96
+ {
97
+ query: {
98
+ filter_schema: `${FILTER_MODE_IS}:${props.schemaId}`,
99
+ filter_table: `${FILTER_MODE_IS}:${props.table.name}`,
100
+ limit: 1,
101
+ },
102
+ },
103
+ );
104
+ previewRow.value = res.results?.[0] ?? null;
105
+ previewLoadedFor.value = key;
106
+ } catch {
107
+ previewError.value = true;
108
+ previewRow.value = null;
109
+ } finally {
110
+ previewLoading.value = false;
111
+ }
112
+ }
113
+
114
+ const previewJson = computed(() =>
115
+ previewRow.value ? JSON.stringify(previewRow.value, null, 2) : "",
116
+ );
117
+
118
+ // The panel is mounted only while the drawer is open, so the preview loads as
119
+ // soon as its tab is selected (no `open` prop to gate on anymore).
120
+ watch(
121
+ () => [tab.value, props.table?.name] as const,
122
+ () => {
123
+ if (tab.value === "preview") loadPreview();
124
+ },
125
+ );
126
+
127
+ const browseTo = computed(() => {
128
+ if (!props.schemaId || !props.table) return DATA_PATH;
129
+ return `${DATA_PATH}?schema=${encodeURIComponent(props.schemaId)}&table=${encodeURIComponent(props.table.name)}`;
130
+ });
131
+
132
+ // "Browse data" navigates to the data browser and closes the drawer so the user
133
+ // lands on the table without the panel lingering over it.
134
+ function browseData() {
135
+ router.push(browseTo.value);
136
+ emit("success");
137
+ }
138
+ </script>
139
+
140
+ <template>
141
+ <!-- Fixed width so the right-side drawer keeps a stable, comfortable size
142
+ instead of shrinking/growing with the active tab's content (the shared
143
+ DynamicDrawer sizes a right drawer to its content and exposes no width). -->
144
+ <div class="flex h-full w-[36rem] max-w-[calc(100vw-3rem)] flex-col">
145
+ <!-- tabs -->
146
+ <div class="border-b border-default pb-3">
147
+ <DmsSegmented v-model="tab" :items="tabItems" />
148
+ </div>
149
+
150
+ <!-- body -->
151
+ <div class="flex-1 overflow-y-auto py-4">
152
+ <!-- columns -->
153
+ <table v-if="tab === 'columns'" class="w-full text-sm">
154
+ <thead>
155
+ <tr class="border-b border-default text-left">
156
+ <th class="text-dimmed py-2 pr-3 text-xs font-semibold">
157
+ {{ $t("dms_database.schemas.inspector.colName") }}
158
+ </th>
159
+ <th class="text-dimmed py-2 px-3 text-xs font-semibold">
160
+ {{ $t("dms_database.schemas.inspector.colType") }}
161
+ </th>
162
+ <th class="text-dimmed py-2 pl-3 text-xs font-semibold">
163
+ {{ $t("dms_database.schemas.inspector.colNullable") }}
164
+ </th>
165
+ </tr>
166
+ </thead>
167
+ <tbody>
168
+ <tr
169
+ v-for="field in fieldEntries"
170
+ :key="field.name"
171
+ class="border-b border-default/60 last:border-0"
172
+ >
173
+ <td class="py-2 pr-3">
174
+ <span class="flex items-center gap-2 font-mono text-toned">
175
+ <UIcon
176
+ :name="field.pk ? 'i-ph-key' : field.meta.icon"
177
+ class="size-3.5 shrink-0"
178
+ :class="field.pk ? 'text-warning' : field.meta.color"
179
+ />
180
+ {{ field.name }}
181
+ </span>
182
+ </td>
183
+ <td class="py-2 px-3">
184
+ <span
185
+ class="font-mono text-[11px]"
186
+ :class="field.meta.color"
187
+ >
188
+ {{ field.pk ? "pk" : field.meta.label }}
189
+ </span>
190
+ </td>
191
+ <td class="py-2 pl-3 text-dimmed">
192
+ {{
193
+ field.nullable
194
+ ? $t("dms_database.schemas.inspector.yes")
195
+ : $t("dms_database.schemas.inspector.no")
196
+ }}
197
+ </td>
198
+ </tr>
199
+ </tbody>
200
+ </table>
201
+
202
+ <!-- indexes -->
203
+ <div v-else-if="tab === 'indexes'" class="space-y-2.5">
204
+ <div
205
+ v-for="idx in indexEntries"
206
+ :key="idx.name"
207
+ class="flex items-center gap-3 rounded-md border border-default bg-default px-3 py-2.5"
208
+ >
209
+ <UBadge
210
+ :color="idx.multi ? 'info' : 'neutral'"
211
+ variant="soft"
212
+ size="sm"
213
+ >
214
+ {{
215
+ idx.multi
216
+ ? $t("dms_database.schemas.inspector.multi")
217
+ : $t("dms_database.schemas.inspector.single")
218
+ }}
219
+ </UBadge>
220
+ <span class="font-mono text-[12.5px] text-toned">{{ idx.name }}</span>
221
+ <span
222
+ v-if="idx.fields.length"
223
+ class="font-mono text-[11px] text-dimmed ml-auto truncate"
224
+ >
225
+ {{ idx.fields.join(", ") }}
226
+ </span>
227
+ </div>
228
+ <p
229
+ v-if="indexEntries.length === 0"
230
+ class="text-dimmed text-sm py-6 text-center"
231
+ >
232
+ {{ $t("dms_database.schemas.inspector.noIndexes") }}
233
+ </p>
234
+ </div>
235
+
236
+ <!-- preview -->
237
+ <div v-else>
238
+ <div v-if="previewLoading" class="py-10 text-center">
239
+ <UProgress size="sm" />
240
+ </div>
241
+ <div
242
+ v-else-if="previewError"
243
+ class="text-dimmed text-sm py-10 text-center"
244
+ >
245
+ {{ $t("dms_database.schemas.inspector.previewError") }}
246
+ </div>
247
+ <div
248
+ v-else-if="!previewRow"
249
+ class="text-dimmed text-sm py-10 text-center"
250
+ >
251
+ {{ $t("dms_database.schemas.inspector.previewEmpty") }}
252
+ </div>
253
+ <pre
254
+ v-else
255
+ class="text-xs rounded-lg border border-default bg-default p-4 overflow-auto whitespace-pre text-toned"
256
+ >{{ previewJson }}</pre
257
+ >
258
+ </div>
259
+ </div>
260
+
261
+ <!-- footer -->
262
+ <div class="flex items-center gap-2 border-t border-default pt-4">
263
+ <span class="flex items-center gap-1.5 text-xs text-success">
264
+ <UIcon name="i-ph-check-circle" class="size-3.5" />
265
+ {{ $t("dms_database.schemas.inspector.healthy") }}
266
+ </span>
267
+ <UButton
268
+ color="neutral"
269
+ variant="outline"
270
+ size="sm"
271
+ icon="i-ph-rows"
272
+ :label="$t('dms_database.schemas.browseData')"
273
+ class="ml-auto"
274
+ @click="browseData"
275
+ />
276
+ </div>
277
+ </div>
278
+ </template>
@@ -0,0 +1,162 @@
1
+ // Per-tab grid state (paging, sort, filters, column widths) and a data cache
2
+ // so switching tabs restores instantly without a refetch. Keyed by
3
+ // BrowserTab.id.
4
+
5
+ export interface ColumnFilterState {
6
+ mode: "is" | "contains";
7
+ value: string;
8
+ }
9
+
10
+ export interface GridState {
11
+ page: number;
12
+ pageSize: number;
13
+ sortKey: string | null;
14
+ sortDirection: "asc" | "desc";
15
+ search: string;
16
+ filters: Record<string, ColumnFilterState>;
17
+ columnWidths: Record<string, number>;
18
+ }
19
+
20
+ export interface GridColumn {
21
+ name: string;
22
+ // Coarse runtime type sampled by the backend: string | number | boolean |
23
+ // object | array | null | undefined.
24
+ type: string;
25
+ isPrimaryKey: boolean;
26
+ }
27
+
28
+ export interface GridData {
29
+ columns: GridColumn[];
30
+ rows: Record<string, unknown>[];
31
+ total: number;
32
+ primaryKey: string | null;
33
+ // Serialised query parameters that produced this payload; lets the grid
34
+ // skip refetching when nothing changed.
35
+ paramsKey: string;
36
+ }
37
+
38
+ export const GRID_PAGE_SIZES = [25, 50, 100, 200];
39
+
40
+ function defaultGridState(): GridState {
41
+ return {
42
+ page: 0,
43
+ pageSize: 25,
44
+ sortKey: null,
45
+ sortDirection: "asc",
46
+ search: "",
47
+ filters: {},
48
+ columnWidths: {},
49
+ };
50
+ }
51
+
52
+ export function useDataBrowserGrid() {
53
+ const states = useDmsState<Record<string, GridState>>(
54
+ "dms-database-grid-states",
55
+ () => ({}),
56
+ );
57
+ const cache = useDmsState<Record<string, GridData>>(
58
+ "dms-database-grid-cache",
59
+ () => ({}),
60
+ );
61
+
62
+ function getState(tabId: string): GridState {
63
+ if (!states.value[tabId]) states.value[tabId] = defaultGridState();
64
+ return states.value[tabId];
65
+ }
66
+
67
+ function getCache(tabId: string): GridData | null {
68
+ return cache.value[tabId] ?? null;
69
+ }
70
+
71
+ function setCache(tabId: string, data: GridData) {
72
+ cache.value[tabId] = data;
73
+ }
74
+
75
+ function invalidate(tabId: string) {
76
+ Reflect.deleteProperty(cache.value, tabId);
77
+ }
78
+
79
+ // Forget everything about a closed tab.
80
+ function drop(tabId: string) {
81
+ Reflect.deleteProperty(states.value, tabId);
82
+ Reflect.deleteProperty(cache.value, tabId);
83
+ }
84
+
85
+ return { getState, getCache, setCache, invalidate, drop };
86
+ }
87
+
88
+ // --- query helpers shared by the grid ---
89
+
90
+ // Reserved by the wire protocol for table selection (the backend's
91
+ // SELECTION_FILTER_KEYS): a data filter on a column literally named `schema`,
92
+ // `table` or `instance` would clobber the selection parameters, so it is never
93
+ // emitted and the filter UI is disabled for these columns.
94
+ const RESERVED_FILTER_FIELDS = new Set(["schema", "table", "instance"]);
95
+
96
+ export function isReservedFilterField(field: string): boolean {
97
+ return RESERVED_FILTER_FIELDS.has(field);
98
+ }
99
+
100
+ // Normalized (empty-pruned, reserved-pruned, name-sorted) filter entries: the
101
+ // single source for both the wire query and the cache key, so key-insertion
102
+ // order or an empty draft can't cause spurious cache misses.
103
+ function activeFilterEntries(
104
+ state: GridState,
105
+ ): Array<[string, ColumnFilterState]> {
106
+ return Object.entries(state.filters)
107
+ .filter(([field, filter]) => filter.value && !isReservedFilterField(field))
108
+ .sort(([a], [b]) => a.localeCompare(b));
109
+ }
110
+
111
+ // Single encoder for the `<mode>:<value>` wire tokens parseFilter decodes
112
+ // server-side (src/utils/query.ts) — the grammar is defined here once instead
113
+ // of scattered string literals.
114
+ export function filterToken(mode: "is" | "contains", value: string): string {
115
+ return `${mode}:${value}`;
116
+ }
117
+
118
+ export function isFilterToken(value: string): string {
119
+ return filterToken("is", value);
120
+ }
121
+
122
+ // Selection filters identifying the table, in the wire format the browse
123
+ // backend decodes (see resolveSelection in src/routes/data.ts).
124
+ export function browseSelectionQuery(tab: BrowserTab): Record<string, string> {
125
+ const query: Record<string, string> = {
126
+ filter_schema: isFilterToken(tab.schema),
127
+ filter_table: isFilterToken(tab.table),
128
+ };
129
+ if (tab.instance !== DEFAULT_INSTANCE_VALUE) {
130
+ query.filter_instance = isFilterToken(tab.instance);
131
+ }
132
+ return query;
133
+ }
134
+
135
+ export function browseListQuery(
136
+ state: GridState,
137
+ ): Record<string, string | number> {
138
+ const query: Record<string, string | number> = {
139
+ offset: state.page * state.pageSize,
140
+ limit: state.pageSize,
141
+ sortDirection: state.sortDirection,
142
+ };
143
+ if (state.sortKey) query.sortKey = state.sortKey;
144
+ if (state.search) query.search = state.search;
145
+ for (const [field, filter] of activeFilterEntries(state)) {
146
+ query[`filter_${field}`] = filterToken(filter.mode, filter.value);
147
+ }
148
+ return query;
149
+ }
150
+
151
+ // Everything that changes the server payload (column widths deliberately not).
152
+ export function browseParamsKey(tab: BrowserTab, state: GridState): string {
153
+ return JSON.stringify([
154
+ tab.id,
155
+ state.page,
156
+ state.pageSize,
157
+ state.sortKey,
158
+ state.sortDirection,
159
+ state.search,
160
+ activeFilterEntries(state),
161
+ ]);
162
+ }
@@ -0,0 +1,255 @@
1
+ // Shared state for the data-browser tabs: each tab pins a (schema, instance,
2
+ // table) triplet. The active tab is mirrored to the URL query (deep-links) and
3
+ // the whole tab set is persisted to sessionStorage (survives a page refresh,
4
+ // stays per browser tab).
5
+
6
+ export const DEFAULT_INSTANCE_VALUE = "__DEFAULT__";
7
+ export const CROSS_INSTANCE_VALUE = "__CROSS_INSTANCE__";
8
+
9
+ export interface BrowserTab {
10
+ id: string;
11
+ schema: string;
12
+ // DEFAULT_INSTANCE_VALUE, CROSS_INSTANCE_VALUE or a named instance id.
13
+ instance: string;
14
+ table: string;
15
+ // VS Code-style preview tab: the next openTab reuses it instead of adding
16
+ // a new tab. At most one per session; pinning (double-click, pin icon,
17
+ // drag-reorder, editing a cell) clears the flag.
18
+ preview?: boolean;
19
+ }
20
+
21
+ const STORAGE_KEY = "dms-database:data-browser:tabs";
22
+
23
+ function browserTabId(
24
+ schema: string,
25
+ instance: string,
26
+ table: string,
27
+ ): string {
28
+ return `${schema}::${instance}::${table}`;
29
+ }
30
+
31
+ interface PersistedTabs {
32
+ tabs: BrowserTab[];
33
+ activeId: string | null;
34
+ }
35
+
36
+ function readPersisted(): PersistedTabs | null {
37
+ if (typeof window === "undefined") return null;
38
+ try {
39
+ const raw = window.sessionStorage.getItem(STORAGE_KEY);
40
+ if (!raw) return null;
41
+ const parsed = JSON.parse(raw) as PersistedTabs;
42
+ if (!Array.isArray(parsed.tabs)) return null;
43
+ const tabs = parsed.tabs
44
+ .filter(
45
+ (tab) =>
46
+ tab &&
47
+ typeof tab.schema === "string" &&
48
+ typeof tab.instance === "string" &&
49
+ typeof tab.table === "string",
50
+ )
51
+ .map((tab) => ({
52
+ ...tab,
53
+ id: browserTabId(tab.schema, tab.instance, tab.table),
54
+ preview: tab.preview === true,
55
+ }))
56
+ // Recomputed ids can collide on a legacy/hand-edited payload; duplicate
57
+ // ids would break the tab bar's v-for keys and closeTab.
58
+ .filter(
59
+ (tab, index, all) => all.findIndex((t) => t.id === tab.id) === index,
60
+ );
61
+ // The preview slot is a singleton; a hand-edited payload could carry
62
+ // several — honour only the first.
63
+ const firstPreview = tabs.findIndex((tab) => tab.preview);
64
+ for (const [index, tab] of tabs.entries()) {
65
+ if (index !== firstPreview) tab.preview = false;
66
+ }
67
+ const activeId = tabs.some((tab) => tab.id === parsed.activeId)
68
+ ? parsed.activeId
69
+ : (tabs[0]?.id ?? null);
70
+ return { tabs, activeId };
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ export function useDataBrowserTabs() {
77
+ const tabs = useDmsState<BrowserTab[]>("dms-database-browser-tabs", () => []);
78
+ const activeId = useDmsState<string | null>(
79
+ "dms-database-browser-active",
80
+ () => null,
81
+ );
82
+ const route = useDmsRoute();
83
+ const router = useDmsRouter();
84
+ // Captured at setup so closeTab can drop the closed tab's
85
+ // grid state itself instead of relying on every caller to remember to.
86
+ const grid = useDataBrowserGrid();
87
+
88
+ const activeTab = computed(
89
+ () => tabs.value.find((tab) => tab.id === activeId.value) ?? null,
90
+ );
91
+ // When tabs span several schemas the tab bar disambiguates its labels.
92
+ const hasMultipleSchemas = computed(
93
+ () => new Set(tabs.value.map((tab) => tab.schema)).size > 1,
94
+ );
95
+
96
+ function persist() {
97
+ if (typeof window === "undefined") return;
98
+ try {
99
+ window.sessionStorage.setItem(
100
+ STORAGE_KEY,
101
+ JSON.stringify({ tabs: tabs.value, activeId: activeId.value }),
102
+ );
103
+ } catch {
104
+ // Quota/private-mode failures only cost tab restoration.
105
+ }
106
+ }
107
+
108
+ // The URL carries the ACTIVE tab only (deep-links); per-tab grid state
109
+ // deliberately stays out of it.
110
+ function syncUrl() {
111
+ const tab = activeTab.value;
112
+ const query = { ...route.query };
113
+ delete query.schema;
114
+ delete query.instance;
115
+ delete query.table;
116
+ if (tab) {
117
+ query.schema = tab.schema;
118
+ query.table = tab.table;
119
+ if (tab.instance !== DEFAULT_INSTANCE_VALUE) query.instance = tab.instance;
120
+ }
121
+ if (
122
+ Object.keys(query).length === Object.keys(route.query).length &&
123
+ Object.entries(query).every(([key, value]) => route.query[key] === value)
124
+ ) return;
125
+ router.replace({ query });
126
+ }
127
+
128
+ function activateTab(id: string) {
129
+ if (!tabs.value.some((tab) => tab.id === id)) return;
130
+ activeId.value = id;
131
+ syncUrl();
132
+ persist();
133
+ }
134
+
135
+ // Promote a preview tab to a permanent one (double-click, pin icon, drag).
136
+ function pinTab(id: string) {
137
+ const tab = tabs.value.find((t) => t.id === id);
138
+ if (!tab?.preview) return;
139
+ tabs.value = tabs.value.map((t) =>
140
+ t.id === id ? { ...t, preview: false } : t,
141
+ );
142
+ persist();
143
+ }
144
+
145
+ // Opens as a preview by default (VS Code-style: single-click reuses the
146
+ // preview slot). Pass preview = false for an explicitly permanent open
147
+ // (sidebar double-click, deep-links) — it also pins the tab if already
148
+ // open as preview.
149
+ function openTab(
150
+ schema: string,
151
+ instance: string,
152
+ table: string,
153
+ preview = true,
154
+ ) {
155
+ const id = browserTabId(schema, instance, table);
156
+ const existing = tabs.value.find((tab) => tab.id === id);
157
+ if (existing) {
158
+ if (!preview) pinTab(id);
159
+ activateTab(id);
160
+ return;
161
+ }
162
+ const tab: BrowserTab = { id, schema, instance, table, preview };
163
+ const previewIndex = tabs.value.findIndex((t) => t.preview);
164
+ if (preview && previewIndex >= 0) {
165
+ // Reuse the preview slot in place: swap its content, drop the replaced
166
+ // tab's grid state (its id dies with it).
167
+ const replaced = tabs.value[previewIndex]!;
168
+ const next = [...tabs.value];
169
+ next[previewIndex] = tab;
170
+ tabs.value = next;
171
+ grid.drop(replaced.id);
172
+ } else {
173
+ tabs.value = [...tabs.value, tab];
174
+ }
175
+ activateTab(id);
176
+ }
177
+
178
+ function closeTab(id: string) {
179
+ const index = tabs.value.findIndex((tab) => tab.id === id);
180
+ if (index < 0) return;
181
+ const next = tabs.value.filter((tab) => tab.id !== id);
182
+ tabs.value = next;
183
+ if (activeId.value === id) {
184
+ // Right neighbour first, then left — mirrors browser tab behaviour.
185
+ activeId.value = (next[index] ?? next[index - 1])?.id ?? null;
186
+ }
187
+ // The closed tab's grid state and cached rows go with it — otherwise they
188
+ // leak for the rest of the session.
189
+ grid.drop(id);
190
+ syncUrl();
191
+ persist();
192
+ }
193
+
194
+ // Reorder (drag & drop): move the dragged tab to the target tab's position.
195
+ // Pure reorder — pin-on-drag is a gesture policy and lives at the drop site
196
+ // (the tab bar's onDrop), not in this primitive.
197
+ function moveTab(draggedId: string, targetId: string) {
198
+ if (draggedId === targetId) return;
199
+ const next = [...tabs.value];
200
+ const from = next.findIndex((tab) => tab.id === draggedId);
201
+ const to = next.findIndex((tab) => tab.id === targetId);
202
+ if (from < 0 || to < 0) return;
203
+ const [moved] = next.splice(from, 1);
204
+ if (!moved) return;
205
+ next.splice(to, 0, moved);
206
+ tabs.value = next;
207
+ persist();
208
+ }
209
+
210
+ // Restore persisted tabs, then let the URL win for the active selection so
211
+ // existing deep-links keep working. Call exactly once, from the page's
212
+ // onMounted.
213
+ function restore() {
214
+ const persisted = readPersisted();
215
+ if (persisted) {
216
+ tabs.value = persisted.tabs;
217
+ activeId.value = persisted.activeId;
218
+ }
219
+ const schema = route.query.schema;
220
+ const table = route.query.table;
221
+ if (
222
+ typeof schema === "string" &&
223
+ schema &&
224
+ typeof table === "string" &&
225
+ table
226
+ ) {
227
+ const instance =
228
+ typeof route.query.instance === "string" && route.query.instance
229
+ ? route.query.instance
230
+ : DEFAULT_INSTANCE_VALUE;
231
+ // Deep-links carry explicit intent (overview row double-click, the
232
+ // inspector's "browse data" button, shared URLs): open permanently, the
233
+ // preview slot is for casual sidebar browsing only.
234
+ openTab(schema, instance, table, false);
235
+ } else if (!schema && activeTab.value) {
236
+ // Only reclaim the URL when it carries no selection at all: a
237
+ // schema-only deep-link (?schema=X) is the sidebar's to honour and
238
+ // must not be clobbered by the restored tab.
239
+ syncUrl();
240
+ }
241
+ }
242
+
243
+ return {
244
+ tabs,
245
+ activeId,
246
+ activeTab,
247
+ hasMultipleSchemas,
248
+ openTab,
249
+ closeTab,
250
+ activateTab,
251
+ pinTab,
252
+ moveTab,
253
+ restore,
254
+ };
255
+ }