@rebasepro/client 0.17.3 → 0.18.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/README.md +4 -0
- package/dist/auth.d.ts +80 -0
- package/dist/functions.d.ts +6 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.es.js +471 -94
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -1
- package/dist/offline.d.ts +23 -1
- package/dist/query-contract.types.d.ts +30 -0
- package/dist/realtime-channel.d.ts +29 -1
- package/dist/sdk_query_builder.d.ts +21 -2
- package/dist/transport.d.ts +24 -0
- package/package.json +28 -15
- package/src/admin.ts +0 -90
- package/src/anonymous-client-guard.test.ts +0 -190
- package/src/api-keys.ts +0 -87
- package/src/auth-listener-errors.test.ts +0 -57
- package/src/auth-refresh-overflow.test.ts +0 -89
- package/src/auth.ts +0 -982
- package/src/backups.ts +0 -40
- package/src/client-close.test.ts +0 -80
- package/src/collection-listen-meta.test.ts +0 -105
- package/src/collection-observe.test.ts +0 -138
- package/src/collection.test.ts +0 -293
- package/src/collection.ts +0 -525
- package/src/cron.test.ts +0 -164
- package/src/cron.ts +0 -62
- package/src/data-proxy.test.ts +0 -183
- package/src/errors.ts +0 -9
- package/src/functions.ts +0 -82
- package/src/index.ts +0 -639
- package/src/like-pattern-redos.test.ts +0 -61
- package/src/offline-codec.ts +0 -79
- package/src/offline-connectivity.test.ts +0 -191
- package/src/offline-connectivity.ts +0 -255
- package/src/offline-idb-store.test.ts +0 -340
- package/src/offline-integration.test.ts +0 -180
- package/src/offline-query.test.ts +0 -431
- package/src/offline-query.ts +0 -529
- package/src/offline-store.ts +0 -357
- package/src/offline-sync-engine.test.ts +0 -857
- package/src/offline.test.ts +0 -897
- package/src/offline.ts +0 -1928
- package/src/query-contract.types.ts +0 -206
- package/src/query_builder.ts +0 -1
- package/src/realtime-channel.test.ts +0 -542
- package/src/realtime-channel.ts +0 -539
- package/src/realtime-concurrent-subscribe.test.ts +0 -102
- package/src/realtime-error-surfacing.test.ts +0 -105
- package/src/realtime-optout.test.ts +0 -279
- package/src/realtime-row-identity.test.ts +0 -254
- package/src/realtime-subscription-key.test.ts +0 -92
- package/src/reviver.ts +0 -39
- package/src/sdk_query_builder.ts +0 -206
- package/src/storage-key-encoding.test.ts +0 -65
- package/src/storage-registry.ts +0 -102
- package/src/storage.ts +0 -253
- package/src/transport-baseurl.test.ts +0 -101
- package/src/transport.ts +0 -505
- package/src/vector-search-listen.test.ts +0 -42
- package/src/vector-search-query.test.ts +0 -55
- package/src/websocket-url.test.ts +0 -97
- package/src/websocket.ts +0 -1837
package/src/storage.ts
DELETED
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
import { StorageSource, UploadFileProps, UploadFileResult, DownloadConfig, StorageListResult, DownloadMetadata, PUBLIC_STORAGE_PREFIX, isPublicStoragePath } from "@rebasepro/types";
|
|
2
|
-
import { Transport } from "./transport";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Create a StorageSource that talks to the Rebase backend REST API.
|
|
6
|
-
*
|
|
7
|
-
* @param transport - HTTP transport instance
|
|
8
|
-
* @param storageId - Optional storage-source key for multi-backend routing.
|
|
9
|
-
* When set, it is forwarded to the server so the correct
|
|
10
|
-
* `StorageController` is resolved from the registry.
|
|
11
|
-
*/
|
|
12
|
-
/**
|
|
13
|
-
* A storage key, encoded for a URL path.
|
|
14
|
-
*
|
|
15
|
-
* Every call below interpolated the key raw, and the server decodes what it
|
|
16
|
-
* receives — so three ordinary filenames did three different wrong things:
|
|
17
|
-
*
|
|
18
|
-
* `Invoice #12.pdf` the `#` began the fragment, the server saw
|
|
19
|
-
* `default/Invoice ` and looked up a key that is not the
|
|
20
|
-
* file — and a scoped `?token=` after it was swallowed too
|
|
21
|
-
* `100% done.png` `decodeURIComponent` threw URIError → 500
|
|
22
|
-
* `a%2Fb.png` decoded to `a/b.png`, silently resolving a DIFFERENT
|
|
23
|
-
* object
|
|
24
|
-
*
|
|
25
|
-
* Per SEGMENT, because `/` is the key's own separator and must survive; every
|
|
26
|
-
* other character is encoded, including a literal `%`. Verified to round-trip
|
|
27
|
-
* through the server's decode for all of the above, plus `+`, accented
|
|
28
|
-
* characters and a trailing `%`.
|
|
29
|
-
*/
|
|
30
|
-
export function encodeStorageKey(key: string): string {
|
|
31
|
-
return key.split("/").map(encodeURIComponent).join("/");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function createStorage(transport: Transport, storageId?: string): StorageSource {
|
|
35
|
-
const urlsCache = new Map<string, { config: DownloadConfig; expiresAt?: number }>();
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Base for URLs the *browser* will fetch on its own (file downloads,
|
|
39
|
-
* previews). API requests keep going to `baseUrl`; see
|
|
40
|
-
* {@link RebaseClientConfig.storageUrlOrigin} for why these can differ.
|
|
41
|
-
*/
|
|
42
|
-
const fileUrlBase = (): string =>
|
|
43
|
-
`${transport.storageUrlOrigin ?? transport.baseUrl}${transport.apiPath}`;
|
|
44
|
-
|
|
45
|
-
/** Append ?storageId=... to a path when multi-backend routing is active. */
|
|
46
|
-
const withStorageId = (path: string): string => {
|
|
47
|
-
if (!storageId) return path;
|
|
48
|
-
const sep = path.includes("?") ? "&" : "?";
|
|
49
|
-
return `${path}${sep}storageId=${encodeURIComponent(storageId)}`;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
async function putObject({
|
|
53
|
-
file,
|
|
54
|
-
key,
|
|
55
|
-
metadata,
|
|
56
|
-
bucket,
|
|
57
|
-
public: isPublic
|
|
58
|
-
}: UploadFileProps): Promise<UploadFileResult> {
|
|
59
|
-
const formData = new FormData();
|
|
60
|
-
formData.append("file", file);
|
|
61
|
-
|
|
62
|
-
// Public objects live under the public prefix so they can be served
|
|
63
|
-
// token-less via a stable, permanent URL. Normalize the key here so the
|
|
64
|
-
// stored path is self-describing (no server round-trip needed to know
|
|
65
|
-
// it's public).
|
|
66
|
-
let effectiveKey = key;
|
|
67
|
-
if (isPublic && effectiveKey && !isPublicStoragePath(effectiveKey)) {
|
|
68
|
-
effectiveKey = `${PUBLIC_STORAGE_PREFIX}${effectiveKey.replace(/^\/+/, "")}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (effectiveKey) formData.append("key", effectiveKey);
|
|
72
|
-
if (bucket) formData.append("bucket", bucket);
|
|
73
|
-
if (storageId) formData.append("storageId", storageId);
|
|
74
|
-
|
|
75
|
-
if (metadata) {
|
|
76
|
-
for (const [key, value] of Object.entries(metadata)) {
|
|
77
|
-
if (value !== undefined && value !== null) {
|
|
78
|
-
formData.append(
|
|
79
|
-
`metadata_${key}`,
|
|
80
|
-
typeof value === "string" ? value : JSON.stringify(value)
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const result = await transport.request<{ data: UploadFileResult }>(withStorageId("/storage/upload"), {
|
|
87
|
-
method: "POST",
|
|
88
|
-
body: formData,
|
|
89
|
-
headers: {}
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
return result.data;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function getSignedUrl(
|
|
96
|
-
keyOrUrl: string,
|
|
97
|
-
bucket?: string
|
|
98
|
-
): Promise<DownloadConfig> {
|
|
99
|
-
const cacheKey = bucket ? `${bucket}/${keyOrUrl}` : keyOrUrl;
|
|
100
|
-
const cachedEntry = urlsCache.get(cacheKey);
|
|
101
|
-
if (cachedEntry) {
|
|
102
|
-
if (!cachedEntry.expiresAt || cachedEntry.expiresAt > Date.now()) {
|
|
103
|
-
return cachedEntry.config;
|
|
104
|
-
}
|
|
105
|
-
urlsCache.delete(cacheKey);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
let filePath = keyOrUrl;
|
|
109
|
-
|
|
110
|
-
if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://") || filePath.startsWith("gs://"))) {
|
|
111
|
-
filePath = filePath.substring(filePath.indexOf("://") + 3);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (bucket && filePath && !filePath.startsWith(bucket)) {
|
|
115
|
-
filePath = `${bucket}/${filePath}`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (!filePath || filePath.trim() === "" || filePath === "/") {
|
|
119
|
-
return { url: null, fileNotFound: true };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ── Public objects ────────────────────────────────────────────────
|
|
123
|
-
// A public file (under the public prefix) is served token-less via a
|
|
124
|
-
// stable, permanent, CDN-cacheable URL. No metadata round-trip and no
|
|
125
|
-
// token are needed — build the URL directly and cache it forever.
|
|
126
|
-
if (isPublicStoragePath(filePath)) {
|
|
127
|
-
const publicConfig: DownloadConfig = {
|
|
128
|
-
url: withStorageId(`${fileUrlBase()}/storage/file/${encodeStorageKey(filePath)}`)
|
|
129
|
-
};
|
|
130
|
-
urlsCache.set(cacheKey, { config: publicConfig }); // no expiry
|
|
131
|
-
return publicConfig;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
try {
|
|
135
|
-
const result = await transport.request<{ data: DownloadMetadata }>(withStorageId(`/storage/metadata/${encodeStorageKey(filePath)}`));
|
|
136
|
-
|
|
137
|
-
// Public object (server-confirmed): token-less permanent URL.
|
|
138
|
-
if (result.data.public) {
|
|
139
|
-
const publicConfig: DownloadConfig = {
|
|
140
|
-
url: withStorageId(`${fileUrlBase()}/storage/file/${encodeStorageKey(filePath)}`),
|
|
141
|
-
metadata: result.data
|
|
142
|
-
};
|
|
143
|
-
urlsCache.set(cacheKey, { config: publicConfig }); // no expiry
|
|
144
|
-
return publicConfig;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Private object: use the short-lived, file-scoped download token
|
|
148
|
-
// minted by the server. We deliberately do NOT fall back to the
|
|
149
|
-
// caller's access token — a URL must never carry a full-privilege
|
|
150
|
-
// credential. If no scoped token is present the URL fails closed.
|
|
151
|
-
const scopedToken = result.data.token;
|
|
152
|
-
const tokenQuery = scopedToken ? `?token=${scopedToken}` : "";
|
|
153
|
-
|
|
154
|
-
const downloadConfig: DownloadConfig = {
|
|
155
|
-
// `withStorageId` picks `?` or `&` based on whether the token
|
|
156
|
-
// query is already present, so the URL stays valid even when
|
|
157
|
-
// there is no token.
|
|
158
|
-
url: withStorageId(`${fileUrlBase()}/storage/file/${encodeStorageKey(filePath)}${tokenQuery}`),
|
|
159
|
-
metadata: result.data
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
const expiresAt = result.data.tokenExpiresIn
|
|
163
|
-
? Date.now() + (result.data.tokenExpiresIn - 10) * 1000 // subtract 10s buffer
|
|
164
|
-
: undefined;
|
|
165
|
-
|
|
166
|
-
urlsCache.set(cacheKey, { config: downloadConfig, expiresAt });
|
|
167
|
-
return downloadConfig;
|
|
168
|
-
} catch (e: unknown) {
|
|
169
|
-
if (e instanceof Error && "status" in e && (e as { status: number }).status === 404) {
|
|
170
|
-
return { url: null, fileNotFound: true };
|
|
171
|
-
}
|
|
172
|
-
throw e;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async function getObject(
|
|
177
|
-
key: string,
|
|
178
|
-
bucket?: string
|
|
179
|
-
): Promise<File | null> {
|
|
180
|
-
const downloadConfig = await getSignedUrl(key, bucket);
|
|
181
|
-
if (downloadConfig.fileNotFound || !downloadConfig.url) {
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Fetch using the signed URL directly. Since the scoped token is in the ?token= query param,
|
|
186
|
-
// we explicitly omit any Authorization headers to prevent passing full access tokens to file serving routes.
|
|
187
|
-
const response = await transport.fetchFn(downloadConfig.url, {
|
|
188
|
-
headers: {}
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
if (response.status === 404) return null;
|
|
192
|
-
if (!response.ok) throw new Error("Failed to get file");
|
|
193
|
-
|
|
194
|
-
const blob = await response.blob();
|
|
195
|
-
const fileName = (bucket ? `${bucket}/${key}` : key).split("/").pop() || "file";
|
|
196
|
-
return new File([blob], fileName, { type: blob.type });
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async function deleteObject(
|
|
200
|
-
key: string,
|
|
201
|
-
bucket?: string
|
|
202
|
-
): Promise<void> {
|
|
203
|
-
let filePath = key;
|
|
204
|
-
|
|
205
|
-
if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://") || filePath.startsWith("gs://"))) {
|
|
206
|
-
filePath = filePath.substring(filePath.indexOf("://") + 3);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (bucket && filePath && !filePath.startsWith(bucket)) {
|
|
210
|
-
filePath = `${bucket}/${filePath}`;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (!filePath || filePath.trim() === "" || filePath === "/") {
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
try {
|
|
218
|
-
await transport.request(withStorageId(`/storage/file/${encodeStorageKey(filePath)}`), { method: "DELETE" });
|
|
219
|
-
} catch (e: unknown) {
|
|
220
|
-
if (!(e instanceof Error && "status" in e && (e as { status: number }).status === 404)) throw e;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
urlsCache.delete(bucket ? `${bucket}/${key}` : key);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async function listObjects(
|
|
227
|
-
prefix: string,
|
|
228
|
-
options?: {
|
|
229
|
-
bucket?: string;
|
|
230
|
-
maxResults?: number;
|
|
231
|
-
pageToken?: string;
|
|
232
|
-
}
|
|
233
|
-
): Promise<StorageListResult> {
|
|
234
|
-
const params = new URLSearchParams();
|
|
235
|
-
if (prefix) params.set("prefix", prefix);
|
|
236
|
-
if (options?.bucket) params.set("bucket", options.bucket);
|
|
237
|
-
if (options?.maxResults) params.set("maxResults", String(options.maxResults));
|
|
238
|
-
if (options?.pageToken) params.set("pageToken", options.pageToken);
|
|
239
|
-
|
|
240
|
-
if (storageId) params.set("storageId", storageId);
|
|
241
|
-
|
|
242
|
-
const result = await transport.request<{ data: StorageListResult }>(`/storage/list?${params.toString()}`);
|
|
243
|
-
return result.data;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return {
|
|
247
|
-
putObject,
|
|
248
|
-
getSignedUrl,
|
|
249
|
-
getObject,
|
|
250
|
-
deleteObject,
|
|
251
|
-
listObjects
|
|
252
|
-
};
|
|
253
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach, jest } from "@jest/globals";
|
|
2
|
-
import { createTransport } from "./transport";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* `baseUrl` is optional because the common production shape is a Rebase backend
|
|
6
|
-
* serving its own SPA: the API is the page's origin. These pin the resolution,
|
|
7
|
-
* because getting it wrong pushes apps into baking an absolute host — which
|
|
8
|
-
* breaks the moment a custom domain points at the same app, and which CORS
|
|
9
|
-
* cannot repair (a SameSite=Lax auth cookie is not sent cross-site either).
|
|
10
|
-
*/
|
|
11
|
-
const setWindow = (origin?: string) => {
|
|
12
|
-
if (origin === undefined) { delete (globalThis as never as { window?: unknown }).window; return; }
|
|
13
|
-
(globalThis as never as { window: unknown }).window = { location: { origin, href: origin + "/" } };
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
afterEach(() => setWindow(undefined));
|
|
17
|
-
|
|
18
|
-
describe("transport baseUrl resolution", () => {
|
|
19
|
-
it("uses the page origin when unset in a browser", () => {
|
|
20
|
-
setWindow("https://dadaki.com");
|
|
21
|
-
expect(createTransport({}).baseUrl).toBe("https://dadaki.com");
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("gives callers something they can build an absolute URL from", () => {
|
|
25
|
-
setWindow("https://dadaki.com");
|
|
26
|
-
const t = createTransport({});
|
|
27
|
-
// The empty-string version of this threw, which is what drove apps to
|
|
28
|
-
// bake a hostname into the bundle.
|
|
29
|
-
expect(() => new URL(`${t.baseUrl}/api/functions/doc-content/get`)).not.toThrow();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("follows the page, so a second hostname on the same app just works", () => {
|
|
33
|
-
setWindow("https://dadaki.rebase.website");
|
|
34
|
-
expect(createTransport({}).baseUrl).toBe("https://dadaki.rebase.website");
|
|
35
|
-
setWindow("https://dadaki.com");
|
|
36
|
-
expect(createTransport({}).baseUrl).toBe("https://dadaki.com");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("still honours an explicit baseUrl, which is what dev needs", () => {
|
|
40
|
-
setWindow("https://dadaki.com");
|
|
41
|
-
expect(createTransport({ baseUrl: "http://localhost:3001" }).baseUrl).toBe("http://localhost:3001");
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("strips a trailing slash so joins do not double up", () => {
|
|
45
|
-
setWindow(undefined);
|
|
46
|
-
expect(createTransport({ baseUrl: "https://api.example.com/" }).baseUrl).toBe("https://api.example.com");
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("stays relative off-browser, where there is no origin to borrow", () => {
|
|
50
|
-
setWindow(undefined);
|
|
51
|
-
expect(createTransport({}).baseUrl).toBe("");
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
describe("baseUrl that already contains the apiPath", () => {
|
|
56
|
-
/**
|
|
57
|
-
* The docblock on `baseUrl` says this "silently builds `/api/api/…` and
|
|
58
|
-
* every request 404s" — a failure mode understood well enough to be written
|
|
59
|
-
* down, and still left to be discovered at runtime. This package's own
|
|
60
|
-
* tests configured it that way a dozen times over, which is about as clear
|
|
61
|
-
* a signal as a trap gets.
|
|
62
|
-
*/
|
|
63
|
-
it("warns rather than silently building /api/api", () => {
|
|
64
|
-
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
65
|
-
createTransport({ baseUrl: "http://localhost:3000/api" });
|
|
66
|
-
|
|
67
|
-
expect(warn).toHaveBeenCalledWith(expect.stringContaining("/api/api"));
|
|
68
|
-
warn.mockRestore();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("warns for a custom apiPath too, and tolerates a trailing slash", () => {
|
|
72
|
-
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
73
|
-
createTransport({ baseUrl: "https://api.example.com/v2/", apiPath: "/v2" });
|
|
74
|
-
|
|
75
|
-
expect(warn).toHaveBeenCalledTimes(1);
|
|
76
|
-
warn.mockRestore();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("says nothing about a baseUrl that merely ends in a similar word", () => {
|
|
80
|
-
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
81
|
-
createTransport({ baseUrl: "https://rapid.example.com" });
|
|
82
|
-
createTransport({ baseUrl: "https://example.com/myapi" });
|
|
83
|
-
createTransport({ baseUrl: "https://example.com" });
|
|
84
|
-
|
|
85
|
-
expect(warn).not.toHaveBeenCalled();
|
|
86
|
-
warn.mockRestore();
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("storageUrlOrigin that already contains the apiPath", () => {
|
|
91
|
-
it("warns, because storage composes it the same way baseUrl is composed", () => {
|
|
92
|
-
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
93
|
-
createTransport({
|
|
94
|
-
baseUrl: "https://example.com",
|
|
95
|
-
storageUrlOrigin: "https://files.example.com/api"
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
expect(warn).toHaveBeenCalledWith(expect.stringContaining("storageUrlOrigin"));
|
|
99
|
-
warn.mockRestore();
|
|
100
|
-
});
|
|
101
|
-
});
|