@johnhenry/graceholder 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +492 -2
- package/STABILITY.md +24 -0
- package/package.json +126 -4
- package/src/adapters/apollo.d.ts +13 -0
- package/src/adapters/apollo.js +48 -0
- package/src/adapters/fetch.d.ts +15 -0
- package/src/adapters/fetch.js +150 -0
- package/src/adapters/shared.d.ts +14 -0
- package/src/adapters/shared.js +65 -0
- package/src/adapters/urql.d.ts +8 -0
- package/src/adapters/urql.js +53 -0
- package/src/anchor.js +13 -0
- package/src/args.js +41 -0
- package/src/bulk/csv.js +118 -0
- package/src/bulk/ndjson.js +10 -0
- package/src/bulk/plan.js +195 -0
- package/src/bulk/shape.js +67 -0
- package/src/bulk/source.js +24 -0
- package/src/bulk/sql.js +366 -0
- package/src/bulk/stream.js +55 -0
- package/src/cursor.js +25 -0
- package/src/dbg.d.ts +720 -0
- package/src/detmath.js +74 -0
- package/src/directives.js +113 -0
- package/src/dist.js +90 -0
- package/src/errors.js +17 -0
- package/src/execute.js +577 -0
- package/src/explain.js +473 -0
- package/src/fast-check.d.ts +31 -0
- package/src/fast-check.js +58 -0
- package/src/generate.js +84 -0
- package/src/generator-types.d.ts +90 -0
- package/src/generators.js +361 -0
- package/src/image.js +55 -0
- package/src/index.d.ts +507 -0
- package/src/index.js +62 -0
- package/src/infer.js +888 -0
- package/src/key.js +30 -0
- package/src/learn.js +397 -0
- package/src/naming.js +74 -0
- package/src/pack-registry.js +59 -0
- package/src/packs/PROVENANCE.md +26 -0
- package/src/packs/commerce.d.ts +4 -0
- package/src/packs/commerce.js +83 -0
- package/src/packs/finance.d.ts +4 -0
- package/src/packs/finance.js +180 -0
- package/src/packs/geo.d.ts +4 -0
- package/src/packs/geo.js +227 -0
- package/src/packs/health.d.ts +4 -0
- package/src/packs/health.js +76 -0
- package/src/packs/index.d.ts +7 -0
- package/src/packs/index.js +9 -0
- package/src/rng.js +89 -0
- package/src/rules.js +228 -0
- package/src/scenario.js +120 -0
- package/src/schema.js +148 -0
- package/src/spec.js +235 -0
- package/src/suggest.js +138 -0
- package/src/typed.d.ts +723 -0
- package/src/vocab/PROVENANCE.md +12 -0
- package/src/vocab/en.js +106 -0
- package/src/world.js +461 -0
- package/src/xxh64.js +87 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Apollo Client link backed by a graceholder world. `@apollo/client` is an optional peer
|
|
2
|
+
// dependency and is imported only here.
|
|
3
|
+
import { ApolloLink, Observable } from "@apollo/client";
|
|
4
|
+
import { resolveWorld, seedOf, latencyFor } from "./shared.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A terminating link: it answers every operation from a world and never forwards.
|
|
8
|
+
* Place it last in the link chain, in place of `HttpLink`.
|
|
9
|
+
*/
|
|
10
|
+
export class GraceholderLink extends ApolloLink {
|
|
11
|
+
/** @param {import("./apollo.js").GraceholderLinkOptions} [options] */
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
super();
|
|
14
|
+
this.world = resolveWorld(options);
|
|
15
|
+
this._seed = seedOf(this.world);
|
|
16
|
+
this._latency = options.latency;
|
|
17
|
+
this._delay = options.delay;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
request(operation) {
|
|
21
|
+
const { query, variables, operationName } = operation;
|
|
22
|
+
const ms = latencyFor(this._latency, this._seed, { query, operationName, variables });
|
|
23
|
+
return new Observable((observer) => {
|
|
24
|
+
let closed = false;
|
|
25
|
+
let timer;
|
|
26
|
+
const answer = () => {
|
|
27
|
+
if (closed) return;
|
|
28
|
+
// the document goes to the world as is: it accepts a DocumentNode
|
|
29
|
+
const result = this.world.query(query, { variables, operationName });
|
|
30
|
+
observer.next(result);
|
|
31
|
+
observer.complete();
|
|
32
|
+
};
|
|
33
|
+
if (this._delay) {
|
|
34
|
+
Promise.resolve(this._delay(ms)).then(answer);
|
|
35
|
+
} else if (ms > 0) {
|
|
36
|
+
timer = setTimeout(answer, ms);
|
|
37
|
+
} else {
|
|
38
|
+
answer();
|
|
39
|
+
}
|
|
40
|
+
return () => {
|
|
41
|
+
closed = true;
|
|
42
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { GraceholderLink as default };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { World } from "../index.js";
|
|
2
|
+
import type { AdapterOptions } from "./shared.js";
|
|
3
|
+
|
|
4
|
+
export interface CreateFetchOptions extends AdapterOptions {
|
|
5
|
+
/** Which requests the world answers. Default: URL paths ending in `/graphql`. A string is an exact path or URL. */
|
|
6
|
+
match?: string | RegExp | ((url: URL, request: { method: string }) => boolean);
|
|
7
|
+
/** Where non-matching requests go. Default `globalThis.fetch`. */
|
|
8
|
+
fallback?: typeof fetch;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type GraceholderFetch = typeof fetch & { readonly world: World };
|
|
12
|
+
|
|
13
|
+
/** A fetch-compatible function: GraphQL requests are answered by a world, everything else passes through. */
|
|
14
|
+
export declare function createFetch(options?: CreateFetchOptions): GraceholderFetch;
|
|
15
|
+
export default createFetch;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// A fetch-compatible function that answers GraphQL requests from a graceholder world. It uses
|
|
2
|
+
// only web-standard globals (Request, Response, URL, Headers), no optional dependency.
|
|
3
|
+
import { parse } from "graphql";
|
|
4
|
+
import { resolveWorld, seedOf, latencyFor, wait } from "./shared.js";
|
|
5
|
+
|
|
6
|
+
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
|
7
|
+
|
|
8
|
+
const respond = (status, body, extra) => new Response(JSON.stringify(body), { status, headers: { ...JSON_HEADERS, ...extra } });
|
|
9
|
+
const badRequest = (message, status = 400, code = "bad-request") => respond(status, { errors: [{ message, extensions: { code } }] });
|
|
10
|
+
|
|
11
|
+
// relative URLs resolve against the page when it has an http(s) origin (not about:blank, not a file)
|
|
12
|
+
const base = () => (typeof location !== "undefined" && /^https?:/.test(location.href) ? location.href : "http://localhost/");
|
|
13
|
+
|
|
14
|
+
function toUrl(input) {
|
|
15
|
+
const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
16
|
+
try {
|
|
17
|
+
return new URL(raw, base());
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeMatcher(match) {
|
|
24
|
+
if (match === undefined) return (url) => url.pathname.replace(/\/+$/, "").endsWith("/graphql");
|
|
25
|
+
if (typeof match === "string") return (url) => url.pathname === match || url.href === match;
|
|
26
|
+
if (match instanceof RegExp) return (url) => match.test(url.href);
|
|
27
|
+
if (typeof match === "function") return match;
|
|
28
|
+
throw new TypeError("match must be a string, a RegExp or a function (url, { method }) => boolean");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
32
|
+
|
|
33
|
+
/** Normalise one GraphQL-over-HTTP request object. @returns {{ error: Response } | { query: string, variables?: object, operationName?: string }} */
|
|
34
|
+
function readOperation(raw) {
|
|
35
|
+
if (!isObject(raw)) return { error: badRequest("A GraphQL request must be a JSON object.") };
|
|
36
|
+
if (typeof raw.query !== "string" || raw.query === "") return { error: badRequest("Must provide a query string.") };
|
|
37
|
+
let variables = raw.variables;
|
|
38
|
+
if (typeof variables === "string") {
|
|
39
|
+
try {
|
|
40
|
+
variables = JSON.parse(variables);
|
|
41
|
+
} catch {
|
|
42
|
+
return { error: badRequest("Variables are invalid JSON.") };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (variables !== undefined && variables !== null && !isObject(variables)) return { error: badRequest("Variables must be a JSON object.") };
|
|
46
|
+
const operationName = raw.operationName ?? undefined;
|
|
47
|
+
if (operationName !== undefined && typeof operationName !== "string") return { error: badRequest("operationName must be a string.") };
|
|
48
|
+
return { query: raw.query, variables: variables ?? undefined, operationName };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The kind of the operation that would run, or undefined when the document does not parse. */
|
|
52
|
+
function operationKind({ query, operationName }) {
|
|
53
|
+
try {
|
|
54
|
+
const ops = parse(query).definitions.filter((d) => d.kind === "OperationDefinition");
|
|
55
|
+
const op = operationName ? ops.find((d) => d.name?.value === operationName) : ops.length === 1 ? ops[0] : undefined;
|
|
56
|
+
return op?.operation;
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const abortError = (signal) => signal.reason ?? new DOMException("This operation was aborted", "AbortError");
|
|
63
|
+
|
|
64
|
+
/** Wait for `ms`, rejecting if `signal` aborts first. */
|
|
65
|
+
function waitOrAbort(ms, delay, signal) {
|
|
66
|
+
if (!signal) return wait(ms, delay);
|
|
67
|
+
if (signal.aborted) return Promise.reject(abortError(signal));
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const onAbort = () => reject(abortError(signal));
|
|
70
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
71
|
+
wait(ms, delay).then(
|
|
72
|
+
() => {
|
|
73
|
+
signal.removeEventListener("abort", onAbort);
|
|
74
|
+
resolve();
|
|
75
|
+
},
|
|
76
|
+
reject,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {import("./fetch.js").CreateFetchOptions} [options]
|
|
83
|
+
* @returns {typeof fetch & { world: import("../index.js").World }}
|
|
84
|
+
*/
|
|
85
|
+
export function createFetch(options = {}) {
|
|
86
|
+
const world = resolveWorld(options);
|
|
87
|
+
const seed = seedOf(world);
|
|
88
|
+
const matches = makeMatcher(options.match);
|
|
89
|
+
|
|
90
|
+
const fetchLike = async function graceholderFetch(input, init) {
|
|
91
|
+
const url = toUrl(input);
|
|
92
|
+
const method = (init?.method ?? (typeof input === "object" && input && "method" in input ? input.method : "GET")).toUpperCase();
|
|
93
|
+
if (!url || !matches(url, { method })) {
|
|
94
|
+
const next = options.fallback ?? globalThis.fetch;
|
|
95
|
+
if (typeof next !== "function") throw new TypeError("graceholder fetch: no fallback and no globalThis.fetch");
|
|
96
|
+
return next(input, init);
|
|
97
|
+
}
|
|
98
|
+
const signal = init?.signal ?? (typeof input === "object" && input && "signal" in input ? input.signal : undefined);
|
|
99
|
+
|
|
100
|
+
let payload;
|
|
101
|
+
let isMutationAllowed = true;
|
|
102
|
+
if (method === "GET" || method === "HEAD") {
|
|
103
|
+
isMutationAllowed = false;
|
|
104
|
+
const p = url.searchParams;
|
|
105
|
+
payload = { query: p.get("query") ?? undefined, variables: p.get("variables") ?? undefined, operationName: p.get("operationName") ?? undefined };
|
|
106
|
+
} else if (method === "POST") {
|
|
107
|
+
let text;
|
|
108
|
+
if (init?.body !== undefined && init.body !== null) text = typeof init.body === "string" ? init.body : await new Response(init.body).text();
|
|
109
|
+
else if (typeof input === "object" && input && "clone" in input) text = await input.clone().text();
|
|
110
|
+
else text = "";
|
|
111
|
+
const type = new Headers(init?.headers ?? (typeof input === "object" && input && "headers" in input ? input.headers : undefined)).get("content-type") ?? "";
|
|
112
|
+
if (/application\/graphql\b/i.test(type)) {
|
|
113
|
+
payload = { query: text };
|
|
114
|
+
} else {
|
|
115
|
+
try {
|
|
116
|
+
payload = JSON.parse(text);
|
|
117
|
+
} catch {
|
|
118
|
+
return badRequest("POST body is not valid JSON.");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
return badRequest(`GraphQL answers GET and POST, not ${method}.`, 405, "method-not-allowed");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const batched = Array.isArray(payload);
|
|
126
|
+
if (batched && payload.length === 0) return badRequest("A batch must not be empty.");
|
|
127
|
+
const items = batched ? payload : [payload];
|
|
128
|
+
|
|
129
|
+
const outcomes = [];
|
|
130
|
+
for (const item of items) {
|
|
131
|
+
const op = readOperation(item);
|
|
132
|
+
if (op.error) {
|
|
133
|
+
if (!batched) return op.error;
|
|
134
|
+
outcomes.push({ errors: [{ message: "Invalid request in batch.", extensions: { code: "bad-request" } }] });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!isMutationAllowed && operationKind(op) === "mutation") {
|
|
138
|
+
return badRequest("Mutations must be sent with POST.", 405, "method-not-allowed");
|
|
139
|
+
}
|
|
140
|
+
const ms = latencyFor(options.latency, seed, op);
|
|
141
|
+
await waitOrAbort(ms, options.delay, signal);
|
|
142
|
+
outcomes.push(world.query(op.query, { variables: op.variables, operationName: op.operationName }));
|
|
143
|
+
}
|
|
144
|
+
return respond(200, batched ? outcomes : outcomes[0]);
|
|
145
|
+
};
|
|
146
|
+
fetchLike.world = world;
|
|
147
|
+
return fetchLike;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export { createFetch as default };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { World, WorldOptions } from "../index.js";
|
|
2
|
+
|
|
3
|
+
/** Simulated latency in milliseconds: fixed, or drawn from [min, max] by the seed and the request. */
|
|
4
|
+
export type Latency = number | [number, number] | { min: number; max: number } | false;
|
|
5
|
+
|
|
6
|
+
/** Options every adapter shares: a ready `world`, or world options from which one is created. */
|
|
7
|
+
export interface AdapterOptions extends WorldOptions {
|
|
8
|
+
/** Answer from this world (mutations then persist in it). Wins over the world options. */
|
|
9
|
+
world?: World;
|
|
10
|
+
/** Default none. Deterministic: a pure function of the seed and the request. */
|
|
11
|
+
latency?: Latency;
|
|
12
|
+
/** Replaces `setTimeout` for the wait; receives the milliseconds. Use it in tests. */
|
|
13
|
+
delay?: (ms: number) => void | Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Pieces shared by the client adapters (docs/design.md section 16, Adapters). Nothing here
|
|
2
|
+
// imports an optional peer dependency.
|
|
3
|
+
import { print } from "graphql";
|
|
4
|
+
import { createWorld } from "../world.js";
|
|
5
|
+
import { keyStream } from "../key.js";
|
|
6
|
+
|
|
7
|
+
const ADAPTER_KEYS = new Set(["world", "latency", "delay", "match", "fallback"]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The world an adapter answers from: the given `world`, or a new one built from the remaining
|
|
11
|
+
* options (seed, spec, anchorDate, locale, strict, log, ...).
|
|
12
|
+
*/
|
|
13
|
+
export function resolveWorld(options = {}) {
|
|
14
|
+
if (options.world) {
|
|
15
|
+
if (typeof options.world.query !== "function") throw new TypeError("adapter option `world` must come from createWorld()");
|
|
16
|
+
return options.world;
|
|
17
|
+
}
|
|
18
|
+
const worldOptions = {};
|
|
19
|
+
for (const [k, v] of Object.entries(options)) if (!ADAPTER_KEYS.has(k)) worldOptions[k] = v;
|
|
20
|
+
return createWorld(worldOptions);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The seed that keys latency: the world's own seed, so a world and its adapter agree. */
|
|
24
|
+
export function seedOf(world) {
|
|
25
|
+
return world.export().seed;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const toRange = (latency) => {
|
|
29
|
+
if (latency === undefined || latency === false || latency === null) return null;
|
|
30
|
+
if (typeof latency === "number") return [latency, latency];
|
|
31
|
+
if (Array.isArray(latency)) return [latency[0], latency[1]];
|
|
32
|
+
if (typeof latency === "object") return [latency.min, latency.max];
|
|
33
|
+
throw new TypeError("latency must be a number, [min, max] or { min, max }");
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Milliseconds of simulated latency for one request. It is a pure function of the seed and the
|
|
38
|
+
* request (operation name, query text, variables): the same request always waits the same time.
|
|
39
|
+
* @returns {number} 0 when latency is off
|
|
40
|
+
*/
|
|
41
|
+
export function latencyFor(latency, seed, { query, operationName, variables }) {
|
|
42
|
+
const range = toRange(latency);
|
|
43
|
+
if (!range) return 0;
|
|
44
|
+
const [lo, hi] = range;
|
|
45
|
+
if (!Number.isSafeInteger(lo) || !Number.isSafeInteger(hi) || lo < 0 || hi < lo) {
|
|
46
|
+
throw new RangeError("latency needs non-negative integers with min <= max");
|
|
47
|
+
}
|
|
48
|
+
if (lo === hi) return lo;
|
|
49
|
+
const text = typeof query === "string" ? query : print(query);
|
|
50
|
+
return keyStream({
|
|
51
|
+
seed,
|
|
52
|
+
scope: "a",
|
|
53
|
+
typeName: "$adapter",
|
|
54
|
+
entityKey: operationName ?? "",
|
|
55
|
+
fieldName: "latency",
|
|
56
|
+
subKeys: [text, JSON.stringify(variables ?? {})],
|
|
57
|
+
}).int(lo, hi);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Wait `ms` (the injected `delay`, or setTimeout). Resolves at once for 0. */
|
|
61
|
+
export function wait(ms, delay) {
|
|
62
|
+
if (delay) return Promise.resolve(delay(ms));
|
|
63
|
+
if (ms <= 0) return Promise.resolve();
|
|
64
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
65
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Exchange } from "@urql/core";
|
|
2
|
+
import type { AdapterOptions } from "./shared.js";
|
|
3
|
+
|
|
4
|
+
export type GraceholderExchangeOptions = AdapterOptions;
|
|
5
|
+
|
|
6
|
+
/** An exchange that answers queries and mutations from a graceholder world; it replaces `fetchExchange`. */
|
|
7
|
+
export declare function graceholderExchange(options?: GraceholderExchangeOptions): Exchange;
|
|
8
|
+
export default graceholderExchange;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// urql exchange backed by a graceholder world. `@urql/core` and `wonka` are optional peer
|
|
2
|
+
// dependencies and are imported only here.
|
|
3
|
+
import { makeResult } from "@urql/core";
|
|
4
|
+
import { pipe, share, filter, mergeMap, merge, fromPromise, takeUntil } from "wonka";
|
|
5
|
+
import { resolveWorld, seedOf, latencyFor, wait } from "./shared.js";
|
|
6
|
+
|
|
7
|
+
const answers = (op) => op.kind === "query" || op.kind === "mutation";
|
|
8
|
+
|
|
9
|
+
function operationNameOf(operation) {
|
|
10
|
+
const def = operation.query.definitions.find((d) => d.kind === "OperationDefinition");
|
|
11
|
+
return def?.name?.value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* An exchange that answers query and mutation operations from a world.
|
|
16
|
+
*
|
|
17
|
+
* It terminates those operations, so it takes the place of `fetchExchange`: put it last, after
|
|
18
|
+
* `cacheExchange` (and after `mapExchange`, `authExchange` and the like, which do not apply to
|
|
19
|
+
* a backend that is not there). Subscriptions are passed to `forward`, so an exchange for
|
|
20
|
+
* them may follow it. Keeping `fetchExchange` after it is harmless: it never sees a query or a
|
|
21
|
+
* mutation.
|
|
22
|
+
*/
|
|
23
|
+
export function graceholderExchange(options = {}) {
|
|
24
|
+
const world = resolveWorld(options);
|
|
25
|
+
const seed = seedOf(world);
|
|
26
|
+
|
|
27
|
+
return ({ forward }) =>
|
|
28
|
+
(ops$) => {
|
|
29
|
+
const shared$ = share(ops$);
|
|
30
|
+
const handled$ = pipe(
|
|
31
|
+
shared$,
|
|
32
|
+
filter(answers),
|
|
33
|
+
mergeMap((operation) => {
|
|
34
|
+
const variables = operation.variables ?? undefined;
|
|
35
|
+
const operationName = operationNameOf(operation);
|
|
36
|
+
const ms = latencyFor(options.latency, seed, { query: operation.query, operationName, variables });
|
|
37
|
+
// a teardown with the same key cancels the answer that is still waiting
|
|
38
|
+
const teardown$ = pipe(
|
|
39
|
+
shared$,
|
|
40
|
+
filter((o) => o.kind === "teardown" && o.key === operation.key),
|
|
41
|
+
);
|
|
42
|
+
const result$ = fromPromise(
|
|
43
|
+
wait(ms, options.delay).then(() => makeResult(operation, world.query(operation.query, { variables, operationName }))),
|
|
44
|
+
);
|
|
45
|
+
return pipe(result$, takeUntil(teardown$));
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
const forwarded$ = forward(pipe(shared$, filter((op) => !answers(op))));
|
|
49
|
+
return merge([handled$, forwarded$]);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { graceholderExchange as default };
|
package/src/anchor.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The constant default anchor and its parsing (docs/design.md 6.4). The clock is never read.
|
|
2
|
+
import { GraceholderError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_ANCHOR = "2025-01-01T00:00:00.000Z";
|
|
5
|
+
|
|
6
|
+
/** @param {string|Date} anchorDate @returns {number} milliseconds since the epoch */
|
|
7
|
+
export function anchorMs(anchorDate) {
|
|
8
|
+
const ms = anchorDate instanceof Date ? anchorDate.getTime() : typeof anchorDate === "string" ? Date.parse(anchorDate) : NaN;
|
|
9
|
+
if (!Number.isFinite(ms)) {
|
|
10
|
+
throw new GraceholderError("bad-options", `anchorDate must be an ISO date string or a Date, got ${String(anchorDate)}`);
|
|
11
|
+
}
|
|
12
|
+
return ms;
|
|
13
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Argument handling: canonical JSON, identifying arguments and collection keys
|
|
2
|
+
// (docs/design.md 3.1, 3.3). FROZEN at 1.0.0.
|
|
3
|
+
|
|
4
|
+
export const PAGING_ARGS = new Set(["first", "last", "after", "before", "offset", "limit", "skip", "take", "page", "pageSize", "perPage", "cursor"]);
|
|
5
|
+
/** Identifying argument names, in priority order. */
|
|
6
|
+
export const ID_ARGS = ["id", "key", "slug", "uuid", "email", "username", "handle", "sku"];
|
|
7
|
+
const COMPOSITE_SEP = "\x1e";
|
|
8
|
+
|
|
9
|
+
export function canonicalJson(value) {
|
|
10
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
11
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
12
|
+
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
|
|
13
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const keyText = (v) => (typeof v === "string" ? v : canonicalJson(v));
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The entity key selected by identifying arguments, or undefined.
|
|
20
|
+
* @param {Record<string, any>} args
|
|
21
|
+
* @param {string[]} [keyArgs] explicit (possibly composite) identifying argument names
|
|
22
|
+
*/
|
|
23
|
+
export function entityKeyFromArgs(args, keyArgs) {
|
|
24
|
+
if (keyArgs && keyArgs.length) {
|
|
25
|
+
const parts = [];
|
|
26
|
+
for (const name of [...keyArgs].sort()) {
|
|
27
|
+
if (args[name] === undefined || args[name] === null) return undefined;
|
|
28
|
+
parts.push(keyText(args[name]));
|
|
29
|
+
}
|
|
30
|
+
return parts.join(COMPOSITE_SEP);
|
|
31
|
+
}
|
|
32
|
+
for (const name of ID_ARGS) if (args[name] !== undefined && args[name] !== null) return keyText(args[name]);
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Canonical JSON of the non-paging arguments, or "" when there are none. */
|
|
37
|
+
export function argsKeyOf(args) {
|
|
38
|
+
const rest = {};
|
|
39
|
+
for (const [k, v] of Object.entries(args)) if (!PAGING_ARGS.has(k) && v !== undefined) rest[k] = v;
|
|
40
|
+
return Object.keys(rest).length ? canonicalJson(rest) : "";
|
|
41
|
+
}
|
package/src/bulk/csv.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// CSV (RFC 4180) with flattened column paths. See docs/api.md "toCSV".
|
|
2
|
+
import { GraceholderError } from "../errors.js";
|
|
3
|
+
import { isAsync, isSync } from "./source.js";
|
|
4
|
+
import { shapeOfDocument, shapeOfValue } from "./shape.js";
|
|
5
|
+
|
|
6
|
+
const bad = (msg) => new GraceholderError("bad-options", msg);
|
|
7
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The ordered column paths of a shape (`address.city`, `tags`, `posts` for a JSON-encoded list).
|
|
11
|
+
* @param {import("./shape.js").ShapeNode} node
|
|
12
|
+
* @param {{ explode: (path: string) => boolean, prefix?: string }} options
|
|
13
|
+
* @returns {string[]}
|
|
14
|
+
*/
|
|
15
|
+
export function columnsOf(node, { explode, prefix = "" }) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const f of node.fields.values()) {
|
|
18
|
+
const path = prefix + f.key;
|
|
19
|
+
const exploded = f.list && explode(path);
|
|
20
|
+
if (f.kind === "object" && (!f.list || exploded)) out.push(...columnsOf(/** @type {any} */ (f.node), { explode, prefix: `${path}.` }));
|
|
21
|
+
else out.push(path);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Rows (maps of column path to value) for one item; a non-exploded item is exactly one row. */
|
|
27
|
+
function rowsOf(node, obj, explode, prefix) {
|
|
28
|
+
/** @type {Map<string, any>[]} */
|
|
29
|
+
let rows = [new Map()];
|
|
30
|
+
const cross = (subRows) => (rows = rows.flatMap((r) => subRows.map((s) => new Map([...r, ...s]))));
|
|
31
|
+
for (const f of node.fields.values()) {
|
|
32
|
+
const path = prefix + f.key;
|
|
33
|
+
const v = obj == null ? null : obj[f.key];
|
|
34
|
+
const exploded = f.list && explode(path);
|
|
35
|
+
if (f.kind === "object" && (!f.list || exploded)) {
|
|
36
|
+
const blank = () => new Map(columnsOf(f.node, { explode, prefix: `${path}.` }).map((c) => [c, null]));
|
|
37
|
+
if (!f.list) {
|
|
38
|
+
cross(isObject(v) ? rowsOf(f.node, v, explode, `${path}.`) : [blank()]);
|
|
39
|
+
} else {
|
|
40
|
+
const subs = Array.isArray(v) ? v.flatMap((e) => rowsOf(f.node, e, explode, `${path}.`)) : [];
|
|
41
|
+
cross(subs.length ? subs : [blank()]);
|
|
42
|
+
}
|
|
43
|
+
} else if (f.list && exploded) {
|
|
44
|
+
const values = Array.isArray(v) && v.length ? v : [null];
|
|
45
|
+
cross(values.map((e) => new Map([[path, e]])));
|
|
46
|
+
} else {
|
|
47
|
+
cross([new Map([[path, v === undefined ? null : v]])]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function cell(value, delimiter) {
|
|
54
|
+
if (value === null || value === undefined) return "";
|
|
55
|
+
const text = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
56
|
+
if (text === "") return '""'; // an empty string is not a null
|
|
57
|
+
return text.includes(delimiter) || /["\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {any} source items, for example a `stream()` (an iterable or an async iterable)
|
|
62
|
+
* @param {{ delimiter?: string, eol?: string, header?: boolean, columns?: string[], lists?: "json"|"explode", explode?: string[] }} [options]
|
|
63
|
+
* @returns {Generator<string>|AsyncGenerator<string>} the header line, then one or more lines per item
|
|
64
|
+
*/
|
|
65
|
+
export function toCSV(source, options = {}) {
|
|
66
|
+
const delimiter = options.delimiter ?? ",";
|
|
67
|
+
const eol = options.eol ?? "\r\n";
|
|
68
|
+
const header = options.header ?? true;
|
|
69
|
+
if (typeof delimiter !== "string" || delimiter.length !== 1 || /["\r\n]/.test(delimiter)) throw bad(`delimiter must be one character other than a quote or a line break, got ${JSON.stringify(delimiter)}`);
|
|
70
|
+
if (typeof eol !== "string" || !/^(\r\n|\n|\r)$/.test(eol)) throw bad('eol must be "\\r\\n", "\\n" or "\\r"');
|
|
71
|
+
const lists = options.lists ?? "json";
|
|
72
|
+
if (lists !== "json" && lists !== "explode") throw bad(`lists must be "json" or "explode", got ${JSON.stringify(lists)}`);
|
|
73
|
+
if (options.columns !== undefined && (!Array.isArray(options.columns) || !options.columns.every((c) => typeof c === "string"))) throw bad("columns must be an array of column paths");
|
|
74
|
+
if (options.explode !== undefined && (!Array.isArray(options.explode) || !options.explode.every((c) => typeof c === "string"))) throw bad("explode must be an array of list paths");
|
|
75
|
+
if (!isSync(source) && !isAsync(source)) throw bad("the source must be an iterable or an async iterable of items, for example a stream()");
|
|
76
|
+
// `explode` names the lists to explode; `lists: "explode"` alone explodes every list
|
|
77
|
+
const wanted = options.explode ? new Set(options.explode) : null;
|
|
78
|
+
const explode = wanted ? (path) => wanted.has(path) : lists === "explode" ? () => true : () => false;
|
|
79
|
+
|
|
80
|
+
// a stream knows its shape; any other source is sampled from its first item
|
|
81
|
+
/** @type {any} */
|
|
82
|
+
let shape = source?.plan ? shapeOfDocument(source.plan.inferred.document, source.plan.inferred.model, source.plan.inferred.rootType ?? "Query") : null;
|
|
83
|
+
/** @type {string[]} */
|
|
84
|
+
// @ts-ignore assigned by begin() before any row is written
|
|
85
|
+
let columns = options.columns;
|
|
86
|
+
const line = (cells) => `${cells.map((c) => cell(c, delimiter)).join(delimiter)}${eol}`;
|
|
87
|
+
const begin = (sample) => {
|
|
88
|
+
shape ??= shapeOfValue(sample);
|
|
89
|
+
columns ??= columnsOf(/** @type {any} */ (shape), { explode });
|
|
90
|
+
return header ? [line(columns)] : [];
|
|
91
|
+
};
|
|
92
|
+
const rowLines = (item) => {
|
|
93
|
+
const obj = isObject(item) ? item : { value: item };
|
|
94
|
+
return rowsOf(shape, obj, explode, "").map((r) => line(columns.map((c) => r.get(c) ?? null)));
|
|
95
|
+
};
|
|
96
|
+
let started = false;
|
|
97
|
+
const step = (item) => {
|
|
98
|
+
const out = [];
|
|
99
|
+
if (!started) {
|
|
100
|
+
started = true;
|
|
101
|
+
out.push(...begin(isObject(item) ? item : { value: item }));
|
|
102
|
+
}
|
|
103
|
+
out.push(...rowLines(item));
|
|
104
|
+
return out;
|
|
105
|
+
};
|
|
106
|
+
const finish = () => (started ? [] : begin({}));
|
|
107
|
+
|
|
108
|
+
if (isSync(source)) {
|
|
109
|
+
return (function* () {
|
|
110
|
+
for (const item of source) yield* step(item);
|
|
111
|
+
yield* finish();
|
|
112
|
+
})();
|
|
113
|
+
}
|
|
114
|
+
return (async function* () {
|
|
115
|
+
for await (const item of source) yield* step(item);
|
|
116
|
+
yield* finish();
|
|
117
|
+
})();
|
|
118
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Newline-delimited JSON: one compact JSON document per item, each followed by "\n".
|
|
2
|
+
import { mapSource } from "./source.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {Iterable<any>|AsyncIterable<any>} source items, for example a `stream()`
|
|
6
|
+
* @returns {Generator<string>|AsyncGenerator<string>} lines (strings ending in "\n"); join them or pipe them to a writable
|
|
7
|
+
*/
|
|
8
|
+
export function toNDJSON(source) {
|
|
9
|
+
return mapSource(source, (item) => `${JSON.stringify(item === undefined ? null : item)}\n`);
|
|
10
|
+
}
|