@loradb/lora-graphql 0.18.0 → 0.20.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/dist/driver.d.ts CHANGED
@@ -27,10 +27,15 @@ export interface RunOptions {
27
27
  signal?: AbortSignal | undefined;
28
28
  /**
29
29
  * A read the library vouches for: generated, or a @cypher statement
30
- * checked read-only at startup. The driver may stream it outside a
30
+ * checked read-only at startup. The driver may run it outside a
31
31
  * read-only transaction.
32
32
  */
33
33
  verified?: boolean;
34
+ /**
35
+ * A verified read of at most one row, by an exact @key seek, projecting
36
+ * only stored properties. Cheap enough to run synchronously.
37
+ */
38
+ bounded?: boolean;
34
39
  }
35
40
  /** An open interactive transaction: statements see earlier writes. */
36
41
  export interface DriverTransaction {
@@ -82,20 +87,24 @@ export interface LoraDatabaseLike {
82
87
  timeoutMs?: number;
83
88
  signal?: AbortSignal;
84
89
  }): Promise<QueryResult[]>;
85
- explain?(query: string, params?: never): Promise<QueryPlan>;
86
- changes?(options?: {
87
- fromLsn?: number;
90
+ execute?(query: string, params?: never, options?: {
91
+ timeoutMs?: number;
88
92
  signal?: AbortSignal;
89
- }): AsyncIterable<DriverChangeBatch> & {
90
- ready: Promise<void>;
91
- };
93
+ }): Promise<QueryResult>;
92
94
  stream?(query: string, params?: never, options?: {
93
95
  timeoutMs?: number;
94
96
  signal?: AbortSignal;
95
97
  }): {
96
- columns(): string[];
98
+ columns(): string[] | Promise<string[]>;
97
99
  toArray(): Promise<Array<Record<string, unknown>>>;
98
100
  };
101
+ explain?(query: string, params?: never): Promise<QueryPlan>;
102
+ changes?(options?: {
103
+ fromLsn?: number;
104
+ signal?: AbortSignal;
105
+ }): AsyncIterable<DriverChangeBatch> & {
106
+ ready: Promise<void>;
107
+ };
99
108
  begin?(mode?: "read_write" | "read_only"): Promise<{
100
109
  execute(query: string, params?: never, options?: {
101
110
  timeoutMs?: number;
package/dist/errors.d.ts CHANGED
@@ -15,6 +15,6 @@ export declare class ModelError extends Error {
15
15
  constructor(problems: ModelProblem[]);
16
16
  }
17
17
  export declare function formatProblem(p: ModelProblem): string;
18
- export type LoraGraphQLErrorCode = "BAD_USER_INPUT" | "INVALID_CURSOR" | "LIMIT_EXCEEDED" | "COST_EXCEEDED" | "UNAUTHENTICATED" | "FORBIDDEN" | "NOT_FOUND" | "CONSTRAINT_VIOLATION" | "DATABASE_ERROR" | "PERSISTED_QUERY_ONLY";
18
+ export type LoraGraphQLErrorCode = "BAD_USER_INPUT" | "INVALID_CURSOR" | "LIMIT_EXCEEDED" | "COST_EXCEEDED" | "UNAUTHENTICATED" | "FORBIDDEN" | "NOT_FOUND" | "CONSTRAINT_VIOLATION" | "DATABASE_ERROR" | "PERSISTED_QUERY_ONLY" | "WRONG_OPERATION_TYPE";
19
19
  /** A request-time error with a stable `extensions.code`. */
20
20
  export declare function requestError(code: LoraGraphQLErrorCode, message: string, cause?: unknown, extensions?: Record<string, unknown>): GraphQLError;
@@ -3,7 +3,7 @@ import { GraphModel } from '../model/types.js';
3
3
  import { WriteChange } from './changes.js';
4
4
  export declare class EngineFeed {
5
5
  #private;
6
- constructor(driver: LoraDriver, model: GraphModel, emit: (change: WriteChange) => void);
6
+ constructor(driver: LoraDriver, model: GraphModel, emit: (change: WriteChange) => void, onError?: (err: unknown) => void);
7
7
  /** Subscribe; resolves once every later commit will be delivered. */
8
8
  start(): Promise<void>;
9
9
  stop(): void;
package/dist/guards.d.ts CHANGED
@@ -1,7 +1,18 @@
1
1
  import { ParseOptions, ValidationRule } from 'graphql';
2
2
  export interface DocumentGuards {
3
- /** Deepest field nesting, fragments followed. Default 12. */
3
+ /**
4
+ * Deepest field nesting, fragments followed. Default 12. An
5
+ * introspection field (`__schema`, `__type`) counts as one level here:
6
+ * its subtree follows the type-reference chain, not the graph, and is
7
+ * bounded by `maxIntrospectionDepth`.
8
+ */
4
9
  maxDepth?: number;
10
+ /**
11
+ * Deepest nesting under `__schema` or `__type`, the field included.
12
+ * Default 20: the standard introspection query (every option on) needs
13
+ * 15, and a runaway `ofType { ofType … }` chain is still refused.
14
+ */
15
+ maxIntrospectionDepth?: number;
5
16
  /** Aliased fields in one document. Default 30. */
6
17
  maxAliases?: number;
7
18
  /** Root fields in one operation. Default 20. */
@@ -16,6 +27,7 @@ export interface DocumentGuards {
16
27
  }
17
28
  export declare const DEFAULT_GUARDS: {
18
29
  readonly maxDepth: 12;
30
+ readonly maxIntrospectionDepth: 20;
19
31
  readonly maxAliases: 30;
20
32
  readonly maxRootFields: 20;
21
33
  readonly maxTokens: 5000;
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export type * from './model/types.js';
6
6
  export { inferRequirements, requirementDdl, type SchemaRequirement, } from './analyze/indexes.js';
7
7
  export { checkPlans, scanExpands, type PlanFinding, type PlanReport, } from './analyze/plans.js';
8
8
  export type { DegreeStats, Statistics } from './analyze/statistics.js';
9
+ export type { AccessEntry, AccessVerdict } from './analyze/access.js';
9
10
  export type { CypherFinding } from './analyze/cypher-check.js';
10
11
  export { diffSchemas, type ApiChange, type SchemaDiff, } from './analyze/diff.js';
11
12
  export type { CompiledRead, ReadSet, SeekExpectation } from './compile/read.js';
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-CQj9gkvl.js";
2
- import { d as x } from "./diff-fpWH5KUa.js";
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-C7a5NkfH.js";
2
+ import { d as x } from "./diff-DUOLUgrI.js";
3
3
  export {
4
4
  e as DEFAULT_GUARDS,
5
5
  r as LoraGraphQL,
@@ -2,6 +2,7 @@ import { DocumentNode, ExecutionResult, GraphQLSchema, GraphQLFieldResolver, Gra
2
2
  import { CypherFinding } from './analyze/cypher-check.js';
3
3
  import { SchemaRequirement } from './analyze/indexes.js';
4
4
  import { PlanReport } from './analyze/plans.js';
5
+ import { AccessEntry } from './analyze/access.js';
5
6
  import { Statistics } from './analyze/statistics.js';
6
7
  import { CompiledRead, ReadSet } from './compile/read.js';
7
8
  import { LoraDriver, Statement } from './driver.js';
@@ -92,6 +93,17 @@ export interface LoraGraphQLOptions extends ModelOptions, ObservabilityOptions {
92
93
  * persisted operations by `id`. Default false.
93
94
  */
94
95
  persistedOnly?: boolean;
96
+ /**
97
+ * How `execute()` commits a mutation with several root fields.
98
+ * `"field"` (the default): each root field in its own transaction, so a
99
+ * later failure leaves the earlier ones committed. `"operation"`: every
100
+ * root field in one transaction, committed only when the operation
101
+ * reports no error, rolled back (with `data: null`) otherwise. A
102
+ * `transaction` in the context takes precedence. Servers that call
103
+ * graphql-js on `getSchema()` directly get per-operation atomicity by
104
+ * putting a `lora.begin()` transaction in the context.
105
+ */
106
+ mutationTransaction?: "field" | "operation";
95
107
  }
96
108
  export interface CostEvent {
97
109
  field: string;
@@ -106,7 +118,10 @@ export interface CostEvent {
106
118
  export interface DatabaseErrorEvent {
107
119
  /** Correlation id, also in the client error's `extensions.id`. */
108
120
  id: string;
109
- /** The root field that failed. */
121
+ /**
122
+ * The root field that failed, or `changeFeed` for a failure of the
123
+ * change feed (it reopens from where it stopped after a backoff).
124
+ */
110
125
  field: string;
111
126
  /** The engine's message, which may name labels, properties and Cypher. */
112
127
  message: string;
@@ -176,6 +191,11 @@ export interface CheckReport {
176
191
  }>;
177
192
  /** Schema lint: valid but costly or risky choices (not failures). */
178
193
  lint: readonly ModelWarning[];
194
+ /**
195
+ * `@mutation` types with writes no rule guards (failures): declare them
196
+ * with `@authorization(public: [...])` when that is intended.
197
+ */
198
+ security: readonly ModelWarning[];
179
199
  /** Plan findings per operation and root field. */
180
200
  plans: Array<{
181
201
  operation: string;
@@ -230,6 +250,13 @@ export declare class LoraGraphQL {
230
250
  * The CI gate: @cypher statements plan, the database has what the API
231
251
  * needs, and each operation's statements seek where they should.
232
252
  */
253
+ /**
254
+ * Who may do what: for every type and guarded field, each operation as
255
+ * each kind of caller (anonymous, authenticated, and each role the rules
256
+ * test), with the verdict and the rules that decide it. Read off the
257
+ * model; stable, so it can be snapshotted and reviewed as a diff.
258
+ */
259
+ accessMatrix(): AccessEntry[];
233
260
  check(options?: CheckOptions): Promise<CheckReport>;
234
261
  /**
235
262
  * Compile a query without running it: one entry per root field, with its
@@ -273,7 +300,8 @@ export declare class LoraGraphQL {
273
300
  /**
274
301
  * Execute a query or mutation against the schema. Parsed and validated
275
302
  * documents are cached by source text; persisted ones by id. A
276
- * subscription runs with {@link LoraGraphQL.subscribe}.
303
+ * subscription runs with {@link LoraGraphQL.subscribe}; given one, this
304
+ * returns a `WRONG_OPERATION_TYPE` error.
277
305
  */
278
306
  execute(args: ExecuteArgs): Promise<ExecutionResult>;
279
307
  /**
@@ -281,15 +309,19 @@ export declare class LoraGraphQL {
281
309
  * single result with the errors when it cannot start (an unknown id, a
282
310
  * validation error, a denied subscribe). Takes the same arguments as
283
311
  * {@link LoraGraphQL.execute}, a persisted id included, with the same
284
- * document cache and guards. End it with `return()` on the iterator, or
285
- * with an `AbortSignal` passed as `context.signal`.
312
+ * document cache and guards; a query or mutation gets a
313
+ * `WRONG_OPERATION_TYPE` error. End it with `return()` on the iterator
314
+ * or with an `AbortSignal` passed as `context.signal`: either ends it at
315
+ * once, without waiting for another event, and drops its listener. The
316
+ * cost limit applies to each event on its own.
286
317
  */
287
318
  subscribe(args: ExecuteArgs): Promise<AsyncIterableIterator<ExecutionResult> | ExecutionResult>;
288
319
  /** Called after every committed mutation with its exact write-set. */
289
320
  onWrite(listener: (change: WriteChange) => void): () => void;
290
321
  /**
291
322
  * Committed writes as an async iterator, e.g. for a subscription
292
- * resolver. Only writes made through this instance are seen.
323
+ * resolver. Only writes made through this instance are seen. `return()`
324
+ * or aborting `signal` (already aborted included) ends it at once.
293
325
  */
294
326
  changes(options?: {
295
327
  signal?: AbortSignal;
@@ -0,0 +1,34 @@
1
+ import { ModelProblem } from '../errors.js';
2
+ import { AuthorizationWhere, NodeType, RelationshipPropertiesType } from './types.js';
3
+ /** The `@viewer` claim: which node, by which unique field, is the caller. */
4
+ export interface ViewerMapping {
5
+ claim: string;
6
+ type: string;
7
+ field: string;
8
+ }
9
+ /** Named rules: claims-only schema rules, and rules per node type. */
10
+ export interface NamedRules {
11
+ schema: ReadonlyMap<string, AuthorizationWhere>;
12
+ byType: ReadonlyMap<string, ReadonlyMap<string, AuthorizationWhere>>;
13
+ }
14
+ export interface DesugarContext {
15
+ nodes: ReadonlyMap<string, NodeType>;
16
+ props: ReadonlyMap<string, RelationshipPropertiesType>;
17
+ viewer: ViewerMapping | undefined;
18
+ rules?: NamedRules;
19
+ /** Named rules being expanded, to report a cycle with its chain. */
20
+ expanding?: string[];
21
+ at: (message: string) => void;
22
+ }
23
+ /**
24
+ * A rule's where, sugar expanded. `owner` is the type the rule's `node`
25
+ * part tests (undefined on relationship properties: claims only).
26
+ */
27
+ export declare function desugarRule(ctx: DesugarContext, owner: NodeType | undefined, where: AuthorizationWhere, ends?: {
28
+ source: NodeType;
29
+ target: NodeType;
30
+ }): AuthorizationWhere;
31
+ /** A `node` filter over `node`, sugar expanded, keys kept in order. */
32
+ export declare function desugarNode(ctx: DesugarContext, node: NodeType, where: unknown): unknown;
33
+ /** Model problems for a `@viewer` mapping, once the node types are built. */
34
+ export declare function checkViewer(viewer: ViewerMapping, nodes: ReadonlyMap<string, NodeType>, jwtType: string, problems: ModelProblem[]): void;
@@ -1,3 +1,3 @@
1
- export declare const directiveTypeDefs = "\n \"A node label set. Defaults to the type name.\"\n directive @node(labels: [String!], plural: String) on OBJECT\n\n \"Natural key: required, unique and immutable; the tie-breaker of every sort and the anchor of cursors, global ids, updates and deletes. With generate: true, creates fill it with a UUID when the input leaves it out.\"\n directive @key(generate: Boolean = false) on FIELD_DEFINITION\n\n \"On an interface field: every implementation declares this relationship (with @relationship, possibly of different types or directions), so clients can select it on the interface.\"\n directive @declareRelationship on FIELD_DEFINITION\n\n \"Offer this field as a grouping key of <plural>Grouped (needs @query(aggregate: true)).\"\n directive @groupBy on FIELD_DEFINITION\n\n \"A field computed in JavaScript by the resolver passed in the resolvers option. requires is a selection on this type (for example name capacity) fetched in the same statement, so the resolver reads it from its source.\"\n directive @customResolver(requires: String) on FIELD_DEFINITION\n\n \"On a custom scalar: how its values are stored. Filters, sorts and indexes follow the storage type; the scalar is passed through unless the scalars option supplies an implementation.\"\n directive @storedAs(type: StorageType!) on SCALAR\n\n enum StorageType {\n STRING\n INT\n FLOAT\n BOOLEAN\n DATETIME\n DATE\n }\n\n \"A uniqueness constraint.\"\n directive @unique on FIELD_DEFINITION\n\n \"An explicit index. Usually unnecessary: inferred from @filterable and @sortable.\"\n directive @index(kind: IndexKind!) on FIELD_DEFINITION\n\n \"An edge. The target type is the field's type.\"\n directive @relationship(\n type: String!\n direction: RelationshipDirection!\n properties: String\n \"UNDIRECTED reads follow the relationship both ways; writes use direction.\"\n queryDirection: QueryDirection = DIRECTED\n \"What deleting this node does to nodes reached through the field.\"\n onDelete: OnDelete = DETACH\n \"Which nested writes mutation inputs offer for the field.\"\n nestedOperations: [NestedOperation!] = [\n CREATE\n CONNECT\n DISCONNECT\n UPDATE\n DELETE\n ]\n \"false: no aggregate on the field's connection and no aggregate filter.\"\n aggregate: Boolean = true\n ) on FIELD_DEFINITION\n\n enum NestedOperation {\n CREATE\n CONNECT\n DISCONNECT\n UPDATE\n DELETE\n }\n\n enum QueryDirection {\n DIRECTED\n UNDIRECTED\n }\n\n \"DETACH removes the relationships; CASCADE deletes the related nodes too; RESTRICT refuses while any exist.\"\n enum OnDelete {\n DETACH\n CASCADE\n RESTRICT\n }\n\n \"Which mutations may set the field. @readonly is onCreate: false, onUpdate: false.\"\n directive @settable(\n onCreate: Boolean = true\n onUpdate: Boolean = true\n ) on FIELD_DEFINITION\n\n \"Whether the field can be read (onRead: false makes it write-only) or aggregated.\"\n directive @selectable(\n onRead: Boolean = true\n onAggregate: Boolean = true\n ) on FIELD_DEFINITION\n\n \"Set by a callback passed to LoraGraphQL({ callbacks }) on these operations; never client-settable.\"\n directive @populatedBy(\n callback: String!\n operations: [TimestampOperation!]! = [CREATE, UPDATE]\n ) on FIELD_DEFINITION\n\n \"The shape of the request's claims. Rules may only test declared claims.\"\n directive @jwt on OBJECT\n\n \"Where a declared claim lives in the token, e.g. app_metadata.roles.\"\n directive @jwtClaim(path: String!) on FIELD_DEFINITION\n\n \"Properties carried by a relationship type.\"\n directive @relationshipProperties on OBJECT\n\n \"API name differs from the stored property.\"\n directive @alias(property: String!) on FIELD_DEFINITION\n\n \"Stored, never exposed.\"\n directive @private on FIELD_DEFINITION\n\n \"Declared upper bound on a relationship's fan-out, used by cost estimates.\"\n directive @cardinality(max: Int!) on FIELD_DEFINITION\n\n \"Generated read operations for a node type. Reads are on by default.\"\n directive @query(\n read: Boolean = true\n aggregate: Boolean = false\n ) on OBJECT | INTERFACE | UNION\n\n \"The plural of an interface or union, for its root field.\"\n directive @plural(value: String!) on INTERFACE | UNION\n\n \"Filter operators for a field. Without arguments: EQ and IN. On a relationship field: enables relationship filters.\"\n directive @filterable(byValue: [FilterOperator!]) on FIELD_DEFINITION\n\n \"Sort and keyset-paginate on this field.\"\n directive @sortable on FIELD_DEFINITION\n\n \"Page size bounds for lists of this type.\"\n directive @limit(\n default: Int\n max: Int\n ) on OBJECT | INTERFACE | UNION | FIELD_DEFINITION\n\n \"Expose an opaque global id derived from this @key field.\"\n directive @relayId on FIELD_DEFINITION\n\n \"Full-text search over String fields: a FULLTEXT index and a search root field per entry.\"\n directive @fulltext(indexes: [FulltextIndex!]!) on OBJECT\n\n \"A vector embedding ([Float!]): a VECTOR index and a similarity root field.\"\n directive @vector(\n dimensions: Int!\n similarity: VectorSimilarity = COSINE\n queryName: String\n ) on FIELD_DEFINITION\n\n input FulltextIndex {\n \"Index name. Default: <label>_search for the first, required after.\"\n name: String\n fields: [String!]!\n analyzer: FulltextAnalyzer = STANDARD\n \"Root field name. Default: search<Plural>, or search<Plural>By<Name>.\"\n queryName: String\n }\n\n enum FulltextAnalyzer {\n STANDARD\n SIMPLE\n }\n\n enum VectorSimilarity {\n COSINE\n EUCLIDEAN\n }\n\n \"Generated subscriptions to changes made through the library. None without this directive.\"\n directive @subscription(\n operations: [MutationOperation!]! = [CREATE, UPDATE, DELETE]\n \"Also send CONNECT and DISCONNECT events for the type's relationships.\"\n relationships: Boolean = false\n \"Send the stored values before an update or delete (one extra read per write).\"\n previousState: Boolean = false\n ) on OBJECT\n\n \"Generated mutations for a node type. None without this directive.\"\n directive @mutation(\n operations: [MutationOperation!]! = [CREATE, UPDATE, DELETE]\n ) on OBJECT\n\n \"Value stored on create when the input leaves the field out.\"\n directive @default(value: DefaultValue!) on FIELD_DEFINITION\n\n \"Set to the current time by the listed operations; never client-settable.\"\n directive @timestamp(\n operations: [TimestampOperation!]! = [CREATE, UPDATE]\n ) on FIELD_DEFINITION\n\n \"Readable but never client-settable.\"\n directive @readonly on FIELD_DEFINITION\n\n \"A field backed by a Cypher statement. `this` is the parent node; arguments are $parameters, and $jwt holds the request's claims.\"\n directive @cypher(statement: String!, columnName: String) on FIELD_DEFINITION\n\n \"Require an authenticated request (a jwt in the context) for these operations.\"\n directive @authentication(\n operations: [AuthOperation!]! = [\n READ\n CREATE\n UPDATE\n DELETE\n CREATE_RELATIONSHIP\n DELETE_RELATIONSHIP\n SUBSCRIBE\n ]\n \"Claims the request must also satisfy, such as a role in roles.\"\n jwt: AuthorizationWhere\n ) on OBJECT | FIELD_DEFINITION\n\n \"Row-level rules over the node and the request's claims, compiled into the statements.\"\n directive @authorization(\n filter: [AuthorizationFilterRule!]\n validate: [AuthorizationValidateRule!]\n ) on OBJECT | FIELD_DEFINITION\n\n input AuthorizationFilterRule {\n operations: [AuthOperation!]! = [READ, UPDATE, DELETE]\n requireAuthentication: Boolean = true\n where: AuthorizationWhere!\n }\n\n input AuthorizationValidateRule {\n operations: [AuthOperation!]! = [READ, CREATE, UPDATE, DELETE]\n when: [AuthorizationWhen!]! = [BEFORE, AFTER]\n requireAuthentication: Boolean = true\n where: AuthorizationWhere!\n }\n\n enum MutationOperation {\n CREATE\n UPDATE\n DELETE\n }\n\n enum TimestampOperation {\n CREATE\n UPDATE\n }\n\n enum AuthOperation {\n READ\n CREATE\n UPDATE\n DELETE\n CREATE_RELATIONSHIP\n DELETE_RELATIONSHIP\n SUBSCRIBE\n }\n\n enum AuthorizationWhen {\n BEFORE\n AFTER\n }\n\n \"Any literal.\"\n scalar DefaultValue\n\n \"{ node: <Type>Where-shaped filter, jwt: claim filter, AND, OR, NOT }. String values starting with $jwt. are replaced by claims.\"\n scalar AuthorizationWhere\n\n enum IndexKind {\n RANGE\n TEXT\n POINT\n }\n\n enum RelationshipDirection {\n IN\n OUT\n }\n\n enum FilterOperator {\n EQ\n IN\n LT\n LTE\n GT\n GTE\n CONTAINS\n STARTS_WITH\n ENDS_WITH\n WITHIN_BBOX\n DISTANCE\n INCLUDES\n IS_NULL\n CASE_INSENSITIVE\n }\n\n scalar BigInt\n scalar Date\n scalar Time\n scalar LocalTime\n scalar DateTime\n scalar LocalDateTime\n scalar Duration\n\n type Point {\n longitude: Float!\n latitude: Float!\n height: Float\n srid: Int!\n crs: String!\n }\n\n type CartesianPoint {\n x: Float!\n y: Float!\n z: Float\n srid: Int!\n crs: String!\n }\n";
1
+ export declare const directiveTypeDefs = "\n \"A node label set. Defaults to the type name.\"\n directive @node(labels: [String!], plural: String) on OBJECT\n\n \"Natural key: required, unique and immutable; the tie-breaker of every sort and the anchor of cursors, global ids, updates and deletes. With generate: true, creates fill it with a UUID when the input leaves it out.\"\n directive @key(\n generate: Boolean = false\n \"VIEWER: a created key must start with the caller's @viewer claim and the separator (lou:tomorrowland).\"\n scope: KeyScope\n separator: String = \":\"\n ) on FIELD_DEFINITION\n\n enum KeyScope {\n VIEWER\n }\n\n \"On an interface field: every implementation declares this relationship (with @relationship, possibly of different types or directions), so clients can select it on the interface.\"\n directive @declareRelationship on FIELD_DEFINITION\n\n \"Offer this field as a grouping key of <plural>Grouped (needs @query(aggregate: true)).\"\n directive @groupBy on FIELD_DEFINITION\n\n \"A field computed in JavaScript by the resolver passed in the resolvers option. requires is a selection on this type (for example name capacity) fetched in the same statement, so the resolver reads it from its source.\"\n directive @customResolver(requires: String) on FIELD_DEFINITION\n\n \"On a custom scalar: how its values are stored. Filters, sorts and indexes follow the storage type; the scalar is passed through unless the scalars option supplies an implementation.\"\n directive @storedAs(type: StorageType!) on SCALAR\n\n enum StorageType {\n STRING\n INT\n FLOAT\n BOOLEAN\n DATETIME\n DATE\n }\n\n \"A uniqueness constraint.\"\n directive @unique on FIELD_DEFINITION\n\n \"An explicit index. Usually unnecessary: inferred from @filterable and @sortable.\"\n directive @index(kind: IndexKind!) on FIELD_DEFINITION\n\n \"An edge. The target type is the field's type.\"\n directive @relationship(\n type: String!\n direction: RelationshipDirection!\n properties: String\n \"UNDIRECTED reads follow the relationship both ways; writes use direction.\"\n queryDirection: QueryDirection = DIRECTED\n \"What deleting this node does to nodes reached through the field.\"\n onDelete: OnDelete = DETACH\n \"Which nested writes mutation inputs offer for the field.\"\n nestedOperations: [NestedOperation!] = [\n CREATE\n CONNECT\n DISCONNECT\n UPDATE\n DELETE\n ]\n \"false: no aggregate on the field's connection and no aggregate filter.\"\n aggregate: Boolean = true\n ) on FIELD_DEFINITION\n\n enum NestedOperation {\n CREATE\n CONNECT\n DISCONNECT\n UPDATE\n DELETE\n }\n\n enum QueryDirection {\n DIRECTED\n UNDIRECTED\n }\n\n \"DETACH removes the relationships; CASCADE deletes the related nodes too; RESTRICT refuses while any exist.\"\n enum OnDelete {\n DETACH\n CASCADE\n RESTRICT\n }\n\n \"Which mutations may set the field. @readonly is onCreate: false, onUpdate: false.\"\n directive @settable(\n onCreate: Boolean = true\n onUpdate: Boolean = true\n ) on FIELD_DEFINITION\n\n \"Whether the field can be read (onRead: false makes it write-only) or aggregated.\"\n directive @selectable(\n onRead: Boolean = true\n onAggregate: Boolean = true\n ) on FIELD_DEFINITION\n\n \"Set by a callback passed to LoraGraphQL({ callbacks }) on these operations; never client-settable.\"\n directive @populatedBy(\n callback: String!\n operations: [TimestampOperation!]! = [CREATE, UPDATE]\n ) on FIELD_DEFINITION\n\n \"The shape of the request's claims. Rules may only test declared claims.\"\n directive @jwt on OBJECT\n\n \"Where a declared claim lives in the token, e.g. app_metadata.roles.\"\n directive @jwtClaim(path: String!) on FIELD_DEFINITION\n\n \"The claim that identifies the caller: the node of `type` whose `field` (a @key or @unique field) equals it. Enables isViewer and viewer in rules.\"\n directive @viewer(type: String!, field: String!) on FIELD_DEFINITION\n\n \"Properties carried by a relationship type.\"\n directive @relationshipProperties on OBJECT\n\n \"API name differs from the stored property.\"\n directive @alias(property: String!) on FIELD_DEFINITION\n\n \"Stored, never exposed.\"\n directive @private on FIELD_DEFINITION\n\n \"Declared upper bound on a relationship's fan-out, used by cost estimates.\"\n directive @cardinality(max: Int!) on FIELD_DEFINITION\n\n \"Generated read operations for a node type. Reads are on by default.\"\n directive @query(\n read: Boolean = true\n aggregate: Boolean = false\n ) on OBJECT | INTERFACE | UNION\n\n \"The plural of an interface or union, for its root field.\"\n directive @plural(value: String!) on INTERFACE | UNION\n\n \"Filter operators for a field. Without arguments: EQ and IN. On a relationship field: enables relationship filters.\"\n directive @filterable(byValue: [FilterOperator!]) on FIELD_DEFINITION\n\n \"Sort and keyset-paginate on this field.\"\n directive @sortable on FIELD_DEFINITION\n\n \"Page size bounds for lists of this type.\"\n directive @limit(\n default: Int\n max: Int\n ) on OBJECT | INTERFACE | UNION | FIELD_DEFINITION\n\n \"Expose an opaque global id derived from this @key field.\"\n directive @relayId on FIELD_DEFINITION\n\n \"Full-text search over String fields: a FULLTEXT index and a search root field per entry.\"\n directive @fulltext(indexes: [FulltextIndex!]!) on OBJECT\n\n \"A vector embedding ([Float!]): a VECTOR index and a similarity root field.\"\n directive @vector(\n dimensions: Int!\n similarity: VectorSimilarity = COSINE\n queryName: String\n ) on FIELD_DEFINITION\n\n input FulltextIndex {\n \"Index name. Default: <label>_search for the first, required after.\"\n name: String\n fields: [String!]!\n analyzer: FulltextAnalyzer = STANDARD\n \"Root field name. Default: search<Plural>, or search<Plural>By<Name>.\"\n queryName: String\n }\n\n enum FulltextAnalyzer {\n STANDARD\n SIMPLE\n }\n\n enum VectorSimilarity {\n COSINE\n EUCLIDEAN\n }\n\n \"Generated subscriptions to changes made through the library. None without this directive.\"\n directive @subscription(\n operations: [MutationOperation!]! = [CREATE, UPDATE, DELETE]\n \"Also send CONNECT and DISCONNECT events for the type's relationships.\"\n relationships: Boolean = false\n \"Send the stored values before an update or delete (one extra read per write).\"\n previousState: Boolean = false\n ) on OBJECT\n\n \"Generated mutations for a node type. None without this directive.\"\n directive @mutation(\n operations: [MutationOperation!]! = [CREATE, UPDATE, DELETE]\n ) on OBJECT\n\n \"Value stored on create when the input leaves the field out.\"\n directive @default(value: DefaultValue!) on FIELD_DEFINITION\n\n \"Set to the current time by the listed operations; never client-settable.\"\n directive @timestamp(\n operations: [TimestampOperation!]! = [CREATE, UPDATE]\n ) on FIELD_DEFINITION\n\n \"Readable but never client-settable.\"\n directive @readonly on FIELD_DEFINITION\n\n \"A field backed by a Cypher statement. `this` is the parent node; arguments are $parameters, and $jwt holds the request's claims.\"\n directive @cypher(statement: String!, columnName: String) on FIELD_DEFINITION\n\n \"Require an authenticated request (a jwt in the context) for these operations.\"\n directive @authentication(\n operations: [AuthOperation!]! = [\n READ\n CREATE\n UPDATE\n DELETE\n CREATE_RELATIONSHIP\n DELETE_RELATIONSHIP\n SUBSCRIBE\n ]\n \"Claims the request must also satisfy, such as a role in roles.\"\n jwt: AuthorizationWhere\n ) on OBJECT | FIELD_DEFINITION\n\n \"Row-level rules over the node and the request's claims, compiled into the statements.\"\n directive @authorization(\n filter: [AuthorizationFilterRule!]\n validate: [AuthorizationValidateRule!]\n \"On a type: false keeps the schema's bypass (see @authorizationDefaults) from skipping this type's rules.\"\n bypass: Boolean\n \"On a @mutation type: operations deliberately open to every caller, so check() does not report them as unguarded.\"\n public: [AuthOperation!]\n \"On a scalar field: a row failing unless reads the field as value (null when left out) instead of failing the request.\"\n mask: [AuthorizationMask!]\n ) on OBJECT | FIELD_DEFINITION\n\n \"Named rules over claims, usable in any rule as { rule: name }, on `extend schema`.\"\n directive @authorizationRules(\n rules: [AuthorizationRuleDefinition!]!\n ) on SCHEMA\n\n input AuthorizationRuleDefinition {\n name: String!\n where: AuthorizationWhere!\n }\n\n \"A named rule of this type, usable in its rules as { rule: name } and through a relationship to it as { rel: { rule: name } }.\"\n directive @authorizationRule(\n name: String!\n where: AuthorizationWhere!\n ) repeatable on OBJECT\n\n \"Schema-wide authorization settings, on `extend schema`.\"\n directive @authorizationDefaults(\n \"A claims-only test (jwt, AND, OR, NOT): a request passing it skips every filter and validate rule (not @authentication).\"\n bypass: AuthorizationWhere\n \"The write rule of every @mutation type that declares no CREATE, UPDATE or DELETE rule of its own.\"\n mutations: AuthorizationWhere\n ) on SCHEMA\n\n input AuthorizationMask {\n unless: AuthorizationWhere!\n value: DefaultValue\n }\n\n input AuthorizationFilterRule {\n operations: [AuthOperation!]! = [READ, UPDATE, DELETE]\n \"Default true: without a token the rule denies.\"\n requireAuthentication: Boolean\n where: AuthorizationWhere!\n }\n\n input AuthorizationValidateRule {\n operations: [AuthOperation!]! = [READ, CREATE, UPDATE, DELETE]\n when: [AuthorizationWhen!]! = [BEFORE, AFTER]\n \"Default true: without a token the rule denies.\"\n requireAuthentication: Boolean\n where: AuthorizationWhere!\n }\n\n enum MutationOperation {\n CREATE\n UPDATE\n DELETE\n }\n\n enum TimestampOperation {\n CREATE\n UPDATE\n }\n\n enum AuthOperation {\n READ\n CREATE\n UPDATE\n DELETE\n CREATE_RELATIONSHIP\n DELETE_RELATIONSHIP\n SUBSCRIBE\n \"On a relationship field: creating one of its relationships (connect, nested create).\"\n CONNECT\n \"On a relationship field: removing one of its relationships (disconnect).\"\n DISCONNECT\n \"On a relationship field: setting properties on an existing relationship (edge update, re-connect).\"\n UPDATE_EDGE\n \"On a relationship field: reading, filtering, sorting or aggregating its properties.\"\n READ_EDGE\n }\n\n enum AuthorizationWhen {\n BEFORE\n AFTER\n }\n\n \"Any literal.\"\n scalar DefaultValue\n\n \"{ node: <Type>Where-shaped filter, jwt: claim filter, AND, OR, NOT }. String values starting with $jwt. are replaced by claims.\"\n scalar AuthorizationWhere\n\n enum IndexKind {\n RANGE\n TEXT\n POINT\n }\n\n enum RelationshipDirection {\n IN\n OUT\n }\n\n enum FilterOperator {\n EQ\n IN\n LT\n LTE\n GT\n GTE\n CONTAINS\n STARTS_WITH\n ENDS_WITH\n WITHIN_BBOX\n DISTANCE\n INCLUDES\n IS_NULL\n CASE_INSENSITIVE\n }\n\n scalar BigInt\n scalar Date\n scalar Time\n scalar LocalTime\n scalar DateTime\n scalar LocalDateTime\n scalar Duration\n\n type Point {\n longitude: Float!\n latitude: Float!\n height: Float\n srid: Int!\n crs: String!\n }\n\n type CartesianPoint {\n x: Float!\n y: Float!\n z: Float\n srid: Int!\n crs: String!\n }\n";
2
2
  /** Names defined by the prelude; never treated as user types. */
3
3
  export declare const PRELUDE_TYPES: Set<string>;
@@ -0,0 +1,22 @@
1
+ import { GraphQLNamedType, GraphQLSchema } from 'graphql';
2
+ import { ModelProblem } from '../errors.js';
3
+ export type Position = "node type" | "relationship properties type" | "@jwt type" | "object type without @node" | "Query or Mutation type" | "interface" | "union" | "node field" | "relationship field" | "@cypher field" | "Query or Mutation field" | "@customResolver field" | "relationship property" | "interface field" | "interface relationship field" | "field of an object type without @node" | "@jwt claim";
4
+ /**
5
+ * The directives each position applies, plus those it refuses with a more
6
+ * specific message elsewhere in the model build (listed so they are not
7
+ * reported twice).
8
+ */
9
+ export declare const DIRECTIVE_POSITIONS: Readonly<Record<Position, readonly string[]>>;
10
+ /** The directives this library defines (not graphql's built-ins). */
11
+ export declare function libraryDirectives(): ReadonlySet<string>;
12
+ export interface PositionContext {
13
+ schema: GraphQLSchema;
14
+ nodeNames: ReadonlySet<string>;
15
+ propsNames: ReadonlySet<string>;
16
+ jwtType: string | undefined;
17
+ /** @node types, interfaces and unions: what a relationship may target. */
18
+ targetNames: ReadonlySet<string>;
19
+ userTypes: readonly GraphQLNamedType[];
20
+ }
21
+ /** Report every library directive used where the model would ignore it. */
22
+ export declare function checkDirectivePositions(ctx: PositionContext, problems: ModelProblem[]): void;
@@ -4,7 +4,10 @@ export type ScalarType = "String" | "ID" | "Int" | "Float" | "Boolean" | "BigInt
4
4
  export type FilterOperator = "EQ" | "IN" | "LT" | "LTE" | "GT" | "GTE" | "CONTAINS" | "STARTS_WITH" | "ENDS_WITH" | "WITHIN_BBOX" | "DISTANCE" | "INCLUDES" | "IS_NULL" | "CASE_INSENSITIVE";
5
5
  export type IndexKind = "RANGE" | "TEXT" | "POINT";
6
6
  export type MutationOperation = "CREATE" | "UPDATE" | "DELETE";
7
- export type AuthOperation = "READ" | MutationOperation | "CREATE_RELATIONSHIP" | "DELETE_RELATIONSHIP" | "SUBSCRIBE";
7
+ export type AuthOperation = "READ" | MutationOperation | "CREATE_RELATIONSHIP" | "DELETE_RELATIONSHIP" | "SUBSCRIBE" | RelationshipOperation;
8
+ /** Operations of rules on a relationship field, over `source`, `target`, `edge`. */
9
+ export type RelationshipOperation = "CONNECT" | "DISCONNECT" | "UPDATE_EDGE" | "READ_EDGE";
10
+ export declare const RELATIONSHIP_OPERATIONS: ReadonlySet<AuthOperation>;
8
11
  /** Field-level options shared by every field kind. */
9
12
  interface FieldBase {
10
13
  /** Operations on which reading this field needs an authenticated request. */
@@ -39,6 +42,13 @@ export interface ScalarField extends FieldBase {
39
42
  indexes: readonly IndexKind[];
40
43
  /** `@key(generate: true)`: creates fill a UUID when the input omits it. */
41
44
  generate: boolean;
45
+ /**
46
+ * `@key(scope: VIEWER)`: a created key starts with the caller's `@viewer`
47
+ * claim and `separator`.
48
+ */
49
+ keyScope?: {
50
+ separator: string;
51
+ } | undefined;
42
52
  /** `@default(value:)`, stored on create when the input omits the field. */
43
53
  defaultValue: {
44
54
  value: unknown;
@@ -177,17 +187,33 @@ export type AuthorizationWhere = Record<string, unknown>;
177
187
  export interface AuthorizationFilterRule {
178
188
  operations: ReadonlySet<AuthOperation>;
179
189
  requireAuthentication: boolean;
190
+ /** `requireAuthentication` was left to its default (true). */
191
+ requireAuthenticationDefaulted?: boolean;
180
192
  where: AuthorizationWhere;
181
193
  }
182
194
  export interface AuthorizationValidateRule {
183
195
  operations: ReadonlySet<AuthOperation>;
184
196
  when: ReadonlySet<"BEFORE" | "AFTER">;
185
197
  requireAuthentication: boolean;
198
+ /** `requireAuthentication` was left to its default (true). */
199
+ requireAuthenticationDefaulted?: boolean;
186
200
  where: AuthorizationWhere;
187
201
  }
188
202
  export interface Authorization {
189
203
  filter: readonly AuthorizationFilterRule[];
190
204
  validate: readonly AuthorizationValidateRule[];
205
+ /** `bypass: false`: the schema's bypass does not skip these rules. */
206
+ bypass?: boolean;
207
+ /** `public:` operations deliberately open to every caller. */
208
+ public?: ReadonlySet<AuthOperation>;
209
+ /**
210
+ * `mask:` on a scalar field: a row failing `unless` reads the field as
211
+ * `value`, in projections and filters alike.
212
+ */
213
+ mask?: ReadonlyArray<{
214
+ unless: AuthorizationWhere;
215
+ value: unknown;
216
+ }>;
191
217
  }
192
218
  export interface PageLimit {
193
219
  default: number;
@@ -307,6 +333,17 @@ export interface GraphModel {
307
333
  objects: ReadonlyMap<string, PlainObjectType>;
308
334
  /** The `@jwt` claims shape, when declared: claim name → token path. */
309
335
  jwt: ReadonlyMap<string, string> | undefined;
336
+ /**
337
+ * `@authorizationDefaults(bypass:)`: a claims-only test that, when a
338
+ * request passes it, skips every filter and validate rule.
339
+ */
340
+ bypass: AuthorizationWhere | undefined;
341
+ /** The `@viewer` claim: the caller's node type and identifying field. */
342
+ viewer: {
343
+ claim: string;
344
+ type: string;
345
+ field: string;
346
+ } | undefined;
310
347
  /** Secret cursors are signed with, when configured. */
311
348
  cursorSecret: string | undefined;
312
349
  }
package/dist/testing.d.ts CHANGED
@@ -34,3 +34,30 @@ export declare function expectSeeks(lora: LoraGraphQL, document: string | Docume
34
34
  rowBudget?: number;
35
35
  context?: unknown;
36
36
  }): Promise<void>;
37
+ /** The access `expectAccess` expects: entries allowed, and entries denied. */
38
+ export interface AccessExpectations {
39
+ /** The claims to act as; undefined acts anonymously. */
40
+ as: Record<string, unknown> | undefined;
41
+ /** Other GraphQL context values the rules read (`$context.*`). */
42
+ context?: Record<string, unknown>;
43
+ allowed?: readonly string[];
44
+ denied?: readonly string[];
45
+ }
46
+ /**
47
+ * Assert who may do what, against the database: each entry runs as the
48
+ * caller in a transaction that is always rolled back, and every mismatch
49
+ * is reported at once. Entries:
50
+ *
51
+ * - `read Trip lou:tomorrowland` (denied: not visible, or refused)
52
+ * - `create Trip lou:x {"name": "X"}` (other input fields as JSON)
53
+ * - `update Trip lou:x {"name": "Y"}` and `delete Trip lou:x`
54
+ * - `connect Trip.members lou:tomorrowland → f1` (also `->`)
55
+ * - `disconnect Trip.members lou:tomorrowland → f1`
56
+ * - `update-edge Trip.members lou:tomorrowland → f1 {"rsvp": "GOING"}`
57
+ *
58
+ * Denied means `FORBIDDEN`, `UNAUTHENTICATED` or `NOT_FOUND` (a node the
59
+ * caller cannot see); any other error is reported as a mismatch either way.
60
+ */
61
+ export declare function expectAccess(target: LoraGraphQL | {
62
+ lora: LoraGraphQL;
63
+ }, expectations: AccessExpectations): Promise<void>;
package/dist/testing.js CHANGED
@@ -1,56 +1,173 @@
1
1
  import { graphql as h } from "graphql";
2
- import { L as w, l as u } from "./driver-CQj9gkvl.js";
3
- async function L(l) {
4
- let s;
2
+ import { L as w, l as g } from "./driver-C7a5NkfH.js";
3
+ async function _(d) {
4
+ let l;
5
5
  try {
6
- ({ createDatabase: s } = await import("@loradb/lora-node"));
6
+ ({ createDatabase: l } = 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 t = await s(), n = [], { seed: a, onStatement: i, ...c } = l, r = new w({
12
+ const i = await l(), e = [], { seed: t, onStatement: a, ...c } = d, n = new w({
13
13
  ...c,
14
- driver: u(t),
15
- onStatement: (e) => {
16
- n.push(e), i?.(e);
14
+ driver: g(i),
15
+ onStatement: (r) => {
16
+ e.push(r), a?.(r);
17
17
  }
18
18
  });
19
- if (await r.assertSchema({ create: !0 }), typeof a == "function") await a(t);
19
+ if (await n.assertSchema({ create: !0 }), typeof t == "function") await t(i);
20
20
  else
21
- for (const e of typeof a == "string" ? [a] : a ?? [])
22
- await t.execute(e);
23
- const o = r.getSchema(), m = (e, p = {}, d = {}) => h({ schema: o, source: e, variableValues: p, contextValue: d });
21
+ for (const r of typeof t == "string" ? [t] : t ?? [])
22
+ await i.execute(r);
23
+ const o = n.getSchema(), s = (r, u = {}, p = {}) => h({ schema: o, source: r, variableValues: u, contextValue: p });
24
24
  return {
25
- lora: r,
26
- db: t,
25
+ lora: n,
26
+ db: i,
27
27
  schema: o,
28
- statements: n,
29
- run: m,
30
- data: async (e, p, d) => {
31
- const f = await m(e, p, d);
32
- if (f.errors) throw f.errors[0];
33
- return f.data;
28
+ statements: e,
29
+ run: s,
30
+ data: async (r, u, p) => {
31
+ const $ = await s(r, u, p);
32
+ if ($.errors) throw $.errors[0];
33
+ return $.data;
34
34
  },
35
- close: () => t.dispose?.()
35
+ close: () => i.dispose?.()
36
36
  };
37
37
  }
38
- async function b(l, s, t = {}, n = {}) {
39
- const i = (await l.explain(s, t, n)).flatMap(
38
+ async function N(d, l, i = {}, e = {}) {
39
+ const a = (await d.explain(l, i, e)).flatMap(
40
40
  (c) => c.reports.flatMap(
41
- (r) => r.findings.map((o) => `${c.field}: ${o.message}
41
+ (n) => n.findings.map((o) => `${c.field}: ${o.message}
42
42
  ${o.statement}`)
43
43
  )
44
44
  );
45
- if (i.length > 0)
45
+ if (a.length > 0)
46
46
  throw new Error(
47
47
  `expected every root field to seek:
48
- ${i.join(`
48
+ ${a.join(`
49
49
  `)}`
50
50
  );
51
51
  }
52
+ async function x(d, l) {
53
+ const i = d instanceof w ? d : d.lora, e = [], t = async (a, c) => {
54
+ let n;
55
+ try {
56
+ n = await b(i, a, l);
57
+ } catch (o) {
58
+ e.push(
59
+ `${a}: ${o instanceof Error ? o.message : String(o)}`
60
+ );
61
+ return;
62
+ }
63
+ n.kind === "error" ? e.push(`${a}: failed with ${n.message}`) : c && n.kind === "denied" ? e.push(
64
+ `expected allowed, was denied: ${a} (${n.message})`
65
+ ) : !c && n.kind === "allowed" && e.push(`expected denied, was allowed: ${a}`);
66
+ };
67
+ for (const a of l.allowed ?? []) await t(a, !0);
68
+ for (const a of l.denied ?? []) await t(a, !1);
69
+ if (e.length > 0) {
70
+ const a = l.as ? JSON.stringify(l.as) : "anonymous";
71
+ throw new Error(
72
+ `access as ${a} differs in ${e.length} ${e.length === 1 ? "entry" : "entries"}:
73
+ ${e.join(`
74
+ `)}`
75
+ );
76
+ }
77
+ }
78
+ const v = /* @__PURE__ */ new Set(["FORBIDDEN", "UNAUTHENTICATED", "NOT_FOUND"]);
79
+ async function b(d, l, i) {
80
+ const { source: e, variables: t, isRead: a, root: c } = E(d, l), n = await d.begin();
81
+ try {
82
+ const o = await d.execute({
83
+ source: e,
84
+ variables: t,
85
+ context: {
86
+ ...i.context,
87
+ ...i.as ? { jwt: i.as } : {},
88
+ transaction: n
89
+ }
90
+ }), s = o.errors?.[0];
91
+ if (s) {
92
+ const r = s.extensions?.code;
93
+ return typeof r == "string" && v.has(r) ? { kind: "denied", message: `${r}: ${s.message}` } : {
94
+ kind: "error",
95
+ message: `${String(r ?? "error")}: ${s.message}`
96
+ };
97
+ }
98
+ return a && o.data?.[c] == null ? { kind: "denied", message: "not visible" } : { kind: "allowed" };
99
+ } finally {
100
+ await n.rollback();
101
+ }
102
+ }
103
+ function E(d, l) {
104
+ 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
+ l
106
+ );
107
+ if (!i)
108
+ throw new Error(
109
+ "expected `<read|create|update|delete> Type key [json]` or `<connect|disconnect|update-edge> Type.field key → key [json]`"
110
+ );
111
+ const [, e, t, a, c, n, o] = i, s = d.model.nodes.get(t);
112
+ if (!s) throw new Error(`${t} is not a @node type`);
113
+ const r = o ? JSON.parse(o) : {}, u = `${s.key.customScalar ?? s.key.type}!`, p = t.charAt(0).toLowerCase() + t.slice(1), $ = s.plural.charAt(0).toUpperCase() + s.plural.slice(1), m = ["connect", "disconnect", "update-edge"].includes(
114
+ e
115
+ );
116
+ if (m !== (a !== void 0 && n !== void 0))
117
+ throw new Error(
118
+ m ? `${e} takes Type.field key → key` : `${e} takes Type key`
119
+ );
120
+ switch (e) {
121
+ case "read":
122
+ return {
123
+ operation: e,
124
+ source: `query($key: ${u}) { ${p}(${s.key.name}: $key) { ${s.key.name} } }`,
125
+ variables: { key: c },
126
+ isRead: !0,
127
+ root: p
128
+ };
129
+ case "create":
130
+ return {
131
+ operation: e,
132
+ source: `mutation($input: [${t}CreateInput!]!) { create${$}(input: $input) { __typename } }`,
133
+ variables: { input: [{ ...r, [s.key.name]: c }] },
134
+ isRead: !1,
135
+ root: `create${$}`
136
+ };
137
+ case "update":
138
+ return {
139
+ operation: e,
140
+ source: `mutation($key: ${u}, $update: ${t}UpdateInput!) { update${t}(${s.key.name}: $key, update: $update) { __typename } }`,
141
+ variables: { key: c, update: r },
142
+ isRead: !1,
143
+ root: `update${t}`
144
+ };
145
+ case "delete":
146
+ return {
147
+ operation: e,
148
+ source: `mutation($key: ${u}) { delete${t}(${s.key.name}: $key) { __typename } }`,
149
+ variables: { key: c },
150
+ isRead: !1,
151
+ root: `delete${t}`
152
+ };
153
+ }
154
+ const f = s.fields.get(a);
155
+ if (f?.kind !== "relationship")
156
+ throw new Error(`${t}.${a} is not a relationship field`);
157
+ const y = d.model.nodes.get(f.target)?.key.name ?? "key", k = e === "connect" ? {
158
+ connect: f.list ? [{ [y]: n, ...o ? { edge: r } : {} }] : { [y]: n, ...o ? { edge: r } : {} }
159
+ } : e === "disconnect" ? { disconnect: f.list ? [n] : !0 } : { update: [{ [y]: n, edge: r }] };
160
+ return {
161
+ operation: e,
162
+ source: `mutation($key: ${u}, $update: ${t}UpdateInput!) { update${t}(${s.key.name}: $key, update: $update) { __typename } }`,
163
+ variables: { key: c, update: { [a]: k } },
164
+ isRead: !1,
165
+ root: `update${t}`
166
+ };
167
+ }
52
168
  export {
53
- L as createTestLoraGraphQL,
54
- b as expectSeeks
169
+ _ as createTestLoraGraphQL,
170
+ x as expectAccess,
171
+ N as expectSeeks
55
172
  };
56
173
  //# sourceMappingURL=testing.js.map