@loradb/lora-graphql 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +151 -31
  2. package/dist/analyze/input-schemas.d.ts +11 -0
  3. package/dist/analyze/plans.d.ts +8 -0
  4. package/dist/analyze/stored.d.ts +29 -0
  5. package/dist/cli.js +263 -244
  6. package/dist/cli.js.map +1 -1
  7. package/dist/compile/cache.d.ts +9 -1
  8. package/dist/compile/context.d.ts +3 -0
  9. package/dist/compile/read/common.d.ts +2 -0
  10. package/dist/compile/read/root.d.ts +7 -0
  11. package/dist/compile/selection.d.ts +5 -0
  12. package/dist/diff-CdZZG-N1.js +134 -0
  13. package/dist/diff-CdZZG-N1.js.map +1 -0
  14. package/dist/driver.d.ts +36 -1
  15. package/dist/errors.d.ts +21 -1
  16. package/dist/events/outbox.d.ts +14 -0
  17. package/dist/events/worker.d.ts +67 -0
  18. package/dist/execute/mutate/checks.d.ts +16 -0
  19. package/dist/execute/mutate/env.d.ts +50 -0
  20. package/dist/execute/mutate/errors.d.ts +6 -0
  21. package/dist/execute/mutate/links.d.ts +38 -0
  22. package/dist/execute/mutate/plan.d.ts +5 -0
  23. package/dist/execute/mutate/runner.d.ts +101 -25
  24. package/dist/execute/mutate/single-call.d.ts +92 -0
  25. package/dist/execute/mutate/triggers.d.ts +21 -0
  26. package/dist/execute/mutate/unique-together.d.ts +5 -1
  27. package/dist/execute/mutate/validate.d.ts +11 -0
  28. package/dist/execute/transaction.d.ts +13 -2
  29. package/dist/index.d.ts +7 -2
  30. package/dist/index.js +16 -15
  31. package/dist/lora-graphql-D11QFUkf.js +17222 -0
  32. package/dist/lora-graphql-D11QFUkf.js.map +1 -0
  33. package/dist/lora-graphql.d.ts +30 -1
  34. package/dist/model/build/authorization.d.ts +11 -1
  35. package/dist/model/build/constraints.d.ts +22 -0
  36. package/dist/model/build/rule-checks.d.ts +6 -1
  37. package/dist/model/build/trigger.d.ts +9 -0
  38. package/dist/model/constraints.d.ts +112 -0
  39. package/dist/model/desugar.d.ts +7 -0
  40. package/dist/model/directives.d.ts +1 -1
  41. package/dist/model/types.d.ts +34 -0
  42. package/dist/options.d.ts +76 -3
  43. package/dist/results.d.ts +12 -1
  44. package/dist/schema/scalars.d.ts +8 -1
  45. package/dist/standard-schema.d.ts +37 -0
  46. package/dist/testing.js +1 -1
  47. package/package.json +2 -2
  48. package/dist/diff-K8-GSeKH.js +0 -100
  49. package/dist/diff-K8-GSeKH.js.map +0 -1
  50. package/dist/driver-CXKPLfkz.js +0 -14447
  51. package/dist/driver-CXKPLfkz.js.map +0 -1
package/README.md CHANGED
@@ -65,6 +65,7 @@ const yoga = createYoga({
65
65
  - [The smart layer](#the-smart-layer)
66
66
  - [Change tracking](#change-tracking)
67
67
  - [Subscriptions](#subscriptions)
68
+ - [Triggers and workers](#triggers-and-workers)
68
69
  - [Transactions](#transactions)
69
70
  - [CLI](#cli)
70
71
  - [Drivers, limits and errors](#drivers-limits-and-errors)
@@ -97,6 +98,13 @@ Relationship fields take `where`, `sort` and `limit`; list relationships
97
98
  also get `…Connection`, whose edges carry the relationship properties and
98
99
  filter on them (`where: { node, edge }`).
99
100
 
101
+ `@query(aggregate: true)` on a type also gives every relationship
102
+ connection that leads to it types of its own (`CityVenuesConnection` in
103
+ place of `VenueConnection`), since its `aggregate` covers that
104
+ relationship: turning it on renames types in the schema. A count does not
105
+ need it: `{ venuesConnection { totalCount } }` selects no page and reads
106
+ none.
107
+
100
108
  The surface is restrictive: a field is filterable only with `@filterable`,
101
109
  and only by the operators listed; sortable only with `@sortable`;
102
110
  `printPublicSchema()` prints exactly what clients see, with no directives.
@@ -157,8 +165,12 @@ API:
157
165
  | `@sortable` | field | Sort and paginate by this field (on a relationship property: `sort: [{ edge: { ... } }]`) |
158
166
  | `@groupBy` | field | A grouping key of `<plural>Grouped(by:)` (needs `@query(aggregate: true)`) |
159
167
  | `@limit(default:, max:)` | type, interface, union, list relationship | Page size bounds |
160
- | `@size(max:)` | list argument of a `@cypher` field | The most items it takes (default `maxListArgument`) |
161
- | `@range(min:, max:)` | Int / Float argument of a `@cypher` field | Bounds of its value (each item of a list); outside is `BAD_USER_INPUT` |
168
+ | `@length(min:, max:)` | String / ID field | Its length in code points (each item of a list) |
169
+ | `@pattern(regex:)` | String / ID field | An expression the whole value must match; needs `@length(max:)` |
170
+ | `@format(as:)` | String / ID field | `email`, `email-strict`, `url`, `uuid`, `hostname`, `ipv4`, `ipv6`, `slug`, `hex-color`, `ulid`, `e164` |
171
+ | `@range(min:, max:, exclusiveMin:, exclusiveMax:)` | number field; argument of a `@cypher` field | Bounds of its value (each item of a list); on an argument, `min` and `max` |
172
+ | `@multipleOf(value:)` | Int / Float field | A whole multiple of `value` |
173
+ | `@size(min:, max:, unique:)` | list field; list argument of `@cypher` | How many items it holds; on an argument, `max` (default `maxListArgument`) |
162
174
  | `@relayId` | `@key` field | Adds a global `id` and the `Node` interface |
163
175
  | `@authentication(operations:, jwt:)` | type, field | Needs an authenticated request, whose claims satisfy `jwt` |
164
176
  | `@authorization(filter:, validate:)` | type (filter and validate), field (validate) | Row-level rules, compiled into statements |
@@ -178,6 +190,29 @@ string), `Date`, `Time`, `LocalTime`, `DateTime`, `LocalDateTime`,
178
190
  `Duration` (ISO-8601 strings), `Point` and `CartesianPoint` (objects; set with
179
191
  `PointInput` / `CartesianPointInput`), and non-null lists of these.
180
192
 
193
+ The six rows from `@length` to `@size` are validation rules. On a field
194
+ they are checked wherever the field is set (create, update, upsert, nested
195
+ creates, relationship properties), in JavaScript before the transaction
196
+ opens, and a mutation that breaks any is `BAD_USER_INPUT` with every
197
+ violation in `extensions.violations` (`path`, `coordinate`, `rule`,
198
+ `params`, `message`; never the value). `null` and absent values pass; a
199
+ pattern must match the whole value; values computed during the write (an
200
+ `adjust`, a `@populatedBy` result) are checked in the transaction and fail
201
+ with `CONSTRAINT_VIOLATION`. Raw Cypher and `@cypher` mutations are not
202
+ checked. Each takes `message:`. `@normalize(trim:, case:, unicode:,
203
+ emptyToNull:)` changes a string before it is validated and stored (it is
204
+ what makes a `@unique` email treat `Ada@X.com ` and `ada@x.com` as one
205
+ value). The rules and `@normalize` may sit on a custom scalar (`scalar
206
+ Email @storedAs(type: STRING) @format(as: "email")`), for every field of
207
+ that type. For a rule across fields, the `validators` option takes a
208
+ Standard Schema validator per node type (zod, valibot, arktype, yup),
209
+ run on each input after the field rules. The same directives go on
210
+ `@cypher` arguments. Each input field says its rules in its description,
211
+ `lora.inputSchemas()` (CLI: `json-schema`) exports them as JSON Schema
212
+ for clients, `diff` reports a tightened rule as breaking, and `check`
213
+ lists stored values that no longer pass. See the
214
+ [directive reference](https://loradb.com/docs/graphql/directives#validation).
215
+
181
216
  ## Queries
182
217
 
183
218
  ```graphql
@@ -1369,6 +1404,85 @@ replay earlier writes. `onWrite` still reports
1369
1404
  this instance's mutations, and `previousState` needs them: the feed carries
1370
1405
  the state after the write. Call `lora.close()` to stop the feed.
1371
1406
 
1407
+ ## Triggers and workers
1408
+
1409
+ Three things react to a write, at three moments:
1410
+
1411
+ - a **trigger** runs inside the mutation's transaction, before the
1412
+ commit: derived data that must never be out of step, or a refusal;
1413
+ - an **event** (`onWrite`, `changes()`, a subscription) reports what
1414
+ committed;
1415
+ - a **worker** delivers what a transaction cannot hold (a push, an
1416
+ e-mail, a webhook) after the commit, with retries.
1417
+
1418
+ ```graphql
1419
+ type Comment
1420
+ @node
1421
+ @mutation
1422
+ @authentication
1423
+ @trigger(
1424
+ on: [CREATE]
1425
+ statement: """
1426
+ MATCH (this)-[:ON]->(post:Post)<-[:WROTE]-(author:Person)
1427
+ CREATE (author)<-[:FOR]-(n:Notification {
1428
+ key: 'comment:' + this.key, text: 'New comment on ' + post.title
1429
+ })
1430
+ RETURN n AS created
1431
+ """
1432
+ ) {
1433
+ key: String! @key
1434
+ text: String!
1435
+ post: Post! @relationship(type: "ON", direction: OUT)
1436
+ }
1437
+
1438
+ type Notification @node @outbox @subscription(operations: [CREATE]) {
1439
+ key: String! @key
1440
+ text: String!
1441
+ }
1442
+ ```
1443
+
1444
+ ```ts
1445
+ const lora = new LoraGraphQL({
1446
+ typeDefs,
1447
+ driver: loraDriver(db),
1448
+ // What Cypher cannot say; throwing rolls the mutation back.
1449
+ triggers: {
1450
+ Comment: {
1451
+ create: async ({ keys, transaction }) => {
1452
+ /* transaction.execute(cypher, params) */
1453
+ },
1454
+ },
1455
+ },
1456
+ });
1457
+
1458
+ // After the commit, at least once, with retries: make it idempotent.
1459
+ lora.worker("Notification", {
1460
+ handle: async (notification, { key, attempt, signal }) => {
1461
+ await push.send(key, notification.text, { signal });
1462
+ },
1463
+ });
1464
+ ```
1465
+
1466
+ - A `@trigger` statement runs once per mutation with `this` bound to
1467
+ every node of the type the mutation wrote; `$jwt`, `$viewer` and
1468
+ `$operation` are bound. `fields:` narrows an `UPDATE` trigger to
1469
+ updates that named one of them.
1470
+ - Nodes a statement returns as `created` or `updated` join the
1471
+ mutation's write-set, so `onWrite` and subscriptions see them without
1472
+ `changeFeed`.
1473
+ - Triggers run only for a write every rule has passed, and what a
1474
+ trigger writes fires no trigger.
1475
+ - **Only generated mutations fire triggers.** Raw Cypher and `@cypher`
1476
+ mutations fire none.
1477
+ - A trigger function holds the writer lock, bounded by
1478
+ `triggerTimeoutMs` (default 1000). Network calls belong in a worker.
1479
+ - `@outbox` makes each node of a type work for `lora.worker()`. The
1480
+ delivery state lives on the node, outside the API, so the work is as
1481
+ durable as the data and is found again after a restart.
1482
+
1483
+ The full guide, with a tested example and every option, is on the
1484
+ [site](https://loradb.com/docs/graphql/triggers).
1485
+
1372
1486
  ## Transactions
1373
1487
 
1374
1488
  ```ts
@@ -1608,37 +1722,43 @@ transaction. Mutations need interactive transactions, which only the Node
1608
1722
  binding has, so the WASM binding serves reads.
1609
1723
  `explain()`, and so plan checks, need the Node binding too.
1610
1724
 
1611
- | Option | Default | Meaning |
1612
- | ---------------------------- | ------------- | --------------------------------------------------- |
1613
- | `timeoutMs` | 10 000 | Per statement and lock wait; a `signal` cancels |
1614
- | `operationTimeoutMs` | 2 × timeout | All root fields of one query; then `TIMEOUT` |
1615
- | `maxConcurrentStatements` | 2 | Statements one operation runs at once |
1616
- | `maxCost` | 50 000 | Estimated rows per operation |
1617
- | `maxBatch` | 1000 | Nodes written per mutation; bulk `limit` by default |
1618
- | `compileCacheBytes` | 64 MiB | Compile cache and document cache, each |
1619
- | `maxQueuedChanges` | 1000 | How far a change consumer may fall behind |
1620
- | `maxSubscriptions` | 100 | Live subscriptions per `subscriptionScope` |
1621
- | `subscriptionScope` | the context | What `maxSubscriptions` counts per |
1622
- | `maxSubscriptionFilterDepth` | 1 | Relationship filter nesting in a subscription where |
1623
- | `subscriptionTimeoutMs` | 2000 | Per statement checking a change for a subscriber |
1624
- | `defaultLimit` / `maxLimit` | 25 / 100 | Global page sizes; `@limit` may only lower `max` |
1625
- | `callbacks` | | Named callbacks for `@populatedBy` |
1626
- | `jwt` | `context.jwt` | Where the claims are |
1627
- | `onStatement` | | Observe every statement |
1628
- | `cursorSecret` | | Sign cursors; reject unsigned or forged ones |
1629
- | `maskErrors` | production | Clients get `DATABASE_ERROR` and an `id` only |
1630
- | `onError` | | Receives each database error's detail and `id` |
1631
- | `guards` | see below | Document limits; `false` turns them off |
1632
- | `persistedOnly` | false | `execute()`, `subscribe()` run persisted ops only |
1633
- | `budget` | | Cost limit per request, from the context |
1634
- | `onCost` | | Each root field's estimate, total and limit |
1635
- | `onStatementEnd` | | Duration, rows and error of every statement call |
1636
- | `timing` | false | `extensions.timing` in `execute()` results |
1637
- | `tracer` / `traceStatements` | | OpenTelemetry-style spans; Cypher text on request |
1638
- | `metrics` | | Counters and histograms (see below) |
1725
+ | Option | Default | Meaning |
1726
+ | ---------------------------- | ------------- | -------------------------------------------------------- |
1727
+ | `timeoutMs` | 10 000 | Per statement and lock wait; a `signal` cancels |
1728
+ | `operationTimeoutMs` | 2 × timeout | All root fields of one query; then `TIMEOUT` |
1729
+ | `maxConcurrentStatements` | 2 | Statements one operation runs at once |
1730
+ | `maxCost` | 50 000 | Estimated rows per operation |
1731
+ | `maxBatch` | 1000 | Nodes written per mutation; bulk `limit` by default |
1732
+ | `compileCacheBytes` | 64 MiB | Compile cache and document cache, each |
1733
+ | `maxQueuedChanges` | 1000 | How far a change consumer may fall behind |
1734
+ | `maxSubscriptions` | 100 | Live subscriptions per `subscriptionScope` |
1735
+ | `maxQueuedWrites` | 512 | Writes waiting for the writer lock; more is `OVERLOADED` |
1736
+ | `maxTotalCount` | none | Where a root connection's `totalCount` stops counting |
1737
+ | `triggerTimeoutMs` | 1000 | Per trigger function; past it the mutation fails |
1738
+ | `singleCallMutations` | true | A create is one call that begins, runs, commits |
1739
+ | `subscriptionScope` | the context | What `maxSubscriptions` counts per |
1740
+ | `maxSubscriptionFilterDepth` | 1 | Relationship filter nesting in a subscription where |
1741
+ | `subscriptionTimeoutMs` | 2000 | Per statement checking a change for a subscriber |
1742
+ | `defaultLimit` / `maxLimit` | 25 / 100 | Global page sizes; `@limit` may only lower `max` |
1743
+ | `callbacks` | | Named callbacks for `@populatedBy` |
1744
+ | `jwt` | `context.jwt` | Where the claims are |
1745
+ | `onStatement` | | Observe every statement |
1746
+ | `cursorSecret` | | Sign cursors; reject unsigned or forged ones |
1747
+ | `maskErrors` | production | Clients get `DATABASE_ERROR` and an `id` only |
1748
+ | `onError` | | Receives each database error's detail and `id` |
1749
+ | `guards` | see below | Document limits; `false` turns them off |
1750
+ | `persistedOnly` | false | `execute()`, `subscribe()` run persisted ops only |
1751
+ | `budget` | | Cost limit per request, from the context |
1752
+ | `onCost` | | Each root field's estimate, total and limit |
1753
+ | `onStatementEnd` | | Duration, rows and error of every statement call |
1754
+ | `timing` | false | `extensions.timing` in `execute()` results |
1755
+ | `tracer` / `traceStatements` | | OpenTelemetry-style spans; Cypher text on request |
1756
+ | `metrics` | | Counters and histograms (see below) |
1639
1757
 
1640
1758
  Errors carry `extensions.code`: `BAD_USER_INPUT`, `INVALID_CURSOR`,
1641
- `LIMIT_EXCEEDED`, `COST_EXCEEDED`, `TIMEOUT` (with `operationTimeoutMs`),
1759
+ `LIMIT_EXCEEDED`, `COST_EXCEEDED`, `OVERLOADED` (more than
1760
+ `maxQueuedWrites` mutations are waiting for the database: answer 503 and
1761
+ let the client back off), `TIMEOUT` (with `operationTimeoutMs`),
1642
1762
  `UNAUTHENTICATED`, `FORBIDDEN`,
1643
1763
  `NOT_FOUND`, `CONSTRAINT_VIOLATION` (with `type` and `field`),
1644
1764
  `DATABASE_ERROR` (with an `id`, also given to `onError`),
@@ -0,0 +1,11 @@
1
+ import { GraphModel } from '../model/types.js';
2
+ export type JsonSchema = Record<string, unknown>;
3
+ /**
4
+ * A JSON Schema per generated create and update input, by input type
5
+ * name (`PersonCreateInput`), describing the scalar fields a client may
6
+ * set and the rules on them. A pattern is anchored, since here it must
7
+ * match the whole value; a format is given both by name and, where it is
8
+ * an expression, as a pattern, since validators treat `format` as a
9
+ * remark by default.
10
+ */
11
+ export declare function inputSchemas(model: GraphModel): Record<string, JsonSchema>;
@@ -35,3 +35,11 @@ export declare function checkPlans(driver: LoraDriver, compiled: CompiledRead, o
35
35
  * that start from keys (every mutation statement) must have none.
36
36
  */
37
37
  export declare function scanExpands(statement: Statement, plan: QueryPlan): PlanFinding[];
38
+ /**
39
+ * Plan the statements a mutation ran. Every one starts from keys, so none
40
+ * may expand from a full scan (`scanExpands`); the engine's row estimate
41
+ * is held to `rowBudget` when one is given.
42
+ */
43
+ export declare function checkStatements(driver: LoraDriver, statements: readonly Statement[], options?: {
44
+ rowBudget?: number;
45
+ }): Promise<PlanReport[]>;
@@ -0,0 +1,29 @@
1
+ import { LoraDriver } from '../driver.js';
2
+ import { GraphModel } from '../model/types.js';
3
+ /** A stored value that breaks a rule of its field. */
4
+ export interface StoredViolation {
5
+ type: string;
6
+ /** The node's `@key`; for a relationship property, the declaring node's. */
7
+ key: unknown;
8
+ field: string;
9
+ /**
10
+ * The rule, as in a request's `violations`; `normalize` for a value not
11
+ * in its stored form; `fulltext` for one a full-text index skips.
12
+ */
13
+ rule: string;
14
+ message: string;
15
+ }
16
+ export interface StoredReport {
17
+ violations: StoredViolation[];
18
+ /**
19
+ * Nodes read per type, and relationships per `Owner.field` whose
20
+ * properties have rules; one with more than `limit` is read in part.
21
+ */
22
+ scanned: Record<string, number>;
23
+ /** Types with more nodes than were read. */
24
+ partial: string[];
25
+ }
26
+ export declare function scanStored(driver: LoraDriver, model: GraphModel, options?: {
27
+ limit?: number;
28
+ timeoutMs?: number;
29
+ }): Promise<StoredReport>;