@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,735 @@
1
+ <script setup lang="ts">
2
+ import type { BrowserTab } from "../composables/useDataBrowserTabs";
3
+ import type {
4
+ ColumnFilterState,
5
+ GridColumn,
6
+ GridData,
7
+ } from "../composables/useDataBrowserGrid";
8
+
9
+ const BROWSE_ENDPOINT = "/api/database/browse";
10
+ const QUERY_PATH = "/modules/database/query";
11
+ // Browse-only synthetic columns; never editable, never persisted.
12
+ const SYNTHETIC_FIELDS = new Set(["_instance"]);
13
+ const DEFAULT_COLUMN_WIDTH = 192;
14
+ // Primary key assumed when the backend reports none (e.g. MongoDB's implicit id).
15
+ const DEFAULT_PRIMARY_KEY = "_id";
16
+ // Debounce for the toolbar's global search input.
17
+ const SEARCH_DEBOUNCE_MS = 300;
18
+
19
+ interface ColumnsResponse {
20
+ columns: string[];
21
+ types: Record<string, string>;
22
+ primaryKey: string | null;
23
+ }
24
+
25
+ interface ListResponse {
26
+ results: Record<string, unknown>[];
27
+ total: number;
28
+ offset: number;
29
+ limit: number;
30
+ primaryKey: string | null;
31
+ }
32
+
33
+ const props = defineProps<{
34
+ tab: BrowserTab;
35
+ // Schema-introspected field names, used when the table is empty and the
36
+ // backend column sampling returns nothing.
37
+ fallbackFields: string[];
38
+ }>();
39
+
40
+ const { t } = useI18n();
41
+ const { $authFetch } = useAuthFetch();
42
+ const toast = useToast();
43
+ const router = useDmsRouter();
44
+ const grid = useDataBrowserGrid();
45
+ const { tabs: browserTabs, pinTab } = useDataBrowserTabs();
46
+
47
+ const state = grid.getState(props.tab.id);
48
+ const readOnly = computed(() => props.tab.instance === CROSS_INSTANCE_VALUE);
49
+
50
+ // --- data loading ---
51
+ const data = ref<GridData | null>(grid.getCache(props.tab.id));
52
+ const loading = ref(false);
53
+ const loadError = ref<string | null>(null);
54
+ let requestSeq = 0;
55
+ // Column metadata only depends on the tab's selection (the component remounts
56
+ // per tab), so it is fetched once — not on every page/sort/filter change — and
57
+ // refreshed only by an explicit reload. `null` means not fetched or failed.
58
+ const columnsMeta = ref<ColumnsResponse | null>(null);
59
+
60
+ async function load(force = false) {
61
+ const key = browseParamsKey(props.tab, state);
62
+ const cached = grid.getCache(props.tab.id);
63
+ if (!force && cached && cached.paramsKey === key) {
64
+ // Restoring from cache must also retire any in-flight request (its late
65
+ // response would pass the seq guard and overwrite the cache with rows
66
+ // for parameters the user has already left) and clear transient state
67
+ // from a previous attempt.
68
+ requestSeq += 1;
69
+ data.value = cached;
70
+ loadError.value = null;
71
+ loading.value = false;
72
+ return;
73
+ }
74
+ const seq = ++requestSeq;
75
+ loading.value = true;
76
+ loadError.value = null;
77
+ try {
78
+ const selection = browseSelectionQuery(props.tab);
79
+ const needColumns = force || columnsMeta.value === null;
80
+ const [columnsRes, listRes] = await Promise.all([
81
+ needColumns
82
+ ? // A columns failure must not discard a successful rows fetch: the
83
+ // grid falls back to schema-introspected field names instead.
84
+ $authFetch<ColumnsResponse>(`${BROWSE_ENDPOINT}/columns`, {
85
+ query: selection,
86
+ }).catch(() => null)
87
+ : Promise.resolve(columnsMeta.value),
88
+ $authFetch<ListResponse>(`${BROWSE_ENDPOINT}/list`, {
89
+ query: { ...selection, ...browseListQuery(state) },
90
+ }),
91
+ ]);
92
+ // Ignore a response that arrived after the params changed again.
93
+ if (seq !== requestSeq) return;
94
+ if (columnsRes) columnsMeta.value = columnsRes;
95
+ const meta = columnsMeta.value;
96
+ const primaryKey = listRes.primaryKey ?? meta?.primaryKey ?? null;
97
+ const sampled = meta?.columns ?? [];
98
+ const names = sampled.length > 0 ? sampled : props.fallbackFields;
99
+ const next: GridData = {
100
+ columns: names.map((name) => ({
101
+ name,
102
+ type: meta?.types[name] ?? "string",
103
+ isPrimaryKey: name === (primaryKey ?? DEFAULT_PRIMARY_KEY),
104
+ })),
105
+ rows: listRes.results ?? [],
106
+ total: listRes.total ?? 0,
107
+ primaryKey,
108
+ paramsKey: key,
109
+ };
110
+ grid.setCache(props.tab.id, next);
111
+ data.value = next;
112
+ // An open positional editor whose row no longer sits at the same index
113
+ // must not survive a rows replacement (commit would target another row).
114
+ const editingCell = editing.value;
115
+ if (
116
+ editingCell &&
117
+ rowIdOf(next.rows[editingCell.r] ?? {}) !== editingCell.rowId
118
+ ) {
119
+ cancelEdit();
120
+ }
121
+ // Clamp the page when the total shrank under the current offset (rows
122
+ // deleted elsewhere): correcting state.page re-triggers the watcher.
123
+ if (next.total > 0 && state.page * state.pageSize >= next.total) {
124
+ state.page = Math.max(0, Math.ceil(next.total / state.pageSize) - 1);
125
+ }
126
+ } catch (error) {
127
+ if (seq !== requestSeq) return;
128
+ loadError.value = error instanceof Error ? error.message : String(error);
129
+ } finally {
130
+ if (seq === requestSeq) loading.value = false;
131
+ }
132
+ }
133
+
134
+ watch(
135
+ () => browseParamsKey(props.tab, state),
136
+ () => load(),
137
+ { immediate: true },
138
+ );
139
+
140
+ // An empty table cached with no columns while the schemas fetch was still in
141
+ // flight must recover once the schema-introspected fields arrive — nothing
142
+ // else re-triggers the load (fallbackFields is not part of the params key).
143
+ watch(
144
+ () => props.fallbackFields,
145
+ (fields) => {
146
+ if (
147
+ fields.length > 0 &&
148
+ (data.value?.columns.length ?? 0) === 0 &&
149
+ !loading.value
150
+ ) {
151
+ load(true);
152
+ }
153
+ },
154
+ );
155
+
156
+ function reload() {
157
+ grid.invalidate(props.tab.id);
158
+ load(true);
159
+ }
160
+
161
+ const columns = computed(() => data.value?.columns ?? []);
162
+ const rows = computed(() => data.value?.rows ?? []);
163
+ const total = computed(() => data.value?.total ?? 0);
164
+ const rowIdKey = computed(() => data.value?.primaryKey ?? DEFAULT_PRIMARY_KEY);
165
+
166
+ // --- toolbar: debounced global search ---
167
+ const searchDraft = ref(state.search);
168
+ let searchTimer: ReturnType<typeof setTimeout> | undefined;
169
+ watch(searchDraft, (value) => {
170
+ clearTimeout(searchTimer);
171
+ searchTimer = setTimeout(() => {
172
+ state.search = value.trim();
173
+ state.page = 0;
174
+ }, SEARCH_DEBOUNCE_MS);
175
+ });
176
+ onBeforeUnmount(() => {
177
+ clearTimeout(searchTimer);
178
+ // Retire any in-flight browse request: its late response would otherwise
179
+ // setCache under this tab's id AFTER the tab was replaced/closed and
180
+ // grid.drop() ran — resurrecting the entry, whose paramsKey then matches a
181
+ // fresh default state on re-open and serves stale rows with no refetch.
182
+ requestSeq += 1;
183
+ // Flush (not drop) a pending draft so a search typed just before switching
184
+ // tabs survives into this tab's state.
185
+ const value = searchDraft.value.trim();
186
+ if (value !== state.search) {
187
+ state.search = value;
188
+ state.page = 0;
189
+ }
190
+ });
191
+
192
+ const activeFilterCount = computed(
193
+ () => Object.values(state.filters).filter((filter) => filter.value).length,
194
+ );
195
+
196
+ function clearFilters() {
197
+ state.filters = {};
198
+ state.page = 0;
199
+ }
200
+
201
+ // --- sorting / filtering / resize ---
202
+ function toggleSort(name: string) {
203
+ if (state.sortKey !== name) {
204
+ state.sortKey = name;
205
+ state.sortDirection = "asc";
206
+ } else if (state.sortDirection === "asc") {
207
+ state.sortDirection = "desc";
208
+ } else {
209
+ state.sortKey = null;
210
+ state.sortDirection = "asc";
211
+ }
212
+ state.page = 0;
213
+ }
214
+
215
+ function setFilter(name: string, filter: ColumnFilterState | null) {
216
+ const next = { ...state.filters };
217
+ if (filter) next[name] = filter;
218
+ else Reflect.deleteProperty(next, name);
219
+ state.filters = next;
220
+ state.page = 0;
221
+ }
222
+
223
+ function widthFor(name: string): number {
224
+ return state.columnWidths[name] ?? DEFAULT_COLUMN_WIDTH;
225
+ }
226
+ function resizeColumn(name: string, width: number) {
227
+ state.columnWidths = { ...state.columnWidths, [name]: width };
228
+ }
229
+
230
+ // --- pagination ---
231
+ const pageSizeOptions = GRID_PAGE_SIZES.map((size) => ({
232
+ label: String(size),
233
+ value: size,
234
+ }));
235
+ const pageStart = computed(() =>
236
+ total.value === 0 ? 0 : state.page * state.pageSize + 1,
237
+ );
238
+ const pageEnd = computed(() =>
239
+ Math.min(total.value, (state.page + 1) * state.pageSize),
240
+ );
241
+ const hasNext = computed(() => pageEnd.value < total.value);
242
+ function setPageSize(size: number) {
243
+ state.pageSize = size;
244
+ state.page = 0;
245
+ }
246
+
247
+ // --- open in query console (prefilled DSL, kept from the previous page) ---
248
+ function schemaAccessExpr(schemaId: string): string {
249
+ // Dotted access for valid identifiers, bracket access otherwise — mirrors
250
+ // the query builder's own schema resolution.
251
+ return /^[A-Z_$][\w$]*$/i.test(schemaId)
252
+ ? `schemas.${schemaId}`
253
+ : `schemas[${JSON.stringify(schemaId)}]`;
254
+ }
255
+ function instanceArgExpr(): string {
256
+ if (props.tab.instance === DEFAULT_INSTANCE_VALUE) return "";
257
+ if (props.tab.instance === CROSS_INSTANCE_VALUE) return "CROSS_INSTANCE";
258
+ return JSON.stringify(props.tab.instance);
259
+ }
260
+ function openInQueryConsole() {
261
+ const source = `${schemaAccessExpr(props.tab.schema)}.instance(${instanceArgExpr()}).table(${JSON.stringify(props.tab.table)}).slice(0, 100)`;
262
+ router.push({ path: QUERY_PATH, query: { source } });
263
+ }
264
+
265
+ // --- cell focus & keyboard navigation ---
266
+ const scroller = useTemplateRef<HTMLDivElement>("scroller");
267
+ const focused = ref<{ r: number; c: number } | null>(null);
268
+
269
+ function focusCell(r: number, c: number) {
270
+ focused.value = { r, c };
271
+ nextTick(() => {
272
+ scroller.value
273
+ ?.querySelector(`[data-cell="${r}:${c}"]`)
274
+ ?.scrollIntoView({ block: "nearest", inline: "nearest" });
275
+ });
276
+ }
277
+
278
+ function onKeydown(event: KeyboardEvent) {
279
+ if (editing.value || jsonPanel.value) return;
280
+ const current = focused.value;
281
+ if (!current) return;
282
+ const maxR = rows.value.length - 1;
283
+ const maxC = columns.value.length - 1;
284
+ let { r, c } = current;
285
+ switch (event.key) {
286
+ case "ArrowUp":
287
+ r -= 1;
288
+ break;
289
+ case "ArrowDown":
290
+ r += 1;
291
+ break;
292
+ case "ArrowLeft":
293
+ c -= 1;
294
+ break;
295
+ case "ArrowRight":
296
+ c += 1;
297
+ break;
298
+ case "Tab": {
299
+ // Tab past either edge releases focus to the browser (no trap): the
300
+ // user can reach the toolbar/footer instead of being clamped inside.
301
+ const nextColumn = c + (event.shiftKey ? -1 : 1);
302
+ if (nextColumn < 0 || nextColumn > maxC) {
303
+ focused.value = null;
304
+ return;
305
+ }
306
+ c = nextColumn;
307
+ break;
308
+ }
309
+ case "Enter":
310
+ event.preventDefault();
311
+ openEditorAt(current.r, current.c);
312
+ return;
313
+ case "Escape":
314
+ focused.value = null;
315
+ return;
316
+ default:
317
+ return;
318
+ }
319
+ event.preventDefault();
320
+ focusCell(Math.max(0, Math.min(maxR, r)), Math.max(0, Math.min(maxC, c)));
321
+ }
322
+
323
+ // --- inline editing ---
324
+ // Both editors carry the target rowId captured at open time: a mid-edit reload
325
+ // replaces the rows array, and a stale positional index would silently write
326
+ // into whichever row now sits at that position. r/c only locate the visual
327
+ // cell; every write resolves the row by id at commit time.
328
+ const editing = ref<{
329
+ r: number;
330
+ c: number;
331
+ rowId: string | null;
332
+ field: string;
333
+ } | null>(null);
334
+ const invalidDraft = ref(false);
335
+ const pendingCell = ref<{ rowId: string; field: string } | null>(null);
336
+ const savingJson = ref(false);
337
+ const jsonPanel = ref<{
338
+ rowId: string | null;
339
+ field: string;
340
+ value: unknown;
341
+ readOnly: boolean;
342
+ } | null>(null);
343
+
344
+ function columnAt(c: number): GridColumn | null {
345
+ return columns.value[c] ?? null;
346
+ }
347
+
348
+ function isCellEditable(field: string): boolean {
349
+ if (readOnly.value) return false;
350
+ if (field === DEFAULT_PRIMARY_KEY || field === rowIdKey.value) return false;
351
+ if (SYNTHETIC_FIELDS.has(field)) return false;
352
+ return true;
353
+ }
354
+
355
+ function rowIdOf(row: Record<string, unknown>): string | null {
356
+ const id = row[rowIdKey.value];
357
+ if (id === null || id === undefined) return null;
358
+ return String(id);
359
+ }
360
+
361
+ function openEditorAt(r: number, c: number) {
362
+ const column = columnAt(c);
363
+ const row = rows.value[r];
364
+ if (!column || !row) return;
365
+ const value = row[column.name];
366
+ if (value !== null && value !== undefined && typeof value === "object") {
367
+ // Structured cell: always viewable through the JSON panel; editable only
368
+ // when the field itself is writable and the row is addressable — the
369
+ // backend silently drops writes to protected fields, so offering a Save
370
+ // there would fake success.
371
+ jsonPanel.value = {
372
+ rowId: rowIdOf(row),
373
+ field: column.name,
374
+ value,
375
+ readOnly: !isCellEditable(column.name) || rowIdOf(row) === null,
376
+ };
377
+ return;
378
+ }
379
+ // A row with no addressable primary key cannot be saved on any path, so
380
+ // refuse to open the editor instead of failing at commit time (mirrors the
381
+ // JSON panel's readOnly guard).
382
+ const rowId = rowIdOf(row);
383
+ if (!isCellEditable(column.name) || rowId === null) return;
384
+ invalidDraft.value = false;
385
+ editing.value = { r, c, rowId, field: column.name };
386
+ }
387
+
388
+ // Refocusing the grid after an editor closes must not steal focus from a
389
+ // control the user has since clicked (toolbar search, another cell's editor):
390
+ // only reclaim it when the blur left focus on the document body.
391
+ function refocusGrid() {
392
+ nextTick(() => {
393
+ if (document.activeElement === document.body) scroller.value?.focus();
394
+ });
395
+ }
396
+
397
+ function cancelEdit() {
398
+ editing.value = null;
399
+ invalidDraft.value = false;
400
+ refocusGrid();
401
+ }
402
+
403
+ // Shared persistence for both editors: PUT one field, then merge the echoed
404
+ // row into the row the grid currently renders (resolved by id AFTER the await
405
+ // — a concurrent reload may have replaced the rows array, and assigning into
406
+ // the pre-await capture would update a detached object).
407
+ async function persistField(rowId: string, field: string, value: unknown) {
408
+ const updated = await $authFetch<Record<string, unknown>>(
409
+ `${BROWSE_ENDPOINT}/edit`,
410
+ {
411
+ method: "PUT",
412
+ query: { ...browseSelectionQuery(props.tab), id: rowId },
413
+ body: { [field]: value },
414
+ },
415
+ );
416
+ const live = rows.value.find((candidate) => rowIdOf(candidate) === rowId);
417
+ if (live) Object.assign(live, updated);
418
+ // A table the user edited is "modified" in the VS Code sense: pin its
419
+ // preview tab so the next sidebar click doesn't silently discard it. Gate
420
+ // on the tab OBJECT still being open — the id is content-derived, so a
421
+ // slow edit whose tab was replaced then reopened would otherwise pin the
422
+ // fresh preview without any pin gesture.
423
+ if (browserTabs.value.includes(props.tab)) pinTab(props.tab.id);
424
+ }
425
+
426
+ async function commitCell(draftValue: string | boolean, source: "enter" | "blur") {
427
+ const cell = editing.value;
428
+ if (!cell) return;
429
+ const column = columnAt(cell.c);
430
+ // Resolve by the rowId captured at open time — never by position.
431
+ const row = cell.rowId
432
+ ? rows.value.find((candidate) => rowIdOf(candidate) === cell.rowId)
433
+ : undefined;
434
+ if (!column || !row) {
435
+ cancelEdit();
436
+ return;
437
+ }
438
+ const before = row[column.name];
439
+
440
+ // Preserve the stored type; a NULL cell falls back to the column's sampled
441
+ // type so filling it can't silently flip a number/boolean column to string.
442
+ const targetType =
443
+ before === null || before === undefined ? column.type : typeof before;
444
+ let next: string | number | boolean;
445
+ if (targetType === "number") {
446
+ const text = typeof draftValue === "string" ? draftValue.trim() : "";
447
+ const parsed = Number(text);
448
+ if (text === "" || !Number.isFinite(parsed)) {
449
+ // Enter keeps the editor open to fix the draft; a blur-commit must not
450
+ // leave an unfocused editor behind (it would deaden grid keyboard nav),
451
+ // so click-away discards the invalid draft.
452
+ if (source === "blur") cancelEdit();
453
+ else invalidDraft.value = true;
454
+ return;
455
+ }
456
+ next = parsed;
457
+ } else if (targetType === "boolean") {
458
+ const text =
459
+ typeof draftValue === "string" ? draftValue.trim().toLowerCase() : draftValue;
460
+ if (text === true || text === "true") next = true;
461
+ else if (text === false || text === "false") next = false;
462
+ else {
463
+ if (source === "blur") cancelEdit();
464
+ else invalidDraft.value = true;
465
+ return;
466
+ }
467
+ } else {
468
+ next = String(draftValue);
469
+ }
470
+
471
+ // A double-clicked NULL cell committed untouched must stay NULL — writing
472
+ // "" over null on click-away would silently mutate the row.
473
+ if (
474
+ (before === null || before === undefined) &&
475
+ typeof next === "string" &&
476
+ next === ""
477
+ ) {
478
+ cancelEdit();
479
+ return;
480
+ }
481
+
482
+ // Unchanged echo: close silently, nothing to save.
483
+ if (before !== null && before !== undefined && String(before) === String(next)) {
484
+ cancelEdit();
485
+ return;
486
+ }
487
+
488
+ const rowId = cell.rowId;
489
+ if (!rowId) {
490
+ toast.add({ title: t("dms_database.data.editError"), color: "error" });
491
+ cancelEdit();
492
+ return;
493
+ }
494
+
495
+ editing.value = null;
496
+ invalidDraft.value = false;
497
+ pendingCell.value = { rowId, field: column.name };
498
+ try {
499
+ await persistField(rowId, column.name, next);
500
+ } catch (error) {
501
+ handleEditError(error);
502
+ } finally {
503
+ pendingCell.value = null;
504
+ refocusGrid();
505
+ }
506
+ }
507
+
508
+ async function saveJson(value: unknown) {
509
+ const panel = jsonPanel.value;
510
+ if (!panel || panel.readOnly || !panel.rowId) return;
511
+ savingJson.value = true;
512
+ try {
513
+ await persistField(panel.rowId, panel.field, value);
514
+ jsonPanel.value = null;
515
+ } catch (error) {
516
+ handleEditError(error);
517
+ } finally {
518
+ savingJson.value = false;
519
+ }
520
+ }
521
+
522
+ function handleEditError(error: unknown) {
523
+ const status =
524
+ (error as { statusCode?: number } | null)?.statusCode ??
525
+ (error as { status?: number } | null)?.status;
526
+ if (status === 404) {
527
+ // The row vanished under us: tell the user, drop any open editor that
528
+ // still points at it, and resync the page.
529
+ toast.add({ title: t("dms_database.data.rowVanished"), color: "warning" });
530
+ jsonPanel.value = null;
531
+ reload();
532
+ return;
533
+ }
534
+ const description = error instanceof Error ? error.message : undefined;
535
+ toast.add({
536
+ title: t("dms_database.data.editError"),
537
+ description,
538
+ color: "error",
539
+ });
540
+ }
541
+
542
+ function isPending(row: Record<string, unknown>, field: string): boolean {
543
+ const pending = pendingCell.value;
544
+ return pending !== null && pending.field === field && rowIdOf(row) === pending.rowId;
545
+ }
546
+
547
+ // Cross-instance unions can repeat the same primary key across instances, so
548
+ // the instance tag joins the key to keep v-for keys unique.
549
+ function rowKey(row: Record<string, unknown>, index: number): string | number {
550
+ const id = rowIdOf(row);
551
+ if (id === null) return index;
552
+ return readOnly.value ? `${String(row._instance ?? "")}::${id}` : id;
553
+ }
554
+ </script>
555
+
556
+ <template>
557
+ <div class="flex min-h-0 flex-1 flex-col">
558
+ <!-- toolbar -->
559
+ <div class="flex items-center gap-2 border-b border-default px-3 py-2">
560
+ <UInput
561
+ v-model="searchDraft"
562
+ icon="i-ph-magnifying-glass"
563
+ :placeholder="t('dms_database.data.searchPlaceholder')"
564
+ size="sm"
565
+ class="w-64"
566
+ />
567
+ <UBadge
568
+ v-if="activeFilterCount > 0"
569
+ color="primary"
570
+ variant="subtle"
571
+ class="cursor-pointer"
572
+ @click="clearFilters"
573
+ >
574
+ {{ t("dms_database.data.filtersActive", { count: activeFilterCount }) }} ✕
575
+ </UBadge>
576
+ <UTooltip v-if="readOnly" :text="t('dms_database.data.crossInstanceNotice')">
577
+ <UBadge color="warning" variant="subtle" icon="i-ph-lock">
578
+ {{ t("dms_database.data.readOnly") }}
579
+ </UBadge>
580
+ </UTooltip>
581
+ <div class="ml-auto flex items-center gap-1.5">
582
+ <UButton
583
+ icon="i-ph-arrow-clockwise"
584
+ variant="ghost"
585
+ color="neutral"
586
+ size="sm"
587
+ :loading="loading"
588
+ :label="t('dms_database.data.refresh')"
589
+ @click="reload"
590
+ />
591
+ <UButton
592
+ icon="i-ph-code"
593
+ variant="ghost"
594
+ color="info"
595
+ size="sm"
596
+ :label="t('dms_database.data.openInQuery')"
597
+ @click="openInQueryConsole"
598
+ />
599
+ </div>
600
+ </div>
601
+
602
+ <!-- grid -->
603
+ <div
604
+ ref="scroller"
605
+ class="relative min-h-0 flex-1 overflow-auto outline-none"
606
+ tabindex="0"
607
+ @keydown="onKeydown"
608
+ >
609
+ <table
610
+ v-if="columns.length > 0"
611
+ class="w-max min-w-full border-separate border-spacing-0"
612
+ style="table-layout: fixed"
613
+ >
614
+ <colgroup>
615
+ <col style="width: 44px">
616
+ <col
617
+ v-for="column in columns"
618
+ :key="column.name"
619
+ :style="{ width: `${widthFor(column.name)}px` }"
620
+ >
621
+ </colgroup>
622
+ <thead class="sticky top-0 z-10">
623
+ <tr>
624
+ <th
625
+ class="sticky left-0 z-20 border-r border-b border-default bg-elevated"
626
+ scope="col"
627
+ />
628
+ <DmsDatabaseDataGridHeaderCell
629
+ v-for="column in columns"
630
+ :key="column.name"
631
+ :column="column"
632
+ :sort-key="state.sortKey"
633
+ :sort-direction="state.sortDirection"
634
+ :filter="state.filters[column.name] ?? null"
635
+ :width="widthFor(column.name)"
636
+ @sort="toggleSort(column.name)"
637
+ @filter="setFilter(column.name, $event)"
638
+ @resize="resizeColumn(column.name, $event)"
639
+ />
640
+ </tr>
641
+ </thead>
642
+ <tbody>
643
+ <tr v-for="(row, r) in rows" :key="rowKey(row, r)">
644
+ <td
645
+ class="sticky left-0 z-[5] border-r border-b border-default bg-elevated px-2 py-1 text-right font-mono text-[10.5px] tabular-nums text-dimmed"
646
+ >
647
+ {{ state.page * state.pageSize + r + 1 }}
648
+ </td>
649
+ <DmsDatabaseDataGridCell
650
+ v-for="(column, c) in columns"
651
+ :key="column.name"
652
+ :data-cell="`${r}:${c}`"
653
+ :value="row[column.name]"
654
+ :editable="isCellEditable(column.name)"
655
+ :editing="editing?.r === r && editing?.c === c"
656
+ :pending="isPending(row, column.name)"
657
+ :invalid="invalidDraft && editing?.r === r && editing?.c === c"
658
+ :focused="focused?.r === r && focused?.c === c"
659
+ @select="focusCell(r, c)"
660
+ @start-edit="openEditorAt(r, c)"
661
+ @commit="commitCell"
662
+ @cancel="cancelEdit"
663
+ @open-json="openEditorAt(r, c)"
664
+ />
665
+ </tr>
666
+ </tbody>
667
+ </table>
668
+
669
+ <div
670
+ v-if="columns.length === 0 && !loading"
671
+ class="flex h-full items-center justify-center text-sm text-muted"
672
+ >
673
+ {{ t("dms_database.data.noColumns") }}
674
+ </div>
675
+ <div
676
+ v-else-if="rows.length === 0 && !loading && !loadError"
677
+ class="absolute inset-x-0 top-16 text-center text-sm text-muted"
678
+ >
679
+ {{ t("dms_database.data.noRows") }}
680
+ </div>
681
+ <div v-if="loadError" class="absolute inset-x-0 top-16 mx-auto max-w-md">
682
+ <UAlert color="error" icon="i-ph-warning" :description="loadError" />
683
+ </div>
684
+ <div v-if="loading" class="absolute inset-x-0 top-0">
685
+ <UProgress size="xs" />
686
+ </div>
687
+ </div>
688
+
689
+ <!-- footer / pagination -->
690
+ <div
691
+ class="flex items-center gap-3 border-t border-default px-3 py-1.5 text-xs text-muted"
692
+ >
693
+ <span>{{ t("dms_database.data.rowsTotal", { count: total }) }}</span>
694
+ <div class="ml-auto flex items-center gap-2">
695
+ <USelect
696
+ :model-value="state.pageSize"
697
+ :items="pageSizeOptions"
698
+ size="xs"
699
+ class="w-20"
700
+ @update:model-value="setPageSize($event as number)"
701
+ />
702
+ <span class="tabular-nums">{{ pageStart }}–{{ pageEnd }}</span>
703
+ <UButton
704
+ icon="i-ph-caret-left"
705
+ variant="ghost"
706
+ color="neutral"
707
+ size="xs"
708
+ :disabled="state.page === 0"
709
+ :aria-label="t('dms_database.data.prevPage')"
710
+ @click="state.page -= 1"
711
+ />
712
+ <UButton
713
+ icon="i-ph-caret-right"
714
+ variant="ghost"
715
+ color="neutral"
716
+ size="xs"
717
+ :disabled="!hasNext"
718
+ :aria-label="t('dms_database.data.nextPage')"
719
+ @click="state.page += 1"
720
+ />
721
+ </div>
722
+ </div>
723
+
724
+ <!-- JSON editor panel -->
725
+ <DmsDatabaseJsonCellPanel
726
+ :open="jsonPanel !== null"
727
+ :column="jsonPanel?.field ?? ''"
728
+ :value="jsonPanel?.value"
729
+ :saving="savingJson"
730
+ :read-only="jsonPanel?.readOnly ?? true"
731
+ @update:open="jsonPanel = $event ? jsonPanel : null"
732
+ @save="saveJson"
733
+ />
734
+ </div>
735
+ </template>