@loradb/lora-graphql 0.20.0 → 0.20.1
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/README.md +29 -7
- package/dist/cli.js +2 -2
- package/dist/compile/auth.d.ts +6 -1
- package/dist/compile/context.d.ts +9 -0
- package/dist/{diff-DUOLUgrI.js → diff-BO2igqdY.js} +2 -2
- package/dist/{diff-DUOLUgrI.js.map → diff-BO2igqdY.js.map} +1 -1
- package/dist/{driver-C7a5NkfH.js → driver-XeJM24iP.js} +3449 -3192
- package/dist/driver-XeJM24iP.js.map +1 -0
- package/dist/execute/mutate.d.ts +2 -0
- package/dist/index.js +2 -2
- package/dist/lora-graphql.d.ts +8 -0
- package/dist/model/desugar.d.ts +10 -1
- package/dist/testing.js +62 -51
- package/dist/testing.js.map +1 -1
- package/package.json +2 -2
- package/dist/driver-C7a5NkfH.js.map +0 -1
package/dist/execute/mutate.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type PopulatedByCallback = (args: {
|
|
|
19
19
|
export interface MutationEnv {
|
|
20
20
|
model: GraphModel;
|
|
21
21
|
driver: LoraDriver;
|
|
22
|
+
/** The caller's node's @key, for @key(scope: VIEWER) (see executeMutation). */
|
|
23
|
+
viewerKey?: unknown;
|
|
22
24
|
selection: SelectionContext;
|
|
23
25
|
jwt: Record<string, unknown> | undefined;
|
|
24
26
|
/** The GraphQL context, for `$context` in rules and for callbacks. */
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { D as e, L as r, a as o, M as l, b as i, c as d, d as n, e as t, f, g as m, i as p, l as c, p as h, r as D, s as b, h as u, t as v, v as L } from "./driver-
|
|
2
|
-
import { d as x } from "./diff-
|
|
1
|
+
import { D as e, L as r, a as o, M as l, b as i, c as d, d as n, e as t, f, g as m, i as p, l as c, p as h, r as D, s as b, h as u, t as v, v as L } from "./driver-XeJM24iP.js";
|
|
2
|
+
import { d as x } from "./diff-BO2igqdY.js";
|
|
3
3
|
export {
|
|
4
4
|
e as DEFAULT_GUARDS,
|
|
5
5
|
r as LoraGraphQL,
|
package/dist/lora-graphql.d.ts
CHANGED
|
@@ -152,6 +152,14 @@ export interface SchemaAssertion {
|
|
|
152
152
|
required: SchemaRequirement[];
|
|
153
153
|
missing: SchemaRequirement[];
|
|
154
154
|
created: SchemaRequirement[];
|
|
155
|
+
/**
|
|
156
|
+
* Full-text and vector indexes present under their name but defined
|
|
157
|
+
* differently (labels, properties, kind): search would keep using the
|
|
158
|
+
* old definition. Without `create`; with it they are re-created.
|
|
159
|
+
*/
|
|
160
|
+
mismatched: SchemaRequirement[];
|
|
161
|
+
/** Indexes `create` dropped and created again with the model's definition. */
|
|
162
|
+
recreated: SchemaRequirement[];
|
|
155
163
|
}
|
|
156
164
|
export interface CheckOptions {
|
|
157
165
|
/**
|
package/dist/model/desugar.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ModelProblem } from '../errors.js';
|
|
2
|
-
import { AuthorizationWhere, NodeType, RelationshipPropertiesType } from './types.js';
|
|
2
|
+
import { AbstractType, AuthorizationWhere, NodeType, RelationshipPropertiesType } from './types.js';
|
|
3
3
|
/** The `@viewer` claim: which node, by which unique field, is the caller. */
|
|
4
4
|
export interface ViewerMapping {
|
|
5
5
|
claim: string;
|
|
@@ -10,11 +10,20 @@ export interface ViewerMapping {
|
|
|
10
10
|
export interface NamedRules {
|
|
11
11
|
schema: ReadonlyMap<string, AuthorizationWhere>;
|
|
12
12
|
byType: ReadonlyMap<string, ReadonlyMap<string, AuthorizationWhere>>;
|
|
13
|
+
/** Cycles reported so far, so each is reported once. */
|
|
14
|
+
reportedCycles?: Set<string>;
|
|
13
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Stands where a rule reference could not expand (an error was reported):
|
|
18
|
+
* the rule checks skip it rather than report it again as empty.
|
|
19
|
+
*/
|
|
20
|
+
export declare const UNEXPANDED: Readonly<Record<string, never>>;
|
|
14
21
|
export interface DesugarContext {
|
|
15
22
|
nodes: ReadonlyMap<string, NodeType>;
|
|
16
23
|
props: ReadonlyMap<string, RelationshipPropertiesType>;
|
|
17
24
|
viewer: ViewerMapping | undefined;
|
|
25
|
+
/** Interfaces and unions, for filters through an abstract relationship. */
|
|
26
|
+
abstracts?: ReadonlyMap<string, AbstractType>;
|
|
18
27
|
rules?: NamedRules;
|
|
19
28
|
/** Named rules being expanded, to report a cycle with its chain. */
|
|
20
29
|
expanding?: string[];
|
package/dist/testing.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { graphql as
|
|
2
|
-
import { L as
|
|
3
|
-
async function
|
|
4
|
-
let
|
|
1
|
+
import { graphql as g } from "graphql";
|
|
2
|
+
import { L as h, l as b } from "./driver-XeJM24iP.js";
|
|
3
|
+
async function T(l) {
|
|
4
|
+
let u;
|
|
5
5
|
try {
|
|
6
|
-
({ createDatabase:
|
|
6
|
+
({ createDatabase: u } = await import("@loradb/lora-node"));
|
|
7
7
|
} catch {
|
|
8
8
|
throw new Error(
|
|
9
9
|
"createTestLoraGraphQL needs @loradb/lora-node: npm install --save-dev @loradb/lora-node"
|
|
10
10
|
);
|
|
11
11
|
}
|
|
12
|
-
const i = await
|
|
12
|
+
const i = await u(), e = [], { seed: t, onStatement: a, ...c } = l, n = new h({
|
|
13
13
|
...c,
|
|
14
|
-
driver:
|
|
14
|
+
driver: b(i),
|
|
15
15
|
onStatement: (r) => {
|
|
16
16
|
e.push(r), a?.(r);
|
|
17
17
|
}
|
|
@@ -20,23 +20,23 @@ async function _(d) {
|
|
|
20
20
|
else
|
|
21
21
|
for (const r of typeof t == "string" ? [t] : t ?? [])
|
|
22
22
|
await i.execute(r);
|
|
23
|
-
const o = n.getSchema(), s = (r,
|
|
23
|
+
const o = n.getSchema(), s = (r, p = {}, d = {}) => g({ schema: o, source: r, variableValues: p, contextValue: d });
|
|
24
24
|
return {
|
|
25
25
|
lora: n,
|
|
26
26
|
db: i,
|
|
27
27
|
schema: o,
|
|
28
28
|
statements: e,
|
|
29
29
|
run: s,
|
|
30
|
-
data: async (r,
|
|
31
|
-
const $ = await s(r,
|
|
30
|
+
data: async (r, p, d) => {
|
|
31
|
+
const $ = await s(r, p, d);
|
|
32
32
|
if ($.errors) throw $.errors[0];
|
|
33
33
|
return $.data;
|
|
34
34
|
},
|
|
35
35
|
close: () => i.dispose?.()
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
-
async function
|
|
39
|
-
const a = (await
|
|
38
|
+
async function x(l, u, i = {}, e = {}) {
|
|
39
|
+
const a = (await l.explain(u, i, e)).flatMap(
|
|
40
40
|
(c) => c.reports.flatMap(
|
|
41
41
|
(n) => n.findings.map((o) => `${c.field}: ${o.message}
|
|
42
42
|
${o.statement}`)
|
|
@@ -49,11 +49,11 @@ ${a.join(`
|
|
|
49
49
|
`)}`
|
|
50
50
|
);
|
|
51
51
|
}
|
|
52
|
-
async function
|
|
53
|
-
const i =
|
|
52
|
+
async function _(l, u) {
|
|
53
|
+
const i = l instanceof h ? l : l.lora, e = [], t = async (a, c) => {
|
|
54
54
|
let n;
|
|
55
55
|
try {
|
|
56
|
-
n = await
|
|
56
|
+
n = await D(i, a, u);
|
|
57
57
|
} catch (o) {
|
|
58
58
|
e.push(
|
|
59
59
|
`${a}: ${o instanceof Error ? o.message : String(o)}`
|
|
@@ -64,10 +64,10 @@ async function x(d, l) {
|
|
|
64
64
|
`expected allowed, was denied: ${a} (${n.message})`
|
|
65
65
|
) : !c && n.kind === "allowed" && e.push(`expected denied, was allowed: ${a}`);
|
|
66
66
|
};
|
|
67
|
-
for (const a of
|
|
68
|
-
for (const a of
|
|
67
|
+
for (const a of u.allowed ?? []) await t(a, !0);
|
|
68
|
+
for (const a of u.denied ?? []) await t(a, !1);
|
|
69
69
|
if (e.length > 0) {
|
|
70
|
-
const a =
|
|
70
|
+
const a = u.as ? JSON.stringify(u.as) : "anonymous";
|
|
71
71
|
throw new Error(
|
|
72
72
|
`access as ${a} differs in ${e.length} ${e.length === 1 ? "entry" : "entries"}:
|
|
73
73
|
${e.join(`
|
|
@@ -76,55 +76,59 @@ async function x(d, l) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
const v = /* @__PURE__ */ new Set(["FORBIDDEN", "UNAUTHENTICATED", "NOT_FOUND"]);
|
|
79
|
-
async function
|
|
80
|
-
const { source: e, variables: t, isRead: a, root: c
|
|
79
|
+
async function D(l, u, i) {
|
|
80
|
+
const { source: e, variables: t, isRead: a, root: c, wrote: n } = E(
|
|
81
|
+
l,
|
|
82
|
+
u
|
|
83
|
+
), o = await l.begin();
|
|
81
84
|
try {
|
|
82
|
-
const
|
|
85
|
+
const s = await l.execute({
|
|
83
86
|
source: e,
|
|
84
87
|
variables: t,
|
|
85
88
|
context: {
|
|
86
89
|
...i.context,
|
|
87
90
|
...i.as ? { jwt: i.as } : {},
|
|
88
|
-
transaction:
|
|
91
|
+
transaction: o
|
|
89
92
|
}
|
|
90
|
-
}),
|
|
91
|
-
if (
|
|
92
|
-
const
|
|
93
|
-
return typeof
|
|
93
|
+
}), r = s.errors?.[0];
|
|
94
|
+
if (r) {
|
|
95
|
+
const d = r.extensions?.code;
|
|
96
|
+
return typeof d == "string" && v.has(d) ? { kind: "denied", message: `${d}: ${r.message}` } : {
|
|
94
97
|
kind: "error",
|
|
95
|
-
message: `${String(
|
|
98
|
+
message: `${String(d ?? "error")}: ${r.message}`
|
|
96
99
|
};
|
|
97
100
|
}
|
|
98
|
-
|
|
101
|
+
const p = s.data?.[c];
|
|
102
|
+
return n && (p == null || !n(p)) ? { kind: "denied", message: "not visible, nothing written" } : a && s.data?.[c] == null ? { kind: "denied", message: "not visible" } : { kind: "allowed" };
|
|
99
103
|
} finally {
|
|
100
|
-
await
|
|
104
|
+
await o.rollback();
|
|
101
105
|
}
|
|
102
106
|
}
|
|
103
|
-
function E(
|
|
107
|
+
function E(l, u) {
|
|
104
108
|
const i = /^\s*(read|create|update|delete|connect|disconnect|update-edge)\s+([A-Za-z_][\w]*)(?:\.([A-Za-z_]\w*))?\s+(\S+)(?:\s*(?:→|->)\s*(\S+))?\s*(\{.*\})?\s*$/s.exec(
|
|
105
|
-
|
|
109
|
+
u
|
|
106
110
|
);
|
|
107
111
|
if (!i)
|
|
108
112
|
throw new Error(
|
|
109
113
|
"expected `<read|create|update|delete> Type key [json]` or `<connect|disconnect|update-edge> Type.field key → key [json]`"
|
|
110
114
|
);
|
|
111
|
-
const [, e, t, a, c, n, o] = i, s =
|
|
115
|
+
const [, e, t, a, c, n, o] = i, s = l.model.nodes.get(t);
|
|
112
116
|
if (!s) throw new Error(`${t} is not a @node type`);
|
|
113
|
-
const r = o ? JSON.parse(o) : {},
|
|
117
|
+
const r = o ? JSON.parse(o) : {}, p = `${s.key.customScalar ?? s.key.type}!`, d = t.charAt(0).toLowerCase() + t.slice(1), $ = s.plural.charAt(0).toUpperCase() + s.plural.slice(1), w = ["connect", "disconnect", "update-edge"].includes(
|
|
114
118
|
e
|
|
115
119
|
);
|
|
116
|
-
if (
|
|
120
|
+
if (w !== (a !== void 0 && n !== void 0))
|
|
117
121
|
throw new Error(
|
|
118
|
-
|
|
122
|
+
w ? `${e} takes Type.field key → key` : `${e} takes Type key`
|
|
119
123
|
);
|
|
120
124
|
switch (e) {
|
|
121
125
|
case "read":
|
|
122
126
|
return {
|
|
123
127
|
operation: e,
|
|
124
|
-
source: `query($key: ${
|
|
128
|
+
source: `query($key: ${p}) { ${d}(${s.key.name}: $key) { ${s.key.name} } }`,
|
|
125
129
|
variables: { key: c },
|
|
126
130
|
isRead: !0,
|
|
127
|
-
root:
|
|
131
|
+
root: d
|
|
128
132
|
};
|
|
129
133
|
case "create":
|
|
130
134
|
return {
|
|
@@ -137,37 +141,44 @@ function E(d, l) {
|
|
|
137
141
|
case "update":
|
|
138
142
|
return {
|
|
139
143
|
operation: e,
|
|
140
|
-
source: `mutation($key: ${
|
|
144
|
+
source: `mutation($key: ${p}, $update: ${t}UpdateInput!) { update${t}(${s.key.name}: $key, update: $update) { ${d} { __typename } info { relationshipsDeleted } } }`,
|
|
141
145
|
variables: { key: c, update: r },
|
|
142
146
|
isRead: !1,
|
|
143
|
-
root: `update${t}
|
|
147
|
+
root: `update${t}`,
|
|
148
|
+
wrote: (f) => f[d] != null
|
|
144
149
|
};
|
|
145
150
|
case "delete":
|
|
146
151
|
return {
|
|
147
152
|
operation: e,
|
|
148
|
-
source: `mutation($key: ${
|
|
153
|
+
source: `mutation($key: ${p}) { delete${t}(${s.key.name}: $key) { nodesDeleted } }`,
|
|
149
154
|
variables: { key: c },
|
|
150
155
|
isRead: !1,
|
|
151
|
-
root: `delete${t}
|
|
156
|
+
root: `delete${t}`,
|
|
157
|
+
wrote: (f) => Number(f.nodesDeleted ?? 0) > 0
|
|
152
158
|
};
|
|
153
159
|
}
|
|
154
|
-
const
|
|
155
|
-
if (
|
|
160
|
+
const y = s.fields.get(a);
|
|
161
|
+
if (y?.kind !== "relationship")
|
|
156
162
|
throw new Error(`${t}.${a} is not a relationship field`);
|
|
157
|
-
const
|
|
158
|
-
connect:
|
|
159
|
-
} : e === "disconnect" ? { disconnect:
|
|
163
|
+
const m = l.model.nodes.get(y.target)?.key.name ?? "key", k = e === "connect" ? {
|
|
164
|
+
connect: y.list ? [{ [m]: n, ...o ? { edge: r } : {} }] : { [m]: n, ...o ? { edge: r } : {} }
|
|
165
|
+
} : e === "disconnect" ? { disconnect: y.list ? [n] : !0 } : { update: [{ [m]: n, edge: r }] };
|
|
160
166
|
return {
|
|
161
167
|
operation: e,
|
|
162
|
-
source: `mutation($key: ${
|
|
168
|
+
source: `mutation($key: ${p}, $update: ${t}UpdateInput!) { update${t}(${s.key.name}: $key, update: $update) { ${d} { __typename } info { relationshipsDeleted } } }`,
|
|
163
169
|
variables: { key: c, update: { [a]: k } },
|
|
164
170
|
isRead: !1,
|
|
165
|
-
root: `update${t}
|
|
171
|
+
root: `update${t}`,
|
|
172
|
+
// A hidden owner answers a null node; a disconnect of a pair the
|
|
173
|
+
// caller may not touch removes nothing.
|
|
174
|
+
wrote: (f) => f[d] != null && (e !== "disconnect" || Number(
|
|
175
|
+
f.info?.relationshipsDeleted ?? 0
|
|
176
|
+
) > 0)
|
|
166
177
|
};
|
|
167
178
|
}
|
|
168
179
|
export {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
180
|
+
T as createTestLoraGraphQL,
|
|
181
|
+
_ as expectAccess,
|
|
182
|
+
x as expectSeeks
|
|
172
183
|
};
|
|
173
184
|
//# sourceMappingURL=testing.js.map
|
package/dist/testing.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing.js","sources":["../src/testing.ts"],"sourcesContent":["// Test helpers: an in-memory LoraDB with the schema asserted, and a plan\n// assertion. Import from `@loradb/lora-graphql/testing`; needs\n// `@loradb/lora-node`. Framework-agnostic: failures throw plain errors.\n\nimport {\n graphql,\n type DocumentNode,\n type ExecutionResult,\n type GraphQLSchema,\n} from \"graphql\";\nimport { loraDriver, type LoraDatabaseLike } from \"./driver.js\";\nimport {\n LoraGraphQL,\n type LoraGraphQLOptions,\n type StatementEvent,\n} from \"./lora-graphql.js\";\n\n/** The node binding's database, as far as tests use it. */\nexport type TestDatabase = LoraDatabaseLike & {\n execute(query: string, params?: never): Promise<unknown>;\n dispose?(): void;\n};\n\nexport interface TestLoraGraphQLOptions extends Omit<\n LoraGraphQLOptions,\n \"driver\"\n> {\n /** Cypher to run after the schema is asserted, or a function given the database. */\n seed?: string | string[] | ((db: TestDatabase) => Promise<void>);\n}\n\nexport interface TestLoraGraphQL {\n lora: LoraGraphQL;\n db: TestDatabase;\n schema: GraphQLSchema;\n /** Every statement run so far (clear with `statements.length = 0`). */\n statements: StatementEvent[];\n /** Execute a document; errors stay in the result. */\n run: (\n source: string,\n variables?: Record<string, unknown>,\n context?: Record<string, unknown>,\n ) => Promise<ExecutionResult>;\n /** Execute a document and return its data; throws on the first error. */\n data: <T = Record<string, unknown>>(\n source: string,\n variables?: Record<string, unknown>,\n context?: Record<string, unknown>,\n ) => Promise<T>;\n /** Release the database. */\n close: () => void;\n}\n\n/** A LoraGraphQL over a fresh in-memory database, schema asserted and seeded. */\nexport async function createTestLoraGraphQL(\n options: TestLoraGraphQLOptions,\n): Promise<TestLoraGraphQL> {\n let createDatabase: () => Promise<TestDatabase>;\n try {\n ({ createDatabase } = (await import(\"@loradb/lora-node\")) as unknown as {\n createDatabase: typeof createDatabase;\n });\n } catch {\n throw new Error(\n \"createTestLoraGraphQL needs @loradb/lora-node: npm install --save-dev @loradb/lora-node\",\n );\n }\n const db = await createDatabase();\n const statements: StatementEvent[] = [];\n const { seed, onStatement, ...rest } = options;\n const lora = new LoraGraphQL({\n ...rest,\n driver: loraDriver(db),\n onStatement: (event) => {\n statements.push(event);\n onStatement?.(event);\n },\n });\n await lora.assertSchema({ create: true });\n if (typeof seed === \"function\") await seed(db);\n else {\n for (const text of typeof seed === \"string\" ? [seed] : (seed ?? [])) {\n await db.execute(text);\n }\n }\n const schema = lora.getSchema();\n const run = (\n source: string,\n variables: Record<string, unknown> = {},\n contextValue: Record<string, unknown> = {},\n ) => graphql({ schema, source, variableValues: variables, contextValue });\n return {\n lora,\n db,\n schema,\n statements,\n run,\n data: async <T>(\n source: string,\n variables?: Record<string, unknown>,\n context?: Record<string, unknown>,\n ) => {\n const r = await run(source, variables, context);\n if (r.errors) throw r.errors[0];\n return r.data as T;\n },\n close: () => db.dispose?.(),\n };\n}\n\n/**\n * Assert that every statement of a query's root fields uses the index\n * access it was compiled for (no label scans where a seek was expected,\n * no mutating plans, no row-budget excess). Throws listing each finding.\n */\nexport async function expectSeeks(\n lora: LoraGraphQL,\n document: string | DocumentNode,\n variables: Record<string, unknown> = {},\n options: { rowBudget?: number; context?: unknown } = {},\n): Promise<void> {\n const fields = await lora.explain(document, variables, options);\n const findings = fields.flatMap((f) =>\n f.reports.flatMap((r) =>\n r.findings.map((x) => `${f.field}: ${x.message}\\n ${x.statement}`),\n ),\n );\n if (findings.length > 0) {\n throw new Error(\n `expected every root field to seek:\\n${findings.join(\"\\n\")}`,\n );\n }\n}\n\n/** The access `expectAccess` expects: entries allowed, and entries denied. */\nexport interface AccessExpectations {\n /** The claims to act as; undefined acts anonymously. */\n as: Record<string, unknown> | undefined;\n /** Other GraphQL context values the rules read (`$context.*`). */\n context?: Record<string, unknown>;\n allowed?: readonly string[];\n denied?: readonly string[];\n}\n\n/**\n * Assert who may do what, against the database: each entry runs as the\n * caller in a transaction that is always rolled back, and every mismatch\n * is reported at once. Entries:\n *\n * - `read Trip lou:tomorrowland` (denied: not visible, or refused)\n * - `create Trip lou:x {\"name\": \"X\"}` (other input fields as JSON)\n * - `update Trip lou:x {\"name\": \"Y\"}` and `delete Trip lou:x`\n * - `connect Trip.members lou:tomorrowland → f1` (also `->`)\n * - `disconnect Trip.members lou:tomorrowland → f1`\n * - `update-edge Trip.members lou:tomorrowland → f1 {\"rsvp\": \"GOING\"}`\n *\n * Denied means `FORBIDDEN`, `UNAUTHENTICATED` or `NOT_FOUND` (a node the\n * caller cannot see); any other error is reported as a mismatch either way.\n */\nexport async function expectAccess(\n target: LoraGraphQL | { lora: LoraGraphQL },\n expectations: AccessExpectations,\n): Promise<void> {\n const lora = target instanceof LoraGraphQL ? target : target.lora;\n const problems: string[] = [];\n const check = async (entry: string, allowed: boolean) => {\n let outcome: AccessOutcome;\n try {\n outcome = await tryAccess(lora, entry, expectations);\n } catch (err) {\n problems.push(\n `${entry}: ${err instanceof Error ? err.message : String(err)}`,\n );\n return;\n }\n if (outcome.kind === \"error\") {\n problems.push(`${entry}: failed with ${outcome.message}`);\n } else if (allowed && outcome.kind === \"denied\") {\n problems.push(\n `expected allowed, was denied: ${entry} (${outcome.message})`,\n );\n } else if (!allowed && outcome.kind === \"allowed\") {\n problems.push(`expected denied, was allowed: ${entry}`);\n }\n };\n for (const entry of expectations.allowed ?? []) await check(entry, true);\n for (const entry of expectations.denied ?? []) await check(entry, false);\n if (problems.length > 0) {\n const who = expectations.as ? JSON.stringify(expectations.as) : \"anonymous\";\n throw new Error(\n `access as ${who} differs in ${problems.length} ${problems.length === 1 ? \"entry\" : \"entries\"}:\\n ${problems.join(\"\\n \")}`,\n );\n }\n}\n\ntype AccessOutcome =\n | { kind: \"allowed\" }\n | { kind: \"denied\"; message: string }\n | { kind: \"error\"; message: string };\n\nconst DENIED = new Set([\"FORBIDDEN\", \"UNAUTHENTICATED\", \"NOT_FOUND\"]);\n\nasync function tryAccess(\n lora: LoraGraphQL,\n entry: string,\n expectations: AccessExpectations,\n): Promise<AccessOutcome> {\n const { source, variables, isRead, root } = accessOperation(lora, entry);\n const tx = await lora.begin();\n try {\n const result = await lora.execute({\n source,\n variables,\n context: {\n ...expectations.context,\n ...(expectations.as ? { jwt: expectations.as } : {}),\n transaction: tx,\n },\n });\n const error = result.errors?.[0];\n if (error) {\n const code = (error.extensions as Record<string, unknown> | undefined)?.[\n \"code\"\n ];\n return typeof code === \"string\" && DENIED.has(code)\n ? { kind: \"denied\", message: `${code}: ${error.message}` }\n : {\n kind: \"error\",\n message: `${String(code ?? \"error\")}: ${error.message}`,\n };\n }\n if (isRead) {\n const value = (result.data as Record<string, unknown> | null)?.[root];\n if (value == null) return { kind: \"denied\", message: \"not visible\" };\n }\n return { kind: \"allowed\" };\n } finally {\n await tx.rollback();\n }\n}\n\nfunction accessOperation(\n lora: LoraGraphQL,\n entry: string,\n): {\n operation: string;\n source: string;\n variables: Record<string, unknown>;\n isRead: boolean;\n root: string;\n} {\n const m =\n /^\\s*(read|create|update|delete|connect|disconnect|update-edge)\\s+([A-Za-z_][\\w]*)(?:\\.([A-Za-z_]\\w*))?\\s+(\\S+)(?:\\s*(?:→|->)\\s*(\\S+))?\\s*(\\{.*\\})?\\s*$/s.exec(\n entry,\n );\n if (!m) {\n throw new Error(\n \"expected `<read|create|update|delete> Type key [json]` or `<connect|disconnect|update-edge> Type.field key → key [json]`\",\n );\n }\n const [, operation, typeName, fieldName, key, other, json] = m as unknown as [\n string,\n string,\n string,\n string | undefined,\n string,\n string | undefined,\n string | undefined,\n ];\n const node = lora.model.nodes.get(typeName);\n if (!node) throw new Error(`${typeName} is not a @node type`);\n const input = json ? (JSON.parse(json) as Record<string, unknown>) : {};\n const keyType = `${node.key.customScalar ?? node.key.type}!`;\n const lower = typeName.charAt(0).toLowerCase() + typeName.slice(1);\n const upperPlural =\n node.plural.charAt(0).toUpperCase() + node.plural.slice(1);\n const relational = [\"connect\", \"disconnect\", \"update-edge\"].includes(\n operation,\n );\n if (relational !== (fieldName !== undefined && other !== undefined)) {\n throw new Error(\n relational\n ? `${operation} takes Type.field key → key`\n : `${operation} takes Type key`,\n );\n }\n switch (operation) {\n case \"read\":\n return {\n operation,\n source: `query($key: ${keyType}) { ${lower}(${node.key.name}: $key) { ${node.key.name} } }`,\n variables: { key },\n isRead: true,\n root: lower,\n };\n case \"create\":\n return {\n operation,\n source: `mutation($input: [${typeName}CreateInput!]!) { create${upperPlural}(input: $input) { __typename } }`,\n variables: { input: [{ ...input, [node.key.name]: key }] },\n isRead: false,\n root: `create${upperPlural}`,\n };\n case \"update\":\n return {\n operation,\n source: `mutation($key: ${keyType}, $update: ${typeName}UpdateInput!) { update${typeName}(${node.key.name}: $key, update: $update) { __typename } }`,\n variables: { key, update: input },\n isRead: false,\n root: `update${typeName}`,\n };\n case \"delete\":\n return {\n operation,\n source: `mutation($key: ${keyType}) { delete${typeName}(${node.key.name}: $key) { __typename } }`,\n variables: { key },\n isRead: false,\n root: `delete${typeName}`,\n };\n }\n const field = node.fields.get(fieldName!);\n if (field?.kind !== \"relationship\") {\n throw new Error(`${typeName}.${fieldName} is not a relationship field`);\n }\n const targetKey = lora.model.nodes.get(field.target)?.key.name ?? \"key\";\n const change =\n operation === \"connect\"\n ? {\n connect: field.list\n ? [{ [targetKey]: other, ...(json ? { edge: input } : {}) }]\n : { [targetKey]: other, ...(json ? { edge: input } : {}) },\n }\n : operation === \"disconnect\"\n ? { disconnect: field.list ? [other] : true }\n : { update: [{ [targetKey]: other, edge: input }] };\n return {\n operation,\n source: `mutation($key: ${keyType}, $update: ${typeName}UpdateInput!) { update${typeName}(${node.key.name}: $key, update: $update) { __typename } }`,\n variables: { key, update: { [fieldName!]: change } },\n isRead: false,\n root: `update${typeName}`,\n };\n}\n"],"names":["createTestLoraGraphQL","options","createDatabase","db","statements","seed","onStatement","rest","lora","LoraGraphQL","loraDriver","event","text","schema","run","source","variables","contextValue","graphql","context","r","expectSeeks","document","findings","f","x","expectAccess","target","expectations","problems","check","entry","allowed","outcome","tryAccess","err","who","DENIED","isRead","root","accessOperation","tx","result","error","code","m","operation","typeName","fieldName","key","other","json","node","input","keyType","lower","upperPlural","relational","field","targetKey","change"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"testing.js","sources":["../src/testing.ts"],"sourcesContent":["// Test helpers: an in-memory LoraDB with the schema asserted, and a plan\n// assertion. Import from `@loradb/lora-graphql/testing`; needs\n// `@loradb/lora-node`. Framework-agnostic: failures throw plain errors.\n\nimport {\n graphql,\n type DocumentNode,\n type ExecutionResult,\n type GraphQLSchema,\n} from \"graphql\";\nimport { loraDriver, type LoraDatabaseLike } from \"./driver.js\";\nimport {\n LoraGraphQL,\n type LoraGraphQLOptions,\n type StatementEvent,\n} from \"./lora-graphql.js\";\n\n/** The node binding's database, as far as tests use it. */\nexport type TestDatabase = LoraDatabaseLike & {\n execute(query: string, params?: never): Promise<unknown>;\n dispose?(): void;\n};\n\nexport interface TestLoraGraphQLOptions extends Omit<\n LoraGraphQLOptions,\n \"driver\"\n> {\n /** Cypher to run after the schema is asserted, or a function given the database. */\n seed?: string | string[] | ((db: TestDatabase) => Promise<void>);\n}\n\nexport interface TestLoraGraphQL {\n lora: LoraGraphQL;\n db: TestDatabase;\n schema: GraphQLSchema;\n /** Every statement run so far (clear with `statements.length = 0`). */\n statements: StatementEvent[];\n /** Execute a document; errors stay in the result. */\n run: (\n source: string,\n variables?: Record<string, unknown>,\n context?: Record<string, unknown>,\n ) => Promise<ExecutionResult>;\n /** Execute a document and return its data; throws on the first error. */\n data: <T = Record<string, unknown>>(\n source: string,\n variables?: Record<string, unknown>,\n context?: Record<string, unknown>,\n ) => Promise<T>;\n /** Release the database. */\n close: () => void;\n}\n\n/** A LoraGraphQL over a fresh in-memory database, schema asserted and seeded. */\nexport async function createTestLoraGraphQL(\n options: TestLoraGraphQLOptions,\n): Promise<TestLoraGraphQL> {\n let createDatabase: () => Promise<TestDatabase>;\n try {\n ({ createDatabase } = (await import(\"@loradb/lora-node\")) as unknown as {\n createDatabase: typeof createDatabase;\n });\n } catch {\n throw new Error(\n \"createTestLoraGraphQL needs @loradb/lora-node: npm install --save-dev @loradb/lora-node\",\n );\n }\n const db = await createDatabase();\n const statements: StatementEvent[] = [];\n const { seed, onStatement, ...rest } = options;\n const lora = new LoraGraphQL({\n ...rest,\n driver: loraDriver(db),\n onStatement: (event) => {\n statements.push(event);\n onStatement?.(event);\n },\n });\n await lora.assertSchema({ create: true });\n if (typeof seed === \"function\") await seed(db);\n else {\n for (const text of typeof seed === \"string\" ? [seed] : (seed ?? [])) {\n await db.execute(text);\n }\n }\n const schema = lora.getSchema();\n const run = (\n source: string,\n variables: Record<string, unknown> = {},\n contextValue: Record<string, unknown> = {},\n ) => graphql({ schema, source, variableValues: variables, contextValue });\n return {\n lora,\n db,\n schema,\n statements,\n run,\n data: async <T>(\n source: string,\n variables?: Record<string, unknown>,\n context?: Record<string, unknown>,\n ) => {\n const r = await run(source, variables, context);\n if (r.errors) throw r.errors[0];\n return r.data as T;\n },\n close: () => db.dispose?.(),\n };\n}\n\n/**\n * Assert that every statement of a query's root fields uses the index\n * access it was compiled for (no label scans where a seek was expected,\n * no mutating plans, no row-budget excess). Throws listing each finding.\n */\nexport async function expectSeeks(\n lora: LoraGraphQL,\n document: string | DocumentNode,\n variables: Record<string, unknown> = {},\n options: { rowBudget?: number; context?: unknown } = {},\n): Promise<void> {\n const fields = await lora.explain(document, variables, options);\n const findings = fields.flatMap((f) =>\n f.reports.flatMap((r) =>\n r.findings.map((x) => `${f.field}: ${x.message}\\n ${x.statement}`),\n ),\n );\n if (findings.length > 0) {\n throw new Error(\n `expected every root field to seek:\\n${findings.join(\"\\n\")}`,\n );\n }\n}\n\n/** The access `expectAccess` expects: entries allowed, and entries denied. */\nexport interface AccessExpectations {\n /** The claims to act as; undefined acts anonymously. */\n as: Record<string, unknown> | undefined;\n /** Other GraphQL context values the rules read (`$context.*`). */\n context?: Record<string, unknown>;\n allowed?: readonly string[];\n denied?: readonly string[];\n}\n\n/**\n * Assert who may do what, against the database: each entry runs as the\n * caller in a transaction that is always rolled back, and every mismatch\n * is reported at once. Entries:\n *\n * - `read Trip lou:tomorrowland` (denied: not visible, or refused)\n * - `create Trip lou:x {\"name\": \"X\"}` (other input fields as JSON)\n * - `update Trip lou:x {\"name\": \"Y\"}` and `delete Trip lou:x`\n * - `connect Trip.members lou:tomorrowland → f1` (also `->`)\n * - `disconnect Trip.members lou:tomorrowland → f1`\n * - `update-edge Trip.members lou:tomorrowland → f1 {\"rsvp\": \"GOING\"}`\n *\n * Denied means `FORBIDDEN`, `UNAUTHENTICATED` or `NOT_FOUND` (a node the\n * caller cannot see); any other error is reported as a mismatch either way.\n */\nexport async function expectAccess(\n target: LoraGraphQL | { lora: LoraGraphQL },\n expectations: AccessExpectations,\n): Promise<void> {\n const lora = target instanceof LoraGraphQL ? target : target.lora;\n const problems: string[] = [];\n const check = async (entry: string, allowed: boolean) => {\n let outcome: AccessOutcome;\n try {\n outcome = await tryAccess(lora, entry, expectations);\n } catch (err) {\n problems.push(\n `${entry}: ${err instanceof Error ? err.message : String(err)}`,\n );\n return;\n }\n if (outcome.kind === \"error\") {\n problems.push(`${entry}: failed with ${outcome.message}`);\n } else if (allowed && outcome.kind === \"denied\") {\n problems.push(\n `expected allowed, was denied: ${entry} (${outcome.message})`,\n );\n } else if (!allowed && outcome.kind === \"allowed\") {\n problems.push(`expected denied, was allowed: ${entry}`);\n }\n };\n for (const entry of expectations.allowed ?? []) await check(entry, true);\n for (const entry of expectations.denied ?? []) await check(entry, false);\n if (problems.length > 0) {\n const who = expectations.as ? JSON.stringify(expectations.as) : \"anonymous\";\n throw new Error(\n `access as ${who} differs in ${problems.length} ${problems.length === 1 ? \"entry\" : \"entries\"}:\\n ${problems.join(\"\\n \")}`,\n );\n }\n}\n\ntype AccessOutcome =\n | { kind: \"allowed\" }\n | { kind: \"denied\"; message: string }\n | { kind: \"error\"; message: string };\n\nconst DENIED = new Set([\"FORBIDDEN\", \"UNAUTHENTICATED\", \"NOT_FOUND\"]);\n\nasync function tryAccess(\n lora: LoraGraphQL,\n entry: string,\n expectations: AccessExpectations,\n): Promise<AccessOutcome> {\n const { source, variables, isRead, root, wrote } = accessOperation(\n lora,\n entry,\n );\n const tx = await lora.begin();\n try {\n const result = await lora.execute({\n source,\n variables,\n context: {\n ...expectations.context,\n ...(expectations.as ? { jwt: expectations.as } : {}),\n transaction: tx,\n },\n });\n const error = result.errors?.[0];\n if (error) {\n const code = (error.extensions as Record<string, unknown> | undefined)?.[\n \"code\"\n ];\n return typeof code === \"string\" && DENIED.has(code)\n ? { kind: \"denied\", message: `${code}: ${error.message}` }\n : {\n kind: \"error\",\n message: `${String(code ?? \"error\")}: ${error.message}`,\n };\n }\n const payload = (result.data as Record<string, unknown> | null)?.[root];\n if (\n wrote &&\n (payload == null || !wrote(payload as Record<string, unknown>))\n ) {\n return { kind: \"denied\", message: \"not visible, nothing written\" };\n }\n if (isRead) {\n const value = (result.data as Record<string, unknown> | null)?.[root];\n if (value == null) return { kind: \"denied\", message: \"not visible\" };\n }\n return { kind: \"allowed\" };\n } finally {\n await tx.rollback();\n }\n}\n\nfunction accessOperation(\n lora: LoraGraphQL,\n entry: string,\n): {\n operation: string;\n source: string;\n variables: Record<string, unknown>;\n isRead: boolean;\n root: string;\n /** How the payload says nothing was written: a hidden target answers no error. */\n wrote?: (payload: Record<string, unknown>) => boolean;\n} {\n const m =\n /^\\s*(read|create|update|delete|connect|disconnect|update-edge)\\s+([A-Za-z_][\\w]*)(?:\\.([A-Za-z_]\\w*))?\\s+(\\S+)(?:\\s*(?:→|->)\\s*(\\S+))?\\s*(\\{.*\\})?\\s*$/s.exec(\n entry,\n );\n if (!m) {\n throw new Error(\n \"expected `<read|create|update|delete> Type key [json]` or `<connect|disconnect|update-edge> Type.field key → key [json]`\",\n );\n }\n const [, operation, typeName, fieldName, key, other, json] = m as unknown as [\n string,\n string,\n string,\n string | undefined,\n string,\n string | undefined,\n string | undefined,\n ];\n const node = lora.model.nodes.get(typeName);\n if (!node) throw new Error(`${typeName} is not a @node type`);\n const input = json ? (JSON.parse(json) as Record<string, unknown>) : {};\n const keyType = `${node.key.customScalar ?? node.key.type}!`;\n const lower = typeName.charAt(0).toLowerCase() + typeName.slice(1);\n const upperPlural =\n node.plural.charAt(0).toUpperCase() + node.plural.slice(1);\n const relational = [\"connect\", \"disconnect\", \"update-edge\"].includes(\n operation,\n );\n if (relational !== (fieldName !== undefined && other !== undefined)) {\n throw new Error(\n relational\n ? `${operation} takes Type.field key → key`\n : `${operation} takes Type key`,\n );\n }\n switch (operation) {\n case \"read\":\n return {\n operation,\n source: `query($key: ${keyType}) { ${lower}(${node.key.name}: $key) { ${node.key.name} } }`,\n variables: { key },\n isRead: true,\n root: lower,\n };\n case \"create\":\n return {\n operation,\n source: `mutation($input: [${typeName}CreateInput!]!) { create${upperPlural}(input: $input) { __typename } }`,\n variables: { input: [{ ...input, [node.key.name]: key }] },\n isRead: false,\n root: `create${upperPlural}`,\n };\n case \"update\":\n return {\n operation,\n source: `mutation($key: ${keyType}, $update: ${typeName}UpdateInput!) { update${typeName}(${node.key.name}: $key, update: $update) { ${lower} { __typename } info { relationshipsDeleted } } }`,\n variables: { key, update: input },\n isRead: false,\n root: `update${typeName}`,\n wrote: (p) => p[lower] != null,\n };\n case \"delete\":\n return {\n operation,\n source: `mutation($key: ${keyType}) { delete${typeName}(${node.key.name}: $key) { nodesDeleted } }`,\n variables: { key },\n isRead: false,\n root: `delete${typeName}`,\n wrote: (p) => Number(p[\"nodesDeleted\"] ?? 0) > 0,\n };\n }\n const field = node.fields.get(fieldName!);\n if (field?.kind !== \"relationship\") {\n throw new Error(`${typeName}.${fieldName} is not a relationship field`);\n }\n const targetKey = lora.model.nodes.get(field.target)?.key.name ?? \"key\";\n const change =\n operation === \"connect\"\n ? {\n connect: field.list\n ? [{ [targetKey]: other, ...(json ? { edge: input } : {}) }]\n : { [targetKey]: other, ...(json ? { edge: input } : {}) },\n }\n : operation === \"disconnect\"\n ? { disconnect: field.list ? [other] : true }\n : { update: [{ [targetKey]: other, edge: input }] };\n return {\n operation,\n source: `mutation($key: ${keyType}, $update: ${typeName}UpdateInput!) { update${typeName}(${node.key.name}: $key, update: $update) { ${lower} { __typename } info { relationshipsDeleted } } }`,\n variables: { key, update: { [fieldName!]: change } },\n isRead: false,\n root: `update${typeName}`,\n // A hidden owner answers a null node; a disconnect of a pair the\n // caller may not touch removes nothing.\n wrote: (p) =>\n p[lower] != null &&\n (operation !== \"disconnect\" ||\n Number(\n (p[\"info\"] as Record<string, unknown> | undefined)?.[\n \"relationshipsDeleted\"\n ] ?? 0,\n ) > 0),\n };\n}\n"],"names":["createTestLoraGraphQL","options","createDatabase","db","statements","seed","onStatement","rest","lora","LoraGraphQL","loraDriver","event","text","schema","run","source","variables","contextValue","graphql","context","r","expectSeeks","document","findings","f","x","expectAccess","target","expectations","problems","check","entry","allowed","outcome","tryAccess","err","who","DENIED","isRead","root","wrote","accessOperation","tx","result","error","code","payload","m","operation","typeName","fieldName","key","other","json","node","input","keyType","lower","upperPlural","relational","p","field","targetKey","change"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loradb/lora-graphql",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "Schema-first GraphQL for LoraDB: one annotated SDL becomes an executable, index-aware GraphQL API that compiles every operation to a single Cypher statement.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"packageManager": "yarn@4.5.1",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"bench:load": "node bench/load/main.mjs"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
|
-
"@loradb/lora-node": "^0.20.
|
|
68
|
+
"@loradb/lora-node": "^0.20.1",
|
|
69
69
|
"graphql": "^16.8.0 || ^17.0.0"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|