@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.
Files changed (173) hide show
  1. package/README.md +30 -5
  2. package/dist/db/models/query_history.model.d.ts +1 -0
  3. package/dist/db/models/query_history.model.js +6 -0
  4. package/dist/db/models/query_history.model.js.map +1 -1
  5. package/dist/db/tables/query_history.table.d.ts +3 -0
  6. package/dist/db/tables/query_history.table.js +12 -0
  7. package/dist/db/tables/query_history.table.js.map +1 -1
  8. package/dist/db/tables/saved_queries.table.d.ts +1 -0
  9. package/dist/db/tables/saved_queries.table.js +4 -0
  10. package/dist/db/tables/saved_queries.table.js.map +1 -1
  11. package/dist/i18n/messages.d.ts +39 -0
  12. package/dist/i18n/messages.js +64 -0
  13. package/dist/i18n/messages.js.map +1 -0
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +1 -7
  16. package/dist/index.js.map +1 -1
  17. package/dist/module.d.ts +11 -0
  18. package/dist/module.js +55 -0
  19. package/dist/module.js.map +1 -0
  20. package/dist/pages/data.d.ts +1 -1
  21. package/dist/pages/data.js +9 -6
  22. package/dist/pages/data.js.map +1 -1
  23. package/dist/pages/diagram.d.ts +9 -0
  24. package/dist/pages/diagram.js +45 -0
  25. package/dist/pages/diagram.js.map +1 -0
  26. package/dist/pages/index.d.ts +1 -0
  27. package/dist/pages/index.js +1 -0
  28. package/dist/pages/index.js.map +1 -1
  29. package/dist/pages/overview.d.ts +4 -1
  30. package/dist/pages/overview.js +113 -6
  31. package/dist/pages/overview.js.map +1 -1
  32. package/dist/pages/query.d.ts +1 -1
  33. package/dist/pages/query.js +22 -6
  34. package/dist/pages/query.js.map +1 -1
  35. package/dist/pages/schemas.d.ts +2 -1
  36. package/dist/pages/schemas.js +143 -6
  37. package/dist/pages/schemas.js.map +1 -1
  38. package/dist/routes/data.d.ts +8 -0
  39. package/dist/routes/data.js +84 -21
  40. package/dist/routes/data.js.map +1 -1
  41. package/dist/routes/index.d.ts +1 -0
  42. package/dist/routes/index.js +1 -0
  43. package/dist/routes/index.js.map +1 -1
  44. package/dist/routes/overview.d.ts +12 -0
  45. package/dist/routes/overview.js +55 -0
  46. package/dist/routes/overview.js.map +1 -0
  47. package/dist/routes/query.d.ts +5 -1
  48. package/dist/routes/query.js +162 -27
  49. package/dist/routes/query.js.map +1 -1
  50. package/dist/routes/tables.d.ts +15 -12
  51. package/dist/routes/tables.js +95 -55
  52. package/dist/routes/tables.js.map +1 -1
  53. package/dist/service/catalog.d.ts +3 -0
  54. package/dist/service/catalog.js +26 -0
  55. package/dist/service/catalog.js.map +1 -0
  56. package/dist/service/health.d.ts +3 -3
  57. package/dist/service/health.js +17 -14
  58. package/dist/service/health.js.map +1 -1
  59. package/dist/service/introspect.d.ts +3 -1
  60. package/dist/service/introspect.js +131 -43
  61. package/dist/service/introspect.js.map +1 -1
  62. package/dist/service/overview.d.ts +9 -0
  63. package/dist/service/overview.js +113 -0
  64. package/dist/service/overview.js.map +1 -0
  65. package/dist/service/queryInspection.d.ts +15 -0
  66. package/dist/service/queryInspection.js +107 -0
  67. package/dist/service/queryInspection.js.map +1 -0
  68. package/dist/service/references.d.ts +9 -0
  69. package/dist/service/references.js +52 -0
  70. package/dist/service/references.js.map +1 -0
  71. package/dist/service/schemaFilters.d.ts +26 -0
  72. package/dist/service/schemaFilters.js +42 -0
  73. package/dist/service/schemaFilters.js.map +1 -0
  74. package/dist/service/stagedSerialization.d.ts +2 -0
  75. package/dist/service/stagedSerialization.js +38 -10
  76. package/dist/service/stagedSerialization.js.map +1 -1
  77. package/dist/service/types.d.ts +11 -1
  78. package/dist/types/constants.d.ts +2 -0
  79. package/dist/types/constants.js +3 -1
  80. package/dist/types/constants.js.map +1 -1
  81. package/dist/utils/query.d.ts +0 -2
  82. package/dist/utils/query.js +0 -20
  83. package/dist/utils/query.js.map +1 -1
  84. package/frontend-vue/app/{composables → build/composables}/useDataBrowserGrid.ts +65 -50
  85. package/frontend-vue/app/build/composables/useDataBrowserTabs.ts +366 -0
  86. package/frontend-vue/app/build/composables/useDatabaseHealth.ts +50 -0
  87. package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +140 -0
  88. package/frontend-vue/app/{composables → build/composables}/useDiagramPersistence.ts +47 -39
  89. package/frontend-vue/app/build/composables/useQueryRuntime.ts +217 -0
  90. package/frontend-vue/app/build/composables/useQueryStore.ts +162 -0
  91. package/frontend-vue/app/build/data/cellValues.ts +132 -0
  92. package/frontend-vue/app/build/data/instanceOptions.ts +36 -0
  93. package/frontend-vue/app/build/data/pickerMenus.ts +175 -0
  94. package/frontend-vue/app/build/data/recentTables.ts +48 -0
  95. package/frontend-vue/app/build/data/stagedEdits.ts +117 -0
  96. package/frontend-vue/app/build/data/stagedWrites.ts +64 -0
  97. package/frontend-vue/app/build/data/useStagedSave.ts +298 -0
  98. package/frontend-vue/app/build/diagram/autosave.ts +72 -0
  99. package/frontend-vue/app/build/diagram/graph.ts +585 -0
  100. package/frontend-vue/app/build/diagram/history.ts +178 -0
  101. package/frontend-vue/app/build/diagram/shortcuts.ts +62 -0
  102. package/frontend-vue/app/build/diagram/sync.ts +137 -0
  103. package/frontend-vue/app/build/query/completion.ts +100 -0
  104. package/frontend-vue/app/build/query/csv.ts +32 -0
  105. package/frontend-vue/app/build/query/examples.ts +38 -0
  106. package/frontend-vue/app/build/query/runs.ts +168 -0
  107. package/frontend-vue/app/build/query/tableColumns.ts +35 -0
  108. package/frontend-vue/app/build/query/target.ts +143 -0
  109. package/frontend-vue/app/build/schemas/filterState.ts +128 -0
  110. package/frontend-vue/app/build/schemas/useSchemasListing.ts +62 -0
  111. package/frontend-vue/app/build/utils/databaseLinks.ts +98 -0
  112. package/frontend-vue/app/build/utils/fieldTypes.ts +198 -0
  113. package/frontend-vue/app/build/utils/relativeTime.ts +19 -0
  114. package/frontend-vue/app/{utils → build/utils}/stagedSerialization.ts +7 -1
  115. package/frontend-vue/app/components/ConnectionStatus.vue +122 -0
  116. package/frontend-vue/app/components/DataBrowser.vue +126 -0
  117. package/frontend-vue/app/components/DataBrowserSidebar.vue +293 -210
  118. package/frontend-vue/app/components/DataBrowserTabBar.vue +185 -93
  119. package/frontend-vue/app/components/DataFilterBar.vue +163 -0
  120. package/frontend-vue/app/components/DataGrid.vue +737 -499
  121. package/frontend-vue/app/components/DataGridCell.vue +125 -77
  122. package/frontend-vue/app/components/DataGridHeaderCell.vue +119 -81
  123. package/frontend-vue/app/components/DiagramNoteNode.vue +81 -63
  124. package/frontend-vue/app/components/DiagramRelationEdge.vue +57 -27
  125. package/frontend-vue/app/components/DiagramStubNode.vue +16 -17
  126. package/frontend-vue/app/components/DiagramTableNode.vue +106 -68
  127. package/frontend-vue/app/components/InstancePicker.vue +195 -0
  128. package/frontend-vue/app/components/JsonCellPanel.vue +30 -29
  129. package/frontend-vue/app/components/QueryBuilderPanel.vue +290 -238
  130. package/frontend-vue/app/components/QueryConsole.vue +726 -0
  131. package/frontend-vue/app/components/QueryLibrary.vue +314 -0
  132. package/frontend-vue/app/components/QueryResultPanel.vue +556 -126
  133. package/frontend-vue/app/components/RowDrawer.vue +520 -0
  134. package/frontend-vue/app/components/SchemaDiagram.vue +1016 -1051
  135. package/frontend-vue/app/components/SchemaPicker.vue +86 -0
  136. package/frontend-vue/app/components/SchemasFilterBar.vue +201 -0
  137. package/frontend-vue/app/components/TableInspector.vue +741 -0
  138. package/frontend-vue/app/config/shortcuts-registry.ts +65 -0
  139. package/frontend-vue/dms.frontend.ts +5 -14
  140. package/frontend-vue/i18n/locales/database-en-GB.json +526 -193
  141. package/frontend-vue/i18n/locales/database-fr-FR.json +528 -195
  142. package/frontend-vue/package.json +2 -1
  143. package/frontend-vue/pnpm-lock.yaml +2601 -858
  144. package/frontend-vue/pnpm-workspace.yaml +1 -1
  145. package/frontend-vue/tests/dataBrowser.test.ts +206 -0
  146. package/frontend-vue/tests/diagram.test.ts +516 -0
  147. package/frontend-vue/tests/helpers.test.ts +205 -0
  148. package/frontend-vue/tests/instanceOptions.test.ts +53 -0
  149. package/frontend-vue/tests/locales.test.ts +56 -0
  150. package/frontend-vue/tests/persistence.test.ts +106 -5
  151. package/frontend-vue/tests/pickerMenus.test.ts +254 -0
  152. package/frontend-vue/tests/queryConsole.test.ts +282 -0
  153. package/frontend-vue/tests/queryRuntime.test.ts +2 -2
  154. package/frontend-vue/tests/registration.test.ts +36 -25
  155. package/frontend-vue/tests/schemasFilters.test.ts +115 -0
  156. package/package.json +7 -7
  157. package/frontend-vue/app/components/QueryFavoritesPanel.vue +0 -127
  158. package/frontend-vue/app/components/QueryHistoryPanel.vue +0 -134
  159. package/frontend-vue/app/components/SchemaTableInspector.vue +0 -278
  160. package/frontend-vue/app/composables/useDataBrowserTabs.ts +0 -255
  161. package/frontend-vue/app/composables/useDatabaseHealth.ts +0 -50
  162. package/frontend-vue/app/composables/useDatabaseSchemas.ts +0 -92
  163. package/frontend-vue/app/composables/useQueryRuntime.ts +0 -118
  164. package/frontend-vue/app/composables/useQueryStore.ts +0 -117
  165. package/frontend-vue/app/composables/useTableViewRowClick.ts +0 -22
  166. package/frontend-vue/app/custom-pages/database/data.vue +0 -41
  167. package/frontend-vue/app/custom-pages/database/overview.vue +0 -367
  168. package/frontend-vue/app/custom-pages/database/query.vue +0 -436
  169. package/frontend-vue/app/custom-pages/database/schemas.vue +0 -218
  170. package/frontend-vue/app/utils/databaseFieldModifiers.ts +0 -33
  171. package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +0 -224
  172. package/frontend-vue/app/utils/dmsTableView.ts +0 -83
  173. 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
+ }