@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.
@@ -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 };