sqlseed-web 0.2.4__py3-none-any.whl

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 (87) hide show
  1. sqlseed_web/AGENTS.md +106 -0
  2. sqlseed_web/__init__.py +24 -0
  3. sqlseed_web/__main__.py +8 -0
  4. sqlseed_web/_application.py +205 -0
  5. sqlseed_web/ai_settings.py +290 -0
  6. sqlseed_web/api.py +1102 -0
  7. sqlseed_web/app.py +26 -0
  8. sqlseed_web/managed_worker.py +184 -0
  9. sqlseed_web/operation_errors.py +42 -0
  10. sqlseed_web/plugin_environment.py +231 -0
  11. sqlseed_web/plugin_management.py +322 -0
  12. sqlseed_web/plugin_process.py +131 -0
  13. sqlseed_web/runtime_lifecycle.py +130 -0
  14. sqlseed_web/runtime_session.py +97 -0
  15. sqlseed_web/settings_environment.py +368 -0
  16. sqlseed_web/sqlite_target.py +86 -0
  17. sqlseed_web/state.py +348 -0
  18. sqlseed_web/static/AGENTS.md +180 -0
  19. sqlseed_web/static/ai.css +57 -0
  20. sqlseed_web/static/configs.css +50 -0
  21. sqlseed_web/static/date-picker.css +230 -0
  22. sqlseed_web/static/disclosure.css +149 -0
  23. sqlseed_web/static/graph-clarity.css +103 -0
  24. sqlseed_web/static/index.html +29 -0
  25. sqlseed_web/static/js/api.js +228 -0
  26. sqlseed_web/static/js/app.js +97 -0
  27. sqlseed_web/static/js/dropdown.js +432 -0
  28. sqlseed_web/static/js/filepicker.js +203 -0
  29. sqlseed_web/static/js/genform.js +1066 -0
  30. sqlseed_web/static/js/labels.js +195 -0
  31. sqlseed_web/static/js/pages/browse.js +209 -0
  32. sqlseed_web/static/js/pages/configs.js +424 -0
  33. sqlseed_web/static/js/pages/connect.js +332 -0
  34. sqlseed_web/static/js/pages/heal.js +395 -0
  35. sqlseed_web/static/js/pages/meta.js +110 -0
  36. sqlseed_web/static/js/pages/runs.js +293 -0
  37. sqlseed_web/static/js/pages/settings.js +942 -0
  38. sqlseed_web/static/js/pages/wizard.js +751 -0
  39. sqlseed_web/static/js/pages/workbench.js +3123 -0
  40. sqlseed_web/static/js/tree.js +126 -0
  41. sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
  42. sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
  43. sqlseed_web/static/js/workbench/ai-stream.js +116 -0
  44. sqlseed_web/static/js/workbench/ai.js +888 -0
  45. sqlseed_web/static/js/workbench/connection.js +508 -0
  46. sqlseed_web/static/js/workbench/date-picker.js +445 -0
  47. sqlseed_web/static/js/workbench/dependency-view.js +119 -0
  48. sqlseed_web/static/js/workbench/editor.js +1236 -0
  49. sqlseed_web/static/js/workbench/focus.js +11 -0
  50. sqlseed_web/static/js/workbench/graph-layout.js +332 -0
  51. sqlseed_web/static/js/workbench/graph.js +970 -0
  52. sqlseed_web/static/js/workbench/guidance.js +29 -0
  53. sqlseed_web/static/js/workbench/model.js +124 -0
  54. sqlseed_web/static/js/workbench/plugin-management.js +512 -0
  55. sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
  56. sqlseed_web/static/js/workbench/preview.js +572 -0
  57. sqlseed_web/static/js/workbench/provider-guide.js +33 -0
  58. sqlseed_web/static/js/workbench/recovery.js +28 -0
  59. sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
  60. sqlseed_web/static/js/workbench/session.js +174 -0
  61. sqlseed_web/static/js/workbench/table-data.js +186 -0
  62. sqlseed_web/static/js/workbench/ui.js +262 -0
  63. sqlseed_web/static/navigation.css +92 -0
  64. sqlseed_web/static/preview.css +29 -0
  65. sqlseed_web/static/runs.css +53 -0
  66. sqlseed_web/static/scrollbars.css +42 -0
  67. sqlseed_web/static/settings.css +108 -0
  68. sqlseed_web/static/style.css +3382 -0
  69. sqlseed_web/static/table-data.css +27 -0
  70. sqlseed_web/static/workbench.css +509 -0
  71. sqlseed_web/supervised_plugins.py +173 -0
  72. sqlseed_web/supervisor.py +238 -0
  73. sqlseed_web/workbench.py +381 -0
  74. sqlseed_web/workbench_ai.py +887 -0
  75. sqlseed_web/workbench_ai_relations.py +285 -0
  76. sqlseed_web/workbench_ai_stream.py +172 -0
  77. sqlseed_web/workbench_data.py +163 -0
  78. sqlseed_web/workbench_execution.py +199 -0
  79. sqlseed_web/workbench_runtime.py +1218 -0
  80. sqlseed_web/workbench_schema.py +277 -0
  81. sqlseed_web/workbench_store.py +458 -0
  82. sqlseed_web/worker_control.py +192 -0
  83. sqlseed_web-0.2.4.dist-info/METADATA +105 -0
  84. sqlseed_web-0.2.4.dist-info/RECORD +87 -0
  85. sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
  86. sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
  87. sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
@@ -0,0 +1,445 @@
1
+ // ISO date input with an accessible calendar. The date model stays separate
2
+ // from keyboard navigation and invalid text; only a valid choice is emitted.
3
+ import { h } from '../api.js';
4
+ let nextId = 0;
5
+ let activeClose = null;
6
+ const weekNames = ['星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日'];
7
+ const invalidDate = '日期格式应为 YYYY-MM-DD,且必须是有效日期。';
8
+ function dateAt(year, month, day) {
9
+ const value = new Date(0);
10
+ value.setUTCFullYear(year, month, day);
11
+ value.setUTCHours(0, 0, 0, 0);
12
+ return value;
13
+ }
14
+ function iso(value) {
15
+ return `${String(value.getUTCFullYear()).padStart(4, '0')}-${String(value.getUTCMonth() + 1).padStart(2, '0')}-${String(value.getUTCDate()).padStart(2, '0')}`;
16
+ }
17
+ function parse(value) {
18
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
19
+ return null;
20
+ }
21
+ const [year, month, day] = value.split('-').map(Number);
22
+ if (year < 1 || year > 9999 || month < 1 || month > 12 || day < 1 || day > 31) {
23
+ return null;
24
+ }
25
+ const result = dateAt(year, month - 1, day);
26
+ return iso(result) === value ? result : null;
27
+ }
28
+ function today() {
29
+ const current = new Date();
30
+ return dateAt(current.getFullYear(), current.getMonth(), current.getDate());
31
+ }
32
+ function shiftMonth(value, amount) {
33
+ const first = dateAt(value.getUTCFullYear(), value.getUTCMonth() + amount, 1);
34
+ const lastDay = dateAt(first.getUTCFullYear(), first.getUTCMonth() + 1, 0).getUTCDate();
35
+ return dateAt(first.getUTCFullYear(), first.getUTCMonth(), Math.min(value.getUTCDate(), lastDay));
36
+ }
37
+
38
+ /** onChange receives YYYY-MM-DD or undefined; invalid drafts only notify onValidity. */
39
+ export function createDatePicker({
40
+ value = '',
41
+ label = '日期',
42
+ onChange,
43
+ onValidity
44
+ }) {
45
+ const id = `wb-date-${++nextId}`;
46
+ let disposed = false,
47
+ layer = null,
48
+ dialog = null,
49
+ gridBody = null,
50
+ heading = null;
51
+ let yearInput = null,
52
+ monthInput = null,
53
+ jumpError = null,
54
+ focused = null;
55
+ let changedBackground = [];
56
+ const input = h('input', {
57
+ type: 'text',
58
+ value: String(value ?? ''),
59
+ placeholder: 'YYYY-MM-DD',
60
+ autocomplete: 'off',
61
+ spellcheck: 'false',
62
+ 'aria-describedby': `${id}-format ${id}-error`
63
+ });
64
+ const trigger = h('button', {
65
+ type: 'button',
66
+ class: 'wb-date-trigger',
67
+ 'aria-haspopup': 'dialog',
68
+ 'aria-expanded': 'false',
69
+ 'aria-controls': id,
70
+ onclick: open
71
+ }, '日历');
72
+ const error = h('small', {
73
+ id: `${id}-error`,
74
+ class: 'wb-date-error',
75
+ role: 'alert'
76
+ });
77
+ const el = h('div', {
78
+ class: 'wb-date-control'
79
+ }, h('div', {
80
+ class: 'wb-date-entry'
81
+ }, h('label', {
82
+ class: 'wb-date-input-label'
83
+ }, h('span', {
84
+ class: 'wb-date-sr'
85
+ }, label), input), trigger), h('small', {
86
+ id: `${id}-format`,
87
+ class: 'wb-date-format'
88
+ }, 'YYYY-MM-DD · 可输入或从日历选择'), error);
89
+ function check(commit) {
90
+ if (disposed) {
91
+ return false;
92
+ }
93
+ const empty = !input.value.trim(),
94
+ parsed = parse(input.value);
95
+ const message = empty || parsed ? null : invalidDate;
96
+ if (message) {
97
+ input.setAttribute('aria-invalid', 'true');
98
+ } else {
99
+ input.removeAttribute('aria-invalid');
100
+ }
101
+ error.textContent = message || '';
102
+ trigger.setAttribute('aria-label', parsed ? `${label}:更改日期,${input.value}` : `${label}:选择日期`);
103
+ onValidity?.(message, input);
104
+ if (!message && commit && !disposed) {
105
+ onChange?.(empty ? undefined : input.value);
106
+ }
107
+ return !message;
108
+ }
109
+ input.addEventListener('input', () => check(true));
110
+ input.addEventListener('keydown', event => {
111
+ if (event.altKey && event.key === 'ArrowDown') {
112
+ event.preventDefault();
113
+ event.stopPropagation();
114
+ open();
115
+ }
116
+ });
117
+ function setValue(next) {
118
+ input.value = String(next ?? '');
119
+ check(false);
120
+ }
121
+ function close(restoreFocus = true) {
122
+ if (!layer) {
123
+ return;
124
+ }
125
+ layer.remove();
126
+ layer = null;
127
+ dialog = null;
128
+ for (const element of changedBackground) {
129
+ element.inert = false;
130
+ }
131
+ changedBackground = [];
132
+ trigger.setAttribute('aria-expanded', 'false');
133
+ document.removeEventListener('keydown', onKey, true);
134
+ document.removeEventListener('focusin', containFocus, true);
135
+ document.removeEventListener('scroll', reposition, true);
136
+ window.removeEventListener('resize', reposition);
137
+ if (activeClose === close) {
138
+ activeClose = null;
139
+ }
140
+ if (restoreFocus && trigger.isConnected) {
141
+ trigger.focus();
142
+ }
143
+ }
144
+ function choose(next) {
145
+ input.value = next ? iso(next) : '';
146
+ check(true);
147
+ close();
148
+ }
149
+ function focusDay() {
150
+ const day = dialog?.querySelector(`[data-date="${iso(focused)}"]`);
151
+ day?.focus();
152
+ day?.scrollIntoView?.({
153
+ block: 'nearest',
154
+ inline: 'nearest'
155
+ });
156
+ }
157
+ function move(next, focus = true) {
158
+ if (next.getUTCFullYear() < 1 || next.getUTCFullYear() > 9999) {
159
+ return;
160
+ }
161
+ focused = next;
162
+ renderGrid();
163
+ if (focus) {
164
+ focusDay();
165
+ }
166
+ }
167
+ function renderGrid() {
168
+ const year = focused.getUTCFullYear(),
169
+ month = focused.getUTCMonth();
170
+ heading.textContent = `${year} 年 ${month + 1} 月`;
171
+ yearInput.value = String(year);
172
+ monthInput.value = String(month + 1);
173
+ yearInput.removeAttribute('aria-invalid');
174
+ monthInput.removeAttribute('aria-invalid');
175
+ jumpError.textContent = '';
176
+ const first = dateAt(year, month, 1),
177
+ offset = (first.getUTCDay() + 6) % 7;
178
+ const total = dateAt(year, month + 1, 0).getUTCDate(),
179
+ selected = parse(input.value),
180
+ currentDay = iso(today());
181
+ gridBody.replaceChildren();
182
+ for (let row = 0; row < Math.ceil((offset + total) / 7); row++) {
183
+ const tr = h('tr');
184
+ for (let col = 0; col < 7; col++) {
185
+ const number = row * 7 + col - offset + 1;
186
+ if (number < 1 || number > total) {
187
+ tr.append(h('td'));
188
+ continue;
189
+ }
190
+ tr.append(dayCell(year, month, number, col));
191
+ }
192
+ gridBody.append(tr);
193
+ }
194
+ reposition();
195
+ function dayCell(year, month, number, col) {
196
+ const date = dateAt(year, month, number),
197
+ key = iso(date),
198
+ isSelected = selected && iso(selected) === key;
199
+ const button = h('button', {
200
+ type: 'button',
201
+ class: `wb-date-day${isSelected ? ' selected' : ''}${key === currentDay ? ' today' : ''}`,
202
+ 'data-date': key,
203
+ tabindex: key === iso(focused) ? '0' : '-1',
204
+ 'aria-label': `${year} 年 ${month + 1} 月 ${number} 日,${weekNames[col]}`,
205
+ ...(key === currentDay ? {
206
+ 'aria-current': 'date'
207
+ } : {}),
208
+ onclick: () => choose(date)
209
+ }, String(number));
210
+ button.addEventListener('focus', () => {
211
+ focused = date;
212
+ });
213
+ return h('td', {
214
+ ...(isSelected ? {
215
+ 'aria-selected': 'true'
216
+ } : {})
217
+ }, button);
218
+ }
219
+ }
220
+ function jump() {
221
+ const year = Number(yearInput.value),
222
+ month = Number(monthInput.value);
223
+ const yearValid = /^\d{1,4}$/.test(yearInput.value) && year >= 1 && year <= 9999;
224
+ const monthValid = /^\d{1,2}$/.test(monthInput.value) && month >= 1 && month <= 12;
225
+ if (!yearValid || !monthValid) {
226
+ yearInput.setAttribute('aria-invalid', String(!yearValid));
227
+ monthInput.setAttribute('aria-invalid', String(!monthValid));
228
+ jumpError.textContent = !yearValid ? '年份须为 1–9999 的整数。' : '月份须为 1–12 的整数。';
229
+ (!yearValid ? yearInput : monthInput).focus();
230
+ return;
231
+ }
232
+ move(dateAt(year, month - 1, Math.min(focused.getUTCDate(), dateAt(year, month, 0).getUTCDate())));
233
+ }
234
+ function open() {
235
+ if (disposed || layer) {
236
+ return;
237
+ }
238
+ activeClose?.();
239
+ focused = parse(input.value) || today();
240
+ heading = h('h3', {
241
+ id: `${id}-month`,
242
+ 'aria-live': 'polite',
243
+ 'aria-atomic': 'true'
244
+ });
245
+ const nav = (text, name, amount) => h('button', {
246
+ type: 'button',
247
+ class: 'wb-date-nav',
248
+ 'aria-label': name,
249
+ onclick: () => move(shiftMonth(focused, amount), false)
250
+ }, text);
251
+ yearInput = h('input', {
252
+ type: 'number',
253
+ min: '1',
254
+ max: '9999',
255
+ step: '1',
256
+ 'data-calendar-year': '',
257
+ 'aria-describedby': `${id}-jump-error`
258
+ });
259
+ monthInput = h('input', {
260
+ type: 'number',
261
+ min: '1',
262
+ max: '12',
263
+ step: '1',
264
+ 'data-calendar-month': '',
265
+ 'aria-describedby': `${id}-jump-error`
266
+ });
267
+ for (const input of [yearInput, monthInput]) {
268
+ input.addEventListener('keydown', event => {
269
+ if (event.key === 'Enter') {
270
+ event.preventDefault();
271
+ jump();
272
+ }
273
+ });
274
+ }
275
+ jumpError = h('small', {
276
+ id: `${id}-jump-error`,
277
+ class: 'wb-date-error',
278
+ role: 'alert'
279
+ });
280
+ gridBody = h('tbody');
281
+ dialog = h('section', {
282
+ id,
283
+ class: 'wb-date-dialog',
284
+ role: 'dialog',
285
+ 'aria-modal': 'true',
286
+ 'aria-label': `选择${label}`
287
+ }, h('div', {
288
+ class: 'wb-date-heading'
289
+ }, heading, h('button', {
290
+ type: 'button',
291
+ class: 'wb-date-nav',
292
+ 'aria-label': '关闭日历',
293
+ onclick: () => close()
294
+ }, '×')), h('div', {
295
+ class: 'wb-date-navigation'
296
+ }, nav('«', '上一年', -12), nav('‹', '上个月', -1), h('span', {}, '按月或按年切换'), nav('›', '下个月', 1), nav('»', '下一年', 12)), h('div', {
297
+ class: 'wb-date-jump'
298
+ }, h('label', {}, yearInput, '年'), h('label', {}, monthInput, '月'), h('button', {
299
+ type: 'button',
300
+ class: 'wb-date-action',
301
+ onclick: jump
302
+ }, '跳转')), jumpError, h('table', {
303
+ class: 'wb-date-grid',
304
+ role: 'grid',
305
+ 'aria-labelledby': `${id}-month`,
306
+ 'aria-describedby': `${id}-keys`
307
+ }, h('thead', {}, h('tr', {}, ...weekNames.map(name => h('th', {
308
+ scope: 'col',
309
+ abbr: name
310
+ }, name.slice(2))))), gridBody), h('div', {
311
+ class: 'wb-date-actions'
312
+ }, h('button', {
313
+ type: 'button',
314
+ class: 'wb-date-action',
315
+ onclick: () => choose(today())
316
+ }, '今天'), h('button', {
317
+ type: 'button',
318
+ class: 'wb-date-action',
319
+ onclick: () => choose(null)
320
+ }, '清空'), h('button', {
321
+ type: 'button',
322
+ class: 'wb-date-action',
323
+ onclick: () => close()
324
+ }, '取消')), h('p', {
325
+ id: `${id}-keys`,
326
+ class: 'wb-date-help'
327
+ }, '方向键移动 · Home/End 到周首尾 · PageUp/Down 换月,按住 Shift 换年 · Enter 选择 · Esc 关闭'));
328
+ layer = h('div', {
329
+ class: 'wb-date-layer'
330
+ }, dialog);
331
+ layer.addEventListener('mousedown', event => {
332
+ if (event.target === layer) {
333
+ close();
334
+ }
335
+ });
336
+ // Only undo changes made by this popup. The parent modal may already own
337
+ // inert page siblings, and may restore them before destroying this editor.
338
+ changedBackground = [...document.body.children].filter(element => !element.inert);
339
+ for (const element of changedBackground) {
340
+ element.inert = true;
341
+ }
342
+ document.body.append(layer);
343
+ trigger.setAttribute('aria-expanded', 'true');
344
+ activeClose = close;
345
+ document.addEventListener('keydown', onKey, true);
346
+ document.addEventListener('focusin', containFocus, true);
347
+ document.addEventListener('scroll', reposition, true);
348
+ window.addEventListener('resize', reposition);
349
+ renderGrid();
350
+ focusDay();
351
+ }
352
+ function reposition(event) {
353
+ if (!dialog) {
354
+ return;
355
+ }
356
+ if (!el.isConnected) {
357
+ close(false);
358
+ return;
359
+ }
360
+ if (event?.target && dialog.contains(event.target)) {
361
+ return;
362
+ }
363
+ const rect = trigger.getBoundingClientRect(),
364
+ margin = 8,
365
+ gap = 6;
366
+ const viewportWidth = document.documentElement?.clientWidth || innerWidth,
367
+ viewportHeight = document.documentElement?.clientHeight || innerHeight;
368
+ const width = Math.min(340, viewportWidth - margin * 2),
369
+ height = Math.min(dialog.scrollHeight || 430, viewportHeight - margin * 2);
370
+ const below = viewportHeight - rect.bottom - gap - margin;
371
+ const top = below >= height ? rect.bottom + gap : Math.max(margin, rect.top - gap - height);
372
+ Object.assign(dialog.style, {
373
+ position: 'fixed',
374
+ width: `${width}px`,
375
+ maxHeight: `${viewportHeight - margin * 2}px`,
376
+ left: `${Math.max(margin, Math.min(rect.left, viewportWidth - width - margin))}px`,
377
+ top: `${Math.min(top, viewportHeight - height - margin)}px`
378
+ });
379
+ }
380
+ function containFocus(event) {
381
+ if (dialog && !dialog.contains(event.target)) {
382
+ focusDay();
383
+ }
384
+ }
385
+ function onKey(event) {
386
+ if (!dialog) {
387
+ return;
388
+ }
389
+ if (event.key === 'Escape') {
390
+ event.preventDefault();
391
+ event.stopImmediatePropagation();
392
+ close();
393
+ return;
394
+ }
395
+ if (event.key === 'Tab') {
396
+ event.preventDefault();
397
+ event.stopImmediatePropagation();
398
+ const controls = [...dialog.querySelectorAll('button,input')].filter(control => !control.disabled && control.getAttribute('tabindex') !== '-1');
399
+ const index = controls.indexOf(document.activeElement),
400
+ direction = event.shiftKey ? -1 : 1;
401
+ controls[(index + direction + controls.length) % controls.length]?.focus();
402
+ return;
403
+ }
404
+ if (!event.target?.dataset.date) {
405
+ return;
406
+ }
407
+ const key = event.key,
408
+ weekday = (focused.getUTCDay() + 6) % 7;
409
+ if (key === 'Enter' || key === ' ') {
410
+ event.preventDefault();
411
+ choose(focused);
412
+ return;
413
+ }
414
+ let next;
415
+ if (key === 'PageUp' || key === 'PageDown') {
416
+ next = shiftMonth(focused, (key === 'PageUp' ? -1 : 1) * (event.shiftKey ? 12 : 1));
417
+ } else {
418
+ const days = {
419
+ ArrowLeft: -1,
420
+ ArrowRight: 1,
421
+ ArrowUp: -7,
422
+ ArrowDown: 7,
423
+ Home: -weekday,
424
+ End: 6 - weekday
425
+ }[key];
426
+ if (days === undefined) {
427
+ return;
428
+ }
429
+ next = dateAt(focused.getUTCFullYear(), focused.getUTCMonth(), focused.getUTCDate() + days);
430
+ }
431
+ event.preventDefault();
432
+ move(next);
433
+ }
434
+ check(false);
435
+ return {
436
+ el,
437
+ input,
438
+ button: trigger,
439
+ setValue,
440
+ destroy() {
441
+ disposed = true;
442
+ close(false);
443
+ }
444
+ };
445
+ }
@@ -0,0 +1,119 @@
1
+ /* Pure graph-view projection; implemented independently from DOM and layout. */
2
+ (function (root, factory) {
3
+ 'use strict';
4
+ const api = factory();
5
+ if (typeof module === 'object' && module.exports) module.exports = api;
6
+ else root.SqlseedDependencyView = api;
7
+ })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
8
+ 'use strict';
9
+ const MODES = new Set(['paths', 'upstream', 'downstream', 'neighbors']);
10
+
11
+ function indexGraph(data) {
12
+ if (!data || !Array.isArray(data.nodes) || !Array.isArray(data.edges)) {
13
+ throw new TypeError('Expected node and edge arrays');
14
+ }
15
+ const parents = new Map(), children = new Map(), edgeIds = new Set();
16
+ for (const node of data.nodes) {
17
+ if (!node || typeof node.id !== 'string' || !node.id) throw new TypeError('Node id must be a nonempty string');
18
+ if (parents.has(node.id)) throw new Error(`Duplicate node id: ${node.id}`);
19
+ parents.set(node.id, new Set());
20
+ children.set(node.id, new Set());
21
+ }
22
+ for (const edge of data.edges) {
23
+ if (!edge || typeof edge.id !== 'string' || !edge.id) throw new TypeError('Edge id must be a nonempty string');
24
+ if (edgeIds.has(edge.id)) throw new Error(`Duplicate edge id: ${edge.id}`);
25
+ if (!parents.has(edge.source) || !parents.has(edge.target)) throw new Error(`Unknown endpoint on edge: ${edge.id}`);
26
+ edgeIds.add(edge.id);
27
+ children.get(edge.source).add(edge.target);
28
+ parents.get(edge.target).add(edge.source);
29
+ }
30
+ return { parents, children };
31
+ }
32
+
33
+ // Multi-source traversal visits each node at most once; cycles and diamonds
34
+ // never trigger path enumeration or recursive call-stack growth.
35
+ function reachable(adjacency, seeds) {
36
+ const visited = new Set(seeds), queue = [...visited];
37
+ for (let i = 0; i < queue.length; i++) {
38
+ for (const id of adjacency.get(queue[i])) {
39
+ if (!visited.has(id)) { visited.add(id); queue.push(id); }
40
+ }
41
+ }
42
+ return visited;
43
+ }
44
+
45
+ function project(data, visible) {
46
+ return {
47
+ nodes: data.nodes.filter(node => visible.has(node.id)),
48
+ edges: data.edges.filter(edge => visible.has(edge.source) && visible.has(edge.target)),
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Arrows run from referenced parent (source) to referencing child (target).
54
+ *
55
+ * paths: focus + recursive descendants, then ALL ancestors of that set.
56
+ * This includes every source required by the visible descendants, without
57
+ * expanding unrelated children of those additional sources. For example,
58
+ * orders includes users → orders → items ← products, but excludes reviews
59
+ * referenced from users and inventory referenced from products.
60
+ * upstream/downstream: focus + all ancestors/descendants respectively.
61
+ * neighbors: focus + one hop in either direction, deliberately incomplete.
62
+ *
63
+ * Every FK between visible tables is retained, including parallel constraints,
64
+ * self references and composite column metadata. Input objects are unchanged.
65
+ * upstreamIds/downstreamIds describe the full focal ancestry, excluding focus;
66
+ * relatedSourceIds are extra ancestors added only in paths mode. roles applies
67
+ * to visible nodes; a node both upstream and downstream has role cycle.
68
+ * This projection controls browsing, never the generation selection itself.
69
+ */
70
+ function selectGraph(data, focusId, mode = 'paths') {
71
+ if (!MODES.has(mode)) throw new Error(`Unknown mode: ${mode}`);
72
+ const { parents, children } = indexGraph(data);
73
+ const roles = Object.create(null);
74
+ if (!parents.has(focusId)) {
75
+ return { nodes: [], edges: [], focusId: null, mode, upstreamIds: [], downstreamIds: [], relatedSourceIds: [], roles };
76
+ }
77
+ const upstream = reachable(parents, [focusId]), downstream = reachable(children, [focusId]);
78
+ let visible;
79
+ if (mode === 'paths') visible = reachable(parents, downstream);
80
+ else if (mode === 'upstream') visible = upstream;
81
+ else if (mode === 'downstream') visible = downstream;
82
+ else visible = new Set([focusId, ...parents.get(focusId), ...children.get(focusId)]);
83
+ const relatedSources = new Set([...visible].filter(id => !upstream.has(id) && !downstream.has(id)));
84
+ for (const node of data.nodes) {
85
+ const id = node.id;
86
+ if (!visible.has(id)) continue;
87
+ roles[id] = id === focusId ? 'focus'
88
+ : upstream.has(id) && downstream.has(id) ? 'cycle'
89
+ : upstream.has(id) ? 'upstream'
90
+ : downstream.has(id) ? 'downstream' : 'relatedSource';
91
+ }
92
+ const orderedIds = set => data.nodes.filter(node => node.id !== focusId && set.has(node.id)).map(node => node.id);
93
+ return {
94
+ ...project(data, visible), focusId, mode, roles,
95
+ upstreamIds: orderedIds(upstream),
96
+ downstreamIds: orderedIds(downstream),
97
+ relatedSourceIds: orderedIds(relatedSources),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Dependency analysis uses the REAL set of generation targets, independent
103
+ * of what the user happens to view. Return the targets and all their parents;
104
+ * whether a parent is written, referenced from existing rows, or blocks the
105
+ * run must be resolved by a separate constraint-aware execution planner.
106
+ */
107
+ function dependencyClosure(data, targetIds) {
108
+ if (!Array.isArray(targetIds)) throw new TypeError('Expected target id array');
109
+ const { parents } = indexGraph(data), targets = new Set(targetIds);
110
+ for (const id of targets) if (!parents.has(id)) throw new Error(`Unknown target: ${id}`);
111
+ const visible = reachable(parents, targets);
112
+ return {
113
+ ...project(data, visible),
114
+ targetIds: data.nodes.filter(node => targets.has(node.id)).map(node => node.id),
115
+ upstreamIds: data.nodes.filter(node => visible.has(node.id) && !targets.has(node.id)).map(node => node.id),
116
+ };
117
+ }
118
+ return { selectGraph, dependencyClosure };
119
+ });