@graphty/graph-io 0.3.49 → 0.3.50

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 (208) hide show
  1. package/dist/chunks/{approximations-Bzn3L-je.js → approximations-BRXsll0T.js} +2 -2
  2. package/dist/chunks/{approximations-Bzn3L-je.js.map → approximations-BRXsll0T.js.map} +1 -1
  3. package/dist/chunks/{block-iptrlMBb.js → block-yl0f_qLO.js} +27 -2
  4. package/dist/chunks/block-yl0f_qLO.js.map +1 -0
  5. package/dist/chunks/{context-C3p0RQxR.js → context-DizmppHy.js} +2 -2
  6. package/dist/chunks/{context-C3p0RQxR.js.map → context-DizmppHy.js.map} +1 -1
  7. package/dist/chunks/{credentials-DXHx7nwR.js → credentials-CmvJXYeH.js} +58 -9
  8. package/dist/chunks/credentials-CmvJXYeH.js.map +1 -0
  9. package/dist/chunks/{descriptor-DLA5kINx.js → descriptor-Dhez_Cew.js} +5 -1
  10. package/dist/chunks/{descriptor-DLA5kINx.js.map → descriptor-Dhez_Cew.js.map} +1 -1
  11. package/dist/chunks/{emit-BwHQkZle.js → emit-7Jkpg0yd.js} +132 -4
  12. package/dist/chunks/emit-7Jkpg0yd.js.map +1 -0
  13. package/dist/chunks/{exporter-nDfxIK5D.js → exporter-1Ajud15J.js} +4 -4
  14. package/dist/chunks/{exporter-nDfxIK5D.js.map → exporter-1Ajud15J.js.map} +1 -1
  15. package/dist/chunks/{exporter-D9yMtocA.js → exporter-B6yvL3wy.js} +3 -3
  16. package/dist/chunks/{exporter-D9yMtocA.js.map → exporter-B6yvL3wy.js.map} +1 -1
  17. package/dist/chunks/{exporter-_jrBTnos.js → exporter-CA5q8ROP.js} +4 -4
  18. package/dist/chunks/{exporter-_jrBTnos.js.map → exporter-CA5q8ROP.js.map} +1 -1
  19. package/dist/chunks/{exporter-D2CETOMK.js → exporter-CQQZFJ2v.js} +72 -10
  20. package/dist/chunks/exporter-CQQZFJ2v.js.map +1 -0
  21. package/dist/chunks/{generic-ymWCETAJ.js → generic-Dc21MAdg.js} +3 -3
  22. package/dist/chunks/{generic-ymWCETAJ.js.map → generic-Dc21MAdg.js.map} +1 -1
  23. package/dist/chunks/{importer-CT9mJ1nn.js → importer-C6NVpCXp.js} +7 -6
  24. package/dist/chunks/importer-C6NVpCXp.js.map +1 -0
  25. package/dist/chunks/{importer-GREKMdIf.js → importer-CKFe4q54.js} +4 -4
  26. package/dist/chunks/{importer-GREKMdIf.js.map → importer-CKFe4q54.js.map} +1 -1
  27. package/dist/chunks/{importer-PqaaKKnY.js → importer-CKbUbsJA.js} +4 -4
  28. package/dist/chunks/{importer-PqaaKKnY.js.map → importer-CKbUbsJA.js.map} +1 -1
  29. package/dist/chunks/{importer-DaLTjbds.js → importer-ClCsdgTJ.js} +97 -7
  30. package/dist/chunks/importer-ClCsdgTJ.js.map +1 -0
  31. package/dist/chunks/{importer-B92x1xY5.js → importer-CyVZTrf0.js} +3 -3
  32. package/dist/chunks/{importer-B92x1xY5.js.map → importer-CyVZTrf0.js.map} +1 -1
  33. package/dist/chunks/{importer-vRq4h3j5.js → importer-DgG4v1Ms.js} +89 -20
  34. package/dist/chunks/importer-DgG4v1Ms.js.map +1 -0
  35. package/dist/chunks/{importer-Vn5QvmmJ.js → importer-asFUrIsj.js} +2 -2
  36. package/dist/chunks/{importer-Vn5QvmmJ.js.map → importer-asFUrIsj.js.map} +1 -1
  37. package/dist/chunks/{lexicon-Clf4QIyl.js → lexicon-IVRq2Uoo.js} +2 -2
  38. package/dist/chunks/{lexicon-Clf4QIyl.js.map → lexicon-IVRq2Uoo.js.map} +1 -1
  39. package/dist/chunks/{round-trip-Zm3_ivgq.js → round-trip-Cy3_SHRZ.js} +2 -2
  40. package/dist/chunks/{round-trip-Zm3_ivgq.js.map → round-trip-Cy3_SHRZ.js.map} +1 -1
  41. package/dist/chunks/{sigv4-EokFic1V.js → sigv4-BXwVbza3.js} +3 -3
  42. package/dist/chunks/{sigv4-EokFic1V.js.map → sigv4-BXwVbza3.js.map} +1 -1
  43. package/dist/chunks/{styles-CCs214_3.js → styles-CAVmBLgp.js} +4 -4
  44. package/dist/chunks/{styles-CCs214_3.js.map → styles-CAVmBLgp.js.map} +1 -1
  45. package/dist/chunks/{styles-DPQQIig4.js → styles-CPczw1Jz.js} +4 -4
  46. package/dist/chunks/{styles-DPQQIig4.js.map → styles-CPczw1Jz.js.map} +1 -1
  47. package/dist/chunks/{styles-Cm62yyQX.js → styles-boK6Ln54.js} +65 -30
  48. package/dist/chunks/styles-boK6Ln54.js.map +1 -0
  49. package/dist/chunks/{styles-yed-UMFdNqze.js → styles-yed-XeFBQ6VS.js} +8 -8
  50. package/dist/chunks/{styles-yed-UMFdNqze.js.map → styles-yed-XeFBQ6VS.js.map} +1 -1
  51. package/dist/cx.js +2 -2
  52. package/dist/cx2.js +2 -2
  53. package/dist/cys.js +1 -1
  54. package/dist/dot.js +7 -4
  55. package/dist/gexf.js +2 -2
  56. package/dist/gml.js +8 -8
  57. package/dist/gml.js.map +1 -1
  58. package/dist/graph-io.js +129 -104
  59. package/dist/graph-io.js.map +1 -1
  60. package/dist/graphml.js +2 -2
  61. package/dist/json.js +1 -1
  62. package/dist/pajek.js +1 -1
  63. package/dist/sources-cypher.js +25 -22
  64. package/dist/sources-cypher.js.map +1 -1
  65. package/dist/sources-ndex.js +2 -2
  66. package/dist/sources-rest.js +20 -4
  67. package/dist/sources-rest.js.map +1 -1
  68. package/dist/sources-sparql.js +40 -6
  69. package/dist/sources-sparql.js.map +1 -1
  70. package/dist/src/formats/dot/exporter.d.ts.map +1 -1
  71. package/dist/src/formats/dot/exporter.js +34 -2
  72. package/dist/src/formats/dot/exporter.js.map +1 -1
  73. package/dist/src/formats/dot/importer.d.ts +6 -1
  74. package/dist/src/formats/dot/importer.d.ts.map +1 -1
  75. package/dist/src/formats/dot/importer.js +65 -5
  76. package/dist/src/formats/dot/importer.js.map +1 -1
  77. package/dist/src/formats/dot/index.d.ts +3 -1
  78. package/dist/src/formats/dot/index.d.ts.map +1 -1
  79. package/dist/src/formats/dot/index.js +2 -1
  80. package/dist/src/formats/dot/index.js.map +1 -1
  81. package/dist/src/formats/dot/label-places.d.ts +7 -0
  82. package/dist/src/formats/dot/label-places.d.ts.map +1 -1
  83. package/dist/src/formats/dot/label-places.js +9 -3
  84. package/dist/src/formats/dot/label-places.js.map +1 -1
  85. package/dist/src/formats/dot/names.d.ts +13 -0
  86. package/dist/src/formats/dot/names.d.ts.map +1 -1
  87. package/dist/src/formats/dot/names.js +13 -0
  88. package/dist/src/formats/dot/names.js.map +1 -1
  89. package/dist/src/formats/dot/styles.d.ts +10 -0
  90. package/dist/src/formats/dot/styles.d.ts.map +1 -1
  91. package/dist/src/formats/dot/styles.js +49 -3
  92. package/dist/src/formats/dot/styles.js.map +1 -1
  93. package/dist/src/formats/gml/styles.js +1 -1
  94. package/dist/src/formats/gml/styles.js.map +1 -1
  95. package/dist/src/formats/graphml/styles-yed.js +3 -3
  96. package/dist/src/formats/graphml/styles-yed.js.map +1 -1
  97. package/dist/src/formats/graphml/styles-yfiles3.d.ts.map +1 -1
  98. package/dist/src/formats/graphml/styles-yfiles3.js +6 -4
  99. package/dist/src/formats/graphml/styles-yfiles3.js.map +1 -1
  100. package/dist/src/formats/json/styles.d.ts.map +1 -1
  101. package/dist/src/formats/json/styles.js +94 -6
  102. package/dist/src/formats/json/styles.js.map +1 -1
  103. package/dist/src/formats/pajek/draw.d.ts +8 -0
  104. package/dist/src/formats/pajek/draw.d.ts.map +1 -1
  105. package/dist/src/formats/pajek/draw.js +10 -0
  106. package/dist/src/formats/pajek/draw.js.map +1 -1
  107. package/dist/src/formats/pajek/styles.d.ts.map +1 -1
  108. package/dist/src/formats/pajek/styles.js +142 -25
  109. package/dist/src/formats/pajek/styles.js.map +1 -1
  110. package/dist/src/index.d.ts +3 -3
  111. package/dist/src/index.d.ts.map +1 -1
  112. package/dist/src/index.js +2 -2
  113. package/dist/src/index.js.map +1 -1
  114. package/dist/src/sources/credentials.d.ts +50 -1
  115. package/dist/src/sources/credentials.d.ts.map +1 -1
  116. package/dist/src/sources/credentials.js +69 -0
  117. package/dist/src/sources/credentials.js.map +1 -1
  118. package/dist/src/sources/cypher/index.d.ts.map +1 -1
  119. package/dist/src/sources/cypher/index.js +12 -11
  120. package/dist/src/sources/cypher/index.js.map +1 -1
  121. package/dist/src/sources/cypher/query-builder.d.ts +6 -6
  122. package/dist/src/sources/cypher/query-builder.d.ts.map +1 -1
  123. package/dist/src/sources/cypher/query-builder.js +15 -15
  124. package/dist/src/sources/cypher/query-builder.js.map +1 -1
  125. package/dist/src/sources/rest/descriptor.d.ts.map +1 -1
  126. package/dist/src/sources/rest/descriptor.js +4 -0
  127. package/dist/src/sources/rest/descriptor.js.map +1 -1
  128. package/dist/src/sources/rest/openalex.d.ts +4 -2
  129. package/dist/src/sources/rest/openalex.d.ts.map +1 -1
  130. package/dist/src/sources/rest/openalex.js +4 -2
  131. package/dist/src/sources/rest/openalex.js.map +1 -1
  132. package/dist/src/sources/rest/source.d.ts.map +1 -1
  133. package/dist/src/sources/rest/source.js +23 -0
  134. package/dist/src/sources/rest/source.js.map +1 -1
  135. package/dist/src/sources/run.d.ts +2 -0
  136. package/dist/src/sources/run.d.ts.map +1 -1
  137. package/dist/src/sources/run.js +30 -3
  138. package/dist/src/sources/run.js.map +1 -1
  139. package/dist/src/sources/sparql/index.d.ts.map +1 -1
  140. package/dist/src/sources/sparql/index.js +14 -1
  141. package/dist/src/sources/sparql/index.js.map +1 -1
  142. package/dist/src/sources/sparql/query.d.ts +2 -1
  143. package/dist/src/sources/sparql/query.d.ts.map +1 -1
  144. package/dist/src/sources/sparql/query.js +55 -4
  145. package/dist/src/sources/sparql/query.js.map +1 -1
  146. package/dist/src/sources/types.d.ts +29 -0
  147. package/dist/src/sources/types.d.ts.map +1 -1
  148. package/dist/src/styles/canon/enums.d.ts.map +1 -1
  149. package/dist/src/styles/canon/enums.js +8 -0
  150. package/dist/src/styles/canon/enums.js.map +1 -1
  151. package/dist/src/styles/cyjs/cascade.d.ts.map +1 -1
  152. package/dist/src/styles/cyjs/cascade.js +124 -0
  153. package/dist/src/styles/cyjs/cascade.js.map +1 -1
  154. package/dist/src/styles/cyjs/defaults-table.d.ts.map +1 -1
  155. package/dist/src/styles/cyjs/defaults-table.js +1 -0
  156. package/dist/src/styles/cyjs/defaults-table.js.map +1 -1
  157. package/dist/src/styles/cyjs/emit.d.ts.map +1 -1
  158. package/dist/src/styles/cyjs/emit.js +30 -1
  159. package/dist/src/styles/cyjs/emit.js.map +1 -1
  160. package/dist/src/styles/cyjs/mappers.d.ts.map +1 -1
  161. package/dist/src/styles/cyjs/mappers.js +14 -0
  162. package/dist/src/styles/cyjs/mappers.js.map +1 -1
  163. package/dist/src/styles/types.d.ts +65 -0
  164. package/dist/src/styles/types.d.ts.map +1 -1
  165. package/dist/src/styles/vocabulary.d.ts.map +1 -1
  166. package/dist/src/styles/vocabulary.js +21 -0
  167. package/dist/src/styles/vocabulary.js.map +1 -1
  168. package/dist/styles.js +6 -6
  169. package/dist/xgmml.js +3 -3
  170. package/package.json +2 -2
  171. package/src/formats/dot/exporter.ts +37 -1
  172. package/src/formats/dot/importer.ts +75 -4
  173. package/src/formats/dot/index.ts +3 -1
  174. package/src/formats/dot/label-places.ts +16 -3
  175. package/src/formats/dot/names.ts +15 -0
  176. package/src/formats/dot/styles.ts +55 -3
  177. package/src/formats/gml/styles.ts +1 -1
  178. package/src/formats/graphml/styles-yed.ts +3 -3
  179. package/src/formats/graphml/styles-yfiles3.ts +6 -4
  180. package/src/formats/json/styles.ts +102 -6
  181. package/src/formats/pajek/draw.ts +11 -0
  182. package/src/formats/pajek/styles.ts +151 -26
  183. package/src/index.ts +7 -0
  184. package/src/sources/credentials.ts +90 -1
  185. package/src/sources/cypher/index.ts +12 -11
  186. package/src/sources/cypher/query-builder.ts +15 -15
  187. package/src/sources/rest/descriptor.ts +4 -0
  188. package/src/sources/rest/openalex.ts +4 -2
  189. package/src/sources/rest/source.ts +25 -0
  190. package/src/sources/run.ts +35 -3
  191. package/src/sources/sparql/index.ts +16 -1
  192. package/src/sources/sparql/query.ts +58 -4
  193. package/src/sources/types.ts +33 -0
  194. package/src/styles/canon/enums.ts +9 -0
  195. package/src/styles/cyjs/cascade.ts +143 -0
  196. package/src/styles/cyjs/defaults-table.ts +1 -0
  197. package/src/styles/cyjs/emit.ts +35 -1
  198. package/src/styles/cyjs/mappers.ts +14 -0
  199. package/src/styles/types.ts +65 -0
  200. package/src/styles/vocabulary.ts +21 -0
  201. package/dist/chunks/block-iptrlMBb.js.map +0 -1
  202. package/dist/chunks/credentials-DXHx7nwR.js.map +0 -1
  203. package/dist/chunks/emit-BwHQkZle.js.map +0 -1
  204. package/dist/chunks/exporter-D2CETOMK.js.map +0 -1
  205. package/dist/chunks/importer-CT9mJ1nn.js.map +0 -1
  206. package/dist/chunks/importer-DaLTjbds.js.map +0 -1
  207. package/dist/chunks/importer-vRq4h3j5.js.map +0 -1
  208. package/dist/chunks/styles-Cm62yyQX.js.map +0 -1
@@ -7,7 +7,13 @@
7
7
  */
8
8
 
9
9
  import { failAlone } from "./fail.js";
10
- import { type AwsCredentials, type Credential, type CredentialKind } from "./types.js";
10
+ import {
11
+ type AwsCredentials,
12
+ type Credential,
13
+ type CredentialKind,
14
+ type SourceCredentialSlot,
15
+ type SourceDescriptor,
16
+ } from "./types.js";
11
17
 
12
18
  /**
13
19
  * Where a credential may go: the load's endpoint origin by default. Experimental.
@@ -145,6 +151,63 @@ function base64(text: string): string {
145
151
  return btoa(binary);
146
152
  }
147
153
 
154
+ /**
155
+ * The plain value a slot's credential is made from: a key or a token, or a user name and password.
156
+ * @category Network sources
157
+ */
158
+ export type SlotValue =
159
+ | string
160
+ | {
161
+ /** A basic slot's user name. */
162
+ readonly username: string;
163
+ /** A basic slot's password. */
164
+ readonly password: string;
165
+ };
166
+
167
+ /**
168
+ * The credential a slot describes, from its plain value.
169
+ * @param slot - the slot
170
+ * @param value - the key or token, or a basic slot's user name and password
171
+ * @param bind - the origins it may be sent to
172
+ * @returns the credential
173
+ */
174
+ function forSlot(slot: SourceCredentialSlot, value: SlotValue, bind?: CredentialBind): Credential {
175
+ if (typeof value !== "string") {
176
+ if (slot.kind === "basic") {
177
+ return new SourceCredential({ kind: "basic", username: value.username, password: value.password }, bind);
178
+ }
179
+ } else if (slot.kind === "bearer") {
180
+ return new SourceCredential({ kind: "bearer", token: value }, bind);
181
+ } else if (slot.kind === "api-key") {
182
+ const send = slot.send ?? { query: slot.name };
183
+ const header = "header" in send ? send.header : null;
184
+ const query = "query" in send ? send.query : null;
185
+ return new SourceCredential({ kind: "api-key", header, query, value }, bind);
186
+ }
187
+ return failAlone("E_SOURCE_PARAM", `a ${slot.kind} slot cannot take this value`, {
188
+ name: "credential",
189
+ reason: "type",
190
+ });
191
+ }
192
+
193
+ /**
194
+ * The credential slots a source takes: the ones its descriptor declares, else one slot `default`
195
+ * of its first credential kind when it takes a credential (`auth`), else none. Experimental.
196
+ * @param descriptor - the source's descriptor
197
+ * @returns the slots
198
+ * @category Network sources
199
+ */
200
+ export function credentialSlots(descriptor: SourceDescriptor | undefined): readonly SourceCredentialSlot[] {
201
+ if (descriptor?.credentials !== undefined) {
202
+ return descriptor.credentials;
203
+ }
204
+ const kind = descriptor?.auth?.[0];
205
+ if (kind === undefined || descriptor?.authRequired === "none") {
206
+ return [];
207
+ }
208
+ return [{ name: "default", kind, required: descriptor?.authRequired === "required" }];
209
+ }
210
+
148
211
  /**
149
212
  * The factories that make credentials. Pass the result as the `credential` option of a load; it is
150
213
  * sent only to the load's endpoint origin, or to `bind.origins` when you give them. Experimental.
@@ -213,6 +276,21 @@ export const credentials = Object.freeze({
213
276
  custom(apply: (request: Request) => Promise<Request> | Request, kind: string, bind?: CredentialBind): Credential {
214
277
  return new SourceCredential({ kind: "custom", name: kind, apply }, bind);
215
278
  },
279
+ /**
280
+ * The credential a source's slot describes (`credentialSlots(descriptor)`), from its plain
281
+ * value: an API key sent as the slot says, a bearer token, or a basic slot's user name and
282
+ * password.
283
+ * @example
284
+ * ```ts
285
+ * const [slot] = credentialSlots(openalex.descriptor); // api_key, sent as ?api_key=
286
+ * const credential = credentials.forSlot(slot, key);
287
+ * ```
288
+ * @param slot - the slot
289
+ * @param value - the key or token, or `{ username, password }` for a basic slot
290
+ * @param bind - the origins it may be sent to
291
+ * @returns the credential
292
+ */
293
+ forSlot,
216
294
  });
217
295
 
218
296
  /**
@@ -248,6 +326,17 @@ export function credentialHeader(credential: Credential): string | null {
248
326
  : null;
249
327
  }
250
328
 
329
+ /**
330
+ * The query parameter a credential is sent in, so a cursor can leave it out; null for one sent in
331
+ * a header or not at all.
332
+ * @param credential - the credential
333
+ * @returns the parameter's name
334
+ */
335
+ export function credentialQuery(credential: Credential): string | null {
336
+ const secret = secretOf(credential);
337
+ return secret?.kind === "api-key" && secret.header === null ? (secret.query ?? "api_key") : null;
338
+ }
339
+
251
340
  /**
252
341
  * Add a credential to a request. Internal to graph-io: only `ctx.fetch` calls it.
253
342
  * @param credential - the credential
@@ -253,7 +253,8 @@ class Reader {
253
253
  * Mode "query": the reader's own query, as written. With a node cap (or a cursor from a capped
254
254
  * run) the server returns at most that many rows (`CALL { query } RETURN * LIMIT n`), and a full
255
255
  * result hands back a cursor that runs the query again with twice the rows, for the caller to
256
- * merge. A server that refuses the wrapper runs the query uncapped.
256
+ * merge. A server that refuses the wrapper fails the load with `E_SOURCE_PARAM` (`name:
257
+ * "maxNodes"`, `reason: "uncappable"`): the query is never run past the cap the caller set.
257
258
  * @param query - the query
258
259
  * @param cursor - where to resume: `{ rows }` from an earlier capped run
259
260
  * @returns the one page
@@ -267,7 +268,6 @@ class Reader {
267
268
  ? null
268
269
  : Number(checkedInteger(asked, "maxNodes", 1, Number.MAX_SAFE_INTEGER, ctx.report));
269
270
  let result: QueryResult;
270
- let capped = rows !== null;
271
271
  try {
272
272
  result = await this.transport.run(
273
273
  rows === null
@@ -276,16 +276,18 @@ class Reader {
276
276
  parameters,
277
277
  );
278
278
  } catch (err) {
279
- if (!capped || !(err instanceof ImportError) || err.issue?.code !== SOURCE_ISSUE.E_SOURCE_QUERY) {
279
+ if (rows === null || !(err instanceof ImportError) || err.issue?.code !== SOURCE_ISSUE.E_SOURCE_QUERY) {
280
280
  throw err;
281
281
  }
282
- capped = false;
283
- result = await this.transport.run(query, parameters);
282
+ return failOn(ctx.report, "E_SOURCE_PARAM", "the server cannot cap this query; load it without maxNodes", {
283
+ name: "maxNodes",
284
+ reason: "uncappable",
285
+ });
284
286
  }
285
287
  const collected = new Collector();
286
288
  collected.add(result, false);
287
289
  requireGraph(result, collected, ctx.report);
288
- const full = capped && rows !== null && result.rows.length >= rows;
290
+ const full = rows !== null && result.rows.length >= rows;
289
291
  return { collected, next: full ? { rows: rows * 2 } : null };
290
292
  }
291
293
 
@@ -362,13 +364,12 @@ class Reader {
362
364
  (d) => nodePage(profile, this.filters, String(size), after !== null, d),
363
365
  after === null ? {} : { after },
364
366
  );
365
- const last = rows.at(-1)?.[fields.indexOf("key")];
367
+ const at = fields.indexOf("key");
368
+ const last = rows.at(-1)?.[at];
366
369
  const key = typeof last === "string" || typeof last === "number" ? last : null;
367
370
  if (key !== null) {
368
- const among = await this.transport.run(edgesAmong(profile, this.filters, after !== null), {
369
- last: key,
370
- ...(after === null ? {} : { after }),
371
- });
371
+ const keys = rows.map((row) => row[at]);
372
+ const among = await this.transport.run(edgesAmong(profile, this.filters), { keys, last: key });
372
373
  collected.add(among, false);
373
374
  }
374
375
  const next = rows.length >= size && key !== null ? { phase: "nodes", after: key } : null;
@@ -216,25 +216,25 @@ export function edgePage(profile: CypherProfile, filters: QueryFilters, pageSize
216
216
  }
217
217
 
218
218
  /**
219
- * The relationships among the nodes a capped mode "all" has loaded: both ends at or before the
220
- * node page's last key `$last`, and, after the first page, one end past the previous page's last
221
- * key `$after`, so each relationship arrives with the page that loads its later end.
219
+ * The relationships among the nodes a capped mode "all" has loaded that touch one node page: each
220
+ * starts from the page's own nodes `$keys` (an id or index lookup, never a scan of every
221
+ * relationship), with its other end on this page or an earlier one (at or before `$last`), so each
222
+ * relationship arrives once, with the page that loads its later end.
222
223
  * @param profile - the server's profile
223
224
  * @param filters - the filters
224
- * @param after - whether earlier pages were loaded
225
- * @returns the statement; it reads `$last` and `$after`
225
+ * @returns the statement; it reads `$keys` and `$last`
226
226
  */
227
- export function edgesAmong(profile: CypherProfile, filters: QueryFilters, after: boolean): string {
228
- const where = [nodePredicate("a", filters), nodePredicate("b", filters)].filter((p) => p !== null);
227
+ export function edgesAmong(profile: CypherProfile, filters: QueryFilters): string {
228
+ const ka = idExpression("a", profile, filters);
229
+ const kb = idExpression("b", profile, filters);
230
+ const ends = [nodePredicate("a", filters), nodePredicate("b", filters)].filter((p) => p !== null);
231
+ const rest = ends.length === 0 ? "" : ` AND ${ends.join(" AND ")}`;
232
+ const pattern = `MATCH (a)-[r${typesPattern(filters)}]->(b)`;
229
233
  return [
230
- `MATCH (a)-[r${typesPattern(filters)}]->(b)`,
231
- where.length === 0 ? null : `WHERE ${where.join(" AND ")}`,
232
- `WITH a, r, b, ${idExpression("a", profile, filters)} AS ka, ${idExpression("b", profile, filters)} AS kb`,
233
- `WHERE ka <= $last AND kb <= $last${after ? " AND (ka > $after OR kb > $after)" : ""}`,
234
- "RETURN a, r, b",
235
- ]
236
- .filter((l) => l !== null)
237
- .join(" ");
234
+ `${pattern} WHERE ${ka} IN $keys AND ${kb} <= $last${rest} RETURN a, r, b`,
235
+ "UNION ALL",
236
+ `${pattern} WHERE ${kb} IN $keys AND ${ka} <= $last AND NOT ${ka} IN $keys${rest} RETURN a, r, b`,
237
+ ].join(" ");
238
238
  }
239
239
 
240
240
  /** How far a neighborhood reaches. */
@@ -49,6 +49,10 @@ export const OPENALEX_DESCRIPTOR: SourceDescriptor = Object.freeze({
49
49
  defaultEndpoint: "https://api.openalex.org",
50
50
  auth: ["api-key"],
51
51
  authRequired: "optional",
52
+ // OpenAlex documents its key as the api_key query parameter
53
+ credentials: [
54
+ { name: "api_key", kind: "api-key", required: false, plainName: "API key", send: { query: "api_key" } },
55
+ ],
52
56
  apiKey: { query: "api_key" },
53
57
  rateLimit: { requests: 10, perMs: 1000 },
54
58
  features: ["expand", "probe", "validate", "page"],
@@ -266,8 +266,10 @@ const methods = restMethods<OpenAlexParams>(plan, false);
266
266
  * The OpenAlex preset (https://openalex.org): `work` (the citation graph around a work, default)
267
267
  * or `author` (the works they wrote), `graph: "coauthorship"` for authors joined by shared works,
268
268
  * and `include` (`"authors"`, `"institutions"`, `"topics"`) for more node kinds. Without a key,
269
- * the first page loads and a later one warns W_SOURCE_AUTH with `report.source.next`; pass
270
- * `credentials.apiKey({ query: "api_key" }, key)` and continue with the cursor. Experimental.
269
+ * the first page loads and a later one warns W_SOURCE_AUTH with `report.source.next`. Its one
270
+ * optional credential slot, `api_key`, is sent as the `api_key` query parameter: pass
271
+ * `credentials.forSlot(slot, key)` (or `credentials.apiKey({ query: "api_key" }, key)`) and
272
+ * continue with the cursor. Experimental.
271
273
  * @category Network sources
272
274
  */
273
275
  export const openalexSource: GraphSource<OpenAlexParams> = defineSource<OpenAlexParams>({
@@ -19,6 +19,7 @@ import {
19
19
  type GraphSource,
20
20
  type RecordBatch,
21
21
  type SourceContext,
22
+ type SourceCredentialSlot,
22
23
  type SourceCursor,
23
24
  type SourceDescriptor,
24
25
  type SourceFinish,
@@ -697,6 +698,29 @@ export type RestSourceInfo = {
697
698
  readonly id: string;
698
699
  } & Partial<SourceDescriptor>;
699
700
 
701
+ /**
702
+ * The credential slot a mapping's `auth` describes: an API key's slot is named by the query
703
+ * parameter or header it is sent in (`{ query: "api_key" }` is the slot `api_key`), a bearer
704
+ * token's is `default`. Optional, as a mapping cannot say a key is required.
705
+ * @param mapping - the mapping
706
+ * @returns the slots
707
+ */
708
+ function mappingSlots(mapping: RestMapping): readonly SourceCredentialSlot[] {
709
+ const { auth } = mapping;
710
+ if (auth === undefined) {
711
+ return [];
712
+ }
713
+ if (auth.kind === "bearer") {
714
+ return [{ name: "default", kind: "bearer", required: false }];
715
+ }
716
+ const { header } = auth;
717
+ if (header !== undefined) {
718
+ return [{ name: header.toLowerCase(), kind: "api-key", required: false, send: { header } }];
719
+ }
720
+ const query = auth.query ?? "api_key";
721
+ return [{ name: query, kind: "api-key", required: false, send: { query } }];
722
+ }
723
+
700
724
  /**
701
725
  * Where a mapping's API key goes, as a descriptor says it.
702
726
  * @param auth - the mapping's auth
@@ -748,6 +772,7 @@ export function restSource(mapping: RestMapping, info: RestSourceInfo): GraphSou
748
772
  defaultEndpoint: mapping.endpoint,
749
773
  auth,
750
774
  authRequired: auth.length === 0 ? "none" : "optional",
775
+ credentials: mappingSlots(mapping),
751
776
  ...apiKeyOf(mapping.auth),
752
777
  ...(mapping.rateLimit === undefined ? {} : { rateLimit: mapping.rateLimit }),
753
778
  ...fields,
@@ -15,7 +15,7 @@ import { type FormatRegistry } from "../registry.js";
15
15
  import { SNIFF_HEAD_BYTES } from "../sniff.js";
16
16
  import { type CommonImportOptions, ImportError, type ImportInput, type ImportReport } from "../types.js";
17
17
  import { type CreatedContext, createSourceContext, detectRuntime, LimitStop } from "./context.js";
18
- import { originOf } from "./credentials.js";
18
+ import { credentialQuery, originOf } from "./credentials.js";
19
19
  import { checkParams } from "./define.js";
20
20
  import { failOn, warnOn } from "./fail.js";
21
21
  import { SOURCE_FORMAT_PREFIX } from "./ids.js";
@@ -60,6 +60,8 @@ interface Prepared {
60
60
  readonly params: Record<string, unknown>;
61
61
  /** The sink wrapper's counters, set once the sink is known. */
62
62
  readonly counts: { nodes: Set<number>; edgesAtStart: number; sink: GraphSink | null };
63
+ /** The query parameter the call's credential is sent in, which a cursor never keeps; null for none. */
64
+ readonly secretQuery: string | null;
63
65
  }
64
66
 
65
67
  /**
@@ -131,7 +133,8 @@ function prepare(
131
133
  edges: counts.sink === null ? 0 : counts.sink.edgeCount - counts.edgesAtStart,
132
134
  }),
133
135
  });
134
- return { report, created, params, counts };
136
+ const secretQuery = options.credential === undefined ? null : credentialQuery(options.credential);
137
+ return { report, created, params, counts, secretQuery };
135
138
  }
136
139
 
137
140
  /**
@@ -721,6 +724,7 @@ export async function runMethod<T>(
721
724
  */
722
725
  export function withSource(report: ImportReport, prepared: Prepared, next: SourceCursor | null): ImportReport {
723
726
  const own = prepared.report.finish();
727
+ const cursor = prepared.secretQuery === null || next === null ? next : withoutParam(next, prepared.secretQuery);
724
728
  // issues the context recorded (retries, limits) that a source's own report does not hold
725
729
  const missing = own.issues.filter((i) => !report.issues.includes(i));
726
730
  return Object.freeze({
@@ -728,10 +732,38 @@ export function withSource(report: ImportReport, prepared: Prepared, next: Sourc
728
732
  issues: missing.length === 0 ? report.issues : Object.freeze([...report.issues, ...missing]),
729
733
  warningCount: report.warningCount + missing.filter((i) => i.severity === "warning").length,
730
734
  errorCount: report.errorCount + missing.filter((i) => i.severity === "error").length,
731
- source: prepared.created.info(next),
735
+ source: prepared.created.info(cursor),
732
736
  });
733
737
  }
734
738
 
739
+ /**
740
+ * A cursor without a query parameter in any URL it holds: a next-page link a service returns
741
+ * repeats the API key the request carried, and a cursor is saved where a credential never is.
742
+ * The credential is sent again with every request, so the next page does not need it there.
743
+ * @param value - the cursor, or a value inside it
744
+ * @param name - the parameter
745
+ * @returns the value without it
746
+ */
747
+ function withoutParam<T>(value: T, name: string): T {
748
+ if (typeof value === "string" && /^https?:\/\//i.test(value)) {
749
+ const url = new URL(value);
750
+ if (!url.searchParams.has(name)) {
751
+ return value;
752
+ }
753
+ url.searchParams.delete(name);
754
+ return url.href as T;
755
+ }
756
+ if (Array.isArray(value)) {
757
+ return Object.freeze(value.map((each: unknown) => withoutParam(each, name))) as T;
758
+ }
759
+ if (typeof value === "object" && value !== null) {
760
+ return Object.freeze(
761
+ Object.fromEntries(Object.entries(value).map(([key, each]) => [key, withoutParam(each, name)])),
762
+ ) as T;
763
+ }
764
+ return value;
765
+ }
766
+
735
767
  /**
736
768
  * What a failed call throws: the signal's reason when it aborted, an ImportError with the source
737
769
  * info attached, or the error itself.
@@ -230,13 +230,28 @@ function write(graphed: Graphed, sink: GraphSink, ctx: SourceContext): void {
230
230
  }
231
231
  }
232
232
 
233
+ /**
234
+ * Order strings by UTF-16 code units, as `Array.prototype.sort` does with no compare function, so
235
+ * a content hash never depends on the locale.
236
+ * @param a - one string
237
+ * @param b - the other
238
+ * @returns negative, zero or positive
239
+ */
240
+ function byCodeUnits(a: string, b: string): number {
241
+ if (a === b) {
242
+ return 0;
243
+ }
244
+
245
+ return a < b ? -1 : 1;
246
+ }
247
+
233
248
  /**
234
249
  * The content hash of some triples, in any order.
235
250
  * @param triples - the triples
236
251
  * @returns the SHA-256, hex
237
252
  */
238
253
  function hashOf(triples: readonly Triple[]): Promise<string> {
239
- return sha256Hex(triples.map(canonicalTriple).sort().join("\n")); // NOSONAR(S2871): code-unit order is the point: a locale-free, stable order for the hash
254
+ return sha256Hex(triples.map(canonicalTriple).sort(byCodeUnits).join("\n"));
240
255
  }
241
256
 
242
257
  /**
@@ -37,19 +37,73 @@ function bare(query: string): string {
37
37
  .replaceAll(/#[^\n]*/g, "");
38
38
  }
39
39
 
40
+ /** The keywords of SPARQL Update, which a query this source sends never holds. */
41
+ const UPDATE_KEYWORDS = /\b(INSERT|DELETE|LOAD|CLEAR|DROP|CREATE|ADD|MOVE|COPY|WITH)\b/i;
42
+
43
+ /** One declaration of the prologue, as `bare` leaves it: `BASE <>` or `PREFIX ex: <>`. */
44
+ const DECLARATION = /^\s*(?:BASE|PREFIX\s*[^\s:<]*:)\s*<>/i;
45
+
46
+ /**
47
+ * Bare text after its prologue, one declaration at a time.
48
+ * @param text - the bare text
49
+ * @returns what follows the prologue
50
+ */
51
+ function afterPrologue(text: string): string {
52
+ let rest = text;
53
+ for (let found = DECLARATION.exec(rest); found !== null; found = DECLARATION.exec(rest)) {
54
+ rest = rest.slice(found[0].length);
55
+ }
56
+ return rest;
57
+ }
58
+
59
+ /**
60
+ * The bare text with variables and prefixed names blanked too, so a name such as `ex:LIMIT` or
61
+ * `?DELETE` is never read as a keyword.
62
+ * @param text - the bare text after the prologue
63
+ * @returns the text holding only keywords, punctuation and blanks
64
+ */
65
+ function keywordsOnly(text: string): string {
66
+ // one run of name characters at a time: a run holding a colon is a prefixed name
67
+ return text.replaceAll(/[?$]\w+/g, "?v").replaceAll(/[\w.%:-]+/g, (run) => (run.includes(":") ? "_" : run));
68
+ }
69
+
70
+ /**
71
+ * Whether bare text holds a `;` outside every brace: SPARQL Update separates its operations so, and
72
+ * a query never holds one there.
73
+ * @param text - the text
74
+ * @returns true when it does
75
+ */
76
+ function semicolonOutside(text: string): boolean {
77
+ let depth = 0;
78
+ for (const char of text) {
79
+ if (char === "{") {
80
+ depth++;
81
+ } else if (char === "}") {
82
+ depth--;
83
+ } else if (char === ";" && depth <= 0) {
84
+ return true;
85
+ }
86
+ }
87
+ return false;
88
+ }
89
+
40
90
  /**
41
- * Read what paging a caller's query takes.
91
+ * Read what paging a caller's query takes. Its form is the first keyword after the prologue; a
92
+ * text that holds an update keyword, or a `;` outside braces, has no form, so it is refused.
42
93
  * @param query - the query
43
94
  * @returns its form, its own modifiers and its template variables
44
95
  */
45
96
  export function readQuery(query: string): UserQuery {
46
- const text = bare(query);
47
- const form = /\b(CONSTRUCT|DESCRIBE|SELECT|ASK)\b/i.exec(text)?.[1]?.toLowerCase() ?? null;
97
+ const body = afterPrologue(bare(query));
98
+ const text = keywordsOnly(body);
99
+ const first = /^\s*([A-Za-z]+)/.exec(text)?.[1]?.toUpperCase() ?? "";
100
+ const update = UPDATE_KEYWORDS.test(text) || semicolonOutside(text);
101
+ const form = !update && ["CONSTRUCT", "DESCRIBE", "SELECT", "ASK"].includes(first) ? first.toLowerCase() : null;
48
102
  // the solution modifiers follow the last closing brace (a trailing VALUES block is not handled)
49
103
  const tail = text.slice(text.lastIndexOf("}") + 1);
50
104
  let templateVars: string[] = [];
51
105
  if (form === "construct") {
52
- const template = /\bCONSTRUCT\s*\{([^}]*)\}/i.exec(text)?.[1] ?? "";
106
+ const template = /\bCONSTRUCT\s*\{([^}]*)\}/i.exec(body)?.[1] ?? "";
53
107
  templateVars = [...new Set(template.match(/[?$]\w+/g) ?? [])].map((v) => `?${v.slice(1)}`);
54
108
  }
55
109
  return {
@@ -378,6 +378,34 @@ export interface SourceSample {
378
378
  readonly firstPage?: number | undefined;
379
379
  }
380
380
 
381
+ /**
382
+ * One credential a source takes, as its descriptor declares it: a name a project file records
383
+ * instead of the credential, the kind, whether a load needs it, and for an API key how it is sent.
384
+ * `credentials.forSlot(slot, value)` makes the credential it describes. Experimental.
385
+ * @category Network sources
386
+ */
387
+ export interface SourceCredentialSlot {
388
+ /** The slot's name, recorded as a load's `credentialSlot` ("api_key", "default"). */
389
+ readonly name: string;
390
+ /** The credential kind it takes. */
391
+ readonly kind: CredentialKind;
392
+ /** Whether a load needs it. @defaultValue false */
393
+ readonly required?: boolean | undefined;
394
+ /** Its default name; catalog data an application may replace by name. */
395
+ readonly plainName?: string | undefined;
396
+ /** For an API key: the query parameter or request header it is sent in. */
397
+ readonly send?:
398
+ | {
399
+ /** The query parameter the key is sent in. */
400
+ readonly query: string;
401
+ }
402
+ | {
403
+ /** The request header the key is sent in. */
404
+ readonly header: string;
405
+ }
406
+ | undefined;
407
+ }
408
+
381
409
  /**
382
410
  * What a form needs to ask for a source's parameters, as data. Only `plainName` and `params` are
383
411
  * required. Experimental.
@@ -396,6 +424,11 @@ export interface SourceDescriptor {
396
424
  readonly auth?: readonly CredentialKind[] | undefined;
397
425
  /** Whether a credential is needed. @defaultValue "none" */
398
426
  readonly authRequired?: "required" | "optional" | "none" | undefined;
427
+ /**
428
+ * The credential slots it takes. A source that takes a credential (`auth`) and declares none
429
+ * has one slot, `default`: `credentialSlots(descriptor)` reads either. @defaultValue []
430
+ */
431
+ readonly credentials?: readonly SourceCredentialSlot[] | undefined;
399
432
  /**
400
433
  * Where an `api-key` credential goes, for a form that asks for the key alone: pass it to
401
434
  * `credentials.apiKey(descriptor.apiKey, key)`. Absent when the caller must say.
@@ -231,11 +231,20 @@ const GRAPHVIZ: Partial<Record<StyleEnumKind, Aliases>> = {
231
231
  arrow: { "other:curve": ["curve"], "other:inv-curve": ["icurve"] },
232
232
  };
233
233
 
234
+ /**
235
+ * yEd's (yFiles for Java's) STANDARD arrow is "a triangle with impressed bottom side", a notched arrow; its DELTA is the
236
+ * plain triangle (https://docs.yworks.com/yfiles/doc/api/y/view/Arrow.html).
237
+ */
238
+ const YED: Partial<Record<StyleEnumKind, Aliases>> = {
239
+ arrow: { vee: ["standard"] },
240
+ };
241
+
234
242
  /** Per-dialect overrides; a dialect not listed reads the common table alone. */
235
243
  const OVERRIDES: ReadonlyMap<string, Partial<Record<StyleEnumKind, Aliases>>> = new Map([
236
244
  ["vis", VIS],
237
245
  ["graphviz", GRAPHVIZ],
238
246
  ["cytoscape-js", CYJS],
247
+ ["yed", YED],
239
248
  ]);
240
249
 
241
250
  const OPEN: ReadonlySet<StyleEnumKind> = new Set<StyleEnumKind>(["shape", "borderStyle", "lineStyle", "arrow"]);