@rebasepro/client 0.8.0 → 0.9.1-canary.09aaf62

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,238 @@
1
+ /**
2
+ * Broadcast channels and presence, as an SDK surface.
3
+ *
4
+ * The realtime engine has supported `join_channel`, `broadcast`,
5
+ * `presence_track`, `presence_untrack` and `presence_state` for a while, but
6
+ * the client only recognised those types well enough to send them
7
+ * fire-and-forget: there were no methods to call and no way to receive channel
8
+ * or broadcast events, since `on()` handles only connect / disconnect /
9
+ * reconnect / error. Anything wanting presence therefore opened a *second*
10
+ * socket and reimplemented the AUTHENTICATE → AUTH_SUCCESS handshake, the
11
+ * reconnect backoff, and the presence heartbeat — a couple of hundred lines
12
+ * per app, all of it duplicating this package.
13
+ *
14
+ * Two protocol details this hides, because both are easy to get wrong and
15
+ * neither is discoverable from the message list:
16
+ *
17
+ * - **A joining client is told only about its own join.** The `presence_diff`
18
+ * it receives after `presence_track` contains just itself. The existing
19
+ * roster arrives only in response to an explicit `presence_state` request,
20
+ * so `join()` sends one.
21
+ * - **Presence expires after 30s** (`PRESENCE_TIMEOUT_MS` server-side). A
22
+ * client that tracks once and goes quiet silently vanishes from everyone
23
+ * else's roster while still sitting in the document, so `track()` starts a
24
+ * heartbeat and `leave()` stops it.
25
+ */
26
+
27
+ /** Presence state keyed by the server's client id. */
28
+ export type PresenceState = Record<string, Record<string, unknown>>;
29
+
30
+ export interface PresenceDiff {
31
+ joins: PresenceState;
32
+ leaves: PresenceState;
33
+ }
34
+
35
+ export interface BroadcastEvent {
36
+ event: string;
37
+ payload: unknown;
38
+ }
39
+
40
+ /** The socket operations a channel needs; satisfied by RebaseWebSocketClient. */
41
+ export interface ChannelTransport {
42
+ sendMessage(message: Record<string, unknown>): Promise<unknown>;
43
+ onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void;
44
+ onReconnect(handler: () => void): () => void;
45
+ }
46
+
47
+ /**
48
+ * Re-send presence comfortably inside the server's 30s expiry.
49
+ *
50
+ * Two-thirds of the window: one lost heartbeat still leaves time for the next
51
+ * before the entry is reaped, so a single dropped frame is not a disappearance.
52
+ */
53
+ const PRESENCE_HEARTBEAT_MS = 20_000;
54
+
55
+ export class RebaseRealtimeChannel {
56
+ private presenceHandlers = new Set<(state: PresenceState, diff?: PresenceDiff) => void>();
57
+ private broadcastHandlers = new Set<(event: BroadcastEvent) => void>();
58
+ private unsubscribers: (() => void)[] = [];
59
+
60
+ /** Last known roster, kept so handlers always get a full picture. */
61
+ private presences: PresenceState = {};
62
+ /** What this client last tracked, replayed on reconnect and heartbeat. */
63
+ private trackedState: Record<string, unknown> | null = null;
64
+ private heartbeat: ReturnType<typeof setInterval> | null = null;
65
+ private joined = false;
66
+
67
+ constructor(
68
+ public readonly name: string,
69
+ private transport: ChannelTransport
70
+ ) {}
71
+
72
+ /**
73
+ * Join the channel and ask for the current roster.
74
+ *
75
+ * Called automatically by `track`, `broadcast`, `onPresence` and
76
+ * `onBroadcast`; calling it directly is only needed to start receiving
77
+ * before there is anything to send.
78
+ */
79
+ async join(): Promise<void> {
80
+ if (this.joined) return;
81
+ this.joined = true;
82
+
83
+ this.unsubscribers.push(
84
+ this.transport.onChannelMessage(this.name, (message) => this.handle(message))
85
+ );
86
+
87
+ // A reconnect drops server-side channel membership and presence, so
88
+ // both have to be re-established. Nothing else notices this: the
89
+ // socket comes back, and the client just stops receiving.
90
+ this.unsubscribers.push(
91
+ this.transport.onReconnect(() => {
92
+ void this.rejoin();
93
+ })
94
+ );
95
+
96
+ await this.transport.sendMessage({ type: "join_channel", channel: this.name });
97
+ // Not optional. Joining does not push the roster — without this the
98
+ // channel believes it is alone until somebody else happens to move.
99
+ await this.transport.sendMessage({ type: "presence_state", channel: this.name });
100
+ }
101
+
102
+ private async rejoin(): Promise<void> {
103
+ try {
104
+ await this.transport.sendMessage({ type: "join_channel", channel: this.name });
105
+ await this.transport.sendMessage({ type: "presence_state", channel: this.name });
106
+ if (this.trackedState) {
107
+ await this.transport.sendMessage({
108
+ type: "presence_track",
109
+ channel: this.name,
110
+ state: this.trackedState
111
+ });
112
+ }
113
+ } catch {
114
+ // The socket is down again; the next reconnect will retry.
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Publish this client's presence state, and keep publishing it.
120
+ *
121
+ * Calling `track` again replaces the state (and restarts the heartbeat),
122
+ * which is how you update e.g. a cursor position.
123
+ */
124
+ async track(state: Record<string, unknown>): Promise<void> {
125
+ await this.join();
126
+ this.trackedState = state;
127
+
128
+ await this.transport.sendMessage({ type: "presence_track", channel: this.name, state });
129
+
130
+ if (!this.heartbeat) {
131
+ this.heartbeat = setInterval(() => {
132
+ if (!this.trackedState) return;
133
+ void this.transport
134
+ .sendMessage({ type: "presence_track", channel: this.name, state: this.trackedState })
135
+ .catch(() => { /* a dropped beat is recoverable; the next one carries the same state */ });
136
+ }, PRESENCE_HEARTBEAT_MS);
137
+ // Do not hold a Node process open just to say "still here".
138
+ (this.heartbeat as unknown as { unref?: () => void }).unref?.();
139
+ }
140
+ }
141
+
142
+ /** Stop publishing presence, without leaving the channel. */
143
+ async untrack(): Promise<void> {
144
+ this.stopHeartbeat();
145
+ this.trackedState = null;
146
+ if (this.joined) {
147
+ await this.transport.sendMessage({ type: "presence_untrack", channel: this.name });
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Observe the roster. The handler fires immediately with what is already
153
+ * known, then on every change.
154
+ */
155
+ onPresence(handler: (state: PresenceState, diff?: PresenceDiff) => void): () => void {
156
+ this.presenceHandlers.add(handler);
157
+ void this.join();
158
+ if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
159
+ return () => this.presenceHandlers.delete(handler);
160
+ }
161
+
162
+ /** Send a broadcast. The sender does not receive its own message. */
163
+ async broadcast(event: string, payload: unknown): Promise<void> {
164
+ await this.join();
165
+ await this.transport.sendMessage({ type: "broadcast", channel: this.name, event, payload });
166
+ }
167
+
168
+ /** Observe broadcasts. Pass an event name to filter. */
169
+ onBroadcast(handler: (event: BroadcastEvent) => void): () => void;
170
+ onBroadcast(event: string, handler: (payload: unknown) => void): () => void;
171
+ onBroadcast(
172
+ eventOrHandler: string | ((event: BroadcastEvent) => void),
173
+ maybeHandler?: (payload: unknown) => void
174
+ ): () => void {
175
+ const wrapped: (event: BroadcastEvent) => void = typeof eventOrHandler === "string"
176
+ ? (e) => { if (e.event === eventOrHandler) maybeHandler!(e.payload); }
177
+ : eventOrHandler;
178
+
179
+ this.broadcastHandlers.add(wrapped);
180
+ void this.join();
181
+ return () => this.broadcastHandlers.delete(wrapped);
182
+ }
183
+
184
+ /** Leave the channel and release every listener and timer. */
185
+ async leave(): Promise<void> {
186
+ this.stopHeartbeat();
187
+ this.trackedState = null;
188
+ this.presences = {};
189
+ this.presenceHandlers.clear();
190
+ this.broadcastHandlers.clear();
191
+
192
+ for (const off of this.unsubscribers) off();
193
+ this.unsubscribers = [];
194
+
195
+ if (this.joined) {
196
+ this.joined = false;
197
+ await this.transport.sendMessage({ type: "leave_channel", channel: this.name });
198
+ }
199
+ }
200
+
201
+ private stopHeartbeat(): void {
202
+ if (this.heartbeat) {
203
+ clearInterval(this.heartbeat);
204
+ this.heartbeat = null;
205
+ }
206
+ }
207
+
208
+ /** Fold an incoming frame into the roster and fan it out. */
209
+ private handle(message: Record<string, unknown>): void {
210
+ switch (message.type) {
211
+ case "presence_state": {
212
+ this.presences = (message.presences as PresenceState) ?? {};
213
+ this.emitPresence();
214
+ break;
215
+ }
216
+ case "presence_diff": {
217
+ const joins = (message.joins as PresenceState) ?? {};
218
+ const leaves = (message.leaves as PresenceState) ?? {};
219
+ // A diff carries only what moved, so the roster is maintained
220
+ // here rather than handed to callers to reassemble.
221
+ for (const [id, state] of Object.entries(joins)) this.presences[id] = state;
222
+ for (const id of Object.keys(leaves)) delete this.presences[id];
223
+ this.emitPresence({ joins, leaves });
224
+ break;
225
+ }
226
+ case "broadcast": {
227
+ const event = { event: message.event as string, payload: message.payload };
228
+ for (const handler of this.broadcastHandlers) handler(event);
229
+ break;
230
+ }
231
+ }
232
+ }
233
+
234
+ private emitPresence(diff?: PresenceDiff): void {
235
+ const snapshot = { ...this.presences };
236
+ for (const handler of this.presenceHandlers) handler(snapshot, diff);
237
+ }
238
+ }
@@ -0,0 +1,119 @@
1
+ import { jest } from "@jest/globals";
2
+ import { createRebaseClient } from "./index";
3
+
4
+ /**
5
+ * A WebSocket stand-in that records construction. The real socket keeps the Node
6
+ * event loop alive, which is what makes a one-shot script hang; here we only
7
+ * need to know whether one would have been opened at all.
8
+ */
9
+ function trackingWebSocket() {
10
+ const opened: string[] = [];
11
+ const closed: string[] = [];
12
+
13
+ class FakeWebSocket {
14
+ static readonly OPEN = 1;
15
+ readyState = 0;
16
+ onopen: (() => void) | null = null;
17
+ onclose: (() => void) | null = null;
18
+ onerror: (() => void) | null = null;
19
+ onmessage: (() => void) | null = null;
20
+
21
+ constructor(public url: string) {
22
+ opened.push(url);
23
+ }
24
+
25
+ close() {
26
+ closed.push(this.url);
27
+ }
28
+
29
+ send() { /* no-op */ }
30
+ }
31
+
32
+ return { FakeWebSocket: FakeWebSocket as unknown as typeof WebSocket, opened, closed };
33
+ }
34
+
35
+ describe("realtime opt-out", () => {
36
+ const original = globalThis.WebSocket;
37
+
38
+ afterEach(() => {
39
+ globalThis.WebSocket = original;
40
+ jest.restoreAllMocks();
41
+ });
42
+
43
+ it("opens the socket by default", () => {
44
+ const { FakeWebSocket, opened } = trackingWebSocket();
45
+ globalThis.WebSocket = FakeWebSocket;
46
+
47
+ createRebaseClient({ baseUrl: "http://localhost:3000/api" });
48
+
49
+ expect(opened).toHaveLength(1);
50
+ });
51
+
52
+ it("opens no socket when realtime is disabled", () => {
53
+ const { FakeWebSocket, opened } = trackingWebSocket();
54
+ globalThis.WebSocket = FakeWebSocket;
55
+
56
+ const client = createRebaseClient({
57
+ baseUrl: "http://localhost:3000/api",
58
+ realtime: false
59
+ });
60
+
61
+ // The socket is what keeps a CLI / cron / ETL process alive past its work.
62
+ expect(opened).toHaveLength(0);
63
+ expect(client.ws).toBeUndefined();
64
+ });
65
+
66
+ it("opens no socket when realtime is disabled even if a websocketUrl is given", () => {
67
+ const { FakeWebSocket, opened } = trackingWebSocket();
68
+ globalThis.WebSocket = FakeWebSocket;
69
+
70
+ createRebaseClient({
71
+ baseUrl: "http://localhost:3000/api",
72
+ websocketUrl: "ws://localhost:3000",
73
+ realtime: false
74
+ });
75
+
76
+ expect(opened).toHaveLength(0);
77
+ });
78
+
79
+ it("close() releases the socket", () => {
80
+ const { FakeWebSocket, opened, closed } = trackingWebSocket();
81
+ globalThis.WebSocket = FakeWebSocket;
82
+
83
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
84
+ expect(opened).toHaveLength(1);
85
+
86
+ client.close();
87
+
88
+ expect(closed).toHaveLength(1);
89
+ });
90
+
91
+ it("close() is safe when realtime was never started, and when called twice", () => {
92
+ const { FakeWebSocket } = trackingWebSocket();
93
+ globalThis.WebSocket = FakeWebSocket;
94
+
95
+ const offline = createRebaseClient({ baseUrl: "http://localhost:3000/api", realtime: false });
96
+ expect(() => offline.close()).not.toThrow();
97
+
98
+ const live = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
99
+ live.close();
100
+ expect(() => live.close()).not.toThrow();
101
+ });
102
+
103
+ it("leaves listen() absent so callers can feature-detect, and says why via the query builder", () => {
104
+ const { FakeWebSocket } = trackingWebSocket();
105
+ globalThis.WebSocket = FakeWebSocket;
106
+
107
+ const client = createRebaseClient({
108
+ baseUrl: "http://localhost:3000/api",
109
+ realtime: false
110
+ });
111
+
112
+ // `listen` stays undefined rather than becoming a throwing stub: the
113
+ // optional type is what makes `if (client.listen)` work and what makes
114
+ // TypeScript reject a bare call.
115
+ expect(client.collection("posts").listen).toBeUndefined();
116
+ expect(() => client.data.posts.include("author").listen(() => { /* noop */ }))
117
+ .toThrow(/realtime: false/);
118
+ });
119
+ });
@@ -0,0 +1,254 @@
1
+ import { RebaseWebSocketClient } from "./websocket";
2
+
3
+ /**
4
+ * Realtime patches name a row by address; the cached rows are columns only.
5
+ *
6
+ * The SDK deliberately holds no collection config — a BaaS caller declares
7
+ * nothing — so it cannot derive an address by itself, and used to just read
8
+ * `row.id`. For a table keyed on anything else that is `undefined`: deletes
9
+ * matched no cached row and removed nothing, and updates matched none either,
10
+ * so every edit was prepended as a duplicate of the row it was editing.
11
+ *
12
+ * The server now sends the key columns with the patch, and they are used here.
13
+ */
14
+ describe("collection_patch — cached rows are matched by derived address", () => {
15
+ const SKU_PKS = [{ fieldName: "sku",
16
+ type: "string" as const }];
17
+
18
+ /**
19
+ * A client with one collection subscription holding `cached`, wired to the
20
+ * backend subscription id the patches below use.
21
+ */
22
+ const setup = (cached: Record<string, unknown>[]) => {
23
+ const client = new RebaseWebSocketClient({ url: "ws://localhost:1234" });
24
+ const updates: Record<string, unknown>[][] = [];
25
+
26
+ const internals = client as unknown as {
27
+ backendToCollectionKey: Map<string, string>;
28
+ collectionSubscriptions: Map<string, unknown>;
29
+ handleWebSocketMessage: (m: unknown) => void;
30
+ };
31
+
32
+ internals.backendToCollectionKey.set("backend-1", "sub-key");
33
+ internals.collectionSubscriptions.set("sub-key", {
34
+ backendSubscriptionId: "backend-1",
35
+ callbacks: new Map([["cb", { onUpdate: (rows: Record<string, unknown>[]) => updates.push(rows) }]]),
36
+ props: { path: "sku_items" },
37
+ latestData: cached,
38
+ isInitialDataReceived: true
39
+ });
40
+
41
+ const patch = (message: Record<string, unknown>) =>
42
+ internals.handleWebSocketMessage({ type: "collection_patch",
43
+ subscriptionId: "backend-1",
44
+ ...message });
45
+
46
+ const update = (rows: Record<string, unknown>[], pks?: unknown) =>
47
+ internals.handleWebSocketMessage({ type: "collection_update",
48
+ subscriptionId: "backend-1",
49
+ rows,
50
+ pks });
51
+
52
+ return { patch,
53
+ update,
54
+ updates,
55
+ sub: () => internals.collectionSubscriptions.get("sub-key") as { latestData: Record<string, unknown>[] } };
56
+ };
57
+
58
+ it("removes the deleted row a `sku` key names", () => {
59
+ const { patch, sub } = setup([
60
+ { sku: "ABC-1",
61
+ label: "Widget" },
62
+ { sku: "ABC-2",
63
+ label: "Gadget" }
64
+ ]);
65
+
66
+ patch({ id: "ABC-1",
67
+ row: null,
68
+ pks: SKU_PKS });
69
+
70
+ expect(sub().latestData).toEqual([{ sku: "ABC-2",
71
+ label: "Gadget" }]);
72
+ });
73
+
74
+ it("replaces the row the patch names, not whichever row is first", () => {
75
+ // Both sides of the old comparison stringified to "undefined", so it
76
+ // matched at index 0 every time: editing the second row overwrote the
77
+ // first one, in place, with the second one's values.
78
+ const { patch, sub } = setup([
79
+ { sku: "ABC-1",
80
+ label: "Widget" },
81
+ { sku: "ABC-2",
82
+ label: "Gadget" }
83
+ ]);
84
+
85
+ patch({ id: "ABC-2",
86
+ row: { sku: "ABC-2",
87
+ label: "Gadget v2" },
88
+ pks: SKU_PKS });
89
+
90
+ expect(sub().latestData).toEqual([
91
+ { sku: "ABC-1",
92
+ label: "Widget" },
93
+ { sku: "ABC-2",
94
+ label: "Gadget v2" }
95
+ ]);
96
+ });
97
+
98
+ it("prepends a row that really is new", () => {
99
+ const { patch, sub } = setup([{ sku: "ABC-2",
100
+ label: "Gadget" }]);
101
+
102
+ patch({ id: "ABC-9",
103
+ row: { sku: "ABC-9",
104
+ label: "New" },
105
+ pks: SKU_PKS });
106
+
107
+ expect(sub().latestData).toEqual([
108
+ { sku: "ABC-9",
109
+ label: "New" },
110
+ { sku: "ABC-2",
111
+ label: "Gadget" }
112
+ ]);
113
+ });
114
+
115
+ it("matches a composite key by its joined address", () => {
116
+ const { patch, sub } = setup([
117
+ { tenant_id: 1,
118
+ user_id: 2,
119
+ role: "admin" },
120
+ { tenant_id: 1,
121
+ user_id: 3,
122
+ role: "viewer" }
123
+ ]);
124
+
125
+ patch({
126
+ id: "1:::3",
127
+ row: { tenant_id: 1,
128
+ user_id: 3,
129
+ role: "owner" },
130
+ pks: [{ fieldName: "tenant_id",
131
+ type: "number" }, { fieldName: "user_id",
132
+ type: "number" }]
133
+ });
134
+
135
+ expect(sub().latestData).toEqual([
136
+ { tenant_id: 1,
137
+ user_id: 2,
138
+ role: "admin" },
139
+ { tenant_id: 1,
140
+ user_id: 3,
141
+ role: "owner" }
142
+ ]);
143
+ });
144
+
145
+ it("carries the patch's keys into the refetch merge, preserving unchanged references", () => {
146
+ // Phase 2 of every patch is a full refetch (`collection_update`), whose
147
+ // merge keeps the cached object for rows that did not change so React
148
+ // sees the same reference. That matching needs an address too — the
149
+ // keys learned from the patch are remembered on the subscription.
150
+ const unchanged = { sku: "ABC-1",
151
+ label: "Widget" };
152
+ const { patch, update, sub } = setup([unchanged, { sku: "ABC-2",
153
+ label: "Gadget" }]);
154
+
155
+ patch({ id: "ABC-2",
156
+ row: { sku: "ABC-2",
157
+ label: "Gadget v2" },
158
+ pks: SKU_PKS });
159
+ update([{ sku: "ABC-1",
160
+ label: "Widget" }, { sku: "ABC-2",
161
+ label: "Gadget v3" }], SKU_PKS);
162
+
163
+ expect(sub().latestData[0]).toBe(unchanged);
164
+ expect(sub().latestData[1]).toEqual({ sku: "ABC-2",
165
+ label: "Gadget v3" });
166
+ });
167
+
168
+ it("learns the keys from the rows themselves, for a change that sends no patch", () => {
169
+ // A write from outside the API — psql, a cron job — reaches subscribers
170
+ // through CDC, which invalidates and refetches without ever sending a
171
+ // patch. Keys learned only from patches would never arrive, and every
172
+ // row of the refetch would be a new reference: the whole table
173
+ // re-renders on any external write.
174
+ const unchanged = { sku: "ABC-1",
175
+ label: "Widget" };
176
+ const { update, sub } = setup([unchanged, { sku: "ABC-2",
177
+ label: "Gadget" }]);
178
+
179
+ update([{ sku: "ABC-1",
180
+ label: "Widget" }, { sku: "ABC-2",
181
+ label: "Gadget v2" }], SKU_PKS);
182
+
183
+ expect(sub().latestData[0]).toBe(unchanged);
184
+ expect(sub().latestData[1]).toEqual({ sku: "ABC-2",
185
+ label: "Gadget v2" });
186
+ });
187
+
188
+ it("addresses an `id`-keyed collection from the keys like any other", () => {
189
+ // `id` is not special: the server reports it as the key column, and it
190
+ // is derived through the same path as a `sku` or a composite.
191
+ const { patch, sub } = setup([{ id: 1,
192
+ name: "Camera" }, { id: 2,
193
+ name: "Lens" }]);
194
+
195
+ patch({ id: "1",
196
+ row: null,
197
+ pks: [{ fieldName: "id",
198
+ type: "number" }] });
199
+
200
+ expect(sub().latestData).toEqual([{ id: 2,
201
+ name: "Lens" }]);
202
+ });
203
+
204
+ it("recognises nothing when the server resolved no keys, rather than guessing at `id`", () => {
205
+ // A table with no primary key and no `id` column has no address. The
206
+ // server says so by sending no keys, and inventing one from a column
207
+ // that merely looks like an id would address rows that are not there.
208
+ const { patch, sub } = setup([
209
+ { id: "batch-42",
210
+ name: "Camera" },
211
+ { id: "batch-42",
212
+ name: "Lens" }
213
+ ]);
214
+
215
+ patch({ id: "batch-42",
216
+ row: null });
217
+
218
+ // Nothing removed: the patch names a row this client cannot identify.
219
+ expect(sub().latestData).toHaveLength(2);
220
+ });
221
+
222
+ it("prefers the real key over a column merely named `id`", () => {
223
+ // `event_id` is the key; `id` is ordinary data, and ordinary data has no
224
+ // uniqueness to borrow — both rows here carry the same external ref.
225
+ // Matching on it finds the first, so editing the party rewrote the
226
+ // launch with the party's values.
227
+ const { patch, sub } = setup([
228
+ { event_id: 7,
229
+ id: "batch-42",
230
+ name: "Launch" },
231
+ { event_id: 8,
232
+ id: "batch-42",
233
+ name: "Party" }
234
+ ]);
235
+
236
+ patch({
237
+ id: "8",
238
+ row: { event_id: 8,
239
+ id: "batch-42",
240
+ name: "Party v2" },
241
+ pks: [{ fieldName: "event_id",
242
+ type: "number" }]
243
+ });
244
+
245
+ expect(sub().latestData).toEqual([
246
+ { event_id: 7,
247
+ id: "batch-42",
248
+ name: "Launch" },
249
+ { event_id: 8,
250
+ id: "batch-42",
251
+ name: "Party v2" }
252
+ ]);
253
+ });
254
+ });
package/src/reviver.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { EntityReference, EntityRelation, GeoPoint, Vector, Entity } from "@rebasepro/types";
1
+ import { EntityReference, EntityRelation, GeoPoint, Vector } from "@rebasepro/types";
2
2
 
3
3
  export function rebaseReviver(_key: string, value: unknown): unknown {
4
4
  if (value && typeof value === "object" && "__type" in value) {
@@ -25,7 +25,7 @@ export function rebaseReviver(_key: string, value: unknown): unknown {
25
25
  return new EntityRelation(
26
26
  record.id as string | number,
27
27
  record.path as string,
28
- record.data as Entity | undefined
28
+ record.data as Record<string, unknown> | undefined
29
29
  );
30
30
  case "GeoPoint":
31
31
  return new GeoPoint(record.latitude as number, record.longitude as number);