@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,516 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import type { SchemaSummary } from '../app/build/composables/useDatabaseSchemas'
3
+ import { DiagramAutosave, type SaveStatus } from '../app/build/diagram/autosave'
4
+ import {
5
+ buildGraph,
6
+ EDGE_ACTIVE_COLOR,
7
+ EDGE_COLOR,
8
+ freeSpot,
9
+ type GraphInput,
10
+ layOutSchema,
11
+ neighbourIds,
12
+ NOTE_Z_INDEX,
13
+ type NoteDraft,
14
+ searchTables,
15
+ TABLE_NODE_TYPE,
16
+ type TableNodeData,
17
+ tableNodeId,
18
+ } from '../app/build/diagram/graph'
19
+ import { DiagramHistory, type DiagramState } from '../app/build/diagram/history'
20
+ import { canvasOwnsKey, diagramCommand } from '../app/build/diagram/shortcuts'
21
+ import { DiagramSync, type DiagramStore } from '../app/build/diagram/sync'
22
+
23
+ // shop: order_items -> orders -> customers, order_items -> products, and
24
+ // orders -> billing.accounts in another schema.
25
+ const SHOP = {
26
+ id: 'shop',
27
+ tables: [
28
+ {
29
+ name: 'customers',
30
+ fields: { _id: { kind: 'string' }, name: { kind: 'string' } },
31
+ indexes: {},
32
+ relations: [],
33
+ },
34
+ {
35
+ name: 'products',
36
+ fields: { _id: { kind: 'string' }, tags: { kind: 'array' } },
37
+ indexes: {},
38
+ relations: [],
39
+ },
40
+ {
41
+ name: 'orders',
42
+ fields: {
43
+ _id: { kind: 'string' },
44
+ customer_id: { kind: 'string' },
45
+ account_id: { kind: 'string' },
46
+ },
47
+ indexes: {},
48
+ relations: [
49
+ {
50
+ fromField: 'customer_id',
51
+ toSchema: 'shop',
52
+ toTable: 'customers',
53
+ toField: '_id',
54
+ many: false,
55
+ },
56
+ {
57
+ fromField: 'account_id',
58
+ toSchema: 'billing',
59
+ toTable: 'accounts',
60
+ toField: '_id',
61
+ many: false,
62
+ },
63
+ ],
64
+ },
65
+ {
66
+ name: 'order_items',
67
+ fields: {
68
+ _id: { kind: 'string' },
69
+ order_id: { kind: 'string' },
70
+ product_id: { kind: 'string' },
71
+ },
72
+ indexes: {},
73
+ relations: [
74
+ {
75
+ fromField: 'order_id',
76
+ toSchema: 'shop',
77
+ toTable: 'orders',
78
+ toField: '_id',
79
+ many: false,
80
+ },
81
+ {
82
+ fromField: 'product_id',
83
+ toSchema: 'shop',
84
+ toTable: 'products',
85
+ toField: '_id',
86
+ many: true,
87
+ },
88
+ ],
89
+ },
90
+ ],
91
+ } as unknown as SchemaSummary
92
+
93
+ function input(overrides: Partial<GraphInput> = {}): GraphInput {
94
+ return {
95
+ schema: SHOP,
96
+ positions: {},
97
+ notes: [],
98
+ selectedId: null,
99
+ focusIds: null,
100
+ noteHandlers: {
101
+ onTextChange: () => undefined,
102
+ onDelete: () => undefined,
103
+ onEditEnd: () => undefined,
104
+ },
105
+ onOpenSchema: () => undefined,
106
+ routing: new Map(),
107
+ ...overrides,
108
+ }
109
+ }
110
+
111
+ function tableData(graph: ReturnType<typeof buildGraph>, name: string) {
112
+ const node = graph.nodes.find((n) => n.id === tableNodeId('shop', name))
113
+ return node?.data as unknown as TableNodeData
114
+ }
115
+
116
+ function positionOf(graph: ReturnType<typeof buildGraph>, id: string) {
117
+ return graph.nodes.find((n) => n.id === id)?.position
118
+ }
119
+
120
+ describe('diagram graph', () => {
121
+ it('keeps an unsaved table where the whole schema puts it, focused or not (D-4)', () => {
122
+ const layout = layOutSchema(SHOP)
123
+ const full = buildGraph(input({ layout }))
124
+ const focused = buildGraph(
125
+ input({ layout, focusIds: neighbourIds(SHOP, 'orders') }),
126
+ )
127
+ for (const name of ['orders', 'customers', 'order_items']) {
128
+ const id = tableNodeId('shop', name)
129
+ expect(positionOf(focused, id)).toEqual(positionOf(full, id))
130
+ expect(positionOf(full, id)).toEqual(layout.tables[name])
131
+ }
132
+ // Without a cached layout the result is the same.
133
+ expect(buildGraph(input()).nodes.map((n) => n.position)).toEqual(
134
+ full.nodes.map((n) => n.position),
135
+ )
136
+ })
137
+
138
+ it('draws a stub where the table the focus leaves out is (D-10)', () => {
139
+ const layout = layOutSchema(SHOP)
140
+ const focused = buildGraph(
141
+ input({ layout, focusIds: neighbourIds(SHOP, 'customers') }),
142
+ )
143
+ // customers -> orders is drawn; orders -> billing.accounts is a stub of
144
+ // another schema; order_items is not a neighbour of customers.
145
+ const stub = focused.nodes.find((n) => n.id === 'stub::billing::accounts')
146
+ expect(stub?.data).toMatchObject({
147
+ targetSchema: 'billing',
148
+ targetTable: 'accounts',
149
+ })
150
+ const productsStub = buildGraph(
151
+ input({ layout, focusIds: neighbourIds(SHOP, 'order_items') }),
152
+ )
153
+ const sameSchema = productsStub.nodes.find(
154
+ (n) => n.id === 'stub::shop::customers',
155
+ )
156
+ expect(sameSchema?.position).toEqual(layout.tables.customers)
157
+ })
158
+
159
+ it('opens a stub on its table, in its schema', () => {
160
+ const onOpenSchema = vi.fn()
161
+ const graph = buildGraph(input({ onOpenSchema }))
162
+ const stub = graph.nodes.find((n) => n.id === 'stub::billing::accounts')
163
+ ;(stub?.data.onOpenSchema as (s: string, t: string) => void)(
164
+ 'billing',
165
+ 'accounts',
166
+ )
167
+ expect(onOpenSchema).toHaveBeenCalledWith('billing', 'accounts')
168
+ })
169
+
170
+ it('keeps tables and stubs out of the canvas selection and deletion (D-3, D-9)', () => {
171
+ const graph = buildGraph(input())
172
+ for (const node of graph.nodes) {
173
+ expect(node.deletable).toBe(false)
174
+ expect(node.selectable).toBe(false)
175
+ }
176
+ })
177
+
178
+ it('puts notes above the tables and opens the new one for writing (D-8)', () => {
179
+ const note: NoteDraft = {
180
+ id: 'temp-1',
181
+ schemaName: 'shop',
182
+ text: '',
183
+ x: 0,
184
+ y: 0,
185
+ width: 220,
186
+ height: 140,
187
+ color: null,
188
+ isTemp: true,
189
+ }
190
+ const graph = buildGraph(
191
+ input({ notes: [note, { ...note, id: 'n2' }], editNoteId: 'temp-1' }),
192
+ )
193
+ const notes = graph.nodes.filter((n) => n.id.startsWith('note::'))
194
+ expect(notes.map((n) => n.zIndex)).toEqual([NOTE_Z_INDEX, NOTE_Z_INDEX])
195
+ expect(notes.map((n) => n.data.autoEdit)).toEqual([true, false])
196
+ })
197
+
198
+ it('lights up the column a relation points at when its table is selected (D-19)', () => {
199
+ const graph = buildGraph(
200
+ input({ selectedId: tableNodeId('shop', 'customers') }),
201
+ )
202
+ expect(tableData(graph, 'customers').highlighted).toEqual(['_id'])
203
+ expect(tableData(graph, 'orders').highlighted).toEqual(['customer_id'])
204
+ expect(tableData(graph, 'products').dimmed).toBe(true)
205
+ const fromOrders = buildGraph(
206
+ input({ selectedId: tableNodeId('shop', 'orders') }),
207
+ )
208
+ expect(tableData(fromOrders, 'customers').highlighted).toEqual(['_id'])
209
+ expect(tableData(fromOrders, 'orders').highlighted.sort()).toEqual([
210
+ '_id',
211
+ 'account_id',
212
+ 'customer_id',
213
+ ])
214
+ })
215
+
216
+ it('colours arrows with theme tokens, the active ones in primary (D-1)', () => {
217
+ const graph = buildGraph(
218
+ input({ selectedId: tableNodeId('shop', 'customers') }),
219
+ )
220
+ const colour = (id: string) =>
221
+ graph.edges.find((e) => e.id.startsWith(id))?.markerEnd.color
222
+ expect(colour('shop::orders::customer_id')).toBe(EDGE_ACTIVE_COLOR)
223
+ expect(colour('shop::order_items::order_id')).toBe(EDGE_COLOR)
224
+ for (const edge of graph.edges)
225
+ expect(edge.markerEnd.color).toMatch(/^var\(--/)
226
+ })
227
+
228
+ it('only draws the table nodes it is asked for', () => {
229
+ const graph = buildGraph(
230
+ input({ focusIds: new Set([tableNodeId('shop', 'products')]) }),
231
+ )
232
+ expect(
233
+ graph.nodes.filter((n) => n.type === TABLE_NODE_TYPE).map((n) => n.id),
234
+ ).toEqual([tableNodeId('shop', 'products')])
235
+ })
236
+ })
237
+
238
+ describe('diagram search (D-13)', () => {
239
+ it('finds tables by name first, then by column, each once', () => {
240
+ expect(searchTables(SHOP, 'order')).toEqual(['orders', 'order_items'])
241
+ expect(searchTables(SHOP, 'CUSTOMER')).toEqual(['customers', 'orders'])
242
+ expect(searchTables(SHOP, 'tags')).toEqual(['products'])
243
+ expect(searchTables(SHOP, ' ')).toEqual([])
244
+ expect(searchTables(SHOP, 'nothing')).toEqual([])
245
+ })
246
+ })
247
+
248
+ describe('note placement (D-8)', () => {
249
+ it('keeps the wanted spot when it is free', () => {
250
+ expect(freeSpot({ x: 0, y: 0 }, { width: 100, height: 100 }, [])).toEqual({
251
+ x: 0,
252
+ y: 0,
253
+ })
254
+ })
255
+
256
+ it('moves off a table to the nearest free spot', () => {
257
+ const table = { x: 0, y: 0, width: 200, height: 200 }
258
+ const spot = freeSpot({ x: 50, y: 50 }, { width: 100, height: 100 }, [
259
+ table,
260
+ ])
261
+ const overlaps =
262
+ spot.x < table.x + table.width &&
263
+ table.x < spot.x + 100 &&
264
+ spot.y < table.y + table.height &&
265
+ table.y < spot.y + 100
266
+ expect(overlaps).toBe(false)
267
+ })
268
+ })
269
+
270
+ describe('diagram shortcuts (D-5, D-6)', () => {
271
+ const key = (k: string, mods: Partial<KeyboardEvent> = {}) =>
272
+ diagramCommand({
273
+ key: k,
274
+ ctrlKey: false,
275
+ metaKey: false,
276
+ altKey: false,
277
+ shiftKey: false,
278
+ ...mods,
279
+ })
280
+
281
+ it('reads letters whatever their case', () => {
282
+ expect(key('f')).toBe('search')
283
+ expect(key('F')).toBe('search')
284
+ expect(key('N', { shiftKey: true })).toBe('note')
285
+ expect(key('V')).toBe('select')
286
+ expect(key('H')).toBe('pan')
287
+ expect(key('Escape')).toBe('escape')
288
+ expect(key('x')).toBeNull()
289
+ expect(key('f', { altKey: true })).toBeNull()
290
+ })
291
+
292
+ it('undoes with Ctrl+Z and redoes with Ctrl+Shift+Z or Ctrl+Y', () => {
293
+ expect(key('z', { ctrlKey: true })).toBe('undo')
294
+ expect(key('Z', { ctrlKey: true, shiftKey: true })).toBe('redo')
295
+ expect(key('Z', { metaKey: true, shiftKey: true })).toBe('redo')
296
+ expect(key('y', { ctrlKey: true })).toBe('redo')
297
+ expect(key('f', { ctrlKey: true })).toBeNull()
298
+ })
299
+
300
+ function element(
301
+ matches: Record<string, boolean>,
302
+ page = false,
303
+ dialogOpen = false,
304
+ ): Element & { ownerDocument: unknown } {
305
+ const doc = {
306
+ body: null as unknown,
307
+ documentElement: null,
308
+ querySelector: (selector: string) =>
309
+ dialogOpen && selector.includes('dialog') ? {} : null,
310
+ }
311
+ const el = {
312
+ closest: (selector: string) =>
313
+ Object.entries(matches).some(
314
+ ([part, hit]) => hit && selector.includes(part),
315
+ )
316
+ ? el
317
+ : null,
318
+ ownerDocument: doc,
319
+ }
320
+ if (page) doc.body = el
321
+ return el as unknown as Element & { ownerDocument: unknown }
322
+ }
323
+
324
+ it('leaves keys to fields, dialogs and the rest of the page', () => {
325
+ const inside = element({})
326
+ const canvas = { contains: (e: unknown) => e === inside } as Element
327
+ expect(canvasOwnsKey(inside, canvas)).toBe(true)
328
+ expect(canvasOwnsKey(element({}, true), canvas)).toBe(true)
329
+ expect(canvasOwnsKey(element({ textarea: true }), canvas)).toBe(false)
330
+ // Any open dialog, wherever the focus is.
331
+ expect(canvasOwnsKey(element({}, true, true), canvas)).toBe(false)
332
+ expect(canvasOwnsKey(element({}), canvas)).toBe(false)
333
+ })
334
+ })
335
+
336
+ function memoryStore() {
337
+ const calls: string[] = []
338
+ let next = 0
339
+ const store: DiagramStore = {
340
+ saveLayout: async (schema, positions) => {
341
+ calls.push(`layout ${schema} ${Object.keys(positions).join(',')}`)
342
+ },
343
+ createNote: async (payload) => {
344
+ next += 1
345
+ calls.push(`create ${payload.schemaName} ${payload.text}`)
346
+ return {
347
+ ...payload,
348
+ id: `n${next}`,
349
+ color: payload.color ?? null,
350
+ createdAt: '',
351
+ updatedAt: '',
352
+ }
353
+ },
354
+ updateNote: async (id, patch) => {
355
+ calls.push(`update ${id} ${patch.text}`)
356
+ return {} as never
357
+ },
358
+ deleteNote: async (id) => {
359
+ calls.push(`delete ${id}`)
360
+ },
361
+ }
362
+ return { store, calls }
363
+ }
364
+
365
+ function note(id: string, text: string, schemaName = 'a'): NoteDraft {
366
+ return {
367
+ id,
368
+ schemaName,
369
+ text,
370
+ x: 0,
371
+ y: 0,
372
+ width: 220,
373
+ height: 140,
374
+ color: null,
375
+ isTemp: id.startsWith('temp'),
376
+ }
377
+ }
378
+
379
+ describe('diagram autosave (D-2)', () => {
380
+ afterEach(() => {
381
+ vi.useRealTimers()
382
+ })
383
+
384
+ it('writes a schema left as it was, under its own id', async () => {
385
+ vi.useFakeTimers()
386
+ const { store, calls } = memoryStore()
387
+ const statuses: SaveStatus[] = []
388
+ // One table moved and one note written in a, then b loads at once.
389
+ const shown: DiagramState = {
390
+ positions: { t: { x: 1, y: 2 } },
391
+ notes: [note('temp-1', 'hello')],
392
+ }
393
+ const a = new DiagramAutosave(
394
+ new DiagramSync(store, 'a'),
395
+ () => shown,
396
+ 800,
397
+ (status) => statuses.push(status),
398
+ )
399
+ a.schedule()
400
+ const leaving = a.flush()
401
+ // b's state replaces a's on the canvas before a's write ends.
402
+ shown.positions = { u: { x: 0, y: 0 } }
403
+ shown.notes = []
404
+ await leaving
405
+ expect(calls).toEqual(['layout a t', 'create a hello'])
406
+ expect(statuses).toEqual(['saving', 'saved'])
407
+ // Nothing is left scheduled for a.
408
+ await vi.runAllTimersAsync()
409
+ expect(calls).toHaveLength(2)
410
+ })
411
+
412
+ it('waits for edits to pause', async () => {
413
+ vi.useFakeTimers()
414
+ const { store, calls } = memoryStore()
415
+ const state: DiagramState = { positions: { t: { x: 1, y: 1 } }, notes: [] }
416
+ const autosave = new DiagramAutosave(
417
+ new DiagramSync(store, 'a'),
418
+ () => state,
419
+ 800,
420
+ () => undefined,
421
+ )
422
+ autosave.schedule()
423
+ await vi.advanceTimersByTimeAsync(500)
424
+ autosave.schedule()
425
+ await vi.advanceTimersByTimeAsync(500)
426
+ expect(calls).toEqual([])
427
+ await vi.advanceTimersByTimeAsync(300)
428
+ expect(calls).toEqual(['layout a t'])
429
+ })
430
+
431
+ it('reports a failed save and writes it again on retry', async () => {
432
+ const { store, calls } = memoryStore()
433
+ let fail = true
434
+ const failing: DiagramStore = {
435
+ ...store,
436
+ saveLayout: async (schema, positions) => {
437
+ if (fail) throw new Error('timeout')
438
+ await store.saveLayout(schema, positions)
439
+ },
440
+ }
441
+ const statuses: SaveStatus[] = []
442
+ const state: DiagramState = { positions: { t: { x: 1, y: 1 } }, notes: [] }
443
+ const autosave = new DiagramAutosave(
444
+ new DiagramSync(failing, 'a'),
445
+ () => state,
446
+ 0,
447
+ (status) => statuses.push(status),
448
+ )
449
+ autosave.schedule()
450
+ await autosave.flush()
451
+ expect(statuses).toEqual(['saving', 'error'])
452
+ fail = false
453
+ autosave.schedule()
454
+ await autosave.flush()
455
+ expect(statuses).toEqual(['saving', 'error', 'saving', 'saved'])
456
+ expect(calls).toEqual(['layout a t'])
457
+ })
458
+
459
+ it('clears an error once nothing is left to write', async () => {
460
+ const { store } = memoryStore()
461
+ const failing: DiagramStore = {
462
+ ...store,
463
+ saveLayout: async () => {
464
+ throw new Error('timeout')
465
+ },
466
+ }
467
+ const statuses: SaveStatus[] = []
468
+ let state: DiagramState = { positions: { t: { x: 1, y: 1 } }, notes: [] }
469
+ const autosave = new DiagramAutosave(
470
+ new DiagramSync(failing, 'a'),
471
+ () => state,
472
+ 0,
473
+ (status) => statuses.push(status),
474
+ )
475
+ autosave.schedule()
476
+ await autosave.flush()
477
+ // The move is undone: the stored state is the shown one again.
478
+ state = { positions: {}, notes: [] }
479
+ autosave.schedule()
480
+ await autosave.flush()
481
+ expect(statuses).toEqual(['saving', 'error', 'idle'])
482
+ })
483
+ })
484
+
485
+ describe('diagram sync', () => {
486
+ it('writes a note under its stored id while the canvas keeps the temporary one', async () => {
487
+ const { store, calls } = memoryStore()
488
+ const sync = new DiagramSync(store, 'a')
489
+ await sync.save({ positions: {}, notes: [note('temp-1', 'hi')] })
490
+ expect(
491
+ sync.hasChanges({ positions: {}, notes: [note('temp-1', 'hi')] }),
492
+ ).toBe(false)
493
+ await sync.save({ positions: {}, notes: [note('temp-1', 'hi there')] })
494
+ await sync.save({ positions: {}, notes: [] })
495
+ expect(calls).toEqual(['create a hi', 'update n1 hi there', 'delete n1'])
496
+ })
497
+ })
498
+
499
+ describe('diagram history', () => {
500
+ it('undoes a note created and gives it back on redo', () => {
501
+ const state: DiagramState = { positions: {}, notes: [note('n1', 'x')] }
502
+ const history = new DiagramHistory()
503
+ history.push({ kind: 'noteCreate', snapshot: note('n1', 'x') })
504
+ history.push(
505
+ { kind: 'noteDelete', snapshot: note('n1', 'x') },
506
+ Date.now() + 1000,
507
+ )
508
+ state.notes = []
509
+ expect(history.undo(state)).toBe(true)
510
+ expect(state.notes.map((n) => n.id)).toEqual(['n1'])
511
+ expect(history.undo(state)).toBe(true)
512
+ expect(state.notes).toEqual([])
513
+ expect(history.redo(state)).toBe(true)
514
+ expect(state.notes.map((n) => n.id)).toEqual(['n1'])
515
+ })
516
+ })