@burojs/hasura 0.2.0 → 0.4.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/index.d.ts CHANGED
@@ -1,81 +1,81 @@
1
- import { DataOperationContext, DataProvider, FilterExpression, ErasedResourceHandle, DataLiveProvider } from '@burojs/core';
2
-
1
+ import { DataLiveProvider, DataOperationContext, DataProvider, ErasedResourceHandle, FilterExpression } from "@burojs/core";
2
+ //#region src/data-provider.d.ts
3
3
  interface HasuraDataProviderConfig {
4
- /** Returns a fresh JWT or null if user is not authed. */
5
- getToken: () => Promise<string | null>;
6
- /**
7
- * GraphQL endpoint every query/mutation is POSTed to, and the endpoint the
8
- * live provider derives its WebSocket URL from. May be a same-origin path
9
- * (`/gql/v1/graphql`, `/tandt/gql/v1/graphql`) or an absolute http(s) URL.
10
- * Defaults to {@link DEFAULT_HASURA_ENDPOINT}.
11
- */
12
- endpoint?: string;
13
- /**
14
- * Hasura role sent as `X-Hasura-Role` for resources whose mapping does not
15
- * declare a `role` of its own. `null` (the default) sends no role header at
16
- * all, leaving the JWT's own default role in force.
17
- *
18
- * A resource mapping's `role` always wins when it is defined — including
19
- * `role: null`, which suppresses the header for that one resource.
20
- *
21
- * `''` is treated as `null`, in both halves of the package: it is the shape
22
- * of a missing env var, not a role. See {@link normalizeRole}.
23
- */
24
- defaultRole?: string | null;
25
- /**
26
- * Revalidates authentication after a request comes back with HTTP **401**.
27
- * The callback owns any terminal sign-out; returning true permits one retry
28
- * with a newly obtained token, while false preserves the original 401
29
- * response.
30
- *
31
- * ⚠️ KNOWN GAP, stated here because this is where a consumer reads: HTTP 401
32
- * is NOT how the Hasura deployment this provider was distilled from rejects
33
- * a credential. The one capture in this repository of a real rejection
34
- * (`packages/mock-kit/fixtures/hasura/error-unauthorized.json`, taken from
35
- * the live backend with a deliberately malformed bearer token) came back
36
- * **HTTP 200** with an `invalid-jwt` errors envelope. Replayed through this
37
- * provider it surfaces as `{ kind: 'protocol', code: 'graphql-error' }` and
38
- * `recoverAuth` is never consulted — no refresh, no retry, and app logic
39
- * keyed on `kind: 'auth'` never fires either. Pinned by
40
- * `tests/fixture-fidelity.test.ts`'s `error-unauthorized` case.
41
- *
42
- * So this callback fires only for a deployment that really answers 401 (a
43
- * proxy or gateway in front of Hasura, typically). Keying recovery on the
44
- * error envelope instead is a product decision, deliberately not taken here;
45
- * the existing 401 tests use a FABRICATED 401 and must not be read as proof
46
- * that recovery works against Hasura itself.
47
- */
48
- recoverAuth?: () => Promise<boolean>;
49
- /** Map our resource name → table + field selection. */
50
- resources: Record<string, ResourceMapping>;
51
- /**
52
- * Optional global scope read on every getList. Returns key → value pairs
53
- * that get merged into the where clause as `{ <key>: { _eq: <value> } }`,
54
- * but only for resources that whitelist the key in `scopeFields`.
55
- *
56
- * Example: `() => ({ pg: 'radio', location_id: 5 })` — applied to documents
57
- * (which have both fields) but not products (which only have pg).
58
- */
59
- getScope?: (context: DataOperationContext) => Record<string, unknown>;
60
- /**
61
- * Closed, application-owned custom operation registry. A descriptor selects
62
- * an entry by its already-declared `{ resource, operation }` binding; it can
63
- * never supply a GraphQL document, URL, or result mapper itself.
64
- */
65
- operations?: HasuraOperationRegistry;
66
- fetchImpl?: typeof fetch;
4
+ /** Returns a fresh JWT or null if user is not authed. */
5
+ getToken: () => Promise<string | null>;
6
+ /**
7
+ * GraphQL endpoint every query/mutation is POSTed to, and the endpoint the
8
+ * live provider derives its WebSocket URL from. May be a same-origin path
9
+ * (`/gql/v1/graphql`, `/tandt/gql/v1/graphql`) or an absolute http(s) URL.
10
+ * Defaults to {@link DEFAULT_HASURA_ENDPOINT}.
11
+ */
12
+ endpoint?: string;
13
+ /**
14
+ * Hasura role sent as `X-Hasura-Role` for resources whose mapping does not
15
+ * declare a `role` of its own. `null` (the default) sends no role header at
16
+ * all, leaving the JWT's own default role in force.
17
+ *
18
+ * A resource mapping's `role` always wins when it is defined — including
19
+ * `role: null`, which suppresses the header for that one resource.
20
+ *
21
+ * `''` is treated as `null`, in both halves of the package: it is the shape
22
+ * of a missing env var, not a role. See {@link normalizeRole}.
23
+ */
24
+ defaultRole?: string | null;
25
+ /**
26
+ * Revalidates authentication after a request comes back with HTTP **401**.
27
+ * The callback owns any terminal sign-out; returning true permits one retry
28
+ * with a newly obtained token, while false preserves the original 401
29
+ * response.
30
+ *
31
+ * ⚠️ KNOWN GAP, stated here because this is where a consumer reads: HTTP 401
32
+ * is NOT how the Hasura deployment this provider was distilled from rejects
33
+ * a credential. The one capture in this repository of a real rejection
34
+ * (`packages/mock-kit/fixtures/hasura/error-unauthorized.json`, taken from
35
+ * the live backend with a deliberately malformed bearer token) came back
36
+ * **HTTP 200** with an `invalid-jwt` errors envelope. Replayed through this
37
+ * provider it surfaces as `{ kind: 'protocol', code: 'graphql-error' }` and
38
+ * `recoverAuth` is never consulted — no refresh, no retry, and app logic
39
+ * keyed on `kind: 'auth'` never fires either. Pinned by
40
+ * `tests/fixture-fidelity.test.ts`'s `error-unauthorized` case.
41
+ *
42
+ * So this callback fires only for a deployment that really answers 401 (a
43
+ * proxy or gateway in front of Hasura, typically). Keying recovery on the
44
+ * error envelope instead is a product decision, deliberately not taken here;
45
+ * the existing 401 tests use a FABRICATED 401 and must not be read as proof
46
+ * that recovery works against Hasura itself.
47
+ */
48
+ recoverAuth?: () => Promise<boolean>;
49
+ /** Map our resource name → table + field selection. */
50
+ resources: Record<string, ResourceMapping>;
51
+ /**
52
+ * Optional global scope read on every getList. Returns key → value pairs
53
+ * that get merged into the where clause as `{ <key>: { _eq: <value> } }`,
54
+ * but only for resources that whitelist the key in `scopeFields`.
55
+ *
56
+ * Example: `() => ({ pg: 'radio', location_id: 5 })` — applied to documents
57
+ * (which have both fields) but not products (which only have pg).
58
+ */
59
+ getScope?: (context: DataOperationContext) => Record<string, unknown>;
60
+ /**
61
+ * Closed, application-owned custom operation registry. A descriptor selects
62
+ * an entry by its already-declared `{ resource, operation }` binding; it can
63
+ * never supply a GraphQL document, URL, or result mapper itself.
64
+ */
65
+ operations?: HasuraOperationRegistry;
66
+ fetchImpl?: typeof fetch;
67
67
  }
68
68
  interface HasuraCustomOperation {
69
- /** Which native provider slot may execute this declaration. */
70
- readonly kind: 'query' | 'mutation';
71
- /** Static GraphQL document owned by the application/provider integration. */
72
- readonly document: string;
73
- /** Maps the declared operation input to the document's variables. */
74
- readonly variables: (input: unknown) => Record<string, unknown> | undefined;
75
- /** Maps the full GraphQL response to the declared operation output. */
76
- readonly mapResult?: (data: unknown) => unknown;
77
- /** Overrides the mapped resource role; undefined keeps that mapped role. */
78
- readonly role?: string | null;
69
+ /** Which native provider slot may execute this declaration. */
70
+ readonly kind: 'query' | 'mutation';
71
+ /** Static GraphQL document owned by the application/provider integration. */
72
+ readonly document: string;
73
+ /** Maps the declared operation input to the document's variables. */
74
+ readonly variables: (input: unknown) => Record<string, unknown> | undefined;
75
+ /** Maps the full GraphQL response to the declared operation output. */
76
+ readonly mapResult?: (data: unknown) => unknown;
77
+ /** Overrides the mapped resource role; undefined keeps that mapped role. */
78
+ readonly role?: string | null;
79
79
  }
80
80
  /**
81
81
  * Registry entries are keyed by the resource binding first, then the declared
@@ -84,183 +84,251 @@ interface HasuraCustomOperation {
84
84
  */
85
85
  type HasuraOperationRegistry = Readonly<Record<string, Readonly<Record<string, HasuraCustomOperation>>>>;
86
86
  interface ResourceMapping {
87
- table: string;
88
- selectFields: string[];
89
- /**
90
- * GraphQL scalar name for this table's primary key, interpolated into the
91
- * `$id` variable declaration of every by-pk query/mutation. Default
92
- * `'bigint'`.
93
- *
94
- * DISTILLATION NOTE (divergence 3 of 7 — feature, additive, l2-web-ui only).
95
- * l2-web-ui added `'String'` for tables whose pk is a genuine string
96
- * business key (a GS1 marking code), not a uuid/Int/bigint
97
- * wire type: Hasura's by-pk root for such a table is literally
98
- * `<table>_by_pk(<pk>: $id)` with `$id: String!`, which none of the other
99
- * three names can express (playground's union stopped at
100
- * `'bigint' | 'uuid' | 'Int'`). Purely a type-union widening — no runtime
101
- * branch reads `pkType` beyond interpolating it — so l2-web-ui's wider union
102
- * is taken wholesale rather than forked.
103
- */
104
- pkType?: 'bigint' | 'uuid' | 'Int' | 'String';
105
- /**
106
- * Skip the aggregate on every `getList`. This is a COST decision, not a
107
- * schema fact: the table has an `_aggregate` root but counting it on each
108
- * page is too slow to pay for unprompted. (Measured in one of the apps this
109
- * provider was distilled from: ~9.8M-row and ~28.9M-row tables, the latter
110
- * taking ~55 s per count.) `total` is then omitted and the list renders the
111
- * unknown-total shape.
112
- *
113
- * ⚠️ NOT the same thing as {@link ResourceMapping.aggregate} `: false`, and
114
- * NOT a proxy for it — most `noAggregate` tables CAN be counted on demand,
115
- * which is exactly what `getCount` is for. Deriving one from the other gets
116
- * the flagship resources backwards.
117
- */
118
- noAggregate?: boolean;
119
- /**
120
- * Which aggregate root field backs this table, for the on-demand `getCount`
121
- * (and for `getList`'s own count when it runs):
122
- * - absent — derive `${table}_aggregate` (the Hasura default; today's behaviour);
123
- * - `false` — no aggregate root exists at all, under any name. `getCount`
124
- * answers `{ total: null }` without a round trip and the UI drops the
125
- * affordance for the session;
126
- * - a string — an explicitly-named root, for tables whose custom root fields
127
- * break the derivation.
128
- *
129
- * ⚠️ Establish this by introspecting the live schema under the role the app
130
- * actually runs as, not by reading `noAggregate` or a code comment. Both
131
- * have been wrong here.
132
- */
133
- aggregate?: false | string;
134
- /**
135
- * Root field for a single-row fetch by primary key. Defaults to the derived
136
- * `${table}_by_pk`. Set `false` when the active role exposes only the
137
- * collection root; `getOne` then queries it with pk `where` + `limit: 1`.
138
- *
139
- * ⚠️ Same class of fact as `aggregate`, and established the same way — by
140
- * introspecting the live schema, not by reading a comment. In one of the
141
- * source apps exactly one table of forty had a non-derivable by-pk root (a
142
- * plural table whose root was singular), so the derived name 400'd and its
143
- * Show page was a dead-end error reachable in two clicks from the sidebar.
144
- */
145
- byPkRoot?: string | false;
146
- /** Field to order by in getList. Defaults to 'id'. */
147
- orderByField?: string;
148
- /** Name of the primary-key field in the row (for synthetic id in list rows). Defaults to 'id'. */
149
- pkField?: string;
150
- /**
151
- * Hasura role for this resource. `null` (or `''`) means no role header (use
152
- * the JWT's default role). Undefined falls back to
153
- * {@link HasuraDataProviderConfig.defaultRole}.
154
- */
155
- role?: string | null;
156
- /**
157
- * Columns typed `int64` in a Hasura ClickHouse source. Hasura serialises
158
- * int64 as a JSON STRING and REJECTS a numeric filter value with
159
- * «expected a string for type 'int64'» — so a `document_id` filter sent as a
160
- * number matched nothing and the affected list tabs came back empty.
161
- * Filter values for these fields are coerced to strings before the where
162
- * clause is built. (Verified live: numeric/int literal → error, string → rows.)
163
- */
164
- int64Fields?: string[];
165
- /**
166
- * Whitelist of scope keys this table actually has columns for. Only these
167
- * keys from `getScope()` get injected into where clauses. Defaults to `[]`
168
- * (no scope injection — useful for tables that lack the scope columns).
169
- */
170
- scopeFields?: string[];
171
- /**
172
- * JSONB-spill routing for tables whose forms submit a flat values record but
173
- * whose real columns nest most of those keys inside JSONB blobs (e.g. a
174
- * `documents` table where template head fields live inside `head`, a couple
175
- * live inside `meta`, and only a handful — type/status/… — are real columns).
176
- *
177
- * When set, create/update partition the incoming flat `variables`:
178
- * - keys that ARE real columns (present in `selectFields`) stay top-level;
179
- * - keys listed in `metaKeys` nest under `metaColumn`;
180
- * - every OTHER key nests under `column` (the head spill).
181
- * getOne does the inverse — it spreads the fetched `column`/`metaColumn`
182
- * objects back onto the flat record so edit forms / show pages read those
183
- * keys as flat fields (the raw blobs are kept too).
184
- *
185
- * SCOPE: scalar / relation / enum head+meta fields only. Data-body arrays
186
- * (children) live in a sibling table — see `dataBody`.
187
- */
188
- jsonbSpill?: JsonbSpillConfig;
189
- /**
190
- * Data-body routing for resources whose heavy array payload lives in a
191
- * SEPARATE 1:1 sibling table rather than a JSONB column on the row itself
192
- * (e.g. a `documents.items[]` / `products[]` payload living in
193
- * `documents_data.data`, keyed by `document_id`).
194
- *
195
- * When set, create/update peel the `keys` off the flat `variables` FIRST
196
- * (before `jsonbSpill` runs, so they never leak into `head`), write the parent
197
- * row, then upsert the sibling row (`insert_<table>_one … on_conflict`). getOne
198
- * nested-selects the sibling via `relationship` and spreads its `column` blob
199
- * back onto the flat record (so edit forms read `items` as a top-level field),
200
- * dropping the raw relationship object so it can't round-trip into `head`.
201
- * getList is left untouched — list columns are head/meta scalars, never the
202
- * heavy data body.
203
- */
204
- dataBody?: DataBodyConfig;
205
- /**
206
- * Fixed, always-on Hasura `where` fragment ANDed into every getList /
207
- * getOne query for this resource. Powers "per-type" / projection resources:
208
- * several resource names can map to the SAME table, each scoped to a
209
- * different slice — e.g. `fixedFilter: { type: { _eq: 'work_order' } }` turns
210
- * a `work_orders` resource into a work-orders-only view of a shared
211
- * `documents` table. The fragment is a raw Hasura `<table>_bool_exp` object
212
- * (same shape `filtersToWhere` emits), so nested / `_and` / `_or` clauses are
213
- * all valid. Merged with `mergeWhere` (AND) after user filters + scope, so
214
- * the user can still filter/search WITHIN the fixed slice.
215
- *
216
- * getOne: because `<table>_by_pk` takes no `where`, a resource with a
217
- * `fixedFilter` fetches its single row via a `where`-based `limit: 1` query
218
- * (pk `_eq` id AND fixedFilter) instead — so the row is only returned when it
219
- * actually belongs to the slice (a work-order id won't resolve under
220
- * `movements`). Resources without a `fixedFilter` keep the `_by_pk` path.
221
- */
222
- fixedFilter?: Record<string, unknown>;
223
- /**
224
- * Opt this resource into real-time updates. The live provider then opens a
225
- * cheap change-signal subscription for its list / single-record views and
226
- * invalidates matching native runtime queries when the table changes.
227
- *
228
- * Off by default on purpose: every live resource holds an open server-side
229
- * subscription, so it is a cost you opt into for data that actually moves
230
- * (documents change constantly during processing; a country lookup does not).
231
- */
232
- live?: boolean;
87
+ table: string;
88
+ selectFields: string[];
89
+ /**
90
+ * GraphQL scalar name for this table's primary key, interpolated into the
91
+ * `$id` variable declaration of every by-pk query/mutation. Default
92
+ * `'bigint'`.
93
+ *
94
+ * DISTILLATION NOTE (divergence 3 of 7 — feature, additive, l2-web-ui only).
95
+ * l2-web-ui added `'String'` for tables whose pk is a genuine string
96
+ * business key (a GS1 marking code), not a uuid/Int/bigint
97
+ * wire type: Hasura's by-pk root for such a table is literally
98
+ * `<table>_by_pk(<pk>: $id)` with `$id: String!`, which none of the other
99
+ * three names can express (playground's union stopped at
100
+ * `'bigint' | 'uuid' | 'Int'`). Purely a type-union widening — no runtime
101
+ * branch reads `pkType` beyond interpolating it — so l2-web-ui's wider union
102
+ * is taken wholesale rather than forked.
103
+ */
104
+ pkType?: 'bigint' | 'uuid' | 'Int' | 'String';
105
+ /**
106
+ * Skip the aggregate on every `getList`. This is a COST decision, not a
107
+ * schema fact: the table has an `_aggregate` root but counting it on each
108
+ * page is too slow to pay for unprompted. (Measured in one of the apps this
109
+ * provider was distilled from: ~9.8M-row and ~28.9M-row tables, the latter
110
+ * taking ~55 s per count.) `total` is then omitted and the list renders the
111
+ * unknown-total shape.
112
+ *
113
+ * ⚠️ NOT the same thing as {@link ResourceMapping.aggregate} `: false`, and
114
+ * NOT a proxy for it — most `noAggregate` tables CAN be counted on demand,
115
+ * which is exactly what `getCount` is for. Deriving one from the other gets
116
+ * the flagship resources backwards.
117
+ */
118
+ noAggregate?: boolean;
119
+ /**
120
+ * Which aggregate root field backs this table, for the on-demand `getCount`
121
+ * (and for `getList`'s own count when it runs):
122
+ * - absent — derive `${table}_aggregate` (the Hasura default; today's behaviour);
123
+ * - `false` — no aggregate root exists at all, under any name. `getCount`
124
+ * answers `{ total: null }` without a round trip and the UI drops the
125
+ * affordance for the session;
126
+ * - a string — an explicitly-named root, for tables whose custom root fields
127
+ * break the derivation.
128
+ *
129
+ * ⚠️ Establish this by introspecting the live schema under the role the app
130
+ * actually runs as, not by reading `noAggregate` or a code comment. Both
131
+ * have been wrong here.
132
+ */
133
+ aggregate?: false | string;
134
+ /**
135
+ * Root field for a single-row fetch by primary key. Defaults to the derived
136
+ * `${table}_by_pk`. Set `false` when the active role exposes only the
137
+ * collection root; `getOne` then queries it with pk `where` + `limit: 1`.
138
+ *
139
+ * ⚠️ Same class of fact as `aggregate`, and established the same way — by
140
+ * introspecting the live schema, not by reading a comment. In one of the
141
+ * source apps exactly one table of forty had a non-derivable by-pk root (a
142
+ * plural table whose root was singular), so the derived name 400'd and its
143
+ * Show page was a dead-end error reachable in two clicks from the sidebar.
144
+ */
145
+ byPkRoot?: string | false;
146
+ /** Field to order by in getList. Defaults to 'id'. */
147
+ orderByField?: string;
148
+ /** Name of the primary-key field in the row (for synthetic id in list rows). Defaults to 'id'. */
149
+ pkField?: string;
150
+ /**
151
+ * Hasura role for this resource. `null` (or `''`) means no role header (use
152
+ * the JWT's default role). Undefined falls back to
153
+ * {@link HasuraDataProviderConfig.defaultRole}.
154
+ */
155
+ role?: string | null;
156
+ /**
157
+ * Columns typed `int64` in a Hasura ClickHouse source. Hasura serialises
158
+ * int64 as a JSON STRING and REJECTS a numeric filter value with
159
+ * «expected a string for type 'int64'» — so a `document_id` filter sent as a
160
+ * number matched nothing and the affected list tabs came back empty.
161
+ * Filter values for these fields are coerced to strings before the where
162
+ * clause is built. (Verified live: numeric/int literal → error, string → rows.)
163
+ */
164
+ int64Fields?: string[];
165
+ /**
166
+ * Whitelist of scope keys this table actually has columns for. Only these
167
+ * keys from `getScope()` get injected into where clauses. Defaults to `[]`
168
+ * (no scope injection — useful for tables that lack the scope columns).
169
+ *
170
+ * ⚠️ SCOPE NARROWS THREE READ PATHS, NOT FIVE. `scopeToWhere` is reached
171
+ * only through {@link composeListWhere}, i.e. from `queries.list` (both
172
+ * branches), `queries.count` and `queries.many`. **`queries.one` composes
173
+ * `{pk _eq id} ∧ fixedFilter ∧ localeSlice` and carries NO scope**, so a
174
+ * record excluded from a scoped list is still readable by its id through
175
+ * this provider. `fixedFilter` — a slice that cannot depend on the session —
176
+ * does cover `one`; the session-dependent pair (`getScope` + `scopeFields`)
177
+ * does not. Treat a scoped projection as a LIST narrowing, and do not rely
178
+ * on it as an authorization boundary: that belongs in the backend's own row
179
+ * permissions, which is where a real Hasura deployment enforces it. Raised
180
+ * by the SP4 final review (M3); closing it is a change to the by-pk path
181
+ * with its own blast radius and is carried as a follow-up rather than made
182
+ * here.
183
+ */
184
+ scopeFields?: string[];
185
+ /**
186
+ * JSONB-spill routing for tables whose forms submit a flat values record but
187
+ * whose real columns nest most of those keys inside JSONB blobs (e.g. a
188
+ * `documents` table where template head fields live inside `head`, a couple
189
+ * live inside `meta`, and only a handful — type/status/… — are real columns).
190
+ *
191
+ * When set, create/update partition the incoming flat `variables`:
192
+ * - keys that ARE real columns (present in `selectFields`) stay top-level;
193
+ * - keys listed in `metaKeys` nest under `metaColumn`;
194
+ * - every OTHER key nests under `column` (the head spill).
195
+ * getOne does the inverse — it spreads the fetched `column`/`metaColumn`
196
+ * objects back onto the flat record so edit forms / show pages read those
197
+ * keys as flat fields (the raw blobs are kept too).
198
+ *
199
+ * SCOPE: scalar / relation / enum head+meta fields only. Data-body arrays
200
+ * (children) live in a sibling table — see `dataBody`.
201
+ */
202
+ jsonbSpill?: JsonbSpillConfig;
203
+ /**
204
+ * Data-body routing for resources whose heavy array payload lives in a
205
+ * SEPARATE 1:1 sibling table rather than a JSONB column on the row itself
206
+ * (e.g. a `documents.items[]` / `products[]` payload living in
207
+ * `documents_data.data`, keyed by `document_id`).
208
+ *
209
+ * When set, create/update peel the `keys` off the flat `variables` FIRST
210
+ * (before `jsonbSpill` runs, so they never leak into `head`), write the parent
211
+ * row, then upsert the sibling row (`insert_<table>_one … on_conflict`). getOne
212
+ * nested-selects the sibling via `relationship` and spreads its `column` blob
213
+ * back onto the flat record (so edit forms read `items` as a top-level field),
214
+ * dropping the raw relationship object so it can't round-trip into `head`.
215
+ * getList is left untouched — list columns are head/meta scalars, never the
216
+ * heavy data body.
217
+ */
218
+ dataBody?: DataBodyConfig;
219
+ /**
220
+ * Fixed, always-on Hasura `where` fragment ANDed into every getList /
221
+ * getOne query for this resource. Powers "per-type" / projection resources:
222
+ * several resource names can map to the SAME table, each scoped to a
223
+ * different slice — e.g. `fixedFilter: { type: { _eq: 'work_order' } }` turns
224
+ * a `work_orders` resource into a work-orders-only view of a shared
225
+ * `documents` table. The fragment is a raw Hasura `<table>_bool_exp` object
226
+ * (same shape `filtersToWhere` emits), so nested / `_and` / `_or` clauses are
227
+ * all valid. Merged with `mergeWhere` (AND) after user filters + scope, so
228
+ * the user can still filter/search WITHIN the fixed slice.
229
+ *
230
+ * getOne: because `<table>_by_pk` takes no `where`, a resource with a
231
+ * `fixedFilter` fetches its single row via a `where`-based `limit: 1` query
232
+ * (pk `_eq` id AND fixedFilter) instead — so the row is only returned when it
233
+ * actually belongs to the slice (a work-order id won't resolve under
234
+ * `movements`). Resources without a `fixedFilter` keep the `_by_pk` path.
235
+ */
236
+ fixedFilter?: Record<string, unknown>;
237
+ /**
238
+ * The column on this table that holds a row's LANGUAGE, for a backend that
239
+ * translates by storing one row per locale (the "sidecar translation table"
240
+ * shape: `catalog_product_translation(product_id, locale, name, …)`).
241
+ *
242
+ * When set, every read handler of this resource ANDs `{ <localeField>: {
243
+ * _eq: <descriptor.locale> } }` into its `where` — `queries.list` (both the
244
+ * offset and the cursor branch), `queries.count`, `queries.many` and the
245
+ * by-pk `queries.one` alike — so the caller gets exactly the active
246
+ * language's rows.
247
+ *
248
+ * ⚠️ EXACTLY FOUR HANDLERS, not "every read anywhere in this module", and
249
+ * the difference is worth stating because an earlier revision of this
250
+ * sentence was broader than the code. Two reads are deliberately outside it:
251
+ *
252
+ * - `readMutationRecord` — the re-read a mutation performs to return the
253
+ * row it just wrote. It addresses ONE row the caller has just named by
254
+ * primary key, and per the write rule below the caller supplied its
255
+ * `locale` themselves. Narrowing it by the UI's active language could
256
+ * only turn a successful write into a `null` result for a row the caller
257
+ * is holding in its hand.
258
+ * - `queries.many` with an EMPTY id set — see the note at its early
259
+ * return; it refuses eagerly (`localeWhere` is called before the empty
260
+ * read short-circuits) precisely so this list does not need a third
261
+ * exception.
262
+ * `descriptor.locale` is put there by `@burojs/core`'s
263
+ * `resolveDescriptorLocale`, which fills it ONLY for a resource declaring
264
+ * `data: { localeVarying: true }`.
265
+ *
266
+ * ⚠️ THE TWO DECLARATIONS MUST AGREE, and this provider REFUSES rather than
267
+ * guessing when they do not. A mapping with a `localeField` whose descriptor
268
+ * carries no locale means the resource forgot `localeVarying`, and the
269
+ * consequence is not cosmetic: the query returns EVERY language's rows —
270
+ * three rows per product, in mixed languages, with duplicate business keys —
271
+ * which is a plausible-looking wrong answer, the defect class this package
272
+ * refuses on four other paths (cursor orderability, unsupported operators,
273
+ * unmapped resources, absent aggregate roots). So the read throws
274
+ * {@link LOCALE_REQUIRED_CODE} naming both halves of the fix.
275
+ *
276
+ * ⚠️ WRITES ARE NOT TOUCHED. `create`/`update`/`delete` do not inject the
277
+ * locale: a mutation on a translation row must name its own `locale` in the
278
+ * submitted values (it is a real column), and a by-pk update addresses one
279
+ * row by its own primary key, which already identifies the language. Silently
280
+ * stamping the UI's active language onto a write would be a different and
281
+ * much worse guess than the one refused above.
282
+ *
283
+ * Why a column name here rather than a Hasura ARGUMENT: this provider selects
284
+ * FLAT COLUMNS (see `selectFields`), and `ARRAY_RELATION_ARGS` carries no
285
+ * nested `where`, so a translated value cannot be pulled through a nested
286
+ * relation selection on the parent row at all. Reading the translation table
287
+ * as its own root query, narrowed by locale, is the access path the schema
288
+ * shape actually supports.
289
+ */
290
+ localeField?: string;
291
+ /**
292
+ * Opt this resource into real-time updates. The live provider then opens a
293
+ * cheap change-signal subscription for its list / single-record views and
294
+ * invalidates matching native runtime queries when the table changes.
295
+ *
296
+ * Off by default on purpose: every live resource holds an open server-side
297
+ * subscription, so it is a cost you opt into for data that actually moves
298
+ * (documents change constantly during processing; a country lookup does not).
299
+ */
300
+ live?: boolean;
233
301
  }
234
302
  /** @see ResourceMapping.jsonbSpill */
235
303
  interface JsonbSpillConfig {
236
- /** JSONB column that catches every non-base, non-meta key (e.g. `head`). */
237
- column: string;
238
- /** JSONB column that catches the `metaKeys` (e.g. `meta`). */
239
- metaColumn: string;
240
- /** Flat keys routed to `metaColumn` instead of the `column` spill. */
241
- metaKeys: string[];
242
- /**
243
- * Flat form key → dotted NESTED path inside the head `column`. Lets a flat
244
- * field (e.g. `vsd_number`) persist to a nested head location (e.g.
245
- * `regulator_data.crpt.vsd_number`) without dotted field keys. getOne reads
246
- * the nested value back out to the flat key.
247
- */
248
- nestedKeys?: Record<string, string>;
304
+ /** JSONB column that catches every non-base, non-meta key (e.g. `head`). */
305
+ column: string;
306
+ /** JSONB column that catches the `metaKeys` (e.g. `meta`). */
307
+ metaColumn: string;
308
+ /** Flat keys routed to `metaColumn` instead of the `column` spill. */
309
+ metaKeys: string[];
310
+ /**
311
+ * Flat form key → dotted NESTED path inside the head `column`. Lets a flat
312
+ * field (e.g. `vsd_number`) persist to a nested head location (e.g.
313
+ * `regulator_data.crpt.vsd_number`) without dotted field keys. getOne reads
314
+ * the nested value back out to the flat key.
315
+ */
316
+ nestedKeys?: Record<string, string>;
249
317
  }
250
318
  /** @see ResourceMapping.dataBody */
251
319
  interface DataBodyConfig {
252
- /** Sibling table holding the heavy data body, 1:1 with the parent (e.g. `documents_data`). */
253
- table: string;
254
- /** FK column on the sibling pointing back at the parent pk (e.g. `document_id`). */
255
- fk: string;
256
- /** JSONB column on the sibling that holds the whole data body (e.g. `data`). */
257
- column: string;
258
- /** Reverse object-relationship name on the PARENT row → sibling, for the getOne nested select (e.g. `document_data`). */
259
- relationship: string;
260
- /** `on_conflict` constraint enum for the upsert (e.g. `document_data_pkey`). */
261
- constraint: string;
262
- /** Flat value keys that belong to the data body — routed to the sibling `column` (e.g. `['items', 'products']`). */
263
- keys: string[];
320
+ /** Sibling table holding the heavy data body, 1:1 with the parent (e.g. `documents_data`). */
321
+ table: string;
322
+ /** FK column on the sibling pointing back at the parent pk (e.g. `document_id`). */
323
+ fk: string;
324
+ /** JSONB column on the sibling that holds the whole data body (e.g. `data`). */
325
+ column: string;
326
+ /** Reverse object-relationship name on the PARENT row → sibling, for the getOne nested select (e.g. `document_data`). */
327
+ relationship: string;
328
+ /** `on_conflict` constraint enum for the upsert (e.g. `document_data_pkey`). */
329
+ constraint: string;
330
+ /** Flat value keys that belong to the data body — routed to the sibling `column` (e.g. `['items', 'products']`). */
331
+ keys: string[];
264
332
  }
265
333
  /**
266
334
  * Split a flat `values` record for a data-body resource into the sibling-table
@@ -277,9 +345,9 @@ interface DataBodyConfig {
277
345
  * Exported for unit testing; the provider calls it with `m.dataBody`.
278
346
  */
279
347
  declare function partitionDataBody(values: Record<string, unknown>, cfg: DataBodyConfig): {
280
- dataBody: Record<string, unknown>;
281
- rest: Record<string, unknown>;
282
- hasDataBody: boolean;
348
+ dataBody: Record<string, unknown>;
349
+ rest: Record<string, unknown>;
350
+ hasDataBody: boolean;
283
351
  };
284
352
  /**
285
353
  * Partition a flat `values` record for a jsonb-spill resource.
@@ -329,6 +397,24 @@ declare function flattenJsonbSpill(item: unknown, spill: JsonbSpillConfig): unkn
329
397
  * stable symbol.
330
398
  */
331
399
  declare const NOT_AUTHENTICATED_CODE = "not-authenticated";
400
+ /**
401
+ * Refusal code for "this mapping declares a `localeField` but the descriptor
402
+ * carries no locale". Exported so an application can match a symbol rather
403
+ * than a string — same treatment as {@link FILTER_OPERATOR_UNSUPPORTED_CODE}.
404
+ *
405
+ * @see ResourceMapping.localeField for why this is a refusal and not a default.
406
+ */
407
+ declare const LOCALE_REQUIRED_CODE = "locale-required";
408
+ /**
409
+ * The locale conjunct for a mapping that declares one, or `undefined` for a
410
+ * mapping that does not.
411
+ *
412
+ * The refusal is EAGER — it fires before any request leaves, so it does not
413
+ * depend on how many rows the table happens to hold. (Task 1 of this
414
+ * sub-project recorded the opposite mistake: an absence assertion that stayed
415
+ * green because a LAZY check downstream threw for its own reasons.)
416
+ */
417
+ declare function localeWhere(m: ResourceMapping, locale: string | undefined): Record<string, unknown> | undefined;
332
418
  /**
333
419
  * The shared refusal code for "this provider cannot express that filter
334
420
  * operator". `@burojs/pocketbase` throws the SAME `kind` and the SAME code, so
@@ -348,6 +434,13 @@ declare function scopeToWhere(scope: Record<string, unknown>, scopeFields: strin
348
434
  declare function mergeWhere(a: Record<string, unknown> | undefined, b: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
349
435
  /** Translate the native filter AST into a Hasura bool expression. */
350
436
  declare function filtersToWhere(filter?: FilterExpression<ErasedResourceHandle>): Record<string, unknown> | undefined;
437
+ /**
438
+ * Exported for its own test: bigint coercion matches the WHOLE field string,
439
+ * so a nested predicate needs its full path declared in `int64Fields`.
440
+ * Matching the leaf would coerce a same-named column on any other table the
441
+ * path happens to traverse.
442
+ */
443
+ declare function coerceInt64Filter(filter: FilterExpression<ErasedResourceHandle> | undefined, fields: readonly string[] | undefined): FilterExpression<ErasedResourceHandle> | undefined;
351
444
  /**
352
445
  * Resolve the Hasura aggregate root field for a mapping, or `null` when the
353
446
  * registry declares that none exists. Registry-driven on purpose: which tables
@@ -363,7 +456,202 @@ declare function aggregateRoot(m: ResourceMapping): string | null;
363
456
  */
364
457
  declare function byPkRoot(m: ResourceMapping): string | null;
365
458
  declare function createHasuraDataProvider(config: HasuraDataProviderConfig): DataProvider;
366
-
459
+ //#endregion
460
+ //#region src/cursor.d.ts
461
+ /**
462
+ * Keyset ("seek") cursor pagination for Hasura.
463
+ *
464
+ * ── What a cursor IS here, and why it is synthesised ──────────────────────
465
+ *
466
+ * Hasura issues no opaque cursor tokens of its own — there is no `endCursor`
467
+ * to echo back, no server-side scroll state, nothing. A Relay-style
468
+ * `pageInfo.endCursor` on a Hasura root is a Relay-connection feature of
469
+ * *other* GraphQL servers; the roots this provider talks to take
470
+ * `limit`/`offset`/`order_by`/`where` and nothing else.
471
+ *
472
+ * So the cursor is BUILT by this provider out of the only durable thing a
473
+ * page leaves behind: the ordering values of its last row. The next page is
474
+ * then "every row that sorts strictly after that row", expressed as an
475
+ * ordinary `where` — which is why this needs no backend feature at all and
476
+ * works against any Hasura table.
477
+ *
478
+ * It is nevertheless **opaque above the provider**. Nothing outside this
479
+ * package parses, constructs, or compares a cursor: callers receive
480
+ * `pageInfo.nextCursor` and hand it back as `pagination.after`. The encoding
481
+ * below (base64url of a small JSON envelope) exists to make that boundary
482
+ * obvious at a glance, not to hide anything — a decoded cursor is inert
483
+ * data, and the provider re-validates every field of it before it can shape
484
+ * a query.
485
+ *
486
+ * ── Why a total order is mandatory, not a nicety ──────────────────────────
487
+ *
488
+ * "The rows after this one" is only well defined if the ordering is TOTAL —
489
+ * i.e. no two rows compare equal. Order a product list by `price_cents` and
490
+ * seven rows share the value 1999: a page ending inside that run leaves the
491
+ * provider unable to say which of the seven it stopped at. `price_cents >
492
+ * 1999` SKIPS the rest of the run; `price_cents >= 1999` REPEATS the ones
493
+ * already shown. Both answers look perfectly plausible on screen, which is
494
+ * what makes the bug expensive.
495
+ *
496
+ * The fix is the standard one: append the primary key as a final
497
+ * tie-breaker, and compare rows lexicographically over the whole ordering
498
+ * (see {@link keysetWhere}). The pk is unique, so ordering columns + pk is
499
+ * always total.
500
+ *
501
+ * ── Why this module refuses instead of falling back ───────────────────────
502
+ *
503
+ * Every refusal below is a case where the provider cannot express the
504
+ * request faithfully. None of them degrades to offset pagination, and that
505
+ * is deliberate: converting "not expressible" into a plausible wrong answer
506
+ * is the single defect class this codebase has caught most often (a
507
+ * branchless `_or: []` matching every row; `OP_MAP[op] ?? '_eq'` turning an
508
+ * unknown operator into equality). A cursor list silently served as offset
509
+ * would duplicate and skip rows under concurrent writes and would never
510
+ * report that it had done so.
511
+ */
512
+ /** One column of the effective ordering, after the tie-breaker has been applied. */
513
+ interface CursorOrderingColumn {
514
+ readonly field: string;
515
+ readonly direction: 'asc' | 'desc';
516
+ }
517
+ /**
518
+ * Every refusal this module can raise, as a frozen record so a consumer
519
+ * matches a symbol rather than re-typing a string literal — the same reason
520
+ * `NOT_AUTHENTICATED_CODE` and `FILTER_OPERATOR_UNSUPPORTED_CODE` are
521
+ * exported.
522
+ *
523
+ * `cursor-pagination-unsupported`, the blanket refusal this package threw
524
+ * before cursors existed, is GONE — nothing throws it any more. A consumer
525
+ * that matched on it was matching "this provider has no cursors at all",
526
+ * which is no longer true; the honest replacements are these six, each
527
+ * naming a specific request the provider still cannot serve.
528
+ */
529
+ declare const HASURA_CURSOR_CODES: Readonly<{
530
+ /** The effective ordering cannot be made total — see {@link assertCursorOrderable}. */
531
+ readonly unorderable: "cursor-unorderable";
532
+ /** `pagination.before` was supplied; only forward paging is implemented. */
533
+ readonly backward: "cursor-backward-unsupported";
534
+ /** `pagination.limit` is not a positive integer. */
535
+ readonly limit: "cursor-limit-invalid";
536
+ /** The `after` string is not a cursor this provider minted. */
537
+ readonly malformed: "cursor-malformed";
538
+ /** The cursor was minted under a different ordering than this request asks for. */
539
+ readonly mismatch: "cursor-ordering-mismatch";
540
+ /** The boundary row's ordering value is NULL, so no keyset predicate can address it. */
541
+ readonly boundaryNull: "cursor-boundary-null";
542
+ }>;
543
+ /**
544
+ * The ordering a cursor was minted under, as a single comparable string.
545
+ * Recorded inside the cursor and re-checked on the way back in, so a cursor
546
+ * taken from a list sorted by price cannot be replayed against the same list
547
+ * sorted by name: the keyset predicate would be built from the wrong columns
548
+ * and the resulting page would be arbitrary rather than wrong-looking.
549
+ */
550
+ declare function orderingSignature(ordering: readonly CursorOrderingColumn[]): string;
551
+ /**
552
+ * The ordering this provider will actually emit for a cursor page: the
553
+ * caller's sort (or the mapping's default when there is none), with the
554
+ * primary key appended as the tie-breaker unless the caller already sorts by
555
+ * it.
556
+ *
557
+ * The tie-breaker inherits the direction of the last ordering column rather
558
+ * than being pinned to `asc`. Either choice yields a total order; inheriting
559
+ * keeps a `desc` list reading in one direction all the way down instead of
560
+ * flipping inside each run of equal values, which is what a reader would
561
+ * expect from "newest first, ties broken by newest id".
562
+ */
563
+ declare function effectiveCursorOrdering(sort: readonly {
564
+ readonly field: string;
565
+ readonly direction: 'asc' | 'desc';
566
+ }[] | undefined, mapping: {
567
+ readonly pkField?: string;
568
+ readonly orderByField?: string;
569
+ }): readonly CursorOrderingColumn[];
570
+ /**
571
+ * REFUSAL 1 — the ordering cannot produce a total order this provider can
572
+ * both emit and read back.
573
+ *
574
+ * Two conditions, both checked EAGERLY (before any request leaves the
575
+ * client), because a refusal that depends on how many rows happen to be in
576
+ * the table is not a refusal — it is a coin flip. That lesson is recorded in
577
+ * `@burojs/mock-kit`: an unsupported operator checked lazily answered `[]`
578
+ * for an empty table and threw for a populated one.
579
+ *
580
+ * 1. An ordering column is not in `selectFields`. The cursor is made of the
581
+ * boundary row's ordering values, and a column the query does not select
582
+ * is not in the row that comes back. This is also how the pk tie-breaker
583
+ * is enforced: a mapping that does not select its own primary key cannot
584
+ * be cursor-paginated at all.
585
+ * 2. A sort entry carries `nulls: 'first' | 'last'`. Hasura expresses that
586
+ * as `asc_nulls_first`/`desc_nulls_last`, and a keyset predicate over a
587
+ * nullable column under those semantics needs `IS NULL` branches this
588
+ * provider does not build. The OFFSET path ignores `nulls` outright —
589
+ * which is a real (pre-existing) infidelity there, but harmless in the
590
+ * sense that offset paging stays self-consistent. Ignoring it here would
591
+ * not: pages would be cut at boundaries the emitted `order_by` does not
592
+ * actually produce, so rows would be skipped.
593
+ */
594
+ declare function assertCursorOrderable(ordering: readonly CursorOrderingColumn[], sort: readonly {
595
+ readonly field: string;
596
+ readonly nulls?: 'first' | 'last';
597
+ }[] | undefined, mapping: {
598
+ readonly table: string;
599
+ readonly selectFields: readonly string[];
600
+ }): void;
601
+ /** Positive-integer page size. A cursor page has no other way to bound itself. */
602
+ declare function assertCursorLimit(limit: unknown, table: string): asserts limit is number;
603
+ /**
604
+ * Mints the cursor that addresses the row AFTER `row`.
605
+ *
606
+ * REFUSAL 2 — a NULL (or absent) ordering value on the boundary row. A
607
+ * keyset predicate compares with `_gt`/`_lt`, and in SQL every comparison
608
+ * against NULL is NULL, i.e. matches nothing: a cursor minted from a NULL
609
+ * boundary would return an empty next page and the list would look finished
610
+ * while rows remained. Refused rather than silently truncating the list.
611
+ */
612
+ declare function encodeCursor(ordering: readonly CursorOrderingColumn[], row: Record<string, unknown>, table: string): string;
613
+ /**
614
+ * REFUSAL 3 / 4 — a cursor this provider did not mint (`malformed`), or one
615
+ * minted under a different ordering (`mismatch`).
616
+ *
617
+ * The mismatch check is the load-bearing half. Sorting a list re-issues the
618
+ * query, and a UI that kept the old `after` around would otherwise get a
619
+ * page built by comparing the NEW ordering's columns against the OLD
620
+ * ordering's values — a query that succeeds and returns rows that are simply
621
+ * not the ones asked for.
622
+ */
623
+ declare function decodeCursor(raw: unknown, ordering: readonly CursorOrderingColumn[], table: string): readonly (string | number | boolean)[];
624
+ /**
625
+ * The keyset predicate: "every row that sorts strictly after the boundary",
626
+ * as the lexicographic expansion of a row comparison.
627
+ *
628
+ * For ordering `(a asc, b desc, id asc)` and boundary `(a0, b0, id0)` this
629
+ * emits
630
+ *
631
+ * ```
632
+ * _or: [ {a: {_gt: a0}},
633
+ * {_and: [{a: {_eq: a0}}, {b: {_lt: b0}}]},
634
+ * {_and: [{a: {_eq: a0}}, {b: {_eq: b0}}, {id: {_gt: id0}}]} ]
635
+ * ```
636
+ *
637
+ * Postgres's own `(a, b) > (a0, b0)` row comparison would be shorter, but
638
+ * Hasura's `bool_exp` has no row-comparison operator — this expansion is the
639
+ * only form expressible through it, and it is what every keyset
640
+ * implementation over Hasura ends up emitting.
641
+ *
642
+ * NOT written as `>=` on the leading column with a `_neq` correction, and
643
+ * not as a single `_gte`: both of those are the shapes that duplicate or
644
+ * skip rows across a run of equal values, which is the entire failure this
645
+ * function exists to avoid.
646
+ *
647
+ * `int64Fields` coercion is applied for the same reason
648
+ * `coerceInt64Filter` applies it to user filters: Hasura serialises an
649
+ * `int64` column as a JSON string and REJECTS a numeric filter value for it.
650
+ * A keyset predicate is an ordinary filter and gets no exemption.
651
+ */
652
+ declare function keysetWhere(ordering: readonly CursorOrderingColumn[], boundary: readonly (string | number | boolean)[], int64Fields: readonly string[] | undefined): Record<string, unknown>;
653
+ //#endregion
654
+ //#region src/endpoint.d.ts
367
655
  /**
368
656
  * Same-origin GraphQL path Hasura serves by default. The single source for
369
657
  * both halves of this package: `createHasuraDataProvider`'s POST target and
@@ -390,48 +678,49 @@ declare function createHasuraDataProvider(config: HasuraDataProviderConfig): Dat
390
678
  * `endpoint: `${prefix}/gql/v1/graphql``.
391
679
  */
392
680
  declare const DEFAULT_HASURA_ENDPOINT = "/gql/v1/graphql";
393
-
681
+ //#endregion
682
+ //#region src/live-provider.d.ts
394
683
  interface SubscriptionPayload {
395
- readonly query: string;
396
- readonly variables?: Record<string, unknown>;
684
+ readonly query: string;
685
+ readonly variables?: Record<string, unknown>;
397
686
  }
398
687
  interface SubscriptionSink {
399
- next(value: unknown): void;
400
- error(error: unknown): void;
401
- complete(): void;
688
+ next(value: unknown): void;
689
+ error(error: unknown): void;
690
+ complete(): void;
402
691
  }
403
692
  interface GraphqlWsClient {
404
- subscribe(payload: SubscriptionPayload, sink: SubscriptionSink): () => void;
693
+ subscribe(payload: SubscriptionPayload, sink: SubscriptionSink): () => void;
405
694
  }
406
695
  interface GraphqlWsClientOptions {
407
- readonly url: string;
408
- readonly lazy: boolean;
409
- readonly keepAlive: number;
410
- readonly retryAttempts: number;
411
- readonly shouldRetry: () => boolean;
412
- readonly connectionParams: () => Promise<Record<string, unknown>>;
413
- readonly on: {
414
- connecting(): void;
415
- connected(): void;
416
- closed(): void;
417
- };
696
+ readonly url: string;
697
+ readonly lazy: boolean;
698
+ readonly keepAlive: number;
699
+ readonly retryAttempts: number;
700
+ readonly shouldRetry: () => boolean;
701
+ readonly connectionParams: () => Promise<Record<string, unknown>>;
702
+ readonly on: {
703
+ connecting(): void;
704
+ connected(): void;
705
+ closed(): void;
706
+ };
418
707
  }
419
708
  interface HasuraLiveProviderConfig {
420
- readonly resources: Record<string, ResourceMapping>;
421
- readonly getToken: () => Promise<string | null>;
422
- /**
423
- * The SAME GraphQL endpoint the data provider POSTs to — Hasura serves
424
- * subscriptions on that exact path. Converted to `ws(s)://` by
425
- * {@link hasuraWsUrl}. Defaults to {@link DEFAULT_HASURA_ENDPOINT}.
426
- *
427
- * `createHasuraDataProvider` forwards its own resolved endpoint here, so the
428
- * socket and the POST target can never drift apart.
429
- */
430
- readonly endpoint?: string;
431
- /** `X-Hasura-Role` sent in the socket's connection params. `null`/omitted sends none. */
432
- readonly role?: string | null;
433
- /** Test seam; production uses graphql-ws directly. */
434
- readonly clientFactory?: (options: GraphqlWsClientOptions) => GraphqlWsClient;
709
+ readonly resources: Record<string, ResourceMapping>;
710
+ readonly getToken: () => Promise<string | null>;
711
+ /**
712
+ * The SAME GraphQL endpoint the data provider POSTs to — Hasura serves
713
+ * subscriptions on that exact path. Converted to `ws(s)://` by
714
+ * {@link hasuraWsUrl}. Defaults to {@link DEFAULT_HASURA_ENDPOINT}.
715
+ *
716
+ * `createHasuraDataProvider` forwards its own resolved endpoint here, so the
717
+ * socket and the POST target can never drift apart.
718
+ */
719
+ readonly endpoint?: string;
720
+ /** `X-Hasura-Role` sent in the socket's connection params. `null`/omitted sends none. */
721
+ readonly role?: string | null;
722
+ /** Test seam; production uses graphql-ws directly. */
723
+ readonly clientFactory?: (options: GraphqlWsClientOptions) => GraphqlWsClient;
435
724
  }
436
725
  /**
437
726
  * `https://host/path` → `wss://host/path` (Hasura serves subscriptions on the
@@ -462,5 +751,6 @@ declare function hasuraWsUrl(endpoint: string): string;
462
751
  * single-sourced in the data provider.
463
752
  */
464
753
  declare function createHasuraLiveProvider(config: HasuraLiveProviderConfig): DataLiveProvider;
465
-
466
- export { DEFAULT_HASURA_ENDPOINT, type DataBodyConfig, FILTER_OPERATOR_UNSUPPORTED_CODE, type HasuraCustomOperation, type HasuraDataProviderConfig, type HasuraLiveProviderConfig, type HasuraOperationRegistry, type JsonbSpillConfig, NOT_AUTHENTICATED_CODE, type ResourceMapping, aggregateRoot, byPkRoot, createHasuraDataProvider, createHasuraLiveProvider, filtersToWhere, flattenJsonbSpill, hasuraWsUrl, mergeWhere, partitionDataBody, partitionJsonbSpill, scopeToWhere };
754
+ //#endregion
755
+ export { type CursorOrderingColumn, DEFAULT_HASURA_ENDPOINT, type DataBodyConfig, FILTER_OPERATOR_UNSUPPORTED_CODE, HASURA_CURSOR_CODES, type HasuraCustomOperation, type HasuraDataProviderConfig, type HasuraLiveProviderConfig, type HasuraOperationRegistry, type JsonbSpillConfig, LOCALE_REQUIRED_CODE, NOT_AUTHENTICATED_CODE, type ResourceMapping, aggregateRoot, assertCursorLimit, assertCursorOrderable, byPkRoot, coerceInt64Filter, createHasuraDataProvider, createHasuraLiveProvider, decodeCursor, effectiveCursorOrdering, encodeCursor, filtersToWhere, flattenJsonbSpill, hasuraWsUrl, keysetWhere, localeWhere, mergeWhere, orderingSignature, partitionDataBody, partitionJsonbSpill, scopeToWhere };
756
+ //# sourceMappingURL=index.d.ts.map