@rokkit/graph 1.6.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.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/dist/Graph.svelte +369 -0
- package/dist/Graph.svelte.d.ts +4 -0
- package/dist/GraphState.svelte.d.ts +171 -0
- package/dist/GraphState.svelte.js +398 -0
- package/dist/icons.d.ts +13 -0
- package/dist/icons.js +38 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/layout/cards.d.ts +19 -0
- package/dist/layout/cards.js +31 -0
- package/dist/layout/cluster.d.ts +11 -0
- package/dist/layout/cluster.js +39 -0
- package/dist/layout/clusters.d.ts +39 -0
- package/dist/layout/clusters.js +217 -0
- package/dist/layout/constants.d.ts +12 -0
- package/dist/layout/constants.js +15 -0
- package/dist/layout/edges.d.ts +5 -0
- package/dist/layout/edges.js +110 -0
- package/dist/layout/index.d.ts +9 -0
- package/dist/layout/index.js +7 -0
- package/dist/layout/neighborhood.d.ts +9 -0
- package/dist/layout/neighborhood.js +123 -0
- package/dist/layout/nested.d.ts +13 -0
- package/dist/layout/nested.js +156 -0
- package/dist/layout/points.d.ts +6 -0
- package/dist/layout/points.js +204 -0
- package/dist/layout/types.d.ts +115 -0
- package/dist/layout/types.js +1 -0
- package/dist/model/normalize.d.ts +7 -0
- package/dist/model/normalize.js +215 -0
- package/dist/model/path.d.ts +13 -0
- package/dist/model/path.js +23 -0
- package/dist/preset.d.ts +47 -0
- package/dist/preset.js +61 -0
- package/dist/schema/EntitiesView.svelte +83 -0
- package/dist/schema/EntitiesView.svelte.d.ts +13 -0
- package/dist/schema/EntityView.svelte +116 -0
- package/dist/schema/EntityView.svelte.d.ts +13 -0
- package/dist/schema/NoteBlocks.svelte +31 -0
- package/dist/schema/NoteBlocks.svelte.d.ts +6 -0
- package/dist/schema/column-type.d.ts +4 -0
- package/dist/schema/column-type.js +19 -0
- package/dist/schema/fromSchemaModel.d.ts +66 -0
- package/dist/schema/fromSchemaModel.js +92 -0
- package/dist/schema/index.d.ts +8 -0
- package/dist/schema/index.js +7 -0
- package/dist/schema/notes.d.ts +12 -0
- package/dist/schema/notes.js +65 -0
- package/dist/types.d.ts +160 -0
- package/dist/types.js +1 -0
- package/package.json +62 -0
package/dist/preset.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { categoricalPalette } from '@rokkit/core';
|
|
2
|
+
/**
|
|
3
|
+
* Mirrors `createChartPreset`'s shape on purpose — a consumer who has met one
|
|
4
|
+
* has met both. Shade picks differ from chart's because a cluster is a large
|
|
5
|
+
* background area rather than a small mark.
|
|
6
|
+
*
|
|
7
|
+
* The `groups` ramp and the `patterns` list are chart's own leading entries, in
|
|
8
|
+
* chart's order, so a graph and a chart on one page differentiate alike.
|
|
9
|
+
*/
|
|
10
|
+
export const defaultGraphPreset = {
|
|
11
|
+
groups: ['blue', 'emerald', 'rose', 'amber', 'violet', 'sky', 'pink', 'teal'],
|
|
12
|
+
// The label shade is measured against the FILL shade, not against paper: the cluster label
|
|
13
|
+
// sits on the cluster. 700-on-100 came out at 3.63:1 for green — under the 4.5 text bar —
|
|
14
|
+
// so the label sits two more steps away from its own background.
|
|
15
|
+
shades: {
|
|
16
|
+
light: { fill: '100', stroke: '400', label: '900' },
|
|
17
|
+
dark: { fill: '900', stroke: '600', label: '100' }
|
|
18
|
+
},
|
|
19
|
+
patterns: ['diagonal', 'dots', 'triangles', 'hatch', 'lattice', 'swell', 'checkerboard', 'waves'],
|
|
20
|
+
using: 'color'
|
|
21
|
+
};
|
|
22
|
+
export function createGraphPreset(overrides = {}) {
|
|
23
|
+
return {
|
|
24
|
+
...defaultGraphPreset,
|
|
25
|
+
...overrides,
|
|
26
|
+
shades: {
|
|
27
|
+
light: { ...defaultGraphPreset.shades.light, ...overrides.shades?.light },
|
|
28
|
+
dark: { ...defaultGraphPreset.shades.dark, ...overrides.shades?.dark }
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolves group names to CSS custom properties.
|
|
34
|
+
*
|
|
35
|
+
* Returns custom properties rather than concrete fills so a theme or an app can
|
|
36
|
+
* still override with one attribute rule — the JS decides the DEFAULT, never the
|
|
37
|
+
* final paint.
|
|
38
|
+
*
|
|
39
|
+
* Assignment is by SORTED group name so a group keeps its colour when `arrange`
|
|
40
|
+
* reorders the layout. Callers pass one name per NODE, so names are de-duplicated
|
|
41
|
+
* first; otherwise the ramp would advance per node and two nodes in the same group
|
|
42
|
+
* would disagree.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveGroupStyles(groups, mode, preset = defaultGraphPreset) {
|
|
45
|
+
const styles = new Map();
|
|
46
|
+
const ordered = [...new Set(groups)].sort();
|
|
47
|
+
ordered.forEach((group, index) => {
|
|
48
|
+
if (preset.using === 'pattern') {
|
|
49
|
+
styles.set(group, { '--group-pattern': preset.patterns[index % preset.patterns.length] });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const family = categoricalPalette[preset.groups[index % preset.groups.length]];
|
|
53
|
+
const shades = preset.shades[mode];
|
|
54
|
+
styles.set(group, {
|
|
55
|
+
'--group-fill': family[shades.fill],
|
|
56
|
+
'--group-stroke': family[shades.stroke],
|
|
57
|
+
'--group-label': family[shades.label]
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
return styles;
|
|
61
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getContext, untrack } from 'svelte'
|
|
3
|
+
import { Table } from '@rokkit/ui'
|
|
4
|
+
import type { TableColumn } from '@rokkit/ui'
|
|
5
|
+
import { GraphState } from '../GraphState.svelte.js'
|
|
6
|
+
import type { EntityRow, GraphStateConfig } from '../GraphState.svelte.js'
|
|
7
|
+
import NoteBlocks from './NoteBlocks.svelte'
|
|
8
|
+
import type { GraphFields } from '../types.js'
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
state: provided,
|
|
12
|
+
nodes = [],
|
|
13
|
+
edges = [],
|
|
14
|
+
fields = {},
|
|
15
|
+
caption = undefined,
|
|
16
|
+
class: className = ''
|
|
17
|
+
}: {
|
|
18
|
+
state?: GraphState
|
|
19
|
+
nodes?: unknown[]
|
|
20
|
+
edges?: unknown[]
|
|
21
|
+
fields?: GraphFields
|
|
22
|
+
caption?: string
|
|
23
|
+
class?: string
|
|
24
|
+
} = $props()
|
|
25
|
+
|
|
26
|
+
const config = (): GraphStateConfig => ({ nodes, edges, fields })
|
|
27
|
+
|
|
28
|
+
// untrack: the constructor's initial read must not become a dependency of the creating
|
|
29
|
+
// scope. Only built when this view is standalone — see the resolution order below.
|
|
30
|
+
const own = untrack(() => new GraphState(config()))
|
|
31
|
+
const fromContext = getContext<GraphState | undefined>('graph-state')
|
|
32
|
+
|
|
33
|
+
// Resolved ONCE, in the same three-way order Graph.svelte uses, so a view works inside a
|
|
34
|
+
// <Graph>, beside one, or alone: the prop wins, then context, then the state we build.
|
|
35
|
+
// svelte-ignore state_referenced_locally
|
|
36
|
+
const graph = provided ?? fromContext ?? own
|
|
37
|
+
|
|
38
|
+
// Only sync the state WE own. A prop- or context-supplied state is its owner's to drive.
|
|
39
|
+
$effect(() => {
|
|
40
|
+
if (graph === own) own.update(config())
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const columns: TableColumn[] = [
|
|
44
|
+
{ name: 'label', label: 'Entity', snippet: 'entity' },
|
|
45
|
+
{ name: 'rowCount', label: 'Rows', align: 'right', snippet: 'rows' },
|
|
46
|
+
{ name: 'refCount', label: 'Refs', align: 'right', snippet: 'refs' },
|
|
47
|
+
{ name: 'note', label: 'Comment', sortable: false, snippet: 'comment' }
|
|
48
|
+
]
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
{#snippet entity(_value: unknown, _column: TableColumn, row: Record<string, unknown>)}
|
|
52
|
+
{@const item = row as unknown as EntityRow}
|
|
53
|
+
<span data-graph-entity data-graph-entity-kind={item.kind}>
|
|
54
|
+
{#if item.group}<span data-graph-entity-group>{item.group}.</span>{/if}<span
|
|
55
|
+
data-graph-entity-label>{item.label}</span
|
|
56
|
+
>
|
|
57
|
+
</span>
|
|
58
|
+
{/snippet}
|
|
59
|
+
|
|
60
|
+
{#snippet rows(_value: unknown, _column: TableColumn, row: Record<string, unknown>)}
|
|
61
|
+
<span data-graph-entity-rows>{(row as unknown as EntityRow).rowCount}</span>
|
|
62
|
+
{/snippet}
|
|
63
|
+
|
|
64
|
+
{#snippet refs(_value: unknown, _column: TableColumn, row: Record<string, unknown>)}
|
|
65
|
+
{@const count = (row as unknown as EntityRow).refCount}
|
|
66
|
+
<!-- The em dash is a PRESENTATION choice and belongs here: state reports the number 0,
|
|
67
|
+
and a column of zeros reads as noise next to the counts that matter. -->
|
|
68
|
+
<span data-graph-entity-refs>{count > 0 ? count : '—'}</span>
|
|
69
|
+
{/snippet}
|
|
70
|
+
|
|
71
|
+
{#snippet comment(_value: unknown, _column: TableColumn, row: Record<string, unknown>)}
|
|
72
|
+
<NoteBlocks note={(row as unknown as EntityRow).note} />
|
|
73
|
+
{/snippet}
|
|
74
|
+
|
|
75
|
+
<Table
|
|
76
|
+
data={graph.entities}
|
|
77
|
+
{columns}
|
|
78
|
+
{caption}
|
|
79
|
+
class={className}
|
|
80
|
+
selectable="single"
|
|
81
|
+
onselect={(_value, row) => graph.select((row as unknown as EntityRow).id)}
|
|
82
|
+
snippets={{ entity, rows, refs, comment }}
|
|
83
|
+
/>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { GraphState } from '../GraphState.svelte.js';
|
|
2
|
+
import type { GraphFields } from '../types.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
state?: GraphState;
|
|
5
|
+
nodes?: unknown[];
|
|
6
|
+
edges?: unknown[];
|
|
7
|
+
fields?: GraphFields;
|
|
8
|
+
caption?: string;
|
|
9
|
+
class?: string;
|
|
10
|
+
};
|
|
11
|
+
declare const EntitiesView: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type EntitiesView = ReturnType<typeof EntitiesView>;
|
|
13
|
+
export default EntitiesView;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getContext, untrack } from 'svelte'
|
|
3
|
+
import { GraphState } from '../GraphState.svelte.js'
|
|
4
|
+
import type { GraphStateConfig } from '../GraphState.svelte.js'
|
|
5
|
+
import NoteBlocks from './NoteBlocks.svelte'
|
|
6
|
+
import { baseType, typeSize } from './column-type.js'
|
|
7
|
+
import type { GraphFields } from '../types.js'
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
state: provided,
|
|
11
|
+
nodes = [],
|
|
12
|
+
edges = [],
|
|
13
|
+
fields = {},
|
|
14
|
+
value = undefined,
|
|
15
|
+
class: className = ''
|
|
16
|
+
}: {
|
|
17
|
+
state?: GraphState
|
|
18
|
+
nodes?: unknown[]
|
|
19
|
+
edges?: unknown[]
|
|
20
|
+
fields?: GraphFields
|
|
21
|
+
value?: string | null
|
|
22
|
+
class?: string
|
|
23
|
+
} = $props()
|
|
24
|
+
|
|
25
|
+
const config = (): GraphStateConfig => ({ nodes, edges, fields, value })
|
|
26
|
+
|
|
27
|
+
const own = untrack(() => new GraphState(config()))
|
|
28
|
+
const fromContext = getContext<GraphState | undefined>('graph-state')
|
|
29
|
+
|
|
30
|
+
// Same three-way resolution as Graph and EntitiesView: prop, then context, then the state
|
|
31
|
+
// we build. Resolved once — context captures a value, not a getter.
|
|
32
|
+
// svelte-ignore state_referenced_locally
|
|
33
|
+
const graph = provided ?? fromContext ?? own
|
|
34
|
+
|
|
35
|
+
$effect(() => {
|
|
36
|
+
if (graph === own) own.update(config())
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// A read, not a derivation: GraphState owns the in/out split and the meta passthrough.
|
|
40
|
+
const entity = $derived(graph.entity)
|
|
41
|
+
const indexes = $derived(
|
|
42
|
+
(entity?.meta.indexes ?? []) as { def: string; unique?: boolean; name?: string }[]
|
|
43
|
+
)
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
{#if entity}
|
|
47
|
+
<article data-graph-entity data-node-kind={entity.kind} class={className}>
|
|
48
|
+
<header data-graph-entity-head>
|
|
49
|
+
{#if entity.group}<span data-graph-entity-group>{entity.group}.</span>{/if}
|
|
50
|
+
<h2 data-graph-entity-title>{entity.label}</h2>
|
|
51
|
+
<span data-graph-entity-count>{entity.rows.length} columns</span>
|
|
52
|
+
</header>
|
|
53
|
+
|
|
54
|
+
{#if entity.note}
|
|
55
|
+
<section data-graph-section="note">
|
|
56
|
+
<h3 data-graph-section-title>Comment</h3>
|
|
57
|
+
<NoteBlocks note={entity.note} />
|
|
58
|
+
</section>
|
|
59
|
+
{/if}
|
|
60
|
+
|
|
61
|
+
<section data-graph-section="columns">
|
|
62
|
+
<h3 data-graph-section-title>Columns</h3>
|
|
63
|
+
{#each entity.rows as row (row.name)}
|
|
64
|
+
<div data-graph-column>
|
|
65
|
+
<span data-graph-column-name>{row.name}</span>
|
|
66
|
+
{#if row.note}<span data-graph-column-note>{row.note}</span>{/if}
|
|
67
|
+
<span data-graph-column-badges>
|
|
68
|
+
{#each row.badges as badge (badge)}
|
|
69
|
+
<span data-row-badge={badge}>{badge}</span>
|
|
70
|
+
{/each}
|
|
71
|
+
</span>
|
|
72
|
+
<span data-graph-column-type>{baseType(row.type ?? '')}</span>
|
|
73
|
+
<span data-graph-column-size>{typeSize(row.type ?? '')}</span>
|
|
74
|
+
</div>
|
|
75
|
+
{/each}
|
|
76
|
+
</section>
|
|
77
|
+
|
|
78
|
+
{#if indexes.length > 0}
|
|
79
|
+
<section data-graph-section="indexes">
|
|
80
|
+
<h3 data-graph-section-title>Indexes</h3>
|
|
81
|
+
{#each indexes as index, i (i)}
|
|
82
|
+
<div data-graph-index>
|
|
83
|
+
<span data-graph-index-def>{index.def}</span>
|
|
84
|
+
{#if index.unique}<span data-graph-index-unique>unique</span>{/if}
|
|
85
|
+
{#if index.name}<span data-graph-index-name>{index.name}</span>{/if}
|
|
86
|
+
</div>
|
|
87
|
+
{/each}
|
|
88
|
+
</section>
|
|
89
|
+
{/if}
|
|
90
|
+
|
|
91
|
+
<section data-graph-section="relationships">
|
|
92
|
+
<h3 data-graph-section-title>Relationships</h3>
|
|
93
|
+
{#if graph.relationships.length === 0}
|
|
94
|
+
<p data-graph-relationships-empty>No relationships reference this entity.</p>
|
|
95
|
+
{:else}
|
|
96
|
+
{#each graph.relationships as rel, i (rel.edge.id + i)}
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
data-graph-relationship={rel.direction}
|
|
100
|
+
onclick={() => graph.select(rel.id)}
|
|
101
|
+
>
|
|
102
|
+
{#if rel.group}<span data-graph-relationship-group>{rel.group}.</span>{/if}
|
|
103
|
+
<span data-graph-relationship-label>{rel.label}</span>
|
|
104
|
+
{#if rel.edge.action}
|
|
105
|
+
<span data-graph-relationship-action>{rel.edge.action}</span>
|
|
106
|
+
{/if}
|
|
107
|
+
</button>
|
|
108
|
+
{/each}
|
|
109
|
+
{/if}
|
|
110
|
+
</section>
|
|
111
|
+
</article>
|
|
112
|
+
{:else}
|
|
113
|
+
<article data-graph-entity data-graph-entity-empty class={className}>
|
|
114
|
+
<p>No entity selected.</p>
|
|
115
|
+
</article>
|
|
116
|
+
{/if}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { GraphState } from '../GraphState.svelte.js';
|
|
2
|
+
import type { GraphFields } from '../types.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
state?: GraphState;
|
|
5
|
+
nodes?: unknown[];
|
|
6
|
+
edges?: unknown[];
|
|
7
|
+
fields?: GraphFields;
|
|
8
|
+
value?: string | null;
|
|
9
|
+
class?: string;
|
|
10
|
+
};
|
|
11
|
+
declare const EntityView: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type EntityView = ReturnType<typeof EntityView>;
|
|
13
|
+
export default EntityView;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { noteBlocks } from './notes.js'
|
|
3
|
+
|
|
4
|
+
let { note }: { note?: string } = $props()
|
|
5
|
+
|
|
6
|
+
const blocks = $derived(noteBlocks(note))
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
{#snippet segs(parts: ReturnType<typeof noteBlocks>[number]['lines'][number])}
|
|
10
|
+
<!-- Keyed by INDEX, not by text. A segment has no identity of its own, and its text is
|
|
11
|
+
not unique — "either `a` or `a`" yields two code segments reading `a`, which throws
|
|
12
|
+
each_key_duplicate and renders nothing. Position is the only stable key here, and
|
|
13
|
+
the whole list is re-derived whenever the note changes. -->
|
|
14
|
+
{#each parts as part, i (i)}
|
|
15
|
+
{#if part.code}<code data-graph-note-code>{part.text}</code>{:else}{part.text}{/if}
|
|
16
|
+
{/each}
|
|
17
|
+
{/snippet}
|
|
18
|
+
|
|
19
|
+
<div data-graph-note>
|
|
20
|
+
{#each blocks as block (block)}
|
|
21
|
+
{#if block.type === 'ul'}
|
|
22
|
+
<ul data-graph-note-list>
|
|
23
|
+
{#each block.lines as line (line)}
|
|
24
|
+
<li>{@render segs(line)}</li>
|
|
25
|
+
{/each}
|
|
26
|
+
</ul>
|
|
27
|
+
{:else}
|
|
28
|
+
<p>{@render segs(block.lines[0])}</p>
|
|
29
|
+
{/if}
|
|
30
|
+
{/each}
|
|
31
|
+
</div>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* Splitting a SQL type into its base and its size, for the two-column display in the
|
|
2
|
+
entity detail panel. Ported from dbd's EntityView module block.
|
|
3
|
+
|
|
4
|
+
Parsing, not formatting choice, so it lives here with its own tests rather than inline
|
|
5
|
+
in the template — the component stays free of computation. */
|
|
6
|
+
const ARG = /\(([^)]*)\)/;
|
|
7
|
+
/** `varchar(255)` -> `varchar`, `text[]` -> `text`. */
|
|
8
|
+
export function baseType(type) {
|
|
9
|
+
return type.replace(ARG, '').replace(/\[\]$/, '');
|
|
10
|
+
}
|
|
11
|
+
/** `varchar(255)` -> `255`, `text[]` -> `[]`, `uuid` -> an em dash. */
|
|
12
|
+
export function typeSize(type) {
|
|
13
|
+
const matched = type.match(ARG);
|
|
14
|
+
if (matched)
|
|
15
|
+
return matched[1];
|
|
16
|
+
if (type.endsWith('[]'))
|
|
17
|
+
return '[]';
|
|
18
|
+
return '—';
|
|
19
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { GraphFields, GraphModel } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Field map from a dbd `SchemaModel`'s ER half to the canonical model.
|
|
4
|
+
*
|
|
5
|
+
* Exported so a consumer can hand it straight to `<Graph fields={SCHEMA_FIELDS}>`
|
|
6
|
+
* and skip the transform entirely.
|
|
7
|
+
*
|
|
8
|
+
* Note what is NOT here: dbd's `SchemaModel` type. The package never imports it,
|
|
9
|
+
* so the hand-written TS mirror of `schema_model.rs` stays dbd's concern and this
|
|
10
|
+
* package is not a third definition to keep in step.
|
|
11
|
+
*
|
|
12
|
+
* One canonical field stays deliberately unmapped, because dbd's shape does not carry it and
|
|
13
|
+
* a path that cannot resolve looks correct while silently never firing:
|
|
14
|
+
*
|
|
15
|
+
* - `cardinality` — dbd's `Ref` is `{ from, to, action? }`.
|
|
16
|
+
*/
|
|
17
|
+
export declare const SCHEMA_FIELDS: GraphFields;
|
|
18
|
+
/**
|
|
19
|
+
* Field map for a dbd v2 `DepEdge` — `{ from: { s, n }, to: { s, n }, kind, unresolved }`.
|
|
20
|
+
*
|
|
21
|
+
* `n` not `t`, and no `c`: a dependency links whole objects, not columns. `kind` is the VERB
|
|
22
|
+
* (`reads | writes | calls | member`), which is why it maps to `relation` and the edge kind
|
|
23
|
+
* comes from `defaultEdgeKind` instead — every entry in `deps` is a dependency by definition.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEPS_FIELDS: GraphFields;
|
|
26
|
+
/**
|
|
27
|
+
* Which of a `SchemaModel`'s two graphs to build.
|
|
28
|
+
*
|
|
29
|
+
* dbd v2 splits the database in two and says so in its own doc comments: `tables` is "Tables
|
|
30
|
+
* only", `refs` is "Foreign keys only — the dependency graph is `deps`; an ER renderer wants
|
|
31
|
+
* these and a call-graph renderer wants those".
|
|
32
|
+
*
|
|
33
|
+
* That split is a modelling fact, not a rendering preference. An ER diagram is entities and
|
|
34
|
+
* their relationships; a view is a derived projection and a routine is behaviour, so neither
|
|
35
|
+
* is an entity and putting them on an ER canvas is a category error — they have no keys, no
|
|
36
|
+
* relationships, and (in v2) no columns, so they render as orphan cards with nothing in them.
|
|
37
|
+
*
|
|
38
|
+
* - `er` — `tables` + `refs`. The default, so a v1 caller reading a v2 model is unaffected.
|
|
39
|
+
* - `dependencies` — `tables` + `entities` as nodes, `deps` as edges. Tables stay because a
|
|
40
|
+
* view READS a table; dropping them would leave every dependency dangling.
|
|
41
|
+
*/
|
|
42
|
+
export type SchemaScope = 'er' | 'dependencies';
|
|
43
|
+
/** The minimum of dbd's `SchemaModel` this view reads. v2 fields are optional. */
|
|
44
|
+
type SchemaModelInput = {
|
|
45
|
+
tables: unknown[];
|
|
46
|
+
refs: unknown[];
|
|
47
|
+
entities?: unknown[];
|
|
48
|
+
deps?: unknown[];
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Consumer-side input for one scope: `nodes`, `edges` and the matching field map.
|
|
52
|
+
*
|
|
53
|
+
* Returned separately from `fromSchemaModel` because `<Graph>` takes `nodes`/`edges`/`fields`
|
|
54
|
+
* directly — a consumer binding the component does not want a pre-normalized model, and
|
|
55
|
+
* making them unpack one would mean normalizing twice.
|
|
56
|
+
*/
|
|
57
|
+
export declare function toGraphInput(model: SchemaModelInput, scope?: SchemaScope): {
|
|
58
|
+
nodes: unknown[];
|
|
59
|
+
edges: unknown[];
|
|
60
|
+
fields: GraphFields;
|
|
61
|
+
};
|
|
62
|
+
/** Convenience wrapper over `normalizeGraph` for dbd-shaped schema JSON. */
|
|
63
|
+
export declare function fromSchemaModel(model: SchemaModelInput, options?: {
|
|
64
|
+
as?: SchemaScope;
|
|
65
|
+
}): GraphModel;
|
|
66
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { normalizeGraph } from '../model/normalize.js';
|
|
2
|
+
/**
|
|
3
|
+
* Field map from a dbd `SchemaModel`'s ER half to the canonical model.
|
|
4
|
+
*
|
|
5
|
+
* Exported so a consumer can hand it straight to `<Graph fields={SCHEMA_FIELDS}>`
|
|
6
|
+
* and skip the transform entirely.
|
|
7
|
+
*
|
|
8
|
+
* Note what is NOT here: dbd's `SchemaModel` type. The package never imports it,
|
|
9
|
+
* so the hand-written TS mirror of `schema_model.rs` stays dbd's concern and this
|
|
10
|
+
* package is not a third definition to keep in step.
|
|
11
|
+
*
|
|
12
|
+
* One canonical field stays deliberately unmapped, because dbd's shape does not carry it and
|
|
13
|
+
* a path that cannot resolve looks correct while silently never firing:
|
|
14
|
+
*
|
|
15
|
+
* - `cardinality` — dbd's `Ref` is `{ from, to, action? }`.
|
|
16
|
+
*/
|
|
17
|
+
export const SCHEMA_FIELDS = {
|
|
18
|
+
// Identity must NOT be derived from the group, or regrouping re-keys every node. `id`
|
|
19
|
+
// defaults to `${group}.${label}`, so a consumer switching the axis to `kind` would turn
|
|
20
|
+
// `public.orders` into `table.orders` and every dep edge — which resolves
|
|
21
|
+
// `${from.s}.${name}` — would stop matching and the graph would render entirely unplaced.
|
|
22
|
+
// Safe for a consumer passing raw dbd rows too: a row with no `id` falls back as before.
|
|
23
|
+
id: 'id',
|
|
24
|
+
label: 'name',
|
|
25
|
+
group: 'schema',
|
|
26
|
+
kind: 'kind',
|
|
27
|
+
rows: 'columns',
|
|
28
|
+
note: 'noteMd',
|
|
29
|
+
rowName: 'name',
|
|
30
|
+
rowType: 'type',
|
|
31
|
+
rowNote: 'note',
|
|
32
|
+
// v2 (2026-09-27) added `fk` and `uq` to `Column`. `fk` is ALSO derived from the resolved
|
|
33
|
+
// refs; both are kept because they answer different questions — dbd resolved the
|
|
34
|
+
// constraint against the whole database, so a column referencing a table outside a scoped
|
|
35
|
+
// model is still honestly a foreign key even though no edge in this model proves it.
|
|
36
|
+
rowBadges: { pk: 'pk', nn: 'nn', fk: 'fk', uq: 'uq' },
|
|
37
|
+
source: 'from.t',
|
|
38
|
+
target: 'to.t',
|
|
39
|
+
sourceGroup: 'from.s',
|
|
40
|
+
targetGroup: 'to.s',
|
|
41
|
+
sourceRow: 'from.c',
|
|
42
|
+
targetRow: 'to.c',
|
|
43
|
+
action: 'action'
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Field map for a dbd v2 `DepEdge` — `{ from: { s, n }, to: { s, n }, kind, unresolved }`.
|
|
47
|
+
*
|
|
48
|
+
* `n` not `t`, and no `c`: a dependency links whole objects, not columns. `kind` is the VERB
|
|
49
|
+
* (`reads | writes | calls | member`), which is why it maps to `relation` and the edge kind
|
|
50
|
+
* comes from `defaultEdgeKind` instead — every entry in `deps` is a dependency by definition.
|
|
51
|
+
*/
|
|
52
|
+
export const DEPS_FIELDS = {
|
|
53
|
+
...SCHEMA_FIELDS,
|
|
54
|
+
source: 'from.n',
|
|
55
|
+
target: 'to.n',
|
|
56
|
+
sourceGroup: 'from.s',
|
|
57
|
+
targetGroup: 'to.s',
|
|
58
|
+
sourceRow: undefined,
|
|
59
|
+
targetRow: undefined,
|
|
60
|
+
action: undefined,
|
|
61
|
+
relation: 'kind',
|
|
62
|
+
defaultEdgeKind: 'dependency'
|
|
63
|
+
};
|
|
64
|
+
/** A dbd node row, with the canonical id made explicit so the grouping axis stays free. */
|
|
65
|
+
function identified(rows) {
|
|
66
|
+
return rows.map((row) => {
|
|
67
|
+
const r = (row ?? {});
|
|
68
|
+
return { ...r, id: r.schema ? `${r.schema}.${r.name}` : String(r.name ?? '') };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Consumer-side input for one scope: `nodes`, `edges` and the matching field map.
|
|
73
|
+
*
|
|
74
|
+
* Returned separately from `fromSchemaModel` because `<Graph>` takes `nodes`/`edges`/`fields`
|
|
75
|
+
* directly — a consumer binding the component does not want a pre-normalized model, and
|
|
76
|
+
* making them unpack one would mean normalizing twice.
|
|
77
|
+
*/
|
|
78
|
+
export function toGraphInput(model, scope = 'er') {
|
|
79
|
+
if (scope === 'er') {
|
|
80
|
+
return { nodes: identified(model.tables), edges: model.refs, fields: SCHEMA_FIELDS };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
nodes: identified([...model.tables, ...(model.entities ?? [])]),
|
|
84
|
+
edges: model.deps ?? [],
|
|
85
|
+
fields: DEPS_FIELDS
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Convenience wrapper over `normalizeGraph` for dbd-shaped schema JSON. */
|
|
89
|
+
export function fromSchemaModel(model, options = {}) {
|
|
90
|
+
const { nodes, edges, fields } = toGraphInput(model, options.as ?? 'er');
|
|
91
|
+
return normalizeGraph(nodes, edges, fields);
|
|
92
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { default as EntityView } from './EntityView.svelte';
|
|
2
|
+
export { default as EntitiesView } from './EntitiesView.svelte';
|
|
3
|
+
export { default as NoteBlocks } from './NoteBlocks.svelte';
|
|
4
|
+
export { SCHEMA_FIELDS, DEPS_FIELDS, fromSchemaModel, toGraphInput } from './fromSchemaModel.js';
|
|
5
|
+
export type { SchemaScope } from './fromSchemaModel.js';
|
|
6
|
+
export { inlineSegs, noteBlocks } from './notes.js';
|
|
7
|
+
export { baseType, typeSize } from './column-type.js';
|
|
8
|
+
export type { Block, Seg } from './notes.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Public surface for `@rokkit/graph/schema` — the ER/schema-flavoured views.
|
|
2
|
+
export { default as EntityView } from './EntityView.svelte';
|
|
3
|
+
export { default as EntitiesView } from './EntitiesView.svelte';
|
|
4
|
+
export { default as NoteBlocks } from './NoteBlocks.svelte';
|
|
5
|
+
export { SCHEMA_FIELDS, DEPS_FIELDS, fromSchemaModel, toGraphInput } from './fromSchemaModel.js';
|
|
6
|
+
export { inlineSegs, noteBlocks } from './notes.js';
|
|
7
|
+
export { baseType, typeSize } from './column-type.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type Seg = {
|
|
2
|
+
code: boolean;
|
|
3
|
+
text: string;
|
|
4
|
+
};
|
|
5
|
+
export type Block = {
|
|
6
|
+
type: 'p' | 'ul';
|
|
7
|
+
lines: Seg[][];
|
|
8
|
+
};
|
|
9
|
+
/** Split a line into plain / `code` segments. */
|
|
10
|
+
export declare function inlineSegs(text: string): Seg[];
|
|
11
|
+
/** Parse a note string into paragraph / bullet-list blocks. */
|
|
12
|
+
export declare function noteBlocks(src?: string): Block[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/* Minimal note renderer for DDL comments: paragraphs, bullet lists, and `inline code`.
|
|
2
|
+
Shared by EntitiesView and EntityView so they format comments identically. Returns a
|
|
3
|
+
structured tree the components render.
|
|
4
|
+
|
|
5
|
+
Deliberately NOT a markdown library. It recognises backticks and bullets and nothing
|
|
6
|
+
else, so no tag is ever interpreted — the segment text carries any markup verbatim and
|
|
7
|
+
Svelte's text interpolation escapes it at render time. That is what lets this package
|
|
8
|
+
ship with no markdown parser and no sanitiser, which are exactly the dependencies
|
|
9
|
+
@rokkit/ui's MarkdownRenderer would have dragged in. */
|
|
10
|
+
const BULLET = /^[-•]\s/;
|
|
11
|
+
/** Split a line into plain / `code` segments. */
|
|
12
|
+
export function inlineSegs(text) {
|
|
13
|
+
const segs = [];
|
|
14
|
+
let last = 0;
|
|
15
|
+
for (const m of text.matchAll(/`([^`]+)`/g)) {
|
|
16
|
+
const idx = m.index ?? 0;
|
|
17
|
+
if (idx > last)
|
|
18
|
+
segs.push({ code: false, text: text.slice(last, idx) });
|
|
19
|
+
segs.push({ code: true, text: m[1] });
|
|
20
|
+
last = idx + m[0].length;
|
|
21
|
+
}
|
|
22
|
+
if (last < text.length)
|
|
23
|
+
segs.push({ code: false, text: text.slice(last) });
|
|
24
|
+
return segs;
|
|
25
|
+
}
|
|
26
|
+
/** Consume a run of bullet lines starting at `i`, returning the block and the next index. */
|
|
27
|
+
function takeList(lines, start) {
|
|
28
|
+
const items = [];
|
|
29
|
+
let i = start;
|
|
30
|
+
while (i < lines.length && BULLET.test(lines[i].trim())) {
|
|
31
|
+
items.push(inlineSegs(lines[i].trim().replace(BULLET, '')));
|
|
32
|
+
i++;
|
|
33
|
+
}
|
|
34
|
+
return { block: { type: 'ul', lines: items }, next: i };
|
|
35
|
+
}
|
|
36
|
+
/** Consume a run of non-blank, non-bullet lines as one paragraph. */
|
|
37
|
+
function takeParagraph(lines, start) {
|
|
38
|
+
const para = [];
|
|
39
|
+
let i = start;
|
|
40
|
+
while (i < lines.length && lines[i].trim() && !BULLET.test(lines[i].trim())) {
|
|
41
|
+
para.push(lines[i].trim());
|
|
42
|
+
i++;
|
|
43
|
+
}
|
|
44
|
+
return { block: { type: 'p', lines: [inlineSegs(para.join(' '))] }, next: i };
|
|
45
|
+
}
|
|
46
|
+
/** Parse a note string into paragraph / bullet-list blocks. */
|
|
47
|
+
export function noteBlocks(src) {
|
|
48
|
+
if (!src)
|
|
49
|
+
return [];
|
|
50
|
+
const out = [];
|
|
51
|
+
const lines = src.split('\n');
|
|
52
|
+
let i = 0;
|
|
53
|
+
while (i < lines.length) {
|
|
54
|
+
if (!lines[i].trim()) {
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const { block, next } = BULLET.test(lines[i].trim())
|
|
59
|
+
? takeList(lines, i)
|
|
60
|
+
: takeParagraph(lines, i);
|
|
61
|
+
out.push(block);
|
|
62
|
+
i = next;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|