@burojs/hasura 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/dist/index.cjs +854 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +466 -0
- package/dist/index.d.ts +466 -0
- package/dist/index.js +839 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { DataOperationContext, DataProvider, FilterExpression, ErasedResourceHandle, DataLiveProvider } from '@burojs/core';
|
|
2
|
+
|
|
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;
|
|
67
|
+
}
|
|
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;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Registry entries are keyed by the resource binding first, then the declared
|
|
82
|
+
* custom operation name. This makes cross-resource operation reuse explicit
|
|
83
|
+
* rather than allowing a descriptor to name an arbitrary backend operation.
|
|
84
|
+
*/
|
|
85
|
+
type HasuraOperationRegistry = Readonly<Record<string, Readonly<Record<string, HasuraCustomOperation>>>>;
|
|
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;
|
|
233
|
+
}
|
|
234
|
+
/** @see ResourceMapping.jsonbSpill */
|
|
235
|
+
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>;
|
|
249
|
+
}
|
|
250
|
+
/** @see ResourceMapping.dataBody */
|
|
251
|
+
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[];
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Split a flat `values` record for a data-body resource into the sibling-table
|
|
267
|
+
* payload and the remainder. Keys listed in `cfg.keys` whose value is defined
|
|
268
|
+
* are collected into `dataBody` (they become the sibling `column` blob); every
|
|
269
|
+
* other key passes through in `rest` (to be further partitioned by jsonbSpill).
|
|
270
|
+
* The raw `cfg.relationship` key (the nested object getOne leaves behind on a
|
|
271
|
+
* re-submitted record) is dropped from both so it can never leak into `head`.
|
|
272
|
+
*
|
|
273
|
+
* `hasDataBody` is true iff at least one data-body key was present — callers
|
|
274
|
+
* skip the sibling write entirely when false, so a document type WITHOUT a data
|
|
275
|
+
* body (e.g. a work order) never gets an empty sibling row.
|
|
276
|
+
*
|
|
277
|
+
* Exported for unit testing; the provider calls it with `m.dataBody`.
|
|
278
|
+
*/
|
|
279
|
+
declare function partitionDataBody(values: Record<string, unknown>, cfg: DataBodyConfig): {
|
|
280
|
+
dataBody: Record<string, unknown>;
|
|
281
|
+
rest: Record<string, unknown>;
|
|
282
|
+
hasDataBody: boolean;
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* Partition a flat `values` record for a jsonb-spill resource.
|
|
286
|
+
*
|
|
287
|
+
* Routing rule (first match wins per key):
|
|
288
|
+
* 1. `key === spill.column` / `key === spill.metaColumn` — an explicitly passed
|
|
289
|
+
* JSONB object (e.g. an edit form re-submitting the raw `head` blob). Its
|
|
290
|
+
* entries are merged UNDER any individually-spilled keys so freshly edited
|
|
291
|
+
* flat values win over the stale blob, and un-modelled legacy keys survive.
|
|
292
|
+
* 2. `key ∈ metaKeys` — nests under `metaColumn`.
|
|
293
|
+
* 3. `key ∈ baseColumns` — stays a top-level column.
|
|
294
|
+
* 4. otherwise — nests under the `column` spill (head).
|
|
295
|
+
*
|
|
296
|
+
* `undefined` values are dropped (a never-set optional field must not clobber
|
|
297
|
+
* an existing column / blob key). Empty JSONB objects are omitted entirely.
|
|
298
|
+
*
|
|
299
|
+
* Exported for unit testing; the provider calls it with `baseColumns =
|
|
300
|
+
* m.selectFields` (the table's real columns, which include `column`/`metaColumn`
|
|
301
|
+
* themselves — handled by rule 1 before the base check).
|
|
302
|
+
*/
|
|
303
|
+
declare function partitionJsonbSpill(values: Record<string, unknown>, spill: JsonbSpillConfig, baseColumns: string[]): Record<string, unknown>;
|
|
304
|
+
/**
|
|
305
|
+
* Inverse of {@link partitionJsonbSpill}: spread a fetched row's `head` / `meta`
|
|
306
|
+
* JSONB blobs back onto the flat record so head/meta keys (e.g. `batch_number`,
|
|
307
|
+
* `regulatory_system`) read as top-level fields on show pages, edit forms, AND
|
|
308
|
+
* per-type list columns. Base columns win on collision (`...rec` last), and the
|
|
309
|
+
* raw `head` / `meta` objects are preserved so tabs / re-submission still have
|
|
310
|
+
* them. A non-object / null blob is ignored.
|
|
311
|
+
*
|
|
312
|
+
* Shared by getOne (single record) and getList (each row) so the two stay in
|
|
313
|
+
* lockstep. Returns the input unchanged when it isn't an object.
|
|
314
|
+
*/
|
|
315
|
+
declare function flattenJsonbSpill(item: unknown, spill: JsonbSpillConfig): unknown;
|
|
316
|
+
/**
|
|
317
|
+
* This provider's own code for "no token to send" — `getToken()` resolved `null`, so no
|
|
318
|
+
* request was ever attempted. Exported so downstream error-classification code can tell
|
|
319
|
+
* this apart from a genuine rejected-credentials answer (a real 401 the retry/`recoverAuth`
|
|
320
|
+
* path still couldn't clear) without inventing a parallel distinction of its own: a request
|
|
321
|
+
* that never left the client is the "we never asked" case, not a session failure.
|
|
322
|
+
*
|
|
323
|
+
* DISTILLATION NOTE (divergence 4 of 7 — hygiene, l2-web-ui only, zero
|
|
324
|
+
* behaviour change). playground inlined the literal `'not-authenticated'` at
|
|
325
|
+
* both throw sites; l2-web-ui extracted and exported this constant so its own
|
|
326
|
+
* `classify-data-error.ts` could match a symbol instead of a string. The STRING
|
|
327
|
+
* VALUE is identical either way — nothing observable differs — so l2-web-ui's
|
|
328
|
+
* exported form is taken purely because a published package owes consumers a
|
|
329
|
+
* stable symbol.
|
|
330
|
+
*/
|
|
331
|
+
declare const NOT_AUTHENTICATED_CODE = "not-authenticated";
|
|
332
|
+
/**
|
|
333
|
+
* The shared refusal code for "this provider cannot express that filter
|
|
334
|
+
* operator". `@burojs/pocketbase` throws the SAME `kind` and the SAME code, so
|
|
335
|
+
* an application handling one handles the other — see
|
|
336
|
+
* {@link filtersToWhere} for why that agreement is load-bearing.
|
|
337
|
+
*/
|
|
338
|
+
declare const FILTER_OPERATOR_UNSUPPORTED_CODE = "filter-operator-unsupported";
|
|
339
|
+
/**
|
|
340
|
+
* Filter `scope` to keys whitelisted by `scopeFields`, drop nullish/empty
|
|
341
|
+
* values, and translate to a Hasura where object using `_eq`.
|
|
342
|
+
* Returns `undefined` when nothing applies.
|
|
343
|
+
*/
|
|
344
|
+
declare function scopeToWhere(scope: Record<string, unknown>, scopeFields: string[]): Record<string, unknown> | undefined;
|
|
345
|
+
/**
|
|
346
|
+
* Combine two where objects with `_and`. Either or both may be undefined.
|
|
347
|
+
*/
|
|
348
|
+
declare function mergeWhere(a: Record<string, unknown> | undefined, b: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
|
|
349
|
+
/** Translate the native filter AST into a Hasura bool expression. */
|
|
350
|
+
declare function filtersToWhere(filter?: FilterExpression<ErasedResourceHandle>): Record<string, unknown> | undefined;
|
|
351
|
+
/**
|
|
352
|
+
* Resolve the Hasura aggregate root field for a mapping, or `null` when the
|
|
353
|
+
* registry declares that none exists. Registry-driven on purpose: which tables
|
|
354
|
+
* can be counted is a per-resource FACT, so it is declared once on the mapping
|
|
355
|
+
* (`aggregate`) and read here — never a `if (resource === 'users')` chain in the
|
|
356
|
+
* provider, which is the shape this codebase has been deleting.
|
|
357
|
+
*/
|
|
358
|
+
declare function aggregateRoot(m: ResourceMapping): string | null;
|
|
359
|
+
/**
|
|
360
|
+
* Root field for a by-pk read. Declarative per resource for the same reason
|
|
361
|
+
* {@link aggregateRoot} is: the name is a fact about the backend schema, not
|
|
362
|
+
* something to branch on by resource name inside the provider.
|
|
363
|
+
*/
|
|
364
|
+
declare function byPkRoot(m: ResourceMapping): string | null;
|
|
365
|
+
declare function createHasuraDataProvider(config: HasuraDataProviderConfig): DataProvider;
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Same-origin GraphQL path Hasura serves by default. The single source for
|
|
369
|
+
* both halves of this package: `createHasuraDataProvider`'s POST target and
|
|
370
|
+
* `createHasuraLiveProvider`'s WebSocket derivation.
|
|
371
|
+
*
|
|
372
|
+
* It lives in its own module rather than in `data-provider.ts` because
|
|
373
|
+
* `live-provider.ts` needs it too, and `data-provider.ts` already imports
|
|
374
|
+
* `createHasuraLiveProvider` from `live-provider.ts`. `live-provider.ts`'s
|
|
375
|
+
* existing back-reference is `import type { ResourceMapping }` — TYPE-only, so
|
|
376
|
+
* it is erased and no runtime cycle exists. Importing a VALUE back across that
|
|
377
|
+
* edge would create a real one. This third module keeps the constant defined
|
|
378
|
+
* exactly once without introducing it.
|
|
379
|
+
*
|
|
380
|
+
* DISTILLATION NOTE (divergence 5 of 7 — deployment fact, neither side wins).
|
|
381
|
+
* The two source apps read this from their OWN `env.ts`: playground as
|
|
382
|
+
* `API_BASE_URL` (`import.meta.env.VITE_API_BASE_URL ?? ''`, an origin-root
|
|
383
|
+
* deployment), l2-web-ui as `API_PREFIX` (`?? '/tandt'`, a non-empty default
|
|
384
|
+
* that exists because every GraphQL call 404'd behind an nginx rule stripping
|
|
385
|
+
* that prefix while the dev proxy passed paths through unprefixed). Both
|
|
386
|
+
* defaults are correct FOR THEIR OWN DEPLOYMENT and neither belongs in a
|
|
387
|
+
* published package, so this package takes no opinion: it exposes the
|
|
388
|
+
* `endpoint` config field (the mechanism l2-web-ui had already added) and
|
|
389
|
+
* defaults to the bare same-origin path. An app behind a path prefix passes
|
|
390
|
+
* `endpoint: `${prefix}/gql/v1/graphql``.
|
|
391
|
+
*/
|
|
392
|
+
declare const DEFAULT_HASURA_ENDPOINT = "/gql/v1/graphql";
|
|
393
|
+
|
|
394
|
+
interface SubscriptionPayload {
|
|
395
|
+
readonly query: string;
|
|
396
|
+
readonly variables?: Record<string, unknown>;
|
|
397
|
+
}
|
|
398
|
+
interface SubscriptionSink {
|
|
399
|
+
next(value: unknown): void;
|
|
400
|
+
error(error: unknown): void;
|
|
401
|
+
complete(): void;
|
|
402
|
+
}
|
|
403
|
+
interface GraphqlWsClient {
|
|
404
|
+
subscribe(payload: SubscriptionPayload, sink: SubscriptionSink): () => void;
|
|
405
|
+
}
|
|
406
|
+
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
|
+
};
|
|
418
|
+
}
|
|
419
|
+
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;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* `https://host/path` → `wss://host/path` (Hasura serves subscriptions on the
|
|
438
|
+
* same path it serves queries on). `endpoint` may be `''`, a same-origin path
|
|
439
|
+
* (`/gql/v1/graphql`, `/tandt/gql/v1/graphql`) or an absolute http(s) URL.
|
|
440
|
+
*
|
|
441
|
+
* DISTILLATION NOTE (divergence 6 of 7 — NEITHER a bug fix, verified
|
|
442
|
+
* equivalent; playground's FORM taken, l2-web-ui's TESTS taken). The two apps
|
|
443
|
+
* implement this differently: playground resolves through
|
|
444
|
+
* `new URL(path, window.location.origin)` and flips the protocol; l2-web-ui
|
|
445
|
+
* string-concatenates `origin.replace(/^http/, 'ws') + prefix + path` under a
|
|
446
|
+
* comment warning about a "prior version" bug. That warning describes a
|
|
447
|
+
* since-superseded implementation in l2-web-ui's OWN history, NOT playground's
|
|
448
|
+
* current code — the research pass computed both functions for `('', https)`,
|
|
449
|
+
* `('/tandt', https)`, `('/tandt', http)` and `('', http)` and got
|
|
450
|
+
* byte-identical output every time. playground's `new URL()` form is taken
|
|
451
|
+
* because its input domain is strictly wider (it is also correct when handed
|
|
452
|
+
* an absolute `http(s)://` endpoint, which l2-web-ui's concat form explicitly
|
|
453
|
+
* is not designed for), and l2-web-ui's CONTRACT-level tests (scheme + host +
|
|
454
|
+
* prefix placement, never a literal echo of the implementation) are carried
|
|
455
|
+
* into this package's suite because that is the testing style which caught the
|
|
456
|
+
* original production regression.
|
|
457
|
+
*/
|
|
458
|
+
declare function hasuraWsUrl(endpoint: string): string;
|
|
459
|
+
/**
|
|
460
|
+
* Native Hasura live provider. The socket transmits only a change signal; rows
|
|
461
|
+
* always come from the regular descriptor query, so mapping/pagination remain
|
|
462
|
+
* single-sourced in the data provider.
|
|
463
|
+
*/
|
|
464
|
+
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 };
|