@rig-ts/electric 0.1.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.mts +203 -0
- package/dist/index.mjs +273 -0
- package/package.json +42 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import * as _$_tanstack_db0 from "@tanstack/db";
|
|
2
|
+
import * as _$_tanstack_electric_db_collection0 from "@tanstack/electric-db-collection";
|
|
3
|
+
import { Row, ShapeStreamOptions } from "@electric-sql/client";
|
|
4
|
+
import { Runtime } from "@rig-ts/client";
|
|
5
|
+
|
|
6
|
+
//#region src/params.d.ts
|
|
7
|
+
/** Query params the sync protocol accepts alongside its own. */
|
|
8
|
+
type ShapeParams = NonNullable<ShapeStreamOptions["params"]>;
|
|
9
|
+
/**
|
|
10
|
+
* The TypeScript types a declared shape param can take.
|
|
11
|
+
*
|
|
12
|
+
* UUID, Date, Time and Timestamp are all carried as strings, matching what the
|
|
13
|
+
* generated params types declare and what the server parses off the query
|
|
14
|
+
* string.
|
|
15
|
+
*/
|
|
16
|
+
type ParamValue = string | number | boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Serializes a shape's declared params into the query string the generated
|
|
19
|
+
* server parses.
|
|
20
|
+
*
|
|
21
|
+
* `undefined` values are dropped rather than sent empty — that is what makes an
|
|
22
|
+
* optional param absent, since the server treats an empty value as unset and an
|
|
23
|
+
* absent one as not asked for.
|
|
24
|
+
*/
|
|
25
|
+
declare function serializeParams(params: Readonly<Record<string, ParamValue | undefined>>): ShapeParams;
|
|
26
|
+
/**
|
|
27
|
+
* A stable key for a param set, sorted so two callers passing the same params in
|
|
28
|
+
* a different literal order share one collection.
|
|
29
|
+
*
|
|
30
|
+
* Built on {@link serializeParams} and `URLSearchParams` rather than on a hand
|
|
31
|
+
* rolled `join("&")`, for the reason a hand rolled one cannot be right: a value
|
|
32
|
+
* containing the separators is indistinguishable from more params. `{a: "b&c=d"}`
|
|
33
|
+
* and `{a: "b", c: "d"}` produced the identical key, so two collections over
|
|
34
|
+
* different params shared one instance — and the rows one of them was asking for
|
|
35
|
+
* were never the rows it got. Percent-encoding is what removes the ambiguity.
|
|
36
|
+
*
|
|
37
|
+
* The sort is `URLSearchParams.sort`, which orders by code unit rather than by
|
|
38
|
+
* locale. Only stability matters here — nobody reads this key — and a key that
|
|
39
|
+
* depends on the reader's locale is the weaker of the two.
|
|
40
|
+
*/
|
|
41
|
+
declare function paramsCacheKey(params: Readonly<Record<string, ParamValue | undefined>>): string;
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/create-collection.d.ts
|
|
44
|
+
/** What a generated stream factory passes down. */
|
|
45
|
+
type RigCollectionArgs<TRow extends Row> = {
|
|
46
|
+
/**
|
|
47
|
+
* The client the stream authenticates and resolves its URL through. Taking
|
|
48
|
+
* the whole runtime rather than a base URL is what lets a `Session` refresh
|
|
49
|
+
* before a long poll inherits a token about to expire.
|
|
50
|
+
*/
|
|
51
|
+
runtime: Runtime;
|
|
52
|
+
/**
|
|
53
|
+
* The shape's route, emitted verbatim from `ir.ElectricEndpoint` — for
|
|
54
|
+
* example `/api/v1/todo/_stream`. It is the full route including the API's
|
|
55
|
+
* base path, because that is what the document says it is; nothing here
|
|
56
|
+
* recomposes one.
|
|
57
|
+
*/
|
|
58
|
+
path: string; /** The params the endpoint declares. Absent ones are dropped, not sent empty. */
|
|
59
|
+
params?: Readonly<Record<string, ParamValue | undefined>>; /** The primary key accessor. TanStack DB keys must be a string or a number. */
|
|
60
|
+
getKey: (row: TRow) => string | number;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Builds a read-only collection over one of a rig application's shape endpoints.
|
|
64
|
+
*
|
|
65
|
+
* **No mutation handlers.** Writes go through the API and the sync service
|
|
66
|
+
* delivers the resulting rows, so there is no txid handshake to perform here. A
|
|
67
|
+
* collection that could be written to would be a second way to change a row, and
|
|
68
|
+
* one that skips every rule the server applies.
|
|
69
|
+
*
|
|
70
|
+
* **No schema.** TanStack DB validates only on optimistic mutations, so a schema
|
|
71
|
+
* here would never run; the row types come from the generated API types, which
|
|
72
|
+
* are the ones the server sends.
|
|
73
|
+
*
|
|
74
|
+
* Sync does not start when this is called. It begins when the first live query
|
|
75
|
+
* subscribes and pauses when the last one unsubscribes, so an instance held
|
|
76
|
+
* across a navigation resumes rather than re-syncing — which is what
|
|
77
|
+
* {@link createCollectionCache} exists to make possible.
|
|
78
|
+
*/
|
|
79
|
+
declare function createRigCollection<TRow extends Row>(args: RigCollectionArgs<TRow>): _$_tanstack_db0.Collection<TRow, string | number, _$_tanstack_electric_db_collection0.ElectricCollectionUtils<TRow>, never, TRow> & _$_tanstack_db0.NonSingleResult;
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/collection-cache.d.ts
|
|
82
|
+
/** The slice of a collection's surface the cache needs to observe. */
|
|
83
|
+
type CacheableCollection = {
|
|
84
|
+
on: (event: "status:change", callback: (payload: {
|
|
85
|
+
status: string;
|
|
86
|
+
}) => void) => () => void;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Wraps a collection factory so each distinct client and param set maps to one
|
|
90
|
+
* long-lived instance.
|
|
91
|
+
*
|
|
92
|
+
* Collections are stateful and each one opens a stream, so an instance rebuilt
|
|
93
|
+
* per render or per navigation loses everything the library gives a retained
|
|
94
|
+
* one: data held for its collection time, sync that pauses at zero subscribers
|
|
95
|
+
* and resumes on the next one, and resume-from-offset instead of a full
|
|
96
|
+
* re-sync. Returning the same instance is what makes those pay off — and it is
|
|
97
|
+
* what lets a caller invoke a generated factory during render without a
|
|
98
|
+
* load-bearing `useMemo`.
|
|
99
|
+
*
|
|
100
|
+
* Entries remove themselves once their collection reaches `cleaned-up`, so the
|
|
101
|
+
* map is bounded by what is currently live rather than by everything ever asked
|
|
102
|
+
* for. There is no eviction policy to tune.
|
|
103
|
+
*
|
|
104
|
+
* On the server every call builds a fresh instance. The map would otherwise be
|
|
105
|
+
* module-global and leak one request's collections into the next, and nothing
|
|
106
|
+
* server-side should be syncing anyway.
|
|
107
|
+
*
|
|
108
|
+
* Two things the key deliberately leaves out. The **path** is not in it, because
|
|
109
|
+
* each generated factory wraps its own call to this and so has a map of its own
|
|
110
|
+
* — a caller wrapping two different routes in one cache would collide, which is
|
|
111
|
+
* why the generator does not. And the **credential** is not in it: a client that
|
|
112
|
+
* switches to a different tenant on the same origin keeps the collection it had.
|
|
113
|
+
* The rows it holds were scoped to the old session, so switching tenant should
|
|
114
|
+
* discard the collection rather than expect this to notice.
|
|
115
|
+
*/
|
|
116
|
+
declare function createCollectionCache<TParams extends Readonly<Record<string, ParamValue | undefined>>, TCollection extends CacheableCollection>(build: (runtime: Runtime, params: TParams) => TCollection): (runtime: Runtime, params: TParams) => TCollection;
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/fetch-client.d.ts
|
|
119
|
+
/**
|
|
120
|
+
* The fetch every stream goes out through.
|
|
121
|
+
*
|
|
122
|
+
* Two things it does that a bare `fetch` would not.
|
|
123
|
+
*
|
|
124
|
+
* **The credential.** rig authenticates with `Authorization: Bearer`, so a shape
|
|
125
|
+
* request carries the same credential every other call does — and a `Session`
|
|
126
|
+
* gets to refresh ahead of an expiry before a long poll inherits a token that
|
|
127
|
+
* will die halfway through it. This is the whole reason a collection takes a
|
|
128
|
+
* `Runtime` rather than a base URL.
|
|
129
|
+
*
|
|
130
|
+
* **`cache: "no-store"`.** Without it the browser serves a stale long-poll
|
|
131
|
+
* response back, and the subscription stops advancing while appearing to work.
|
|
132
|
+
*
|
|
133
|
+
* A cross-origin deployment pays for the header: `Authorization` is not
|
|
134
|
+
* CORS-safelisted, so the shape GET becomes a preflighted request, and whatever
|
|
135
|
+
* sits in front has to allow it *and* expose `electric-handle`,
|
|
136
|
+
* `electric-offset`, `electric-schema` and `electric-cursor` — the cursor the
|
|
137
|
+
* client resumes from. Same-origin, which is what rig serves by default, needs
|
|
138
|
+
* none of that.
|
|
139
|
+
*/
|
|
140
|
+
declare function rigFetchClient(runtime: Runtime): typeof fetch;
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/on-error.d.ts
|
|
143
|
+
type ErrorHandler = NonNullable<ShapeStreamOptions["onError"]>;
|
|
144
|
+
/**
|
|
145
|
+
* Routes stream failures back into the client's own auth handling.
|
|
146
|
+
*
|
|
147
|
+
* The sync client has already exhausted its own retries for 5xx, network errors
|
|
148
|
+
* and 429 by the time this runs, so what reaches here is a failure it will not
|
|
149
|
+
* retry on its own. Returning an object retries with backoff; returning
|
|
150
|
+
* `undefined` stops the stream for good.
|
|
151
|
+
*
|
|
152
|
+
* A 401 asks the credential to refresh — once, and only a credential that can do
|
|
153
|
+
* something about it. This is the counterpart of what the REST path does on a
|
|
154
|
+
* 401, and it has to happen here because a long poll cannot be re-sent by the
|
|
155
|
+
* caller: an expired session mid-stream otherwise stalls silently, polling into
|
|
156
|
+
* a session that is not coming back.
|
|
157
|
+
*
|
|
158
|
+
* Any other 4xx is a decision the server already made, and retrying cannot
|
|
159
|
+
* change it. A shape refused for the caller's tenant is refused.
|
|
160
|
+
*/
|
|
161
|
+
declare function streamErrorHandler(runtime: Runtime): ErrorHandler;
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/parsers.d.ts
|
|
164
|
+
type Parser = NonNullable<ShapeStreamOptions["parser"]>;
|
|
165
|
+
/**
|
|
166
|
+
* Wire-format corrections applied to every stream.
|
|
167
|
+
*
|
|
168
|
+
* The rule they all serve: a column reaching the client over REST and the same
|
|
169
|
+
* column reaching it over a stream must decode to the same value. One row
|
|
170
|
+
* arriving two ways and disagreeing with itself is the failure mode a generated
|
|
171
|
+
* client exists to prevent, and it is the one the sync protocol makes easy —
|
|
172
|
+
* REST answers with what Go's `encoding/json` wrote, and a stream answers with
|
|
173
|
+
* what Postgres printed.
|
|
174
|
+
*
|
|
175
|
+
* `date` and `time` deliberately have no entry: Postgres already writes them as
|
|
176
|
+
* `YYYY-MM-DD` and `HH:MM:SS`, which is what the REST path sends too. `numeric`
|
|
177
|
+
* likewise stays a string, because that is the only form that keeps its
|
|
178
|
+
* precision.
|
|
179
|
+
*/
|
|
180
|
+
declare const rigParsers: Parser;
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/shape-url.d.ts
|
|
183
|
+
/**
|
|
184
|
+
* The address one stream subscribes to.
|
|
185
|
+
*
|
|
186
|
+
* It exists because the sync client resolves nothing: it hands its `url` to
|
|
187
|
+
* `new URL()` with no base. So the relative origin `@rig-ts/client` documents as
|
|
188
|
+
* the ordinary same-origin case — `baseUrl: ""`, resolved against the page —
|
|
189
|
+
* arrives there as `/api/v1/todo/_stream` and throws `Invalid URL`. And because
|
|
190
|
+
* a TypeError is not a `FetchError`, the error handler reads it as a failure
|
|
191
|
+
* worth another go and the stream retries the same unusable address with
|
|
192
|
+
* backoff, silently, rather than reporting anything. Resolving it here is what
|
|
193
|
+
* makes the documented default work for a stream as well as for a REST call,
|
|
194
|
+
* where `fetch` does this much itself.
|
|
195
|
+
*
|
|
196
|
+
* Off a browser there is no page to resolve against, so a relative origin is
|
|
197
|
+
* left as it is. Nothing should be syncing during a server render, and a stream
|
|
198
|
+
* that starts anyway should fail naming the origin it was not given rather than
|
|
199
|
+
* one this invented for it.
|
|
200
|
+
*/
|
|
201
|
+
declare function shapeUrl(origin: string, path: string): string;
|
|
202
|
+
//#endregion
|
|
203
|
+
export { type ParamValue, type RigCollectionArgs, type ShapeParams, createCollectionCache, createRigCollection, paramsCacheKey, rigFetchClient, rigParsers, serializeParams, shapeUrl, streamErrorHandler };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { createCollection } from "@tanstack/db";
|
|
2
|
+
import { electricCollectionOptions } from "@tanstack/electric-db-collection";
|
|
3
|
+
import { FetchError } from "@electric-sql/client";
|
|
4
|
+
import { isReauthorizer } from "@rig-ts/client";
|
|
5
|
+
//#region src/fetch-client.ts
|
|
6
|
+
/**
|
|
7
|
+
* The fetch every stream goes out through.
|
|
8
|
+
*
|
|
9
|
+
* Two things it does that a bare `fetch` would not.
|
|
10
|
+
*
|
|
11
|
+
* **The credential.** rig authenticates with `Authorization: Bearer`, so a shape
|
|
12
|
+
* request carries the same credential every other call does — and a `Session`
|
|
13
|
+
* gets to refresh ahead of an expiry before a long poll inherits a token that
|
|
14
|
+
* will die halfway through it. This is the whole reason a collection takes a
|
|
15
|
+
* `Runtime` rather than a base URL.
|
|
16
|
+
*
|
|
17
|
+
* **`cache: "no-store"`.** Without it the browser serves a stale long-poll
|
|
18
|
+
* response back, and the subscription stops advancing while appearing to work.
|
|
19
|
+
*
|
|
20
|
+
* A cross-origin deployment pays for the header: `Authorization` is not
|
|
21
|
+
* CORS-safelisted, so the shape GET becomes a preflighted request, and whatever
|
|
22
|
+
* sits in front has to allow it *and* expose `electric-handle`,
|
|
23
|
+
* `electric-offset`, `electric-schema` and `electric-cursor` — the cursor the
|
|
24
|
+
* client resumes from. Same-origin, which is what rig serves by default, needs
|
|
25
|
+
* none of that.
|
|
26
|
+
*/
|
|
27
|
+
function rigFetchClient(runtime) {
|
|
28
|
+
return async (input, init) => {
|
|
29
|
+
const headers = new Headers(init?.headers);
|
|
30
|
+
await runtime.getCredential()?.apply(headers);
|
|
31
|
+
return await runtime.fetch(input, {
|
|
32
|
+
...init,
|
|
33
|
+
headers,
|
|
34
|
+
cache: "no-store"
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/on-error.ts
|
|
40
|
+
/**
|
|
41
|
+
* Routes stream failures back into the client's own auth handling.
|
|
42
|
+
*
|
|
43
|
+
* The sync client has already exhausted its own retries for 5xx, network errors
|
|
44
|
+
* and 429 by the time this runs, so what reaches here is a failure it will not
|
|
45
|
+
* retry on its own. Returning an object retries with backoff; returning
|
|
46
|
+
* `undefined` stops the stream for good.
|
|
47
|
+
*
|
|
48
|
+
* A 401 asks the credential to refresh — once, and only a credential that can do
|
|
49
|
+
* something about it. This is the counterpart of what the REST path does on a
|
|
50
|
+
* 401, and it has to happen here because a long poll cannot be re-sent by the
|
|
51
|
+
* caller: an expired session mid-stream otherwise stalls silently, polling into
|
|
52
|
+
* a session that is not coming back.
|
|
53
|
+
*
|
|
54
|
+
* Any other 4xx is a decision the server already made, and retrying cannot
|
|
55
|
+
* change it. A shape refused for the caller's tenant is refused.
|
|
56
|
+
*/
|
|
57
|
+
function streamErrorHandler(runtime) {
|
|
58
|
+
let refreshed = false;
|
|
59
|
+
return async (error) => {
|
|
60
|
+
if (typeof window === "undefined") return void 0;
|
|
61
|
+
if (!(error instanceof FetchError)) return {};
|
|
62
|
+
if (error.status === 401 && !refreshed) {
|
|
63
|
+
refreshed = true;
|
|
64
|
+
const credential = runtime.getCredential();
|
|
65
|
+
if (isReauthorizer(credential) && await credential.reauthorize()) return {};
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (error.status >= 400 && error.status < 500) return void 0;
|
|
69
|
+
return {};
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/params.ts
|
|
74
|
+
/**
|
|
75
|
+
* Serializes a shape's declared params into the query string the generated
|
|
76
|
+
* server parses.
|
|
77
|
+
*
|
|
78
|
+
* `undefined` values are dropped rather than sent empty — that is what makes an
|
|
79
|
+
* optional param absent, since the server treats an empty value as unset and an
|
|
80
|
+
* absent one as not asked for.
|
|
81
|
+
*/
|
|
82
|
+
function serializeParams(params) {
|
|
83
|
+
const out = {};
|
|
84
|
+
for (const [name, value] of Object.entries(params)) {
|
|
85
|
+
if (value === void 0) continue;
|
|
86
|
+
out[name] = String(value);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* A stable key for a param set, sorted so two callers passing the same params in
|
|
92
|
+
* a different literal order share one collection.
|
|
93
|
+
*
|
|
94
|
+
* Built on {@link serializeParams} and `URLSearchParams` rather than on a hand
|
|
95
|
+
* rolled `join("&")`, for the reason a hand rolled one cannot be right: a value
|
|
96
|
+
* containing the separators is indistinguishable from more params. `{a: "b&c=d"}`
|
|
97
|
+
* and `{a: "b", c: "d"}` produced the identical key, so two collections over
|
|
98
|
+
* different params shared one instance — and the rows one of them was asking for
|
|
99
|
+
* were never the rows it got. Percent-encoding is what removes the ambiguity.
|
|
100
|
+
*
|
|
101
|
+
* The sort is `URLSearchParams.sort`, which orders by code unit rather than by
|
|
102
|
+
* locale. Only stability matters here — nobody reads this key — and a key that
|
|
103
|
+
* depends on the reader's locale is the weaker of the two.
|
|
104
|
+
*/
|
|
105
|
+
function paramsCacheKey(params) {
|
|
106
|
+
const q = new URLSearchParams(Object.entries(serializeParams(params)).map(([name, value]) => [name, String(value)]));
|
|
107
|
+
q.sort();
|
|
108
|
+
return q.toString();
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/parsers.ts
|
|
112
|
+
/**
|
|
113
|
+
* Wire-format corrections applied to every stream.
|
|
114
|
+
*
|
|
115
|
+
* The rule they all serve: a column reaching the client over REST and the same
|
|
116
|
+
* column reaching it over a stream must decode to the same value. One row
|
|
117
|
+
* arriving two ways and disagreeing with itself is the failure mode a generated
|
|
118
|
+
* client exists to prevent, and it is the one the sync protocol makes easy —
|
|
119
|
+
* REST answers with what Go's `encoding/json` wrote, and a stream answers with
|
|
120
|
+
* what Postgres printed.
|
|
121
|
+
*
|
|
122
|
+
* `date` and `time` deliberately have no entry: Postgres already writes them as
|
|
123
|
+
* `YYYY-MM-DD` and `HH:MM:SS`, which is what the REST path sends too. `numeric`
|
|
124
|
+
* likewise stays a string, because that is the only form that keeps its
|
|
125
|
+
* precision.
|
|
126
|
+
*/
|
|
127
|
+
const rigParsers = {
|
|
128
|
+
int8: parseInt8,
|
|
129
|
+
timestamptz: toRFC3339,
|
|
130
|
+
timestamp: toRFC3339
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Converts a Postgres timestamp to the RFC 3339 the REST path sends.
|
|
134
|
+
*
|
|
135
|
+
* Two corrections, and both matter. Postgres separates the date and the time
|
|
136
|
+
* with a space, which no ISO parser accepts. And it writes a zone offset as
|
|
137
|
+
* `+00` where RFC 3339 wants `Z` or `+00:00` — the shorter form parses nowhere
|
|
138
|
+
* reliably, and `Date.parse` reading it as local time would shift the value by
|
|
139
|
+
* the viewer's offset without failing.
|
|
140
|
+
*
|
|
141
|
+
* A `timestamp` — the wall-clock type, with no zone at all — is given `Z`. That
|
|
142
|
+
* asserts the column is UTC, which is the same assertion the REST path already
|
|
143
|
+
* makes: pgx reads a zone-less timestamp into a `time.Time` in UTC, and Go
|
|
144
|
+
* marshals that with the suffix. rig's own columns are `timestamptz` by
|
|
145
|
+
* convention, so this covers a hand-written column rather than anything rig
|
|
146
|
+
* generates.
|
|
147
|
+
*/
|
|
148
|
+
function toRFC3339(value) {
|
|
149
|
+
const isoish = value.replace(" ", "T");
|
|
150
|
+
const shortOffset = /([+-]\d{2})$/.exec(isoish);
|
|
151
|
+
if (shortOffset !== null) return (shortOffset[1] ?? "") === "+00" ? `${isoish.slice(0, -3)}Z` : `${isoish}:00`;
|
|
152
|
+
if (/([+-]\d{2}:\d{2}|Z)$/.test(isoish)) return isoish;
|
|
153
|
+
return `${isoish}Z`;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Converts an `int8` to the `number` the generated row types declare.
|
|
157
|
+
*
|
|
158
|
+
* Electric's default parser answers with a BigInt, which throws the moment it
|
|
159
|
+
* meets a number in arithmetic — and the row types say `number`, because that is
|
|
160
|
+
* what `JSON.parse` produces on the REST path.
|
|
161
|
+
*
|
|
162
|
+
* Warns rather than throws above the safe-integer range: the REST path loses the
|
|
163
|
+
* same precision silently, so failing here would kill a stream over data the
|
|
164
|
+
* rest of the application accepts. The warning makes the loss visible instead.
|
|
165
|
+
*/
|
|
166
|
+
function parseInt8(value) {
|
|
167
|
+
const parsed = Number(value);
|
|
168
|
+
if (!Number.isSafeInteger(parsed)) console.warn(`[rig] int8 value ${value} is outside the safe integer range and lost precision`);
|
|
169
|
+
return parsed;
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/shape-url.ts
|
|
173
|
+
/**
|
|
174
|
+
* The address one stream subscribes to.
|
|
175
|
+
*
|
|
176
|
+
* It exists because the sync client resolves nothing: it hands its `url` to
|
|
177
|
+
* `new URL()` with no base. So the relative origin `@rig-ts/client` documents as
|
|
178
|
+
* the ordinary same-origin case — `baseUrl: ""`, resolved against the page —
|
|
179
|
+
* arrives there as `/api/v1/todo/_stream` and throws `Invalid URL`. And because
|
|
180
|
+
* a TypeError is not a `FetchError`, the error handler reads it as a failure
|
|
181
|
+
* worth another go and the stream retries the same unusable address with
|
|
182
|
+
* backoff, silently, rather than reporting anything. Resolving it here is what
|
|
183
|
+
* makes the documented default work for a stream as well as for a REST call,
|
|
184
|
+
* where `fetch` does this much itself.
|
|
185
|
+
*
|
|
186
|
+
* Off a browser there is no page to resolve against, so a relative origin is
|
|
187
|
+
* left as it is. Nothing should be syncing during a server render, and a stream
|
|
188
|
+
* that starts anyway should fail naming the origin it was not given rather than
|
|
189
|
+
* one this invented for it.
|
|
190
|
+
*/
|
|
191
|
+
function shapeUrl(origin, path) {
|
|
192
|
+
const url = `${origin}${path}`;
|
|
193
|
+
if (typeof window === "undefined") return url;
|
|
194
|
+
return new URL(url, window.location.href).toString();
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
197
|
+
//#region src/create-collection.ts
|
|
198
|
+
/**
|
|
199
|
+
* Builds a read-only collection over one of a rig application's shape endpoints.
|
|
200
|
+
*
|
|
201
|
+
* **No mutation handlers.** Writes go through the API and the sync service
|
|
202
|
+
* delivers the resulting rows, so there is no txid handshake to perform here. A
|
|
203
|
+
* collection that could be written to would be a second way to change a row, and
|
|
204
|
+
* one that skips every rule the server applies.
|
|
205
|
+
*
|
|
206
|
+
* **No schema.** TanStack DB validates only on optimistic mutations, so a schema
|
|
207
|
+
* here would never run; the row types come from the generated API types, which
|
|
208
|
+
* are the ones the server sends.
|
|
209
|
+
*
|
|
210
|
+
* Sync does not start when this is called. It begins when the first live query
|
|
211
|
+
* subscribes and pauses when the last one unsubscribes, so an instance held
|
|
212
|
+
* across a navigation resumes rather than re-syncing — which is what
|
|
213
|
+
* {@link createCollectionCache} exists to make possible.
|
|
214
|
+
*/
|
|
215
|
+
function createRigCollection(args) {
|
|
216
|
+
return createCollection(electricCollectionOptions({
|
|
217
|
+
shapeOptions: {
|
|
218
|
+
url: shapeUrl(args.runtime.origin, args.path),
|
|
219
|
+
params: serializeParams(args.params ?? {}),
|
|
220
|
+
parser: rigParsers,
|
|
221
|
+
fetchClient: rigFetchClient(args.runtime),
|
|
222
|
+
onError: streamErrorHandler(args.runtime)
|
|
223
|
+
},
|
|
224
|
+
getKey: args.getKey
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/collection-cache.ts
|
|
229
|
+
/**
|
|
230
|
+
* Wraps a collection factory so each distinct client and param set maps to one
|
|
231
|
+
* long-lived instance.
|
|
232
|
+
*
|
|
233
|
+
* Collections are stateful and each one opens a stream, so an instance rebuilt
|
|
234
|
+
* per render or per navigation loses everything the library gives a retained
|
|
235
|
+
* one: data held for its collection time, sync that pauses at zero subscribers
|
|
236
|
+
* and resumes on the next one, and resume-from-offset instead of a full
|
|
237
|
+
* re-sync. Returning the same instance is what makes those pay off — and it is
|
|
238
|
+
* what lets a caller invoke a generated factory during render without a
|
|
239
|
+
* load-bearing `useMemo`.
|
|
240
|
+
*
|
|
241
|
+
* Entries remove themselves once their collection reaches `cleaned-up`, so the
|
|
242
|
+
* map is bounded by what is currently live rather than by everything ever asked
|
|
243
|
+
* for. There is no eviction policy to tune.
|
|
244
|
+
*
|
|
245
|
+
* On the server every call builds a fresh instance. The map would otherwise be
|
|
246
|
+
* module-global and leak one request's collections into the next, and nothing
|
|
247
|
+
* server-side should be syncing anyway.
|
|
248
|
+
*
|
|
249
|
+
* Two things the key deliberately leaves out. The **path** is not in it, because
|
|
250
|
+
* each generated factory wraps its own call to this and so has a map of its own
|
|
251
|
+
* — a caller wrapping two different routes in one cache would collide, which is
|
|
252
|
+
* why the generator does not. And the **credential** is not in it: a client that
|
|
253
|
+
* switches to a different tenant on the same origin keeps the collection it had.
|
|
254
|
+
* The rows it holds were scoped to the old session, so switching tenant should
|
|
255
|
+
* discard the collection rather than expect this to notice.
|
|
256
|
+
*/
|
|
257
|
+
function createCollectionCache(build) {
|
|
258
|
+
const collections = /* @__PURE__ */ new Map();
|
|
259
|
+
return (runtime, params) => {
|
|
260
|
+
if (typeof window === "undefined") return build(runtime, params);
|
|
261
|
+
const key = `${runtime.origin}|${paramsCacheKey(params)}`;
|
|
262
|
+
const cached = collections.get(key);
|
|
263
|
+
if (cached !== void 0) return cached;
|
|
264
|
+
const collection = build(runtime, params);
|
|
265
|
+
collection.on("status:change", ({ status }) => {
|
|
266
|
+
if (status === "cleaned-up") collections.delete(key);
|
|
267
|
+
});
|
|
268
|
+
collections.set(key, collection);
|
|
269
|
+
return collection;
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
//#endregion
|
|
273
|
+
export { createCollectionCache, createRigCollection, paramsCacheKey, rigFetchClient, rigParsers, serializeParams, shapeUrl, streamErrorHandler };
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rig-ts/electric",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"description": "Live-sync collections over a rig application's shape endpoints",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.mts",
|
|
14
|
+
"default": "./dist/index.mjs"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@electric-sql/client": "1.5.24",
|
|
19
|
+
"@tanstack/db": "0.6.17",
|
|
20
|
+
"@tanstack/electric-db-collection": "0.3.15"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@rig-ts/client": "^0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"tsdown": "0.21.10",
|
|
27
|
+
"typescript": "5.9.3",
|
|
28
|
+
"@rig-ts/client": "0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/simonjanss/rig.git",
|
|
36
|
+
"directory": "ts/packages/electric"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsdown",
|
|
40
|
+
"typecheck": "tsc --noEmit"
|
|
41
|
+
}
|
|
42
|
+
}
|