@graphty/graph-format 0.0.0 → 0.2.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 (224) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +216 -43
  3. package/dist/graph-format.d.ts +1 -0
  4. package/dist/graph-format.d.ts.map +1 -0
  5. package/dist/graph-format.js +15798 -0
  6. package/dist/graph-format.js.map +1 -0
  7. package/dist/src/builder/arena.d.ts +85 -0
  8. package/dist/src/builder/arena.d.ts.map +1 -0
  9. package/dist/src/builder/arena.js +154 -0
  10. package/dist/src/builder/arena.js.map +1 -0
  11. package/dist/src/builder/compact.d.ts +515 -0
  12. package/dist/src/builder/compact.d.ts.map +1 -0
  13. package/dist/src/builder/compact.js +1553 -0
  14. package/dist/src/builder/compact.js.map +1 -0
  15. package/dist/src/builder/counting-sort.d.ts +96 -0
  16. package/dist/src/builder/counting-sort.d.ts.map +1 -0
  17. package/dist/src/builder/counting-sort.js +430 -0
  18. package/dist/src/builder/counting-sort.js.map +1 -0
  19. package/dist/src/builder/freeze.d.ts +72 -0
  20. package/dist/src/builder/freeze.d.ts.map +1 -0
  21. package/dist/src/builder/freeze.js +548 -0
  22. package/dist/src/builder/freeze.js.map +1 -0
  23. package/dist/src/builder/graph-builder.d.ts +602 -0
  24. package/dist/src/builder/graph-builder.d.ts.map +1 -0
  25. package/dist/src/builder/graph-builder.js +1940 -0
  26. package/dist/src/builder/graph-builder.js.map +1 -0
  27. package/dist/src/builder/options.d.ts +16 -0
  28. package/dist/src/builder/options.d.ts.map +1 -0
  29. package/dist/src/builder/options.js +21 -0
  30. package/dist/src/builder/options.js.map +1 -0
  31. package/dist/src/columns/bitmap.d.ts +96 -0
  32. package/dist/src/columns/bitmap.d.ts.map +1 -0
  33. package/dist/src/columns/bitmap.js +184 -0
  34. package/dist/src/columns/bitmap.js.map +1 -0
  35. package/dist/src/columns/column.d.ts +220 -0
  36. package/dist/src/columns/column.d.ts.map +1 -0
  37. package/dist/src/columns/column.js +2109 -0
  38. package/dist/src/columns/column.js.map +1 -0
  39. package/dist/src/columns/dictionary.d.ts +60 -0
  40. package/dist/src/columns/dictionary.d.ts.map +1 -0
  41. package/dist/src/columns/dictionary.js +102 -0
  42. package/dist/src/columns/dictionary.js.map +1 -0
  43. package/dist/src/columns/growable.d.ts +198 -0
  44. package/dist/src/columns/growable.d.ts.map +1 -0
  45. package/dist/src/columns/growable.js +296 -0
  46. package/dist/src/columns/growable.js.map +1 -0
  47. package/dist/src/columns/infer.d.ts +121 -0
  48. package/dist/src/columns/infer.d.ts.map +1 -0
  49. package/dist/src/columns/infer.js +268 -0
  50. package/dist/src/columns/infer.js.map +1 -0
  51. package/dist/src/columns/remap.d.ts +143 -0
  52. package/dist/src/columns/remap.d.ts.map +1 -0
  53. package/dist/src/columns/remap.js +588 -0
  54. package/dist/src/columns/remap.js.map +1 -0
  55. package/dist/src/columns/table.d.ts +184 -0
  56. package/dist/src/columns/table.d.ts.map +1 -0
  57. package/dist/src/columns/table.js +485 -0
  58. package/dist/src/columns/table.js.map +1 -0
  59. package/dist/src/constants.d.ts +34 -0
  60. package/dist/src/constants.d.ts.map +1 -0
  61. package/dist/src/constants.js +79 -0
  62. package/dist/src/constants.js.map +1 -0
  63. package/dist/src/errors.d.ts +76 -0
  64. package/dist/src/errors.d.ts.map +1 -0
  65. package/dist/src/errors.js +32 -0
  66. package/dist/src/errors.js.map +1 -0
  67. package/dist/src/ids/edge-id-index.d.ts +77 -0
  68. package/dist/src/ids/edge-id-index.d.ts.map +1 -0
  69. package/dist/src/ids/edge-id-index.js +155 -0
  70. package/dist/src/ids/edge-id-index.js.map +1 -0
  71. package/dist/src/ids/node-id-map.d.ts +280 -0
  72. package/dist/src/ids/node-id-map.d.ts.map +1 -0
  73. package/dist/src/ids/node-id-map.js +1139 -0
  74. package/dist/src/ids/node-id-map.js.map +1 -0
  75. package/dist/src/ids/string-store.d.ts +219 -0
  76. package/dist/src/ids/string-store.d.ts.map +1 -0
  77. package/dist/src/ids/string-store.js +526 -0
  78. package/dist/src/ids/string-store.js.map +1 -0
  79. package/dist/src/index.d.ts +30 -0
  80. package/dist/src/index.d.ts.map +1 -0
  81. package/dist/src/index.js +29 -0
  82. package/dist/src/index.js.map +1 -0
  83. package/dist/src/populate/from-csr.d.ts +61 -0
  84. package/dist/src/populate/from-csr.d.ts.map +1 -0
  85. package/dist/src/populate/from-csr.js +706 -0
  86. package/dist/src/populate/from-csr.js.map +1 -0
  87. package/dist/src/populate/from-edge-arrays.d.ts +51 -0
  88. package/dist/src/populate/from-edge-arrays.d.ts.map +1 -0
  89. package/dist/src/populate/from-edge-arrays.js +217 -0
  90. package/dist/src/populate/from-edge-arrays.js.map +1 -0
  91. package/dist/src/populate/from-records.d.ts +51 -0
  92. package/dist/src/populate/from-records.d.ts.map +1 -0
  93. package/dist/src/populate/from-records.js +447 -0
  94. package/dist/src/populate/from-records.js.map +1 -0
  95. package/dist/src/snapshot/derived.d.ts +211 -0
  96. package/dist/src/snapshot/derived.d.ts.map +1 -0
  97. package/dist/src/snapshot/derived.js +1254 -0
  98. package/dist/src/snapshot/derived.js.map +1 -0
  99. package/dist/src/snapshot/graph-meta.d.ts +23 -0
  100. package/dist/src/snapshot/graph-meta.d.ts.map +1 -0
  101. package/dist/src/snapshot/graph-meta.js +159 -0
  102. package/dist/src/snapshot/graph-meta.js.map +1 -0
  103. package/dist/src/snapshot/graph-snapshot.d.ts +509 -0
  104. package/dist/src/snapshot/graph-snapshot.d.ts.map +1 -0
  105. package/dist/src/snapshot/graph-snapshot.js +1247 -0
  106. package/dist/src/snapshot/graph-snapshot.js.map +1 -0
  107. package/dist/src/snapshot/hash.d.ts +90 -0
  108. package/dist/src/snapshot/hash.d.ts.map +1 -0
  109. package/dist/src/snapshot/hash.js +228 -0
  110. package/dist/src/snapshot/hash.js.map +1 -0
  111. package/dist/src/snapshot/queries.d.ts +74 -0
  112. package/dist/src/snapshot/queries.d.ts.map +1 -0
  113. package/dist/src/snapshot/queries.js +131 -0
  114. package/dist/src/snapshot/queries.js.map +1 -0
  115. package/dist/src/snapshot/validate.d.ts +192 -0
  116. package/dist/src/snapshot/validate.d.ts.map +1 -0
  117. package/dist/src/snapshot/validate.js +990 -0
  118. package/dist/src/snapshot/validate.js.map +1 -0
  119. package/dist/src/snapshot/views.d.ts +274 -0
  120. package/dist/src/snapshot/views.d.ts.map +1 -0
  121. package/dist/src/snapshot/views.js +856 -0
  122. package/dist/src/snapshot/views.js.map +1 -0
  123. package/dist/src/types/builder.d.ts +456 -0
  124. package/dist/src/types/builder.d.ts.map +1 -0
  125. package/dist/src/types/builder.js +11 -0
  126. package/dist/src/types/builder.js.map +1 -0
  127. package/dist/src/types/columns.d.ts +615 -0
  128. package/dist/src/types/columns.d.ts.map +1 -0
  129. package/dist/src/types/columns.js +14 -0
  130. package/dist/src/types/columns.js.map +1 -0
  131. package/dist/src/types/index.d.ts +14 -0
  132. package/dist/src/types/index.d.ts.map +1 -0
  133. package/dist/src/types/index.js +11 -0
  134. package/dist/src/types/index.js.map +1 -0
  135. package/dist/src/types/internal.d.ts +172 -0
  136. package/dist/src/types/internal.d.ts.map +1 -0
  137. package/dist/src/types/internal.js +13 -0
  138. package/dist/src/types/internal.js.map +1 -0
  139. package/dist/src/types/snapshot.d.ts +801 -0
  140. package/dist/src/types/snapshot.d.ts.map +1 -0
  141. package/dist/src/types/snapshot.js +14 -0
  142. package/dist/src/types/snapshot.js.map +1 -0
  143. package/dist/src/types/wire.d.ts +196 -0
  144. package/dist/src/types/wire.d.ts.map +1 -0
  145. package/dist/src/types/wire.js +8 -0
  146. package/dist/src/types/wire.js.map +1 -0
  147. package/dist/src/util/mask.d.ts +55 -0
  148. package/dist/src/util/mask.d.ts.map +1 -0
  149. package/dist/src/util/mask.js +71 -0
  150. package/dist/src/util/mask.js.map +1 -0
  151. package/dist/src/util/options.d.ts +22 -0
  152. package/dist/src/util/options.d.ts.map +1 -0
  153. package/dist/src/util/options.js +49 -0
  154. package/dist/src/util/options.js.map +1 -0
  155. package/dist/src/util/shared-buffers.d.ts +42 -0
  156. package/dist/src/util/shared-buffers.d.ts.map +1 -0
  157. package/dist/src/util/shared-buffers.js +52 -0
  158. package/dist/src/util/shared-buffers.js.map +1 -0
  159. package/dist/src/util/typed-array.d.ts +205 -0
  160. package/dist/src/util/typed-array.d.ts.map +1 -0
  161. package/dist/src/util/typed-array.js +289 -0
  162. package/dist/src/util/typed-array.js.map +1 -0
  163. package/dist/src/wire/bytes.d.ts +69 -0
  164. package/dist/src/wire/bytes.d.ts.map +1 -0
  165. package/dist/src/wire/bytes.js +361 -0
  166. package/dist/src/wire/bytes.js.map +1 -0
  167. package/dist/src/wire/carried-views.d.ts +111 -0
  168. package/dist/src/wire/carried-views.d.ts.map +1 -0
  169. package/dist/src/wire/carried-views.js +286 -0
  170. package/dist/src/wire/carried-views.js.map +1 -0
  171. package/dist/src/wire/from-wire.d.ts +144 -0
  172. package/dist/src/wire/from-wire.d.ts.map +1 -0
  173. package/dist/src/wire/from-wire.js +1425 -0
  174. package/dist/src/wire/from-wire.js.map +1 -0
  175. package/dist/src/wire/to-wire.d.ts +171 -0
  176. package/dist/src/wire/to-wire.d.ts.map +1 -0
  177. package/dist/src/wire/to-wire.js +652 -0
  178. package/dist/src/wire/to-wire.js.map +1 -0
  179. package/dist/tsconfig.build.tsbuildinfo +1 -0
  180. package/package.json +72 -7
  181. package/src/builder/arena.ts +192 -0
  182. package/src/builder/compact.ts +1811 -0
  183. package/src/builder/counting-sort.ts +506 -0
  184. package/src/builder/freeze.ts +689 -0
  185. package/src/builder/graph-builder.ts +2264 -0
  186. package/src/builder/options.ts +29 -0
  187. package/src/columns/bitmap.ts +196 -0
  188. package/src/columns/column.ts +2574 -0
  189. package/src/columns/dictionary.ts +114 -0
  190. package/src/columns/growable.ts +350 -0
  191. package/src/columns/infer.ts +285 -0
  192. package/src/columns/remap.ts +663 -0
  193. package/src/columns/table.ts +568 -0
  194. package/src/constants.ts +89 -0
  195. package/src/errors.ts +115 -0
  196. package/src/ids/edge-id-index.ts +179 -0
  197. package/src/ids/node-id-map.ts +1340 -0
  198. package/src/ids/string-store.ts +589 -0
  199. package/src/index.ts +139 -0
  200. package/src/lib-resizable-array-buffer.d.ts +34 -0
  201. package/src/populate/from-csr.ts +802 -0
  202. package/src/populate/from-edge-arrays.ts +257 -0
  203. package/src/populate/from-records.ts +536 -0
  204. package/src/snapshot/derived.ts +1486 -0
  205. package/src/snapshot/graph-meta.ts +167 -0
  206. package/src/snapshot/graph-snapshot.ts +1503 -0
  207. package/src/snapshot/hash.ts +241 -0
  208. package/src/snapshot/queries.ts +136 -0
  209. package/src/snapshot/validate.ts +1108 -0
  210. package/src/snapshot/views.ts +943 -0
  211. package/src/types/builder.ts +480 -0
  212. package/src/types/columns.ts +727 -0
  213. package/src/types/index.ts +14 -0
  214. package/src/types/internal.ts +195 -0
  215. package/src/types/snapshot.ts +886 -0
  216. package/src/types/wire.ts +207 -0
  217. package/src/util/mask.ts +89 -0
  218. package/src/util/options.ts +57 -0
  219. package/src/util/shared-buffers.ts +56 -0
  220. package/src/util/typed-array.ts +371 -0
  221. package/src/wire/bytes.ts +440 -0
  222. package/src/wire/carried-views.ts +327 -0
  223. package/src/wire/from-wire.ts +1690 -0
  224. package/src/wire/to-wire.ts +755 -0
@@ -0,0 +1,536 @@
1
+ /**
2
+ * `fromRecords()` (design section 8.1, entry point 3): plain node-link records, the shape every JSON
3
+ * dialect parses into and what graphty-element's data sources emit. Every record is pushed into a
4
+ * GraphBuilder scalar by scalar -- the id, the endpoints and the weight through `addNode` / `addEdge`,
5
+ * every other key through `setNodeValue` / `setEdgeValue` -- so attribute columns are inferred and
6
+ * widened per column by the builder exactly as `addNodeRecord` / `addEdgeRecord` would (design
7
+ * section 5.1), and the FreezeReport carries the widenings. The reserved keys (the node id, the edge
8
+ * source / target and the weight) never become attribute columns: the id map and the arc array
9
+ * already hold them.
10
+ *
11
+ * Id coercion (design section 4.1) is applied here, before an id reaches the builder: "keep" (the
12
+ * default; JSON values are already typed), "canonical" (canonical integer text becomes a number,
13
+ * everything else stays a string), "string" and "number".
14
+ */
15
+
16
+ import { GraphBuilder } from "../builder/graph-builder.js";
17
+ import { GraphFormatError } from "../errors.js";
18
+ import { type GraphSnapshot } from "../snapshot/graph-snapshot.js";
19
+ import {
20
+ type BuilderOptionsPatch,
21
+ type ColumnDecl,
22
+ type ColumnHandle,
23
+ type FreezeOptions,
24
+ type FreezeReport,
25
+ type GraphBuilderOptions,
26
+ type IdCoercion,
27
+ type NodeId,
28
+ type RecordsInput,
29
+ } from "../types/index.js";
30
+ import { assertOneOf } from "../util/options.js";
31
+ import { splitOptions } from "./from-edge-arrays.js";
32
+
33
+ /** A node or edge record. */
34
+ type RecordRow = Readonly<Record<string, unknown>>;
35
+
36
+ /** The default source keys, tried in order when `edgeSource` is not given. */
37
+ const SOURCE_KEYS: readonly string[] = ["source", "src", "from"];
38
+
39
+ /** The default target keys, tried in order when `edgeTarget` is not given. */
40
+ const TARGET_KEYS: readonly string[] = ["target", "dst", "to"];
41
+
42
+ /** The canonical integer text of design section 4.1. */
43
+ const CANONICAL_INTEGER = /^-?(0|[1-9][0-9]*)$/;
44
+
45
+ // ============================================================ ids
46
+
47
+ /**
48
+ * Whether a record has an own property (records may come from `JSON.parse`, so prototype lookups are
49
+ * never consulted).
50
+ * @param record - the record
51
+ * @param key - the key
52
+ * @returns true when the key is an own property
53
+ */
54
+ function hasKey(record: RecordRow, key: string): boolean {
55
+ return Object.prototype.hasOwnProperty.call(record, key);
56
+ }
57
+
58
+ /**
59
+ * The E_INVALID_ID error for an id cell the coercion rule cannot turn into a NodeId.
60
+ * @param field - which cell (id / source / target)
61
+ * @param value - the value
62
+ * @param rule - the coercion rule in force
63
+ * @returns the error
64
+ */
65
+ function invalidId(field: string, value: unknown, rule: IdCoercion): GraphFormatError {
66
+ return new GraphFormatError(
67
+ "E_INVALID_ID",
68
+ `${field} value of type ${typeof value} is not a legal id under "${rule}"`,
69
+ {
70
+ field,
71
+ found: typeof value,
72
+ rule,
73
+ },
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Apply an id coercion rule to one id cell (design section 4.1). The result still goes through the
79
+ * builder's own id validation (E_INVALID_ID for anything that is not a finite number or a well-formed
80
+ * string), so this function only converts; it rejects the shapes a rule cannot convert.
81
+ * @param value - the cell value
82
+ * @param rule - the coercion rule
83
+ * @param field - which cell, for the error message
84
+ * @returns the id to hand to the builder
85
+ */
86
+ export function coerceId(value: unknown, rule: IdCoercion, field: string): NodeId {
87
+ switch (rule) {
88
+ case "keep":
89
+ if (typeof value === "string" || typeof value === "number") {
90
+ return value;
91
+ }
92
+ throw invalidId(field, value, rule);
93
+ case "canonical":
94
+ if (typeof value === "number") {
95
+ return value;
96
+ }
97
+ if (typeof value === "string") {
98
+ // "-0" is excluded so the rule stays injective on text (-0 and 0 are one id)
99
+ if (CANONICAL_INTEGER.test(value) && value !== "-0") {
100
+ const n = Number(value);
101
+ return Number.isSafeInteger(n) ? n : value;
102
+ }
103
+ return value;
104
+ }
105
+ throw invalidId(field, value, rule);
106
+ case "string":
107
+ if (typeof value === "string") {
108
+ return value;
109
+ }
110
+ if (
111
+ typeof value === "number" ||
112
+ typeof value === "boolean" ||
113
+ typeof value === "bigint" ||
114
+ value === null
115
+ ) {
116
+ return String(value);
117
+ }
118
+ throw invalidId(field, value, rule);
119
+ case "number":
120
+ if (typeof value === "number") {
121
+ return value;
122
+ }
123
+ if (typeof value === "string") {
124
+ return Number(value);
125
+ }
126
+ throw invalidId(field, value, rule);
127
+ default: {
128
+ const name: string = rule;
129
+ throw new GraphFormatError("E_UNSUPPORTED", `unknown id coercion "${name}"`, {
130
+ field: "ids",
131
+ found: name,
132
+ reason: "unsupported option",
133
+ });
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Read an endpoint cell: the explicit key when one was given, else the first of the default keys the
140
+ * record has.
141
+ * @param record - the edge record
142
+ * @param explicit - the caller's key, or undefined
143
+ * @param defaults - the default keys in order
144
+ * @returns the key that was used and its value; the key is null when none is present
145
+ */
146
+ function endpointOf(
147
+ record: RecordRow,
148
+ explicit: string | undefined,
149
+ defaults: readonly string[],
150
+ ): { readonly key: string | null; readonly value: unknown } {
151
+ if (explicit !== undefined) {
152
+ return { key: hasKey(record, explicit) ? explicit : null, value: record[explicit] };
153
+ }
154
+ for (const key of defaults) {
155
+ if (hasKey(record, key)) {
156
+ return { key, value: record[key] };
157
+ }
158
+ }
159
+ return { key: null, value: undefined };
160
+ }
161
+
162
+ /**
163
+ * The E_INVALID_ID error for an edge record without an endpoint.
164
+ * @param field - "source" or "target"
165
+ * @param keys - the keys that were tried
166
+ * @param edge - the record's position in the edge iteration
167
+ * @returns the error
168
+ */
169
+ function missingEndpoint(field: string, keys: readonly string[], edge: number): GraphFormatError {
170
+ return new GraphFormatError(
171
+ "E_INVALID_ID",
172
+ `edge record ${edge} has no ${field} (keys tried: ${keys.join(", ")})`,
173
+ {
174
+ field,
175
+ keys: [...keys],
176
+ edge,
177
+ reason: "missing endpoint",
178
+ },
179
+ );
180
+ }
181
+
182
+ // ============================================================ attribute policy
183
+
184
+ /**
185
+ * How non-reserved record keys become columns: the builder's inference, one json column per key, no
186
+ * columns at all, or only the declared columns.
187
+ */
188
+ class AttributePolicy {
189
+ private readonly mode: "infer" | "json" | "none" | "declared";
190
+
191
+ private readonly declared: ReadonlyMap<string, ColumnDecl>;
192
+
193
+ private readonly handles: { readonly node: Map<string, ColumnHandle>; readonly edge: Map<string, ColumnHandle> };
194
+
195
+ /**
196
+ * Resolve the `columns` option.
197
+ * @param columns - the option value; default "infer"
198
+ */
199
+ constructor(columns: RecordsInput["columns"]) {
200
+ this.handles = { node: new Map(), edge: new Map() };
201
+ if (columns === undefined || columns === "infer") {
202
+ this.mode = "infer";
203
+ this.declared = new Map();
204
+ return;
205
+ }
206
+ if (columns === "json" || columns === "none") {
207
+ this.mode = columns;
208
+ this.declared = new Map();
209
+ return;
210
+ }
211
+ if (typeof columns === "string") {
212
+ // unreachable for typed callers; an unknown mode string from untyped JS lands here
213
+ const found: string = columns;
214
+ throw new GraphFormatError("E_UNSUPPORTED", `unsupported columns option "${found}"`, {
215
+ field: "columns",
216
+ found,
217
+ reason: "unsupported option",
218
+ });
219
+ }
220
+ const declared = new Map<string, ColumnDecl>();
221
+ const list: readonly ColumnDecl[] = columns;
222
+ for (const decl of list) {
223
+ if (typeof decl.name !== "string" || decl.name.length === 0) {
224
+ throw new GraphFormatError("E_COLUMN_TYPE", "a column declaration needs a non-empty name", {
225
+ field: "name",
226
+ found: decl.name,
227
+ });
228
+ }
229
+ if (declared.has(decl.name)) {
230
+ throw new GraphFormatError("E_COLUMN_EXISTS", `column "${decl.name}" is declared twice`, {
231
+ column: decl.name,
232
+ });
233
+ }
234
+ declared.set(decl.name, decl);
235
+ }
236
+ this.mode = "declared";
237
+ this.declared = declared;
238
+ }
239
+
240
+ /**
241
+ * Whether any attribute key is read at all.
242
+ * @returns false under "none"
243
+ */
244
+ get readsAttributes(): boolean {
245
+ return this.mode !== "none";
246
+ }
247
+
248
+ /**
249
+ * Write one attribute cell according to the policy.
250
+ * @param builder - the builder
251
+ * @param domain - "node" or "edge"
252
+ * @param row - the node index or logical edge index
253
+ * @param key - the record key
254
+ * @param value - the cell value
255
+ */
256
+ write(builder: GraphBuilder, domain: "node" | "edge", row: number, key: string, value: unknown): void {
257
+ switch (this.mode) {
258
+ case "none":
259
+ return;
260
+ case "infer":
261
+ if (domain === "node") {
262
+ builder.setNodeValue(key, row, value);
263
+ } else {
264
+ builder.setEdgeValue(key, row, value);
265
+ }
266
+ return;
267
+ case "json":
268
+ case "declared": {
269
+ const handle = this.handleOf(builder, domain, key);
270
+ if (handle === null) {
271
+ return;
272
+ }
273
+ if (domain === "node") {
274
+ builder.setNodeValue(handle, row, value);
275
+ } else {
276
+ builder.setEdgeValue(handle, row, value);
277
+ }
278
+ return;
279
+ }
280
+ default: {
281
+ const name: string = this.mode;
282
+ throw new GraphFormatError("E_UNSUPPORTED", `unknown attribute mode ${name}`, { found: name });
283
+ }
284
+ }
285
+ }
286
+
287
+ /**
288
+ * The handle of a key's column in one table, declaring it on first use: a json column under
289
+ * "json", the caller's declaration under "declared" (an undeclared key has no column).
290
+ * @param builder - the builder
291
+ * @param domain - "node" or "edge"
292
+ * @param key - the record key
293
+ * @returns the handle, or null when the key is not stored
294
+ */
295
+ private handleOf(builder: GraphBuilder, domain: "node" | "edge", key: string): ColumnHandle | null {
296
+ const cache = this.handles[domain];
297
+ const cached = cache.get(key);
298
+ if (cached !== undefined) {
299
+ return cached;
300
+ }
301
+ let decl: ColumnDecl;
302
+ if (this.mode === "json") {
303
+ decl = { name: key, dtype: "json" };
304
+ } else {
305
+ const declared = this.declared.get(key);
306
+ if (declared === undefined) {
307
+ return null;
308
+ }
309
+ decl = declared;
310
+ }
311
+ const handle = domain === "node" ? builder.declareNodeColumn(decl) : builder.declareEdgeColumn(decl);
312
+ cache.set(key, handle);
313
+ return handle;
314
+ }
315
+ }
316
+
317
+ // ============================================================ the entry point
318
+
319
+ /**
320
+ * The resolved record keys and rules of one fromRecords call.
321
+ */
322
+ interface RecordRules {
323
+ /** The node id key, or null for positional nodes (d3 v3). */
324
+ readonly nodeId: string | null;
325
+ /** The explicit source key, or undefined for the default keys. */
326
+ readonly edgeSource: string | undefined;
327
+ /** The explicit target key, or undefined for the default keys. */
328
+ readonly edgeTarget: string | undefined;
329
+ /** The weight key, or null for unweighted. */
330
+ readonly edgeWeight: string | null;
331
+ /** The id coercion rule. */
332
+ readonly ids: IdCoercion;
333
+ /** The attribute policy. */
334
+ readonly attributes: AttributePolicy;
335
+ }
336
+
337
+ /**
338
+ * Resolve the keys and rules of a RecordsInput with their defaults.
339
+ * @param input - the input
340
+ * @returns the rules
341
+ */
342
+ function rulesOf(input: RecordsInput): RecordRules {
343
+ return {
344
+ nodeId: input.nodeId === undefined ? "id" : input.nodeId,
345
+ edgeSource: input.edgeSource,
346
+ edgeTarget: input.edgeTarget,
347
+ edgeWeight: input.edgeWeight === undefined ? "weight" : input.edgeWeight,
348
+ ids: assertOneOf("ids", input.ids, ["keep", "canonical", "string", "number"] as const) ?? "keep",
349
+ attributes: new AttributePolicy(input.columns),
350
+ };
351
+ }
352
+
353
+ /**
354
+ * Resolve a positional endpoint (nodeId null: endpoints are node indices, design section 8.1): a
355
+ * non-negative integer below the builder's node bound, or -- under addMissingNodes -- one that
356
+ * extends the anonymous node range.
357
+ * @param builder - the builder
358
+ * @param value - the cell value
359
+ * @param field - "source" or "target"
360
+ * @param edge - the record's position, for the error
361
+ * @returns the node index
362
+ */
363
+ function positionalEndpoint(builder: GraphBuilder, value: unknown, field: string, edge: number): number {
364
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
365
+ throw new GraphFormatError(
366
+ "E_INVALID_ID",
367
+ `edge record ${edge}: ${field} ${String(value)} is not a node index`,
368
+ {
369
+ field,
370
+ found: value,
371
+ edge,
372
+ reason: "not an index",
373
+ },
374
+ );
375
+ }
376
+ const bound = builder.nodeBound;
377
+ if (value >= bound) {
378
+ if (!builder.options.addMissingNodes) {
379
+ throw new GraphFormatError("E_UNKNOWN_NODE", `edge record ${edge}: node index ${value} does not exist`, {
380
+ index: value,
381
+ field,
382
+ edge,
383
+ });
384
+ }
385
+ builder.addAnonymousNodes(value - bound + 1);
386
+ }
387
+ return value;
388
+ }
389
+
390
+ /**
391
+ * Read the weight cell of an edge record: absent, undefined and null mean "no weight" (1 in a
392
+ * weighted graph, design section 3.7); anything else must be a number.
393
+ * @param record - the edge record
394
+ * @param key - the weight key
395
+ * @param edge - the record's position, for the error
396
+ * @returns the weight, or undefined when omitted
397
+ */
398
+ function weightOf(record: RecordRow, key: string, edge: number): number | undefined {
399
+ if (!hasKey(record, key)) {
400
+ return undefined;
401
+ }
402
+ const raw = record[key];
403
+ if (raw === undefined || raw === null) {
404
+ return undefined;
405
+ }
406
+ if (typeof raw !== "number") {
407
+ throw new GraphFormatError("E_INVALID_WEIGHT", `edge record ${edge}: weight "${key}" is not a number`, {
408
+ key,
409
+ found: typeof raw,
410
+ edge,
411
+ });
412
+ }
413
+ return raw;
414
+ }
415
+
416
+ /**
417
+ * Push the node records.
418
+ * @param builder - the builder
419
+ * @param rules - the resolved rules
420
+ * @param nodes - the node records
421
+ */
422
+ function pushNodes(builder: GraphBuilder, rules: RecordRules, nodes: Iterable<RecordRow>): void {
423
+ const { nodeId, attributes } = rules;
424
+ let position = 0;
425
+ for (const record of nodes) {
426
+ let index: number;
427
+ if (nodeId === null) {
428
+ index = builder.addAnonymousNodes(1);
429
+ } else {
430
+ if (!hasKey(record, nodeId)) {
431
+ throw new GraphFormatError("E_INVALID_ID", `node record ${position} has no "${nodeId}" key`, {
432
+ field: nodeId,
433
+ node: position,
434
+ reason: "missing id",
435
+ });
436
+ }
437
+ index = builder.addNode(coerceId(record[nodeId], rules.ids, nodeId));
438
+ }
439
+ if (attributes.readsAttributes) {
440
+ for (const key of Object.keys(record)) {
441
+ if (key !== nodeId) {
442
+ attributes.write(builder, "node", index, key, record[key]);
443
+ }
444
+ }
445
+ }
446
+ position++;
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Push the edge records.
452
+ * @param builder - the builder
453
+ * @param rules - the resolved rules
454
+ * @param edges - the edge records
455
+ */
456
+ function pushEdges(builder: GraphBuilder, rules: RecordRules, edges: Iterable<RecordRow>): void {
457
+ const { nodeId, edgeWeight, attributes } = rules;
458
+ let position = 0;
459
+ for (const record of edges) {
460
+ const source = endpointOf(record, rules.edgeSource, SOURCE_KEYS);
461
+ const target = endpointOf(record, rules.edgeTarget, TARGET_KEYS);
462
+ if (source.key === null) {
463
+ throw missingEndpoint(
464
+ "source",
465
+ rules.edgeSource === undefined ? SOURCE_KEYS : [rules.edgeSource],
466
+ position,
467
+ );
468
+ }
469
+ if (target.key === null) {
470
+ throw missingEndpoint(
471
+ "target",
472
+ rules.edgeTarget === undefined ? TARGET_KEYS : [rules.edgeTarget],
473
+ position,
474
+ );
475
+ }
476
+ const weight = edgeWeight === null ? undefined : weightOf(record, edgeWeight, position);
477
+ let edge: number;
478
+ if (nodeId === null) {
479
+ const u = positionalEndpoint(builder, source.value, "source", position);
480
+ const v = positionalEndpoint(builder, target.value, "target", position);
481
+ edge = builder.addEdgeByIndex(u, v, weight);
482
+ } else {
483
+ edge = builder.addEdge(
484
+ coerceId(source.value, rules.ids, "source"),
485
+ coerceId(target.value, rules.ids, "target"),
486
+ weight,
487
+ );
488
+ }
489
+ if (attributes.readsAttributes) {
490
+ for (const key of Object.keys(record)) {
491
+ if (key !== source.key && key !== target.key && key !== edgeWeight) {
492
+ attributes.write(builder, "edge", edge, key, record[key]);
493
+ }
494
+ }
495
+ }
496
+ position++;
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Build a snapshot from node-link records (design section 8.1): node records (optional; every node
502
+ * an edge names is created under addMissingNodes, the default) and edge records, pushed into a
503
+ * GraphBuilder one scalar at a time and frozen. The node id comes from `nodeId` ("id" by default;
504
+ * null makes the node index the record position and the endpoints indices, the d3 v3 shape), the
505
+ * endpoints from `edgeSource` / `edgeTarget` ("source" / "target" by default, falling back to "src"
506
+ * / "from" and "dst" / "to"), the weight from `edgeWeight` ("weight" by default; null = unweighted).
507
+ * Every other key becomes an attribute column per `columns`: "infer" (default) lets the builder
508
+ * infer and widen per column (design section 5.1), "json" stores each key as a json column, "none"
509
+ * loads only the structure, and a ColumnDecl list stores only the declared keys. Ids are coerced per
510
+ * `ids` ("keep" by default). The staging weight precision defaults to f64 so a JSON weight such as
511
+ * 0.1 is never corrupted (the shadow column costs nothing when every value is f32-exact); pass
512
+ * `weightDtype: "f32"` to opt out.
513
+ * @param input - the records and keys; a record without its id or an endpoint, or with an id the
514
+ * coercion rule cannot convert, is E_INVALID_ID; a non-numeric weight is E_INVALID_WEIGHT; an
515
+ * unknown endpoint under addMissingNodes false is E_UNKNOWN_NODE
516
+ * @param options - builder policies and freeze options
517
+ * @returns the snapshot and the freeze report (its `widened` entries name the inferred columns that
518
+ * widened while the records were read)
519
+ */
520
+ export function fromRecords(
521
+ input: RecordsInput,
522
+ options: BuilderOptionsPatch & FreezeOptions = {},
523
+ ): { snapshot: GraphSnapshot; report: FreezeReport } {
524
+ const rules = rulesOf(input);
525
+ const split = splitOptions(input.directed, options);
526
+ const builderOptions: GraphBuilderOptions = {
527
+ ...split.builder,
528
+ weightDtype: options.weightDtype ?? "f64",
529
+ };
530
+ const builder = new GraphBuilder(builderOptions);
531
+ if (input.nodes !== undefined) {
532
+ pushNodes(builder, rules, input.nodes);
533
+ }
534
+ pushEdges(builder, rules, input.edges);
535
+ return builder.freezeWithReport(split.freeze);
536
+ }