@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,168 @@
1
+ import type { HistoryEntry } from '../composables/useQueryStore'
2
+
3
+ // How the library tells runs apart (D-11): a dot per run — fast, slow,
4
+ // failed or changed data — and day headings.
5
+
6
+ export type RunKind = 'fast' | 'slow' | 'failed' | 'changed'
7
+
8
+ // A run slower than this, in milliseconds, is slow; the server flags the
9
+ // recent queries of the overview with the same threshold.
10
+ export const SLOW_QUERY_MS = 100
11
+
12
+ export function runKind(entry: HistoryEntry): RunKind {
13
+ if (entry.status === 'error') return 'failed'
14
+ if (entry.mutation) return 'changed'
15
+ return entry.durationMs > SLOW_QUERY_MS ? 'slow' : 'fast'
16
+ }
17
+
18
+ // A run that changed data reads apart from a fast one by its shape too: a
19
+ // ring, as the info and success tones sit close in some themes.
20
+ export const RUN_DOT_CLASSES: Record<RunKind, string> = {
21
+ fast: 'bg-success',
22
+ slow: 'bg-warning',
23
+ failed: 'bg-error',
24
+ changed: 'bg-default ring-2 ring-inset ring-info',
25
+ }
26
+
27
+ /** Runs the server keeps per user (MAX_HISTORY_PER_USER). */
28
+ export const HISTORY_KEPT = 500
29
+
30
+ export interface DayGroup<T> {
31
+ key: string
32
+ date: Date
33
+ items: T[]
34
+ }
35
+
36
+ function dayKey(date: Date): string {
37
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
38
+ }
39
+
40
+ /** Entries by local calendar day, newest day first, in their given order. */
41
+ export function groupByDay<T>(
42
+ entries: T[],
43
+ dateOf: (entry: T) => string,
44
+ ): DayGroup<T>[] {
45
+ const groups = new Map<string, DayGroup<T>>()
46
+ for (const entry of entries) {
47
+ const date = new Date(dateOf(entry))
48
+ const key = dayKey(date)
49
+ const group = groups.get(key) ?? { key, date, items: [] }
50
+ group.items.push(entry)
51
+ groups.set(key, group)
52
+ }
53
+ return [...groups.values()]
54
+ }
55
+
56
+ const DAY_MS = 86_400_000
57
+
58
+ /** "Today", "Yesterday" or the date, in the interface language. */
59
+ export function dayLabel(
60
+ date: Date,
61
+ locale: string,
62
+ words: { today: string; yesterday: string },
63
+ ): string {
64
+ const today = new Date()
65
+ today.setHours(0, 0, 0, 0)
66
+ const day = new Date(date)
67
+ day.setHours(0, 0, 0, 0)
68
+ const days = Math.round((today.getTime() - day.getTime()) / DAY_MS)
69
+ if (days === 0) return words.today
70
+ if (days === 1) return words.yesterday
71
+ return new Intl.DateTimeFormat(locale, {
72
+ day: 'numeric',
73
+ month: 'short',
74
+ }).format(date)
75
+ }
76
+
77
+ export type UnknownKind = 'schema' | 'instance' | 'table'
78
+
79
+ /** A name a query used that its schema does not know. */
80
+ export interface UnknownName {
81
+ kind: UnknownKind
82
+ name: string
83
+ }
84
+
85
+ /** What an "Unknown schema / instance / table" error names, if it names one. */
86
+ export function unknownName(message: string): UnknownName | null {
87
+ const match = /unknown (schema|instance|table):?\s*["“'«]?\s*([\w$-]+)/i.exec(
88
+ message,
89
+ )
90
+ if (!match) return null
91
+ return {
92
+ kind: (match[1] as string).toLowerCase() as UnknownKind,
93
+ name: match[2] as string,
94
+ }
95
+ }
96
+
97
+ function distance(left: string, right: string): number {
98
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index)
99
+ for (let i = 1; i <= left.length; i += 1) {
100
+ let diagonal = previous[0] as number
101
+ previous[0] = i
102
+ for (let j = 1; j <= right.length; j += 1) {
103
+ const above = previous[j] as number
104
+ const cost = left[i - 1] === right[j - 1] ? 0 : 1
105
+ previous[j] = Math.min(
106
+ above + 1,
107
+ (previous[j - 1] as number) + 1,
108
+ diagonal + cost,
109
+ )
110
+ diagonal = above
111
+ }
112
+ }
113
+ return previous[right.length] as number
114
+ }
115
+
116
+ // Two edits away at most: "invoice" for "invoices", "oders" for "orders".
117
+ const MAX_SUGGESTION_DISTANCE = 2
118
+
119
+ /** The name closest to a mistyped one, when one is close enough. */
120
+ export function closestName(name: string, candidates: string[]): string | null {
121
+ let best: string | null = null
122
+ let bestDistance = MAX_SUGGESTION_DISTANCE + 1
123
+ for (const candidate of candidates) {
124
+ if (candidate === name) continue
125
+ const score = distance(name.toLowerCase(), candidate.toLowerCase())
126
+ if (score < bestDistance) {
127
+ best = candidate
128
+ bestDistance = score
129
+ }
130
+ }
131
+ return best
132
+ }
133
+
134
+ export function escapeRegExp(text: string): string {
135
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
136
+ }
137
+
138
+ const IDENTIFIER = /^[A-Z_$][\w$]*$/i
139
+
140
+ /**
141
+ * The query with a mistyped schema, instance or table name replaced, in the
142
+ * call or access that names it: `table("oders")`, `instance("zz")`,
143
+ * `schemas.shoop` or `schemas["shoop"]`.
144
+ */
145
+ export function replaceName(
146
+ source: string,
147
+ kind: UnknownKind,
148
+ from: string,
149
+ to: string,
150
+ ): string {
151
+ const name = escapeRegExp(from)
152
+ if (kind === 'schema') {
153
+ const access = IDENTIFIER.test(to)
154
+ ? `schemas.${to}`
155
+ : `schemas[${JSON.stringify(to)}]`
156
+ return source.replace(
157
+ new RegExp(
158
+ `schemas\\s*(?:\\.\\s*${name}(?![\\w$])|\\[\\s*(["'\`])${name}\\1\\s*\\])`,
159
+ 'g',
160
+ ),
161
+ access,
162
+ )
163
+ }
164
+ return source.replace(
165
+ new RegExp(`${kind}\\(\\s*(["'\`])${name}\\1\\s*\\)`, 'g'),
166
+ (_, quote: string) => `${kind}(${quote}${to}${quote})`,
167
+ )
168
+ }
@@ -0,0 +1,35 @@
1
+ // How the result table lays out its columns: which ones it shows and which
2
+ // it right-aligns. JSON and the CSV export keep every column as it is.
3
+
4
+ function isEmpty(value: unknown): boolean {
5
+ return value === null || value === undefined
6
+ }
7
+
8
+ /**
9
+ * The columns the result table shows: a column null in every row says
10
+ * nothing (`_instance` on a single-instance read) and is left out, unless
11
+ * every column is.
12
+ */
13
+ export function filledColumns(
14
+ rows: Record<string, unknown>[],
15
+ columns: string[],
16
+ ): string[] {
17
+ const filled = columns.filter((column) =>
18
+ rows.some((row) => !isEmpty(row[column])),
19
+ )
20
+ return filled.length ? filled : columns
21
+ }
22
+
23
+ /** Columns holding numbers and nothing else but nulls: right-aligned. */
24
+ export function numericColumns(
25
+ rows: Record<string, unknown>[],
26
+ columns: string[],
27
+ ): string[] {
28
+ return columns.filter(
29
+ (column) =>
30
+ rows.some((row) => typeof row[column] === 'number') &&
31
+ rows.every(
32
+ (row) => isEmpty(row[column]) || typeof row[column] === 'number',
33
+ ),
34
+ )
35
+ }
@@ -0,0 +1,143 @@
1
+ // The schema, instance and table an encoded query starts from, read from its
2
+ // stages the way the server reads them: what the result header names and the
3
+ // data browser link opens.
4
+
5
+ import { instanceFromUrl } from '../utils/databaseLinks'
6
+ import { CROSS_INSTANCE_SENTINEL } from '../utils/stagedSerialization'
7
+ import { escapeRegExp } from './runs'
8
+
9
+ export interface QueryTarget {
10
+ schema?: string
11
+ /** `""` for the default instance, `"*"` across every instance. */
12
+ instance?: string
13
+ table?: string
14
+ }
15
+
16
+ interface EncodedStage {
17
+ stage?: unknown
18
+ options?: { id?: unknown }
19
+ }
20
+
21
+ function stageId(stages: EncodedStage[], name: string): unknown {
22
+ return stages.find((stage) => stage.stage === name)?.options?.id
23
+ }
24
+
25
+ export function readQueryTarget(
26
+ encoded: Record<string, unknown> | null,
27
+ ): QueryTarget {
28
+ const stages = Array.isArray(encoded?.stages)
29
+ ? (encoded.stages as EncodedStage[])
30
+ : []
31
+ const schema = stageId(stages, 'schema')
32
+ const table = stageId(stages, 'table')
33
+ const hasInstance = stages.some((stage) => stage.stage === 'instance')
34
+ const instance = stageId(stages, 'instance')
35
+ return {
36
+ schema: typeof schema === 'string' ? schema : undefined,
37
+ table: typeof table === 'string' ? table : undefined,
38
+ instance: !hasInstance
39
+ ? undefined
40
+ : instance === CROSS_INSTANCE_SENTINEL || typeof instance === 'symbol'
41
+ ? '*'
42
+ : typeof instance === 'string'
43
+ ? instance
44
+ : '',
45
+ }
46
+ }
47
+
48
+ /** `shop.orders @eu`, or as much of it as the query names. */
49
+ export function describeTarget(target: QueryTarget): string {
50
+ if (!target.schema) return ''
51
+ const table = target.table
52
+ ? `${target.schema}.${target.table}`
53
+ : target.schema
54
+ return target.instance ? `${table} @${target.instance}` : table
55
+ }
56
+
57
+ const SCHEMA_ACCESS =
58
+ /schemas\s*(?:\.\s*([A-Za-z_$][\w$]*)|\[\s*["'`]([\w$-]+)["'`]\s*\])/g
59
+ const INSTANCE_CALL =
60
+ /\.\s*instance\(\s*(?:(["'`])([^"'`]*)\1|(CROSS_INSTANCE))?\s*\)/g
61
+ const TABLE_CALL = /\.\s*table\(\s*(["'`])([^"'`]+)\1\s*\)/g
62
+
63
+ function lastMatch(pattern: RegExp, text: string): RegExpExecArray | null {
64
+ let last: RegExpExecArray | null = null
65
+ pattern.lastIndex = 0
66
+ for (let match = pattern.exec(text); match; match = pattern.exec(text))
67
+ last = match
68
+ return last
69
+ }
70
+
71
+ /**
72
+ * The schema, instance and table the last chain of a query text names, read
73
+ * from the text itself: for a query that never reached the server, or the
74
+ * chain the cursor is in.
75
+ */
76
+ export function targetFromSource(source: string): QueryTarget {
77
+ const schema = lastMatch(SCHEMA_ACCESS, source)
78
+ if (!schema) return {}
79
+ const chain = source.slice(schema.index)
80
+ const instance = lastMatch(INSTANCE_CALL, chain)
81
+ const table = lastMatch(TABLE_CALL, chain)
82
+ return {
83
+ schema: schema[1] ?? schema[2],
84
+ instance: !instance ? undefined : instance[3] ? '*' : (instance[2] ?? ''),
85
+ table: table?.[2],
86
+ }
87
+ }
88
+
89
+ /** Where a past run pointed: its encoded query, else its text. */
90
+ export function runTarget(run: {
91
+ query?: Record<string, unknown> | null
92
+ source: string
93
+ }): QueryTarget {
94
+ const encoded = readQueryTarget(run.query ?? null)
95
+ return encoded.schema ? encoded : targetFromSource(run.source)
96
+ }
97
+
98
+ /**
99
+ * The instance a result row lives in, for its data browser link: the
100
+ * query's, or the row's own `_instance` when the query read every instance.
101
+ * `''` is the default instance; null when the row does not say.
102
+ */
103
+ export function rowInstance(
104
+ target: QueryTarget,
105
+ row: Record<string, unknown>,
106
+ ): string | null {
107
+ if (target.instance !== '*') return target.instance ?? ''
108
+ if (!('_instance' in row)) return null
109
+ const instance = row._instance
110
+ if (instance === null || instance === undefined || instance === '') return ''
111
+ return typeof instance === 'string' ? instance : null
112
+ }
113
+
114
+ /**
115
+ * The `instance(…)` argument for the instance the URL's `instance` names:
116
+ * none for the default one, CROSS_INSTANCE for every instance.
117
+ */
118
+ export function instanceArgument(raw: unknown): string {
119
+ const choice = instanceFromUrl(raw)
120
+ if (choice.kind === 'default') return ''
121
+ if (choice.kind === 'all') return 'CROSS_INSTANCE'
122
+ return JSON.stringify(choice.id)
123
+ }
124
+
125
+ /**
126
+ * The query text with the default instance of a schema, `instance()`, read
127
+ * in a named instance instead: `instance("eu")`.
128
+ */
129
+ export function withInstance(
130
+ source: string,
131
+ schema: string,
132
+ instance: string,
133
+ ): string {
134
+ const name = escapeRegExp(schema)
135
+ const access = new RegExp(
136
+ `(schemas\\s*(?:\\.\\s*${name}(?![\\w$])|\\[\\s*(["'\`])${name}\\2\\s*\\])\\s*\\.\\s*instance\\()\\s*\\)`,
137
+ 'g',
138
+ )
139
+ return source.replace(
140
+ access,
141
+ (_, call: string) => `${call}${JSON.stringify(instance)})`,
142
+ )
143
+ }
@@ -0,0 +1,128 @@
1
+ import type { InstanceChoice } from '../data/instanceOptions'
2
+ import { instanceFromUrl, instanceToUrl } from '../utils/databaseLinks'
3
+
4
+ // The Schemas page's filter bar keeps its state in the URL, so a filtered
5
+ // list can be shared, reloaded and gone back to: `scope` (a schema),
6
+ // `instance`, `q` (a table or column name) and `has` (relations, modifiers,
7
+ // empty). The table view reads the same keys as hidden filters and sends
8
+ // them to its source route, which reads them back (src/service/schemaFilters.ts
9
+ // holds the same rules).
10
+ // `instance` follows the module's convention (see instanceFromUrl): absent
11
+ // for the default instance, `all` for every instance.
12
+
13
+ const FILTER_KEYS = ['scope', 'instance', 'q', 'has'] as const
14
+ type FilterKey = (typeof FILTER_KEYS)[number]
15
+
16
+ export const HAS_FLAGS = ['relations', 'modifiers', 'empty'] as const
17
+ export type HasFlag = (typeof HAS_FLAGS)[number]
18
+ const HAS_SEPARATOR = ','
19
+
20
+ export type { InstanceChoice }
21
+
22
+ export interface SchemasFilterState {
23
+ /** The schema listed; null for every schema. */
24
+ scope: string | null
25
+ instance: InstanceChoice
26
+ /** A part of a table or column name, trimmed; empty for none. */
27
+ q: string
28
+ /** Structure the tables must have, all of them, in a fixed order. */
29
+ has: HasFlag[]
30
+ }
31
+
32
+ export const EMPTY_FILTER_STATE: SchemasFilterState = {
33
+ scope: null,
34
+ instance: { kind: 'default' },
35
+ q: '',
36
+ has: [],
37
+ }
38
+
39
+ type Query = Record<string, unknown>
40
+
41
+ function text(value: unknown): string {
42
+ const first = Array.isArray(value) ? value[0] : value
43
+ return typeof first === 'string' ? first.trim() : ''
44
+ }
45
+
46
+ /** Known flags only, once each, in their fixed order. */
47
+ export function parseHas(param: string | undefined): HasFlag[] {
48
+ if (!param) return []
49
+ const asked = new Set(param.split(HAS_SEPARATOR).map((flag) => flag.trim()))
50
+ return HAS_FLAGS.filter((flag) => asked.has(flag))
51
+ }
52
+
53
+ /** The bar's state from a route query; an empty value reads as unset. */
54
+ export function readFilterState(query: Query): SchemasFilterState {
55
+ return {
56
+ scope: text(query.scope) || null,
57
+ instance: instanceFromUrl(query.instance),
58
+ q: text(query.q),
59
+ has: parseHas(text(query.has)),
60
+ }
61
+ }
62
+
63
+ /** The URL values of a state; undefined for what is unset. */
64
+ export function filterParams(
65
+ state: SchemasFilterState,
66
+ ): Record<FilterKey, string | undefined> {
67
+ const has = parseHas(state.has.join(HAS_SEPARATOR))
68
+ return {
69
+ scope: state.scope || undefined,
70
+ instance: instanceToUrl(state.instance),
71
+ q: state.q.trim() || undefined,
72
+ has: has.length > 0 ? has.join(HAS_SEPARATOR) : undefined,
73
+ }
74
+ }
75
+
76
+ /**
77
+ * A route query carrying this state, the rest of it kept: an unset key is
78
+ * dropped, empty ones included.
79
+ */
80
+ export function withFilterState(
81
+ query: Query,
82
+ state: SchemasFilterState,
83
+ ): Query {
84
+ const next: Query = { ...query }
85
+ for (const [key, value] of Object.entries(filterParams(state))) {
86
+ if (value === undefined) delete next[key]
87
+ else next[key] = value
88
+ }
89
+ return next
90
+ }
91
+
92
+ /** Whether the bar narrows the list at all. */
93
+ export function isFiltered(state: SchemasFilterState): boolean {
94
+ return Object.values(filterParams(state)).some((value) => value !== undefined)
95
+ }
96
+
97
+ /** The source route's query for a state, as the table view sends it. */
98
+ export function sourceQuery(state: SchemasFilterState): Record<string, string> {
99
+ const query: Record<string, string> = {}
100
+ for (const [key, value] of Object.entries(filterParams(state))) {
101
+ if (value !== undefined) query[`filter_${key}`] = `is:${value}`
102
+ }
103
+ return query
104
+ }
105
+
106
+ /** Another schema: a named instance belongs to the one left. */
107
+ export function withScope(
108
+ state: SchemasFilterState,
109
+ scope: string | null,
110
+ ): SchemasFilterState {
111
+ const keepsInstance =
112
+ state.instance.kind !== 'named' || (scope !== null && scope === state.scope)
113
+ return {
114
+ ...state,
115
+ scope,
116
+ instance: keepsInstance ? state.instance : { kind: 'default' },
117
+ }
118
+ }
119
+
120
+ export function toggleHas(
121
+ state: SchemasFilterState,
122
+ flag: HasFlag,
123
+ ): SchemasFilterState {
124
+ const has = state.has.includes(flag)
125
+ ? state.has.filter((entry) => entry !== flag)
126
+ : [...state.has, flag]
127
+ return { ...state, has: HAS_FLAGS.filter((entry) => has.includes(entry)) }
128
+ }
@@ -0,0 +1,62 @@
1
+ import { computed, ref, watch } from 'vue'
2
+ import {
3
+ readFilterState,
4
+ sourceQuery,
5
+ type SchemasFilterState,
6
+ } from './filterState'
7
+
8
+ // What the Schemas page's filter bar counts: the tables its state (read from
9
+ // the URL) lists, through the table view's own source route, and the tables
10
+ // registered before any filter ("12 of 40").
11
+
12
+ const TABLES_SOURCE = '/api/database/tables/source'
13
+
14
+ interface SourceResponse {
15
+ total: number
16
+ all?: number
17
+ }
18
+
19
+ type ListingStatus = 'idle' | 'pending' | 'success' | 'error'
20
+
21
+ export function useSchemasListing() {
22
+ const route = useDmsRoute()
23
+ const { $authFetch } = useAuthFetch()
24
+ const state = computed<SchemasFilterState>(() => readFilterState(route.query))
25
+ const query = computed(() => sourceQuery(state.value))
26
+
27
+ // The counts of the last answer stay while the next one loads.
28
+ const total = ref(0)
29
+ const all = ref<number | null>(null)
30
+ const status = ref<ListingStatus>('idle')
31
+ let latest = 0
32
+
33
+ async function load() {
34
+ const request = ++latest
35
+ status.value = 'pending'
36
+ try {
37
+ const response = await $authFetch<SourceResponse>(TABLES_SOURCE, {
38
+ query: query.value,
39
+ })
40
+ if (request !== latest) return
41
+ total.value = response.total
42
+ all.value = response.all ?? null
43
+ status.value = 'success'
44
+ } catch {
45
+ if (request === latest) status.value = 'error'
46
+ }
47
+ }
48
+
49
+ // In the browser only: the server renders no count.
50
+ if (typeof window !== 'undefined') {
51
+ watch(() => JSON.stringify(query.value), load, { immediate: true })
52
+ }
53
+
54
+ return {
55
+ state,
56
+ /** Tables the bar's filters list. */
57
+ total,
58
+ /** Registered tables before any filter; null until known. */
59
+ all,
60
+ status,
61
+ }
62
+ }
@@ -0,0 +1,98 @@
1
+ import type { InstanceChoice } from '../data/instanceOptions'
2
+
3
+ export type { InstanceChoice }
4
+
5
+ // Where the module's pages live. Module pages resolve under
6
+ // /modules/<module id>; a link without that prefix 404s.
7
+ const MODULE_ROOT = '/modules/database'
8
+
9
+ export const DATABASE_PAGES = {
10
+ overview: `${MODULE_ROOT}/overview`,
11
+ schemas: `${MODULE_ROOT}/schemas`,
12
+ diagram: `${MODULE_ROOT}/diagram`,
13
+ data: `${MODULE_ROOT}/data`,
14
+ query: `${MODULE_ROOT}/query`,
15
+ } as const
16
+
17
+ type DatabasePage = keyof typeof DATABASE_PAGES
18
+
19
+ /** A column value the data browser filters the table on when it opens. */
20
+ interface TableMatch {
21
+ field: string
22
+ value: string
23
+ }
24
+
25
+ interface TableAddress {
26
+ schema: string
27
+ table: string
28
+ /**
29
+ * A named instance, none for the default one; a page link also takes
30
+ * ALL_INSTANCES_PARAM for every instance.
31
+ */
32
+ instance?: string
33
+ match?: TableMatch
34
+ }
35
+
36
+ const MATCH_SEPARATOR = ':'
37
+
38
+ // The URL's `instance`, the same on every page of the module: absent for the
39
+ // default instance, `all` for every instance at once, any other value for
40
+ // the instance of that name. No escaping: an instance named "all" cannot be
41
+ // linked to.
42
+
43
+ /** The URL's `instance` for every instance at once. */
44
+ export const ALL_INSTANCES_PARAM = 'all'
45
+
46
+ /** The instance an `instance` URL parameter names; a missing one is the default. */
47
+ export function instanceFromUrl(param: unknown): InstanceChoice {
48
+ const first = Array.isArray(param) ? param[0] : param
49
+ const value = typeof first === 'string' ? first.trim() : ''
50
+ if (!value) return { kind: 'default' }
51
+ if (value === ALL_INSTANCES_PARAM) return { kind: 'all' }
52
+ return { kind: 'named', id: value }
53
+ }
54
+
55
+ /** The `instance` URL parameter of a choice; undefined (left out) for the default. */
56
+ export function instanceToUrl(choice: InstanceChoice): string | undefined {
57
+ if (choice.kind === 'default') return undefined
58
+ if (choice.kind === 'all') return ALL_INSTANCES_PARAM
59
+ return choice.id
60
+ }
61
+
62
+ function encodeMatch(match: TableMatch): string {
63
+ return `${match.field}${MATCH_SEPARATOR}${match.value}`
64
+ }
65
+
66
+ /** `field:value` back to its parts; the value may hold separators itself. */
67
+ export function decodeMatch(raw: unknown): TableMatch | null {
68
+ if (typeof raw !== 'string') return null
69
+ const index = raw.indexOf(MATCH_SEPARATOR)
70
+ if (index <= 0) return null
71
+ return { field: raw.slice(0, index), value: raw.slice(index + 1) }
72
+ }
73
+
74
+ /** A page of the module opened on one table, e.g. the data browser on it. */
75
+ export function tableLink(page: DatabasePage, address: TableAddress): string {
76
+ const query = new URLSearchParams({
77
+ schema: address.schema,
78
+ table: address.table,
79
+ })
80
+ if (address.instance) query.set('instance', address.instance)
81
+ if (address.match) query.set('match', encodeMatch(address.match))
82
+ return `${DATABASE_PAGES[page]}?${query.toString()}`
83
+ }
84
+
85
+ const IDENTIFIER = /^[A-Z_$][\w$]*$/i
86
+
87
+ /** `schemas.shop` or `schemas["dms-core"]`, as the query DSL reaches a schema. */
88
+ export function schemaAccess(schemaId: string): string {
89
+ return IDENTIFIER.test(schemaId)
90
+ ? `schemas.${schemaId}`
91
+ : `schemas[${JSON.stringify(schemaId)}]`
92
+ }
93
+
94
+ /** The DSL reaching a table, for the query console's prefilled snippets. */
95
+ export function tableAccess(address: TableAddress): string {
96
+ const instance = address.instance ? JSON.stringify(address.instance) : ''
97
+ return `${schemaAccess(address.schema)}.instance(${instance}).table(${JSON.stringify(address.table)})`
98
+ }