@antelopejs/dms-database 0.0.14 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -5
- package/dist/db/models/query_history.model.d.ts +1 -0
- package/dist/db/models/query_history.model.js +6 -0
- package/dist/db/models/query_history.model.js.map +1 -1
- package/dist/db/tables/query_history.table.d.ts +3 -0
- package/dist/db/tables/query_history.table.js +12 -0
- package/dist/db/tables/query_history.table.js.map +1 -1
- package/dist/db/tables/saved_queries.table.d.ts +1 -0
- package/dist/db/tables/saved_queries.table.js +4 -0
- package/dist/db/tables/saved_queries.table.js.map +1 -1
- package/dist/i18n/messages.d.ts +39 -0
- package/dist/i18n/messages.js +64 -0
- package/dist/i18n/messages.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -7
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +11 -0
- package/dist/module.js +55 -0
- package/dist/module.js.map +1 -0
- package/dist/pages/data.d.ts +1 -1
- package/dist/pages/data.js +9 -6
- package/dist/pages/data.js.map +1 -1
- package/dist/pages/diagram.d.ts +9 -0
- package/dist/pages/diagram.js +45 -0
- package/dist/pages/diagram.js.map +1 -0
- package/dist/pages/index.d.ts +1 -0
- package/dist/pages/index.js +1 -0
- package/dist/pages/index.js.map +1 -1
- package/dist/pages/overview.d.ts +4 -1
- package/dist/pages/overview.js +113 -6
- package/dist/pages/overview.js.map +1 -1
- package/dist/pages/query.d.ts +1 -1
- package/dist/pages/query.js +22 -6
- package/dist/pages/query.js.map +1 -1
- package/dist/pages/schemas.d.ts +2 -1
- package/dist/pages/schemas.js +143 -6
- package/dist/pages/schemas.js.map +1 -1
- package/dist/routes/data.d.ts +8 -0
- package/dist/routes/data.js +84 -21
- package/dist/routes/data.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/overview.d.ts +12 -0
- package/dist/routes/overview.js +55 -0
- package/dist/routes/overview.js.map +1 -0
- package/dist/routes/query.d.ts +5 -1
- package/dist/routes/query.js +162 -27
- package/dist/routes/query.js.map +1 -1
- package/dist/routes/tables.d.ts +15 -12
- package/dist/routes/tables.js +95 -55
- package/dist/routes/tables.js.map +1 -1
- package/dist/service/catalog.d.ts +3 -0
- package/dist/service/catalog.js +26 -0
- package/dist/service/catalog.js.map +1 -0
- package/dist/service/health.d.ts +3 -3
- package/dist/service/health.js +17 -14
- package/dist/service/health.js.map +1 -1
- package/dist/service/introspect.d.ts +3 -1
- package/dist/service/introspect.js +131 -43
- package/dist/service/introspect.js.map +1 -1
- package/dist/service/overview.d.ts +9 -0
- package/dist/service/overview.js +113 -0
- package/dist/service/overview.js.map +1 -0
- package/dist/service/queryInspection.d.ts +15 -0
- package/dist/service/queryInspection.js +107 -0
- package/dist/service/queryInspection.js.map +1 -0
- package/dist/service/references.d.ts +9 -0
- package/dist/service/references.js +52 -0
- package/dist/service/references.js.map +1 -0
- package/dist/service/schemaFilters.d.ts +26 -0
- package/dist/service/schemaFilters.js +42 -0
- package/dist/service/schemaFilters.js.map +1 -0
- package/dist/service/stagedSerialization.d.ts +2 -0
- package/dist/service/stagedSerialization.js +38 -10
- package/dist/service/stagedSerialization.js.map +1 -1
- package/dist/service/types.d.ts +11 -1
- package/dist/types/constants.d.ts +2 -0
- package/dist/types/constants.js +3 -1
- package/dist/types/constants.js.map +1 -1
- package/dist/utils/query.d.ts +0 -2
- package/dist/utils/query.js +0 -20
- package/dist/utils/query.js.map +1 -1
- package/frontend-vue/app/{composables → build/composables}/useDataBrowserGrid.ts +65 -50
- package/frontend-vue/app/build/composables/useDataBrowserTabs.ts +366 -0
- package/frontend-vue/app/build/composables/useDatabaseHealth.ts +50 -0
- package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +140 -0
- package/frontend-vue/app/{composables → build/composables}/useDiagramPersistence.ts +47 -39
- package/frontend-vue/app/build/composables/useQueryRuntime.ts +217 -0
- package/frontend-vue/app/build/composables/useQueryStore.ts +162 -0
- package/frontend-vue/app/build/data/cellValues.ts +132 -0
- package/frontend-vue/app/build/data/instanceOptions.ts +36 -0
- package/frontend-vue/app/build/data/pickerMenus.ts +175 -0
- package/frontend-vue/app/build/data/recentTables.ts +48 -0
- package/frontend-vue/app/build/data/stagedEdits.ts +117 -0
- package/frontend-vue/app/build/data/stagedWrites.ts +64 -0
- package/frontend-vue/app/build/data/useStagedSave.ts +298 -0
- package/frontend-vue/app/build/diagram/autosave.ts +72 -0
- package/frontend-vue/app/build/diagram/graph.ts +585 -0
- package/frontend-vue/app/build/diagram/history.ts +178 -0
- package/frontend-vue/app/build/diagram/shortcuts.ts +62 -0
- package/frontend-vue/app/build/diagram/sync.ts +137 -0
- package/frontend-vue/app/build/query/completion.ts +100 -0
- package/frontend-vue/app/build/query/csv.ts +32 -0
- package/frontend-vue/app/build/query/examples.ts +38 -0
- package/frontend-vue/app/build/query/runs.ts +168 -0
- package/frontend-vue/app/build/query/tableColumns.ts +35 -0
- package/frontend-vue/app/build/query/target.ts +143 -0
- package/frontend-vue/app/build/schemas/filterState.ts +128 -0
- package/frontend-vue/app/build/schemas/useSchemasListing.ts +62 -0
- package/frontend-vue/app/build/utils/databaseLinks.ts +98 -0
- package/frontend-vue/app/build/utils/fieldTypes.ts +198 -0
- package/frontend-vue/app/build/utils/relativeTime.ts +19 -0
- package/frontend-vue/app/{utils → build/utils}/stagedSerialization.ts +7 -1
- package/frontend-vue/app/components/ConnectionStatus.vue +122 -0
- package/frontend-vue/app/components/DataBrowser.vue +126 -0
- package/frontend-vue/app/components/DataBrowserSidebar.vue +293 -210
- package/frontend-vue/app/components/DataBrowserTabBar.vue +185 -93
- package/frontend-vue/app/components/DataFilterBar.vue +163 -0
- package/frontend-vue/app/components/DataGrid.vue +737 -499
- package/frontend-vue/app/components/DataGridCell.vue +125 -77
- package/frontend-vue/app/components/DataGridHeaderCell.vue +119 -81
- package/frontend-vue/app/components/DiagramNoteNode.vue +81 -63
- package/frontend-vue/app/components/DiagramRelationEdge.vue +57 -27
- package/frontend-vue/app/components/DiagramStubNode.vue +16 -17
- package/frontend-vue/app/components/DiagramTableNode.vue +106 -68
- package/frontend-vue/app/components/InstancePicker.vue +195 -0
- package/frontend-vue/app/components/JsonCellPanel.vue +30 -29
- package/frontend-vue/app/components/QueryBuilderPanel.vue +290 -238
- package/frontend-vue/app/components/QueryConsole.vue +726 -0
- package/frontend-vue/app/components/QueryLibrary.vue +314 -0
- package/frontend-vue/app/components/QueryResultPanel.vue +556 -126
- package/frontend-vue/app/components/RowDrawer.vue +520 -0
- package/frontend-vue/app/components/SchemaDiagram.vue +1016 -1051
- package/frontend-vue/app/components/SchemaPicker.vue +86 -0
- package/frontend-vue/app/components/SchemasFilterBar.vue +201 -0
- package/frontend-vue/app/components/TableInspector.vue +741 -0
- package/frontend-vue/app/config/shortcuts-registry.ts +65 -0
- package/frontend-vue/dms.frontend.ts +5 -14
- package/frontend-vue/i18n/locales/database-en-GB.json +526 -193
- package/frontend-vue/i18n/locales/database-fr-FR.json +528 -195
- package/frontend-vue/package.json +2 -1
- package/frontend-vue/pnpm-lock.yaml +2601 -858
- package/frontend-vue/pnpm-workspace.yaml +1 -1
- package/frontend-vue/tests/dataBrowser.test.ts +206 -0
- package/frontend-vue/tests/diagram.test.ts +516 -0
- package/frontend-vue/tests/helpers.test.ts +205 -0
- package/frontend-vue/tests/instanceOptions.test.ts +53 -0
- package/frontend-vue/tests/locales.test.ts +56 -0
- package/frontend-vue/tests/persistence.test.ts +106 -5
- package/frontend-vue/tests/pickerMenus.test.ts +254 -0
- package/frontend-vue/tests/queryConsole.test.ts +282 -0
- package/frontend-vue/tests/queryRuntime.test.ts +2 -2
- package/frontend-vue/tests/registration.test.ts +36 -25
- package/frontend-vue/tests/schemasFilters.test.ts +115 -0
- package/package.json +7 -7
- package/frontend-vue/app/components/QueryFavoritesPanel.vue +0 -127
- package/frontend-vue/app/components/QueryHistoryPanel.vue +0 -134
- package/frontend-vue/app/components/SchemaTableInspector.vue +0 -278
- package/frontend-vue/app/composables/useDataBrowserTabs.ts +0 -255
- package/frontend-vue/app/composables/useDatabaseHealth.ts +0 -50
- package/frontend-vue/app/composables/useDatabaseSchemas.ts +0 -92
- package/frontend-vue/app/composables/useQueryRuntime.ts +0 -118
- package/frontend-vue/app/composables/useQueryStore.ts +0 -117
- package/frontend-vue/app/composables/useTableViewRowClick.ts +0 -22
- package/frontend-vue/app/custom-pages/database/data.vue +0 -41
- package/frontend-vue/app/custom-pages/database/overview.vue +0 -367
- package/frontend-vue/app/custom-pages/database/query.vue +0 -436
- package/frontend-vue/app/custom-pages/database/schemas.vue +0 -218
- package/frontend-vue/app/utils/databaseFieldModifiers.ts +0 -33
- package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +0 -224
- package/frontend-vue/app/utils/dmsTableView.ts +0 -83
- package/frontend-vue/app/utils/relativeTime.ts +0 -18
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// How a typed draft becomes a cell value: the stored type is kept, and a
|
|
2
|
+
// column whose cell is empty falls back to the type sampled for it, so filling
|
|
3
|
+
// an empty cell of a number column stores a number. A date column takes a
|
|
4
|
+
// date whatever its cell holds, as dates travel as ISO text.
|
|
5
|
+
//
|
|
6
|
+
// "null" empties a cell, whatever its type. The text "null" itself is typed
|
|
7
|
+
// escaped, "\null" (and "\\null" for "\null"): an editor opens a cell holding
|
|
8
|
+
// such a text escaped, so committing it unchanged keeps it.
|
|
9
|
+
|
|
10
|
+
type DraftResult =
|
|
11
|
+
| { ok: true; value: unknown; unchanged: boolean }
|
|
12
|
+
| { ok: false; reason: 'number' | 'boolean' | 'date' }
|
|
13
|
+
|
|
14
|
+
type Parser = (text: string) => DraftResult
|
|
15
|
+
|
|
16
|
+
const BOOLEANS: Record<string, boolean> = { true: true, false: false }
|
|
17
|
+
const NULL_DRAFT = 'null'
|
|
18
|
+
// A text made of backslashes then "null": typed with one more backslash.
|
|
19
|
+
const NULL_LIKE_TEXT = /^\\*null$/
|
|
20
|
+
const ESCAPED_NULL = /^\\+null$/
|
|
21
|
+
// YYYY-MM-DD, then optionally a time (T or a space) and a zone.
|
|
22
|
+
const DATE_DRAFT =
|
|
23
|
+
/^(\d{4})-(\d{2})-(\d{2})(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/i
|
|
24
|
+
|
|
25
|
+
function parsed(value: unknown): DraftResult {
|
|
26
|
+
return { ok: true, value, unchanged: false }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Date.parse rolls an impossible day over (2026-02-30 is March 2nd): the
|
|
30
|
+
// calendar date typed must exist.
|
|
31
|
+
function isCalendarDate(year: number, month: number, day: number): boolean {
|
|
32
|
+
const date = new Date(Date.UTC(year, month - 1, day))
|
|
33
|
+
return (
|
|
34
|
+
date.getUTCFullYear() === year &&
|
|
35
|
+
date.getUTCMonth() === month - 1 &&
|
|
36
|
+
date.getUTCDate() === day
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseDate(text: string): DraftResult {
|
|
41
|
+
const trimmed = text.trim()
|
|
42
|
+
const match = DATE_DRAFT.exec(trimmed)
|
|
43
|
+
const time = match ? Date.parse(trimmed) : Number.NaN
|
|
44
|
+
if (
|
|
45
|
+
!match ||
|
|
46
|
+
Number.isNaN(time) ||
|
|
47
|
+
!isCalendarDate(Number(match[1]), Number(match[2]), Number(match[3]))
|
|
48
|
+
)
|
|
49
|
+
return { ok: false, reason: 'date' }
|
|
50
|
+
return parsed(new Date(time).toISOString())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const PARSERS: Record<string, Parser> = {
|
|
54
|
+
number: (text) => {
|
|
55
|
+
const trimmed = text.trim()
|
|
56
|
+
const value = Number(trimmed)
|
|
57
|
+
return trimmed === '' || !Number.isFinite(value)
|
|
58
|
+
? { ok: false, reason: 'number' }
|
|
59
|
+
: parsed(value)
|
|
60
|
+
},
|
|
61
|
+
boolean: (text) => {
|
|
62
|
+
const value = BOOLEANS[text.trim().toLowerCase()]
|
|
63
|
+
return value === undefined
|
|
64
|
+
? { ok: false, reason: 'boolean' }
|
|
65
|
+
: parsed(value)
|
|
66
|
+
},
|
|
67
|
+
date: parseDate,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isEmpty(value: unknown): boolean {
|
|
71
|
+
return value === null || value === undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The value a draft stands for, given the cell's current value and column type. */
|
|
75
|
+
export function parseDraft(
|
|
76
|
+
draft: string | boolean,
|
|
77
|
+
before: unknown,
|
|
78
|
+
columnType: string,
|
|
79
|
+
): DraftResult {
|
|
80
|
+
if (typeof draft === 'boolean')
|
|
81
|
+
return { ok: true, value: draft, unchanged: draft === before }
|
|
82
|
+
if (draft.trim() === NULL_DRAFT)
|
|
83
|
+
return { ok: true, value: null, unchanged: isEmpty(before) }
|
|
84
|
+
// Leaving an empty cell empty is not an edit.
|
|
85
|
+
if (isEmpty(before) && draft === '')
|
|
86
|
+
return { ok: true, value: before, unchanged: true }
|
|
87
|
+
const text = ESCAPED_NULL.test(draft) ? draft.slice(1) : draft
|
|
88
|
+
const type =
|
|
89
|
+
columnType === 'date'
|
|
90
|
+
? 'date'
|
|
91
|
+
: isEmpty(before)
|
|
92
|
+
? columnType
|
|
93
|
+
: typeof before
|
|
94
|
+
const result = (PARSERS[type] ?? parsed)(text)
|
|
95
|
+
if (!result.ok) return result
|
|
96
|
+
return {
|
|
97
|
+
...result,
|
|
98
|
+
unchanged: !isEmpty(before) && String(before) === String(result.value),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The text an editor opens a cell with: see the escape above. */
|
|
103
|
+
export function draftText(value: unknown): string {
|
|
104
|
+
if (typeof value === 'string' && NULL_LIKE_TEXT.test(value))
|
|
105
|
+
return `\\${value}`
|
|
106
|
+
return cellText(value)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A cell as one line of text: JSON for objects and lists. */
|
|
110
|
+
export function cellText(value: unknown): string {
|
|
111
|
+
if (isEmpty(value)) return ''
|
|
112
|
+
if (typeof value !== 'object') return String(value)
|
|
113
|
+
try {
|
|
114
|
+
return JSON.stringify(value)
|
|
115
|
+
} catch {
|
|
116
|
+
return String(value)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A cell as the review and the "was" hints name it: an empty cell reads
|
|
122
|
+
* null, and a text that would read as one of those is quoted ("", "null").
|
|
123
|
+
*/
|
|
124
|
+
export function cellLabel(value: unknown): string {
|
|
125
|
+
if (isEmpty(value)) return NULL_DRAFT
|
|
126
|
+
if (value === '' || value === NULL_DRAFT) return JSON.stringify(value)
|
|
127
|
+
return cellText(value)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function isStructured(value: unknown): boolean {
|
|
131
|
+
return value !== null && typeof value === 'object'
|
|
132
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The names a picker lists for a search: schema ids, or the named instances
|
|
2
|
+
// of a schema. A SaaS schema can hold one instance per tenant, so a picker
|
|
3
|
+
// renders at most `limit` of them and says how many matched.
|
|
4
|
+
|
|
5
|
+
export const PICKER_LIMIT = 100
|
|
6
|
+
|
|
7
|
+
/** Every instance, the default one, or a named one. */
|
|
8
|
+
export type InstanceChoice =
|
|
9
|
+
| { kind: 'all' }
|
|
10
|
+
| { kind: 'default' }
|
|
11
|
+
| { kind: 'named'; id: string }
|
|
12
|
+
|
|
13
|
+
const collator = new Intl.Collator(undefined, { numeric: true })
|
|
14
|
+
|
|
15
|
+
/** Names in order, numbers by value, once each. */
|
|
16
|
+
export function sortNames(names: readonly string[]): string[] {
|
|
17
|
+
return [...new Set(names)].sort(collator.compare)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface NameMatches {
|
|
21
|
+
shown: string[]
|
|
22
|
+
total: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Expects names already sorted; matches a name containing the search. */
|
|
26
|
+
export function matchNames(
|
|
27
|
+
sorted: readonly string[],
|
|
28
|
+
search: string,
|
|
29
|
+
limit = PICKER_LIMIT,
|
|
30
|
+
): NameMatches {
|
|
31
|
+
const needle = search.trim().toLowerCase()
|
|
32
|
+
const matches = needle
|
|
33
|
+
? sorted.filter((id) => id.toLowerCase().includes(needle))
|
|
34
|
+
: sorted
|
|
35
|
+
return { shown: matches.slice(0, limit), total: matches.length }
|
|
36
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { InstanceChoice } from './instanceOptions'
|
|
2
|
+
import { matchNames } from './instanceOptions'
|
|
3
|
+
|
|
4
|
+
// The menus of the schema and instance pickers (SchemaPicker.vue,
|
|
5
|
+
// InstancePicker.vue), shared by the Schemas page's filter bar and the data
|
|
6
|
+
// browser's sidebar: what is pinned, the names a search found, and the note
|
|
7
|
+
// under them (loading, failed, more than shown).
|
|
8
|
+
|
|
9
|
+
export interface PickerItem {
|
|
10
|
+
label: string
|
|
11
|
+
value?: string
|
|
12
|
+
icon?: string
|
|
13
|
+
description?: string
|
|
14
|
+
class?: string
|
|
15
|
+
type?: 'label'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Menu values standing for no schema or instance in particular: a NUL never
|
|
19
|
+
// appears in a name.
|
|
20
|
+
export const PICK_ALL = '\u0000all'
|
|
21
|
+
export const PICK_DEFAULT = '\u0000default'
|
|
22
|
+
|
|
23
|
+
// A group holding a `.pin-top` item sticks to the top of the list while it
|
|
24
|
+
// scrolls; notes read as plain text.
|
|
25
|
+
export const PICKER_MENU_UI = {
|
|
26
|
+
viewport: 'scroll-pt-10 scroll-pb-14',
|
|
27
|
+
label: 'font-sans text-xs font-normal normal-case tracking-normal text-muted',
|
|
28
|
+
group:
|
|
29
|
+
'bg-default has-[.pin-top]:sticky has-[.pin-top]:top-0 has-[.pin-top]:z-10',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const SCHEMA_ICON = 'i-ph-stack'
|
|
33
|
+
export const ALL_ICON = 'i-ph-stack-simple'
|
|
34
|
+
export const INSTANCE_ICON = 'i-ph-cube'
|
|
35
|
+
export const READ_ONLY_ICON = 'i-ph-lock-simple'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How a picker offers every instance: as a filter ("filter"), or as the data
|
|
39
|
+
* browser's read-only cross-instance view ("readonly").
|
|
40
|
+
*/
|
|
41
|
+
export type AllInstancesMode = 'filter' | 'readonly'
|
|
42
|
+
|
|
43
|
+
function matching(label: string, needle: string): boolean {
|
|
44
|
+
return !needle || label.toLowerCase().includes(needle)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function groups(...lists: PickerItem[][]): PickerItem[][] {
|
|
48
|
+
return lists.filter((group) => group.length > 0)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface SchemaMenu {
|
|
52
|
+
/** Schema ids, sorted. */
|
|
53
|
+
ids: readonly string[]
|
|
54
|
+
search: string
|
|
55
|
+
/** "All schemas", pinned first; null when a schema must be picked. */
|
|
56
|
+
allLabel: string | null
|
|
57
|
+
more: (shown: number, total: number) => string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function schemaMenu(menu: SchemaMenu): PickerItem[][] {
|
|
61
|
+
const needle = menu.search.trim().toLowerCase()
|
|
62
|
+
const pinned: PickerItem[] =
|
|
63
|
+
menu.allLabel !== null && matching(menu.allLabel, needle)
|
|
64
|
+
? [
|
|
65
|
+
{
|
|
66
|
+
label: menu.allLabel,
|
|
67
|
+
value: PICK_ALL,
|
|
68
|
+
icon: ALL_ICON,
|
|
69
|
+
class: 'pin-top',
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
: []
|
|
73
|
+
const { shown, total } = matchNames(menu.ids, menu.search)
|
|
74
|
+
const named: PickerItem[] = shown.map((id) => ({
|
|
75
|
+
label: id,
|
|
76
|
+
value: id,
|
|
77
|
+
icon: SCHEMA_ICON,
|
|
78
|
+
}))
|
|
79
|
+
if (total > shown.length)
|
|
80
|
+
named.push({ label: menu.more(shown.length, total), type: 'label' })
|
|
81
|
+
return groups(pinned, named)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type InstancesStatus = 'idle' | 'pending' | 'success' | 'error'
|
|
85
|
+
|
|
86
|
+
interface InstanceMenuLabels {
|
|
87
|
+
all: string
|
|
88
|
+
/** Under "all" in the read-only mode. */
|
|
89
|
+
readOnly: string
|
|
90
|
+
default: string
|
|
91
|
+
pickSchema: string
|
|
92
|
+
loading: string
|
|
93
|
+
failed: string
|
|
94
|
+
noNamed: string
|
|
95
|
+
noMatch: string
|
|
96
|
+
more: (shown: number, total: number) => string
|
|
97
|
+
count: (value: number) => string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface InstanceMenu {
|
|
101
|
+
search: string
|
|
102
|
+
/** A schema is picked: its named instances can be listed. */
|
|
103
|
+
hasSchema: boolean
|
|
104
|
+
/** Named instances the search found, at most the picker's limit. */
|
|
105
|
+
names: readonly string[]
|
|
106
|
+
/** Named instances matching the search. */
|
|
107
|
+
matched: number
|
|
108
|
+
/** Named instances of the schema, before any search; null until known. */
|
|
109
|
+
total: number | null
|
|
110
|
+
status: InstancesStatus
|
|
111
|
+
allMode: AllInstancesMode
|
|
112
|
+
labels: InstanceMenuLabels
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function instanceMenu(menu: InstanceMenu): PickerItem[][] {
|
|
116
|
+
const { labels } = menu
|
|
117
|
+
const needle = menu.search.trim().toLowerCase()
|
|
118
|
+
const readOnly = menu.allMode === 'readonly'
|
|
119
|
+
// Every instance: the default one and the named ones. The read-only view
|
|
120
|
+
// is offered whatever the search, as it also reads rows of instances never
|
|
121
|
+
// registered; the filter matches the search.
|
|
122
|
+
const all: PickerItem[] =
|
|
123
|
+
readOnly || matching(labels.all, needle)
|
|
124
|
+
? [
|
|
125
|
+
{
|
|
126
|
+
label:
|
|
127
|
+
menu.total === null
|
|
128
|
+
? labels.all
|
|
129
|
+
: `${labels.all} (${labels.count(menu.total + 1)})`,
|
|
130
|
+
value: PICK_ALL,
|
|
131
|
+
icon: readOnly ? READ_ONLY_ICON : ALL_ICON,
|
|
132
|
+
...(readOnly ? { description: labels.readOnly } : {}),
|
|
133
|
+
...(needle ? {} : { class: 'pin-top' }),
|
|
134
|
+
},
|
|
135
|
+
]
|
|
136
|
+
: []
|
|
137
|
+
const defaultShown = matching(labels.default, needle)
|
|
138
|
+
const pinned: PickerItem[] = defaultShown
|
|
139
|
+
? [{ label: labels.default, value: PICK_DEFAULT, icon: INSTANCE_ICON }]
|
|
140
|
+
: []
|
|
141
|
+
// Without a search "all" comes first, pinned; while one is typed it comes
|
|
142
|
+
// after the matches, so that Enter picks the first match.
|
|
143
|
+
if (!needle) pinned.unshift(...all)
|
|
144
|
+
const last = needle ? all : []
|
|
145
|
+
if (!menu.hasSchema)
|
|
146
|
+
return groups(pinned, [{ label: labels.pickSchema, type: 'label' }], last)
|
|
147
|
+
const named: PickerItem[] = menu.names.map((id) => ({
|
|
148
|
+
label: id,
|
|
149
|
+
value: id,
|
|
150
|
+
icon: INSTANCE_ICON,
|
|
151
|
+
}))
|
|
152
|
+
let note: string | null = null
|
|
153
|
+
if (menu.status === 'error') note = labels.failed
|
|
154
|
+
else if (menu.status !== 'success') note = labels.loading
|
|
155
|
+
else if (menu.total === 0 && !needle) note = labels.noNamed
|
|
156
|
+
else if (menu.matched === 0 && !defaultShown) note = labels.noMatch
|
|
157
|
+
else if (menu.matched > named.length)
|
|
158
|
+
note = labels.more(named.length, menu.matched)
|
|
159
|
+
if (note) named.push({ label: note, type: 'label' })
|
|
160
|
+
return groups(pinned, named, last)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A picker's menu value for an instance choice. */
|
|
164
|
+
export function instancePickValue(choice: InstanceChoice): string {
|
|
165
|
+
if (choice.kind === 'all') return PICK_ALL
|
|
166
|
+
if (choice.kind === 'default') return PICK_DEFAULT
|
|
167
|
+
return choice.id
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The instance choice a picker's menu value stands for. */
|
|
171
|
+
export function instancePickChoice(value: string): InstanceChoice {
|
|
172
|
+
if (value === PICK_ALL) return { kind: 'all' }
|
|
173
|
+
if (value === PICK_DEFAULT) return { kind: 'default' }
|
|
174
|
+
return { kind: 'named', id: value }
|
|
175
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The tables opened lately, offered by the data browser's first-visit state
|
|
2
|
+
// (D-15). Kept in this browser only.
|
|
3
|
+
|
|
4
|
+
export interface RecentTable {
|
|
5
|
+
schema: string
|
|
6
|
+
instance: string
|
|
7
|
+
table: string
|
|
8
|
+
openedAt: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const STORAGE_KEY = 'dms-database:data-browser:recent'
|
|
12
|
+
const MAX_RECENT = 5
|
|
13
|
+
|
|
14
|
+
export function readRecentTables(): RecentTable[] {
|
|
15
|
+
if (typeof window === 'undefined') return []
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '[]')
|
|
18
|
+
return Array.isArray(parsed)
|
|
19
|
+
? parsed.filter(
|
|
20
|
+
(entry): entry is RecentTable =>
|
|
21
|
+
typeof entry?.schema === 'string' &&
|
|
22
|
+
typeof entry?.table === 'string' &&
|
|
23
|
+
typeof entry?.instance === 'string',
|
|
24
|
+
)
|
|
25
|
+
: []
|
|
26
|
+
} catch {
|
|
27
|
+
return []
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function rememberTable(entry: Omit<RecentTable, 'openedAt'>) {
|
|
32
|
+
if (typeof window === 'undefined') return
|
|
33
|
+
const others = readRecentTables().filter(
|
|
34
|
+
(recent) =>
|
|
35
|
+
recent.schema !== entry.schema ||
|
|
36
|
+
recent.table !== entry.table ||
|
|
37
|
+
recent.instance !== entry.instance,
|
|
38
|
+
)
|
|
39
|
+
const next = [
|
|
40
|
+
{ ...entry, openedAt: new Date().toISOString() },
|
|
41
|
+
...others,
|
|
42
|
+
].slice(0, MAX_RECENT)
|
|
43
|
+
try {
|
|
44
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
|
45
|
+
} catch {
|
|
46
|
+
// A full or private storage only loses the suggestions.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Edits of the data browser are staged (D-02): a changed cell keeps its old
|
|
2
|
+
// value until the user saves, and a save can be undone for a few seconds.
|
|
3
|
+
// Staged edits live per tab, keyed by row id then column, and survive
|
|
4
|
+
// switching tabs.
|
|
5
|
+
|
|
6
|
+
export interface StagedCell {
|
|
7
|
+
before: unknown
|
|
8
|
+
after: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** One staged change, as the review lists it. */
|
|
12
|
+
export interface StagedChange extends StagedCell {
|
|
13
|
+
rowId: string
|
|
14
|
+
field: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type RowEdits = Record<string, StagedCell>
|
|
18
|
+
type TabEdits = Record<string, RowEdits>
|
|
19
|
+
|
|
20
|
+
/** Two cell values are the same when their JSON forms are. */
|
|
21
|
+
function sameValue(left: unknown, right: unknown): boolean {
|
|
22
|
+
if (left === right) return true
|
|
23
|
+
try {
|
|
24
|
+
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null)
|
|
25
|
+
} catch {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useStagedEdits() {
|
|
31
|
+
const edits = useDmsState<Record<string, TabEdits>>(
|
|
32
|
+
'dms-database-staged-edits',
|
|
33
|
+
() => ({}),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
function tabEdits(tabId: string): TabEdits {
|
|
37
|
+
return edits.value[tabId] ?? {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function get(
|
|
41
|
+
tabId: string,
|
|
42
|
+
rowId: string,
|
|
43
|
+
field: string,
|
|
44
|
+
): StagedCell | undefined {
|
|
45
|
+
return tabEdits(tabId)[rowId]?.[field]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Stages `after` over `before`; setting a cell back to `before` unstages it. */
|
|
49
|
+
function stage(tabId: string, change: StagedChange) {
|
|
50
|
+
const rows = { ...tabEdits(tabId) }
|
|
51
|
+
const row = { ...(rows[change.rowId] ?? {}) }
|
|
52
|
+
const before = row[change.field]?.before ?? change.before
|
|
53
|
+
if (sameValue(before, change.after))
|
|
54
|
+
Reflect.deleteProperty(row, change.field)
|
|
55
|
+
else row[change.field] = { before, after: change.after }
|
|
56
|
+
if (Object.keys(row).length === 0)
|
|
57
|
+
Reflect.deleteProperty(rows, change.rowId)
|
|
58
|
+
else rows[change.rowId] = row
|
|
59
|
+
edits.value = { ...edits.value, [tabId]: rows }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function list(tabId: string): StagedChange[] {
|
|
63
|
+
return Object.entries(tabEdits(tabId)).flatMap(([rowId, row]) =>
|
|
64
|
+
Object.entries(row).map(([field, cell]) => ({ rowId, field, ...cell })),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function count(tabId: string): number {
|
|
69
|
+
return list(tabId).length
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function rowCount(tabId: string, rowId: string): number {
|
|
73
|
+
return Object.keys(tabEdits(tabId)[rowId] ?? {}).length
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function discard(tabId: string, rowIds?: string[]) {
|
|
77
|
+
if (!rowIds) {
|
|
78
|
+
const next = { ...edits.value }
|
|
79
|
+
Reflect.deleteProperty(next, tabId)
|
|
80
|
+
edits.value = next
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
const rows = { ...tabEdits(tabId) }
|
|
84
|
+
for (const rowId of rowIds) Reflect.deleteProperty(rows, rowId)
|
|
85
|
+
edits.value = { ...edits.value, [tabId]: rows }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The staged values of a row, as the edit route takes them. */
|
|
89
|
+
function rowBody(tabId: string, rowId: string): Record<string, unknown> {
|
|
90
|
+
return Object.fromEntries(
|
|
91
|
+
Object.entries(tabEdits(tabId)[rowId] ?? {}).map(([field, cell]) => [
|
|
92
|
+
field,
|
|
93
|
+
cell.after,
|
|
94
|
+
]),
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function rowIds(tabId: string): string[] {
|
|
99
|
+
return Object.keys(tabEdits(tabId))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { get, stage, list, count, rowCount, discard, rowBody, rowIds }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The values a saved change replaced, grouped per row: what an undo writes. */
|
|
106
|
+
export function undoBodies(
|
|
107
|
+
changes: StagedChange[],
|
|
108
|
+
): Map<string, Record<string, unknown>> {
|
|
109
|
+
const bodies = new Map<string, Record<string, unknown>>()
|
|
110
|
+
for (const change of changes) {
|
|
111
|
+
const body = bodies.get(change.rowId) ?? {}
|
|
112
|
+
// An empty cell is cleared with null: the edit route skips undefined.
|
|
113
|
+
body[change.field] = change.before ?? null
|
|
114
|
+
bodies.set(change.rowId, body)
|
|
115
|
+
}
|
|
116
|
+
return bodies
|
|
117
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Writes rows one call at a time, as a save and its undo do, and tells what
|
|
2
|
+
// went through: the rows written, the rows gone meanwhile (404, nothing left
|
|
3
|
+
// to write to) and the error that stopped the rest, if any. Rows are written
|
|
4
|
+
// in order and the first other failure stops the run, so the rows after it
|
|
5
|
+
// are left untouched.
|
|
6
|
+
|
|
7
|
+
const NOT_FOUND = 404
|
|
8
|
+
|
|
9
|
+
export interface RowWriteOutcome {
|
|
10
|
+
written: string[]
|
|
11
|
+
gone: string[]
|
|
12
|
+
/** The failure that stopped the run; its row and the next ones are left. */
|
|
13
|
+
error: unknown
|
|
14
|
+
/** The rows neither written nor gone, in order. */
|
|
15
|
+
left: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function statusOf(error: unknown): number | undefined {
|
|
19
|
+
const failure = error as { statusCode?: number; status?: number } | null
|
|
20
|
+
return failure?.statusCode ?? failure?.status
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function writeRows(
|
|
24
|
+
rows: Iterable<[string, Record<string, unknown>]>,
|
|
25
|
+
write: (rowId: string, body: Record<string, unknown>) => Promise<unknown>,
|
|
26
|
+
/** Called once a row is settled: written, or gone. */
|
|
27
|
+
onSettled?: (rowId: string) => void,
|
|
28
|
+
): Promise<RowWriteOutcome> {
|
|
29
|
+
const outcome: RowWriteOutcome = {
|
|
30
|
+
written: [],
|
|
31
|
+
gone: [],
|
|
32
|
+
error: null,
|
|
33
|
+
left: [],
|
|
34
|
+
}
|
|
35
|
+
for (const [rowId, body] of rows) {
|
|
36
|
+
if (outcome.error !== null) {
|
|
37
|
+
outcome.left.push(rowId)
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
await write(rowId, body)
|
|
42
|
+
outcome.written.push(rowId)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (statusOf(error) !== NOT_FOUND) {
|
|
45
|
+
// A failure without a message still stops the run.
|
|
46
|
+
outcome.error = error ?? new Error('Write failed')
|
|
47
|
+
outcome.left.push(rowId)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
outcome.gone.push(rowId)
|
|
51
|
+
}
|
|
52
|
+
onSettled?.(rowId)
|
|
53
|
+
}
|
|
54
|
+
return outcome
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The changes of the rows given, in their order of the list. */
|
|
58
|
+
export function changesOf<T extends { rowId: string }>(
|
|
59
|
+
changes: T[],
|
|
60
|
+
rowIds: string[],
|
|
61
|
+
): T[] {
|
|
62
|
+
const rows = new Set(rowIds)
|
|
63
|
+
return changes.filter((change) => rows.has(change.rowId))
|
|
64
|
+
}
|