@antelopejs/dms-database 0.0.13 → 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 +9 -6
  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,585 @@
1
+ import dagre from '@dagrejs/dagre'
2
+ import { MarkerType } from '@vue-flow/core'
3
+ import type {
4
+ SchemaSummary,
5
+ TableSummary,
6
+ } from '../composables/useDatabaseSchemas'
7
+ import type {
8
+ DiagramPosition,
9
+ DiagramPositions,
10
+ } from '../composables/useDiagramPersistence'
11
+
12
+ // Turns a schema into the nodes and edges of the diagram: one node per table,
13
+ // a dashed stub for a table a relation points to that is not drawn, the
14
+ // notes, and one edge per relation column. Table positions come from the
15
+ // saved layout; a table without one takes the place dagre gives it in the
16
+ // whole schema, so it stays put when the canvas focuses on a few tables.
17
+
18
+ export const NODE_WIDTH = 232
19
+ const NODE_HEIGHT_PER_FIELD = 24
20
+ const NODE_HEIGHT_HEADER = 44
21
+ export const STUB_NODE_WIDTH = 180
22
+ const STUB_NODE_HEIGHT = 28
23
+ const LAYOUT = { rankdir: 'LR', nodesep: 48, ranksep: 120 } as const
24
+ const HYSTERESIS_PX = 15
25
+ // Notes sit above the tables, so a new one is never hidden under a table.
26
+ export const NOTE_Z_INDEX = 10
27
+ // Arrow colours, as theme tokens so they follow the light and dark themes.
28
+ export const EDGE_COLOR = 'var(--ui-border-accented)'
29
+ export const EDGE_MANY_COLOR = 'var(--color-dms-500)'
30
+ export const EDGE_ACTIVE_COLOR = 'var(--ui-primary)'
31
+
32
+ export const TABLE_NODE_TYPE = 'tableNode'
33
+ export const STUB_NODE_TYPE = 'stubNode'
34
+ export const NOTE_NODE_TYPE = 'noteNode'
35
+ export const RELATION_EDGE_TYPE = 'relationEdge'
36
+
37
+ export interface NoteDraft {
38
+ id: string
39
+ schemaName: string
40
+ text: string
41
+ x: number
42
+ y: number
43
+ width: number
44
+ height: number
45
+ color: string | null
46
+ /** Not stored yet: created on the canvas, waiting for its save. */
47
+ isTemp: boolean
48
+ }
49
+
50
+ export interface TableNodeData {
51
+ schemaId: string
52
+ tableName: string
53
+ table: TableSummary
54
+ /** Columns of the selected table's relations, drawn highlighted. */
55
+ highlighted: string[]
56
+ dimmed: boolean
57
+ /** Picked on the canvas, by a search or from a link. */
58
+ selected: boolean
59
+ }
60
+
61
+ export interface NoteNodeData {
62
+ noteId: string
63
+ text: string
64
+ width: number
65
+ height: number
66
+ /** Opens the note in edit mode when it mounts: a note just created. */
67
+ autoEdit: boolean
68
+ onTextChange: (noteId: string, text: string) => void
69
+ onDelete: (noteId: string) => void
70
+ /** The note left edit mode, changed or not. */
71
+ onEditEnd: (noteId: string) => void
72
+ }
73
+
74
+ export interface DiagramNode {
75
+ id: string
76
+ type: string
77
+ position: { x: number; y: number }
78
+ selectable?: boolean
79
+ draggable?: boolean
80
+ deletable?: boolean
81
+ zIndex?: number
82
+ data: Record<string, unknown>
83
+ }
84
+
85
+ export interface EdgeEnds {
86
+ sourceBase: string
87
+ targetBase: string
88
+ isStubTarget: boolean
89
+ active: boolean
90
+ }
91
+
92
+ export interface DiagramEdge {
93
+ id: string
94
+ type: typeof RELATION_EDGE_TYPE
95
+ source: string
96
+ target: string
97
+ sourceHandle: string
98
+ targetHandle: string
99
+ label: string
100
+ animated: boolean
101
+ markerEnd: { type: MarkerType; color: string }
102
+ data: EdgeEnds
103
+ }
104
+
105
+ type HandleSide = 'left' | 'right'
106
+ export interface Sides {
107
+ source: HandleSide
108
+ target: HandleSide
109
+ }
110
+
111
+ /** Opens a table, in its own schema's diagram when it belongs to another. */
112
+ export type OpenTable = (schemaId: string, tableName: string) => void
113
+
114
+ /** Where dagre puts each table of a schema, and each stub of another one. */
115
+ export interface SchemaLayout {
116
+ tables: DiagramPositions
117
+ /** Keyed by stub node id. */
118
+ stubs: DiagramPositions
119
+ }
120
+
121
+ export interface GraphInput {
122
+ schema: SchemaSummary
123
+ positions: DiagramPositions
124
+ notes: NoteDraft[]
125
+ /** Node id of the selected table: its edges and columns light up. */
126
+ selectedId: string | null
127
+ /** When set, only these tables are drawn (a table and its neighbours). */
128
+ focusIds: Set<string> | null
129
+ noteHandlers: Pick<NoteNodeData, 'onTextChange' | 'onDelete' | 'onEditEnd'>
130
+ /** The note to open in edit mode: the one just created. */
131
+ editNoteId?: string | null
132
+ /** Opens the table a stub stands for. */
133
+ onOpenSchema: OpenTable
134
+ /** Sides each edge used last, so an edge does not flip on every drag. */
135
+ routing: Map<string, Sides>
136
+ /**
137
+ * Where dagre puts every table of the whole schema; computed when absent.
138
+ * The canvas keeps it per schema, so a render does not lay out again.
139
+ */
140
+ layout?: SchemaLayout
141
+ }
142
+
143
+ export interface BuiltGraph {
144
+ nodes: DiagramNode[]
145
+ edges: DiagramEdge[]
146
+ }
147
+
148
+ export function tableNodeId(schemaId: string, tableName: string): string {
149
+ return `${schemaId}::${tableName}`
150
+ }
151
+
152
+ function stubNodeId(schemaId: string, tableName: string): string {
153
+ return `stub::${schemaId}::${tableName}`
154
+ }
155
+
156
+ export function noteNodeId(noteId: string): string {
157
+ return `note::${noteId}`
158
+ }
159
+
160
+ export function nodeHeight(table: TableSummary): number {
161
+ return (
162
+ NODE_HEIGHT_HEADER +
163
+ Object.keys(table.fields).length * NODE_HEIGHT_PER_FIELD
164
+ )
165
+ }
166
+
167
+ /** Ids of a table and of every table it points to or is pointed at from. */
168
+ export function neighbourIds(
169
+ schema: SchemaSummary,
170
+ tableName: string,
171
+ ): Set<string> {
172
+ const ids = new Set([tableNodeId(schema.id, tableName)])
173
+ for (const table of schema.tables) {
174
+ for (const relation of table.relations) {
175
+ if (table.name === tableName)
176
+ ids.add(tableNodeId(relation.toSchema, relation.toTable))
177
+ if (relation.toSchema === schema.id && relation.toTable === tableName) {
178
+ ids.add(tableNodeId(schema.id, table.name))
179
+ }
180
+ }
181
+ }
182
+ return ids
183
+ }
184
+
185
+ /**
186
+ * Picks the handle sides of an edge from the horizontal distance between its
187
+ * nodes, keeping the previous sides within a small margin.
188
+ */
189
+ export function pickSides(dx: number, width: number, previous?: Sides): Sides {
190
+ const pure: Sides =
191
+ dx >= width
192
+ ? { source: 'right', target: 'left' }
193
+ : dx >= 0
194
+ ? { source: 'right', target: 'right' }
195
+ : dx >= -width
196
+ ? { source: 'left', target: 'left' }
197
+ : { source: 'left', target: 'right' }
198
+ if (!previous) return pure
199
+ const keepRanges: Record<string, [number, number]> = {
200
+ 'right-left': [width - HYSTERESIS_PX, Infinity],
201
+ 'right-right': [-HYSTERESIS_PX, width + HYSTERESIS_PX],
202
+ 'left-left': [-width - HYSTERESIS_PX, HYSTERESIS_PX],
203
+ 'left-right': [-Infinity, -width + HYSTERESIS_PX],
204
+ }
205
+ const [min, max] = keepRanges[`${previous.source}-${previous.target}`] ?? [
206
+ 0, 0,
207
+ ]
208
+ return dx > min && dx < max ? previous : pure
209
+ }
210
+
211
+ interface PendingEdge {
212
+ id: string
213
+ sourceId: string
214
+ targetId: string
215
+ ends: EdgeEnds
216
+ label: string
217
+ many: boolean
218
+ }
219
+
220
+ /** A node as dagre lays it out: its centre and size. */
221
+ interface LaidNode {
222
+ x: number
223
+ y: number
224
+ width: number
225
+ height: number
226
+ }
227
+
228
+ interface StubTarget {
229
+ schema: string
230
+ table: string
231
+ }
232
+
233
+ interface Relations {
234
+ stubs: Map<string, StubTarget>
235
+ pending: PendingEdge[]
236
+ }
237
+
238
+ function relationEdges(
239
+ schema: SchemaSummary,
240
+ selectedId: string | null,
241
+ visible: Set<string>,
242
+ ): Relations {
243
+ const stubs = new Map<string, StubTarget>()
244
+ const pending: PendingEdge[] = []
245
+ for (const table of schema.tables) {
246
+ const sourceId = tableNodeId(schema.id, table.name)
247
+ if (!visible.has(sourceId)) continue
248
+ for (const relation of table.relations) {
249
+ const realTargetId = tableNodeId(relation.toSchema, relation.toTable)
250
+ const isStubTarget = !visible.has(realTargetId)
251
+ const targetId = isStubTarget
252
+ ? stubNodeId(relation.toSchema, relation.toTable)
253
+ : realTargetId
254
+ if (isStubTarget && !stubs.has(targetId)) {
255
+ stubs.set(targetId, {
256
+ schema: relation.toSchema,
257
+ table: relation.toTable,
258
+ })
259
+ }
260
+ const target = schema.tables.find((t) => t.name === relation.toTable)
261
+ pending.push({
262
+ id: `${sourceId}::${relation.fromField}->${realTargetId}`,
263
+ sourceId,
264
+ targetId,
265
+ label: relation.fromField,
266
+ many: relation.many,
267
+ ends: {
268
+ sourceBase:
269
+ relation.fromField in table.fields
270
+ ? relation.fromField
271
+ : '__node__',
272
+ targetBase: isStubTarget
273
+ ? 'stub'
274
+ : target && relation.toField in target.fields
275
+ ? relation.toField
276
+ : '__node__',
277
+ isStubTarget,
278
+ active:
279
+ selectedId !== null &&
280
+ (selectedId === sourceId || selectedId === realTargetId),
281
+ },
282
+ })
283
+ }
284
+ }
285
+ return { stubs, pending }
286
+ }
287
+
288
+ /**
289
+ * Lays out the whole schema with dagre: every table, and a stub for each
290
+ * table of another schema a relation points to.
291
+ */
292
+ export function layOutSchema(schema: SchemaSummary): SchemaLayout {
293
+ const graph = new dagre.graphlib.Graph()
294
+ graph.setDefaultEdgeLabel(() => ({}))
295
+ graph.setGraph({ ...LAYOUT })
296
+ const all = new Set<string>()
297
+ for (const table of schema.tables) {
298
+ const id = tableNodeId(schema.id, table.name)
299
+ all.add(id)
300
+ graph.setNode(id, { width: NODE_WIDTH, height: nodeHeight(table) })
301
+ }
302
+ const { stubs, pending } = relationEdges(schema, null, all)
303
+ for (const id of stubs.keys())
304
+ graph.setNode(id, { width: STUB_NODE_WIDTH, height: STUB_NODE_HEIGHT })
305
+ for (const edge of pending) graph.setEdge(edge.sourceId, edge.targetId)
306
+ dagre.layout(graph)
307
+ const topLeft = (id: string): DiagramPosition => {
308
+ const laid = graph.node(id) as LaidNode
309
+ return { x: laid.x - laid.width / 2, y: laid.y - laid.height / 2 }
310
+ }
311
+ const tables: DiagramPositions = {}
312
+ for (const table of schema.tables)
313
+ tables[table.name] = topLeft(tableNodeId(schema.id, table.name))
314
+ const stubPositions: DiagramPositions = {}
315
+ for (const id of stubs.keys()) stubPositions[id] = topLeft(id)
316
+ return { tables, stubs: stubPositions }
317
+ }
318
+
319
+ /**
320
+ * The columns of `table` lit up by the selection: its relation columns when it
321
+ * or the table they point to is selected, and the columns relations point at
322
+ * when it or the table they come from is selected.
323
+ */
324
+ function highlightedColumns(input: GraphInput, table: TableSummary): string[] {
325
+ const selected = input.selectedId
326
+ if (!selected) return []
327
+ const { schema } = input
328
+ const own = tableNodeId(schema.id, table.name)
329
+ const columns = new Set<string>()
330
+ for (const relation of table.relations) {
331
+ const target = tableNodeId(relation.toSchema, relation.toTable)
332
+ if (own === selected || target === selected) columns.add(relation.fromField)
333
+ }
334
+ for (const other of schema.tables) {
335
+ const from = tableNodeId(schema.id, other.name)
336
+ for (const relation of other.relations) {
337
+ const pointsHere =
338
+ relation.toSchema === schema.id && relation.toTable === table.name
339
+ if (pointsHere && (from === selected || own === selected))
340
+ columns.add(relation.toField)
341
+ }
342
+ }
343
+ return [...columns]
344
+ }
345
+
346
+ function tablePosition(
347
+ input: GraphInput,
348
+ layout: SchemaLayout,
349
+ tableName: string,
350
+ ): DiagramPosition {
351
+ return (
352
+ input.positions[tableName] ?? layout.tables[tableName] ?? { x: 0, y: 0 }
353
+ )
354
+ }
355
+
356
+ function tableNodes(
357
+ input: GraphInput,
358
+ layout: SchemaLayout,
359
+ visible: Set<string>,
360
+ ): DiagramNode[] {
361
+ return input.schema.tables
362
+ .filter((table) => visible.has(tableNodeId(input.schema.id, table.name)))
363
+ .map((table) => {
364
+ const id = tableNodeId(input.schema.id, table.name)
365
+ const highlighted = highlightedColumns(input, table)
366
+ const data: TableNodeData = {
367
+ schemaId: input.schema.id,
368
+ tableName: table.name,
369
+ table,
370
+ highlighted,
371
+ selected: input.selectedId === id,
372
+ // Tables unrelated to the selected one step back.
373
+ dimmed:
374
+ input.selectedId !== null &&
375
+ input.selectedId !== id &&
376
+ highlighted.length === 0,
377
+ }
378
+ return {
379
+ id,
380
+ type: TABLE_NODE_TYPE,
381
+ position: { ...tablePosition(input, layout, table.name) },
382
+ // The canvas keeps its own selection (`data.selected`), and a
383
+ // table leaves the diagram only with its schema.
384
+ selectable: false,
385
+ deletable: false,
386
+ data: data as unknown as Record<string, unknown>,
387
+ }
388
+ })
389
+ }
390
+
391
+ export interface StubNodeData {
392
+ targetSchema: string
393
+ targetTable: string
394
+ onOpenSchema: OpenTable
395
+ }
396
+
397
+ /**
398
+ * A stub for a table of this schema left out by the focus sits where that
399
+ * table is; one for another schema takes the place dagre gives it.
400
+ */
401
+ function stubPosition(
402
+ input: GraphInput,
403
+ layout: SchemaLayout,
404
+ id: string,
405
+ target: StubTarget,
406
+ ): DiagramPosition {
407
+ if (target.schema === input.schema.id)
408
+ return tablePosition(input, layout, target.table)
409
+ return layout.stubs[id] ?? { x: 0, y: 0 }
410
+ }
411
+
412
+ function stubNodes(
413
+ input: GraphInput,
414
+ layout: SchemaLayout,
415
+ stubs: Map<string, StubTarget>,
416
+ ): DiagramNode[] {
417
+ return [...stubs].map(([id, target]) => ({
418
+ id,
419
+ type: STUB_NODE_TYPE,
420
+ position: { ...stubPosition(input, layout, id, target) },
421
+ selectable: false,
422
+ draggable: false,
423
+ deletable: false,
424
+ data: {
425
+ targetSchema: target.schema,
426
+ targetTable: target.table,
427
+ onOpenSchema: input.onOpenSchema,
428
+ } satisfies StubNodeData as unknown as Record<string, unknown>,
429
+ }))
430
+ }
431
+
432
+ function noteNodes(input: GraphInput): DiagramNode[] {
433
+ return input.notes.map((note) => {
434
+ const data: NoteNodeData = {
435
+ noteId: note.id,
436
+ text: note.text,
437
+ width: note.width,
438
+ height: note.height,
439
+ autoEdit: input.editNoteId === note.id,
440
+ ...input.noteHandlers,
441
+ }
442
+ return {
443
+ id: noteNodeId(note.id),
444
+ type: NOTE_NODE_TYPE,
445
+ position: { x: note.x, y: note.y },
446
+ zIndex: NOTE_Z_INDEX,
447
+ data: data as unknown as Record<string, unknown>,
448
+ }
449
+ })
450
+ }
451
+
452
+ function nodeWidth(node: DiagramNode): number {
453
+ return node.type === STUB_NODE_TYPE ? STUB_NODE_WIDTH : NODE_WIDTH
454
+ }
455
+
456
+ function edgeColor(edge: PendingEdge): string {
457
+ if (edge.ends.active) return EDGE_ACTIVE_COLOR
458
+ return edge.many ? EDGE_MANY_COLOR : EDGE_COLOR
459
+ }
460
+
461
+ function routeEdges(
462
+ input: GraphInput,
463
+ pending: PendingEdge[],
464
+ nodes: DiagramNode[],
465
+ ): DiagramEdge[] {
466
+ const byId = new Map(nodes.map((node) => [node.id, node]))
467
+ return pending.flatMap((edge) => {
468
+ const source = byId.get(edge.sourceId)
469
+ const target = byId.get(edge.targetId)
470
+ if (!source || !target) return []
471
+ const dx =
472
+ target.position.x +
473
+ nodeWidth(target) / 2 -
474
+ (source.position.x + nodeWidth(source) / 2)
475
+ const sides = pickSides(dx, NODE_WIDTH, input.routing.get(edge.id))
476
+ input.routing.set(edge.id, sides)
477
+ return [
478
+ {
479
+ id: edge.id,
480
+ type: RELATION_EDGE_TYPE,
481
+ source: edge.sourceId,
482
+ target: edge.targetId,
483
+ sourceHandle: `${edge.ends.sourceBase}::${sides.source}::source`,
484
+ targetHandle: `${edge.ends.targetBase}::${sides.target}::target`,
485
+ label: edge.label,
486
+ animated: edge.many,
487
+ markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor(edge) },
488
+ data: edge.ends,
489
+ },
490
+ ]
491
+ })
492
+ }
493
+
494
+ export function buildGraph(input: GraphInput): BuiltGraph {
495
+ const visible = new Set(
496
+ input.schema.tables
497
+ .map((table) => tableNodeId(input.schema.id, table.name))
498
+ .filter((id) => !input.focusIds || input.focusIds.has(id)),
499
+ )
500
+ const layout = input.layout ?? layOutSchema(input.schema)
501
+ const { stubs, pending } = relationEdges(
502
+ input.schema,
503
+ input.selectedId,
504
+ visible,
505
+ )
506
+ const nodes = [
507
+ ...tableNodes(input, layout, visible),
508
+ ...stubNodes(input, layout, stubs),
509
+ ...noteNodes(input),
510
+ ]
511
+ return { nodes, edges: routeEdges(input, pending, nodes) }
512
+ }
513
+
514
+ /**
515
+ * Tables a search finds: those whose name holds `needle`, then those with a
516
+ * column whose name holds it, each once.
517
+ */
518
+ export function searchTables(schema: SchemaSummary, needle: string): string[] {
519
+ const text = needle.trim().toLowerCase()
520
+ if (!text) return []
521
+ const byName = schema.tables.filter((table) =>
522
+ table.name.toLowerCase().includes(text),
523
+ )
524
+ const byColumn = schema.tables.filter(
525
+ (table) =>
526
+ !byName.includes(table) &&
527
+ Object.keys(table.fields).some((field) =>
528
+ field.toLowerCase().includes(text),
529
+ ),
530
+ )
531
+ return [...byName, ...byColumn].map((table) => table.name)
532
+ }
533
+
534
+ export interface Rect {
535
+ x: number
536
+ y: number
537
+ width: number
538
+ height: number
539
+ }
540
+
541
+ function overlaps(a: Rect, b: Rect, margin: number): boolean {
542
+ return (
543
+ a.x < b.x + b.width + margin &&
544
+ b.x < a.x + a.width + margin &&
545
+ a.y < b.y + b.height + margin &&
546
+ b.y < a.y + a.height + margin
547
+ )
548
+ }
549
+
550
+ const FREE_SPOT_STEP = 40
551
+ const FREE_SPOT_RINGS = 12
552
+ const FREE_SPOT_MARGIN = 16
553
+
554
+ /**
555
+ * The spot nearest `wanted` where a box of `size` covers none of `taken`,
556
+ * searched ring by ring around it; `wanted` itself when none is free.
557
+ */
558
+ export function freeSpot(
559
+ wanted: DiagramPosition,
560
+ size: { width: number; height: number },
561
+ taken: Rect[],
562
+ ): DiagramPosition {
563
+ const fits = (x: number, y: number) =>
564
+ !taken.some((rect) => overlaps({ x, y, ...size }, rect, FREE_SPOT_MARGIN))
565
+ for (let ring = 0; ring <= FREE_SPOT_RINGS; ring++) {
566
+ const candidates: DiagramPosition[] = []
567
+ for (let i = -ring; i <= ring; i++) {
568
+ for (let j = -ring; j <= ring; j++) {
569
+ if (Math.max(Math.abs(i), Math.abs(j)) !== ring) continue
570
+ candidates.push({
571
+ x: wanted.x + i * FREE_SPOT_STEP,
572
+ y: wanted.y + j * FREE_SPOT_STEP,
573
+ })
574
+ }
575
+ }
576
+ candidates.sort(
577
+ (a, b) =>
578
+ Math.hypot(a.x - wanted.x, a.y - wanted.y) -
579
+ Math.hypot(b.x - wanted.x, b.y - wanted.y),
580
+ )
581
+ const free = candidates.find((point) => fits(point.x, point.y))
582
+ if (free) return free
583
+ }
584
+ return wanted
585
+ }