@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
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { jest } from "@jest/globals";
|
|
2
|
-
import { RebaseWebSocketClient } from "./websocket";
|
|
3
|
-
import { or, cond } from "@rebasepro/common";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Two subscriptions are the same subscription only when they ask for the same
|
|
7
|
-
* rows. The key that decides this was built from a hand-listed subset of the
|
|
8
|
-
* props, so any field left off it made two different queries collide — and the
|
|
9
|
-
* second listener was handed the first one's rows, which is worse than not
|
|
10
|
-
* subscribing at all.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
function fakeSocket() {
|
|
14
|
-
const sent: Record<string, unknown>[] = [];
|
|
15
|
-
|
|
16
|
-
class FakeWS {
|
|
17
|
-
static readonly OPEN = 1;
|
|
18
|
-
readyState = 1;
|
|
19
|
-
onopen: (() => void) | null = null;
|
|
20
|
-
onclose: (() => void) | null = null;
|
|
21
|
-
onerror: (() => void) | null = null;
|
|
22
|
-
onmessage: ((event: { data: string }) => void) | null = null;
|
|
23
|
-
|
|
24
|
-
constructor(public url: string) {
|
|
25
|
-
setTimeout(() => this.onopen?.(), 0);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
send(raw: string) {
|
|
29
|
-
const message = JSON.parse(raw) as Record<string, unknown>;
|
|
30
|
-
sent.push(message);
|
|
31
|
-
if (message.type === "AUTHENTICATE") {
|
|
32
|
-
setTimeout(() => this.onmessage?.({
|
|
33
|
-
data: JSON.stringify({ type: "AUTH_SUCCESS", requestId: message.requestId })
|
|
34
|
-
}), 0);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
close() { /* noop */ }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return { FakeWS: FakeWS as unknown as typeof WebSocket, sent };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function subscribeFrames(sent: Record<string, unknown>[]) {
|
|
45
|
-
return sent.filter((m) => m.type === "subscribe_collection");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
describe("collection subscription identity", () => {
|
|
49
|
-
beforeEach(() => jest.useFakeTimers());
|
|
50
|
-
afterEach(() => jest.useRealTimers());
|
|
51
|
-
|
|
52
|
-
async function twoSubscriptions(
|
|
53
|
-
a: Record<string, unknown>,
|
|
54
|
-
b: Record<string, unknown>
|
|
55
|
-
) {
|
|
56
|
-
const { FakeWS, sent } = fakeSocket();
|
|
57
|
-
const client = new RebaseWebSocketClient({
|
|
58
|
-
websocketUrl: "ws://localhost:1234",
|
|
59
|
-
WebSocket: FakeWS,
|
|
60
|
-
getAuthToken: async () => "token"
|
|
61
|
-
});
|
|
62
|
-
client.listenCollection({ path: "posts", ...a } as never, () => {});
|
|
63
|
-
client.listenCollection({ path: "posts", ...b } as never, () => {});
|
|
64
|
-
await jest.advanceTimersByTimeAsync(50);
|
|
65
|
-
return subscribeFrames(sent);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
it("treats different offsets as different subscriptions", async () => {
|
|
69
|
-
// Page one and page two of the same live list. Sharing a subscription
|
|
70
|
-
// here shows page one's rows on page two.
|
|
71
|
-
const frames = await twoSubscriptions({ limit: 10, offset: 0 }, { limit: 10, offset: 10 });
|
|
72
|
-
expect(frames).toHaveLength(2);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("treats different logical groups as different subscriptions", async () => {
|
|
76
|
-
const frames = await twoSubscriptions(
|
|
77
|
-
{ logical: or(cond("status", "==", "draft")) },
|
|
78
|
-
{ logical: or(cond("status", "==", "published")) }
|
|
79
|
-
);
|
|
80
|
-
expect(frames).toHaveLength(2);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("still shares one subscription for genuinely identical queries", async () => {
|
|
84
|
-
// The de-duplication itself is the point of the key: two components
|
|
85
|
-
// watching the same list must not open two server subscriptions.
|
|
86
|
-
const frames = await twoSubscriptions(
|
|
87
|
-
{ limit: 10, offset: 10, logical: or(cond("status", "==", "draft")) },
|
|
88
|
-
{ limit: 10, offset: 10, logical: or(cond("status", "==", "draft")) }
|
|
89
|
-
);
|
|
90
|
-
expect(frames).toHaveLength(1);
|
|
91
|
-
});
|
|
92
|
-
});
|
package/src/reviver.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { EntityReference, EntityRelation, GeoPoint, Vector } from "@rebasepro/types";
|
|
2
|
-
|
|
3
|
-
export function rebaseReviver(_key: string, value: unknown): unknown {
|
|
4
|
-
if (value && typeof value === "object" && "__type" in value) {
|
|
5
|
-
const record = value as Record<string, unknown>;
|
|
6
|
-
switch (record.__type) {
|
|
7
|
-
case "date":
|
|
8
|
-
case "Date": {
|
|
9
|
-
if (typeof record.value !== "string") {
|
|
10
|
-
return value;
|
|
11
|
-
}
|
|
12
|
-
const date = new Date(record.value);
|
|
13
|
-
return isNaN(date.getTime()) ? null : date;
|
|
14
|
-
}
|
|
15
|
-
case "reference":
|
|
16
|
-
case "EntityReference":
|
|
17
|
-
return new EntityReference({
|
|
18
|
-
id: String(record.id),
|
|
19
|
-
path: record.path as string,
|
|
20
|
-
driver: record.driver as string | undefined,
|
|
21
|
-
databaseId: record.databaseId as string | undefined
|
|
22
|
-
});
|
|
23
|
-
case "relation":
|
|
24
|
-
case "EntityRelation":
|
|
25
|
-
return new EntityRelation(
|
|
26
|
-
record.id as string | number,
|
|
27
|
-
record.path as string,
|
|
28
|
-
record.data as Record<string, unknown> | undefined
|
|
29
|
-
);
|
|
30
|
-
case "GeoPoint":
|
|
31
|
-
return new GeoPoint(record.latitude as number, record.longitude as number);
|
|
32
|
-
case "Vector":
|
|
33
|
-
return new Vector(record.value as number[]);
|
|
34
|
-
default:
|
|
35
|
-
return value;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
return value;
|
|
39
|
-
}
|
package/src/sdk_query_builder.ts
DELETED
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
FindParams,
|
|
3
|
-
FindResult,
|
|
4
|
-
LogicalCondition,
|
|
5
|
-
OrderByTuple,
|
|
6
|
-
SDKCollectionClient,
|
|
7
|
-
SDKQueryBuilderInterface,
|
|
8
|
-
WhereFilterOp,
|
|
9
|
-
WhereValueFor,
|
|
10
|
-
type ComputedSortField
|
|
11
|
-
} from "@rebasepro/types";
|
|
12
|
-
import { normalizeOrderBy } from "@rebasepro/common";
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* SDK Query Builder — returns flat rows (`FindResult<M>`) instead of
|
|
16
|
-
* Entity-wrapped results (`FindResponse<M>`).
|
|
17
|
-
*
|
|
18
|
-
* @example
|
|
19
|
-
* const { data } = await rebase.data.posts
|
|
20
|
-
* .where("status", "==", "published")
|
|
21
|
-
* .orderBy("created_at", "desc")
|
|
22
|
-
* .limit(10)
|
|
23
|
-
* .find();
|
|
24
|
-
*
|
|
25
|
-
* console.log(data[0].title); // flat access
|
|
26
|
-
*/
|
|
27
|
-
export class SDKQueryBuilder<M extends Record<string, unknown> = Record<string, unknown>> implements SDKQueryBuilderInterface<M> {
|
|
28
|
-
// The accumulator stays keyed by plain `string`: it is written through
|
|
29
|
-
// `where()` / `orderBy()` below, whose own parameters are typed against `M`,
|
|
30
|
-
// and a `Partial<Record<FieldPath<M>, …>>` is read-only under a generic `M`
|
|
31
|
-
// (TS2862) so it cannot be built up in place. The typing users see is on the
|
|
32
|
-
// methods; this field is the buffer behind them, cast once on the way out.
|
|
33
|
-
private params: FindParams = { where: {} };
|
|
34
|
-
|
|
35
|
-
constructor(private collection: SDKCollectionClient<M>) {}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Add a filter condition to your query.
|
|
39
|
-
* @example
|
|
40
|
-
* client.data.users.where('age', '>=', 18).find()
|
|
41
|
-
*/
|
|
42
|
-
where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): this;
|
|
43
|
-
where(logicalCondition: LogicalCondition): this;
|
|
44
|
-
where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown): this {
|
|
45
|
-
if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
|
|
46
|
-
this.params.logical = columnOrCondition as LogicalCondition;
|
|
47
|
-
return this;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (!this.params.where) {
|
|
51
|
-
this.params.where = {};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const column = columnOrCondition as string;
|
|
55
|
-
const condition: [WhereFilterOp, unknown] = [operator!, value];
|
|
56
|
-
const existing = this.params.where[column];
|
|
57
|
-
|
|
58
|
-
if (existing === undefined) {
|
|
59
|
-
this.params.where[column] = condition;
|
|
60
|
-
} else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) {
|
|
61
|
-
(this.params.where[column] as [WhereFilterOp, unknown][]).push(condition);
|
|
62
|
-
} else {
|
|
63
|
-
let firstCondition: [WhereFilterOp, unknown];
|
|
64
|
-
if (Array.isArray(existing) && existing.length === 2 && typeof existing[0] === "string") {
|
|
65
|
-
firstCondition = existing as [WhereFilterOp, unknown];
|
|
66
|
-
} else {
|
|
67
|
-
firstCondition = ["==", existing];
|
|
68
|
-
}
|
|
69
|
-
this.params.where[column] = [firstCondition, condition];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return this;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Order the results by a specific column.
|
|
77
|
-
*
|
|
78
|
-
* Call it again to add a tie-breaker rather than replace the sort: keys
|
|
79
|
-
* apply in the order they were added, so
|
|
80
|
-
* `.orderBy("roles").orderBy("created_at", "desc")` sorts by role and
|
|
81
|
-
* shows the newest first within each one.
|
|
82
|
-
*/
|
|
83
|
-
orderBy(column: (keyof M & string) | ComputedSortField, direction: "asc" | "desc" = "asc"): this {
|
|
84
|
-
const existing = normalizeOrderBy(this.params.orderBy) ?? [];
|
|
85
|
-
this.params.orderBy = [...existing, [column, direction] as OrderByTuple];
|
|
86
|
-
return this;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Limit the number of results returned.
|
|
91
|
-
*/
|
|
92
|
-
limit(count: number): this {
|
|
93
|
-
this.params.limit = count;
|
|
94
|
-
return this;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Skip the first N results.
|
|
99
|
-
*/
|
|
100
|
-
offset(count: number): this {
|
|
101
|
-
this.params.offset = count;
|
|
102
|
-
return this;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Set a free-text search string if supported by the backend.
|
|
107
|
-
*
|
|
108
|
-
* By default this is a substring match across the collection's top-level
|
|
109
|
-
* string properties. A Postgres collection that declares a `search` block
|
|
110
|
-
* gets ranked full-text matching over the fields it named instead, and each
|
|
111
|
-
* row comes back with a `_score` you can sort on:
|
|
112
|
-
*
|
|
113
|
-
* ```ts
|
|
114
|
-
* client.data.talents.search("auditor iso 14001").orderBy("_score", "desc").find()
|
|
115
|
-
* ```
|
|
116
|
-
*
|
|
117
|
-
* Pass `{ explain: true }` to have each row report which of the declared
|
|
118
|
-
* fields matched, with a highlighted snippet, on `_matches`:
|
|
119
|
-
*
|
|
120
|
-
* ```ts
|
|
121
|
-
* const { data } = await client.data.talents.search("iso 14001", { explain: true }).find();
|
|
122
|
-
* data[0]._matches
|
|
123
|
-
* // [{ field: "questionnaire.certifications", snippet: "<mark>ISO</mark> <mark>14001</mark> Lead Auditor" }]
|
|
124
|
-
* ```
|
|
125
|
-
*/
|
|
126
|
-
search(searchString: string, options?: { explain?: boolean }): this {
|
|
127
|
-
this.params.searchString = searchString;
|
|
128
|
-
if (options?.explain !== undefined) this.params.searchExplain = options.explain;
|
|
129
|
-
return this;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Order rows by nearest-neighbour distance to `vector`.
|
|
134
|
-
*
|
|
135
|
-
* The server has supported this from the REST layer since vectors landed;
|
|
136
|
-
* this is the SDK reaching it. Results come back closest-first with a
|
|
137
|
-
* `_distance` on each row, and any `where` / `orderBy` on the same query is
|
|
138
|
-
* a filter applied before the ordering — distance decides the order.
|
|
139
|
-
*
|
|
140
|
-
* You supply the query vector. Rebase stores and searches embeddings; it
|
|
141
|
-
* does not produce them, so this is where whatever model you already use
|
|
142
|
-
* for the stored vectors gets called.
|
|
143
|
-
*
|
|
144
|
-
* @param property - Name of the `vector` property to compare against.
|
|
145
|
-
* @param vector - The query embedding. Its length must match the property's
|
|
146
|
-
* declared `dimensions`, or the server answers 400.
|
|
147
|
-
* @example
|
|
148
|
-
* client.data.docs.vectorSearch("embedding", queryVector, { threshold: 0.35 }).limit(10).find()
|
|
149
|
-
*/
|
|
150
|
-
vectorSearch(
|
|
151
|
-
property: string,
|
|
152
|
-
vector: number[],
|
|
153
|
-
options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
|
|
154
|
-
): this {
|
|
155
|
-
this.params.vectorSearch = {
|
|
156
|
-
property,
|
|
157
|
-
vector,
|
|
158
|
-
...(options?.distance !== undefined && { distance: options.distance }),
|
|
159
|
-
...(options?.threshold !== undefined && { threshold: options.threshold })
|
|
160
|
-
};
|
|
161
|
-
return this;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Include related entities in the response.
|
|
166
|
-
* Relations will be populated with full data instead of just IDs.
|
|
167
|
-
*
|
|
168
|
-
* @param relations - Relation names to include, or "*" for all.
|
|
169
|
-
* @example
|
|
170
|
-
* client.data.posts.include("tags", "author").find()
|
|
171
|
-
*/
|
|
172
|
-
include(...relations: string[]): this {
|
|
173
|
-
this.params.include = relations;
|
|
174
|
-
return this;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Execute the find query and return the results as flat rows.
|
|
179
|
-
*/
|
|
180
|
-
async find(): Promise<FindResult<M>> {
|
|
181
|
-
return this.collection.find(this.params as FindParams<M>);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Count the records matching this query.
|
|
186
|
-
*/
|
|
187
|
-
async count(): Promise<number> {
|
|
188
|
-
if (!this.collection.count) {
|
|
189
|
-
throw new Error("count() is not supported by this collection client.");
|
|
190
|
-
}
|
|
191
|
-
return this.collection.count(this.params as FindParams<M>);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Listen to realtime updates matching this query.
|
|
196
|
-
*/
|
|
197
|
-
listen(onUpdate: (data: FindResult<M>) => void, onError?: (error: Error) => void): () => void {
|
|
198
|
-
if (!this.collection.listen) {
|
|
199
|
-
throw new Error(
|
|
200
|
-
"Listen is only available when RebaseClient is configured with a websocketUrl, " +
|
|
201
|
-
"and not when it was created with realtime: false."
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
return this.collection.listen(this.params as FindParams<M>, onUpdate, onError);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Storage keys in a URL path.
|
|
3
|
-
*
|
|
4
|
-
* Every call in `storage.ts` interpolated the key raw, and the server decodes
|
|
5
|
-
* what it receives. Three ordinary filenames did three different wrong things,
|
|
6
|
-
* all measured against a real Hono route before this was written:
|
|
7
|
-
*
|
|
8
|
-
* `Invoice #12.pdf` the `#` began the fragment, so the server saw
|
|
9
|
-
* `default/Invoice ` — a key that is not the file — and a
|
|
10
|
-
* scoped `?token=` after it was swallowed with it
|
|
11
|
-
* `100% done.png` `decodeURIComponent` threw URIError, answered 500
|
|
12
|
-
* `a%2Fb.png` decoded to `a/b.png`, silently resolving a DIFFERENT
|
|
13
|
-
* object
|
|
14
|
-
*
|
|
15
|
-
* The last is the one worth the test: no error, no log, the wrong bytes.
|
|
16
|
-
*/
|
|
17
|
-
import { encodeStorageKey } from "./storage";
|
|
18
|
-
|
|
19
|
-
describe("encodeStorageKey", () => {
|
|
20
|
-
it("leaves an ordinary key untouched", () => {
|
|
21
|
-
// The no-op property: every key that already worked still produces the
|
|
22
|
-
// same URL, so nothing cached or CDN-fronted moves.
|
|
23
|
-
expect(encodeStorageKey("default/photo.png")).toBe("default/photo.png");
|
|
24
|
-
expect(encodeStorageKey("public/a/b/c.jpg")).toBe("public/a/b/c.jpg");
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("keeps the key's own separator", () => {
|
|
28
|
-
// `/` separates bucket from path and folder from file. Encoding it
|
|
29
|
-
// would make every existing key unresolvable.
|
|
30
|
-
expect(encodeStorageKey("a/b/c")).toBe("a/b/c");
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it.each([
|
|
34
|
-
["default/Invoice #12.pdf", "default/Invoice%20%2312.pdf"],
|
|
35
|
-
["default/100% done.png", "default/100%25%20done.png"],
|
|
36
|
-
["default/a%2Fb.png", "default/a%252Fb.png"],
|
|
37
|
-
["default/a+b.png", "default/a%2Bb.png"],
|
|
38
|
-
["default/naïve café.png", "default/na%C3%AFve%20caf%C3%A9.png"]
|
|
39
|
-
])("encodes %s", (input, expected) => {
|
|
40
|
-
expect(encodeStorageKey(input)).toBe(expected);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("keeps a key containing an encoded slash distinct from a real one", () => {
|
|
44
|
-
// The silent one. `a%2Fb.png` is a legal object name and it is not
|
|
45
|
-
// `a/b.png`; before this they resolved to the same object.
|
|
46
|
-
expect(encodeStorageKey("default/a%2Fb.png"))
|
|
47
|
-
.not.toBe(encodeStorageKey("default/a/b.png"));
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("survives a round trip through the server's decode", () => {
|
|
51
|
-
// The server runs `decodeURIComponent` over the whole path. Anything
|
|
52
|
-
// that does not come back unchanged is a key that resolves to the
|
|
53
|
-
// wrong object, or a 500.
|
|
54
|
-
for (const key of [
|
|
55
|
-
"default/photo.png",
|
|
56
|
-
"default/Invoice #12.pdf",
|
|
57
|
-
"default/100% done.png",
|
|
58
|
-
"default/a%2Fb.png",
|
|
59
|
-
"default/50%.png",
|
|
60
|
-
"default/naïve café.png"
|
|
61
|
-
]) {
|
|
62
|
-
expect(decodeURIComponent(encodeStorageKey(key))).toBe(key);
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
});
|
package/src/storage-registry.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client-side storage source registry.
|
|
3
|
-
*
|
|
4
|
-
* Manages multiple `StorageSource` instances keyed by
|
|
5
|
-
* `StorageSourceDefinition.key`. Collection properties reference
|
|
6
|
-
* a source by key via `StorageConfig.storageSource`.
|
|
7
|
-
*
|
|
8
|
-
* Typical bootstrap flow:
|
|
9
|
-
* 1. Fetch definitions from `GET /api/storage/sources`
|
|
10
|
-
* 2. Build server-backed sources automatically via `createStorage(transport, key)`
|
|
11
|
-
* 3. Register "direct" sources manually (e.g. Firebase Storage hook)
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type { StorageSource, StorageSourceRegistry, StorageSourceDefinition } from "@rebasepro/types";
|
|
15
|
-
import { DEFAULT_STORAGE_SOURCE_KEY } from "@rebasepro/types";
|
|
16
|
-
import { createStorage } from "./storage";
|
|
17
|
-
import type { Transport } from "./transport";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Default implementation of the client-side `StorageSourceRegistry`.
|
|
21
|
-
*/
|
|
22
|
-
export class ClientStorageSourceRegistry implements StorageSourceRegistry {
|
|
23
|
-
private sources = new Map<string, StorageSource>();
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Register a storage source.
|
|
27
|
-
* @param key - Unique key matching a `StorageSourceDefinition.key`
|
|
28
|
-
* @param source - The `StorageSource` instance
|
|
29
|
-
*/
|
|
30
|
-
register(key: string, source: StorageSource): void {
|
|
31
|
-
this.sources.set(key, source);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
getDefault(): StorageSource {
|
|
35
|
-
const source = this.sources.get(DEFAULT_STORAGE_SOURCE_KEY);
|
|
36
|
-
if (!source) {
|
|
37
|
-
throw new Error(
|
|
38
|
-
`[StorageSourceRegistry] No default storage source registered. ` +
|
|
39
|
-
`Register one with key "${DEFAULT_STORAGE_SOURCE_KEY}".`
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
return source;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
get(key: string | undefined | null): StorageSource | undefined {
|
|
46
|
-
if (key === undefined || key === null) {
|
|
47
|
-
return this.sources.get(DEFAULT_STORAGE_SOURCE_KEY);
|
|
48
|
-
}
|
|
49
|
-
return this.sources.get(key);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
getOrDefault(key: string | undefined | null): StorageSource {
|
|
53
|
-
if (key === undefined || key === null) {
|
|
54
|
-
return this.getDefault();
|
|
55
|
-
}
|
|
56
|
-
const source = this.sources.get(key);
|
|
57
|
-
if (source) return source;
|
|
58
|
-
|
|
59
|
-
// Fallback to default
|
|
60
|
-
console.warn(
|
|
61
|
-
`[StorageSourceRegistry] Storage source "${key}" not found, ` +
|
|
62
|
-
`falling back to "${DEFAULT_STORAGE_SOURCE_KEY}".`
|
|
63
|
-
);
|
|
64
|
-
return this.getDefault();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
has(key: string): boolean {
|
|
68
|
-
return this.sources.has(key);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
list(): string[] {
|
|
72
|
-
return Array.from(this.sources.keys());
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Build a registry from `StorageSourceDefinition[]` and an HTTP transport.
|
|
77
|
-
*
|
|
78
|
-
* - Sources with `transport: "server"` are auto-wired via `createStorage(transport, key)`.
|
|
79
|
-
* - Sources with `transport: "direct"` are **not** auto-wired — they must
|
|
80
|
-
* be registered manually after this call (e.g. via a Firebase hook).
|
|
81
|
-
*
|
|
82
|
-
* @param definitions - Array of storage source definitions
|
|
83
|
-
* @param transport - HTTP transport for server-backed sources
|
|
84
|
-
*/
|
|
85
|
-
static fromDefinitions(
|
|
86
|
-
definitions: StorageSourceDefinition[],
|
|
87
|
-
transport: Transport
|
|
88
|
-
): ClientStorageSourceRegistry {
|
|
89
|
-
const registry = new ClientStorageSourceRegistry();
|
|
90
|
-
|
|
91
|
-
for (const def of definitions) {
|
|
92
|
-
if (def.transport === "server") {
|
|
93
|
-
// Auto-create a server-backed StorageSource for this key
|
|
94
|
-
const source = createStorage(transport, def.key === DEFAULT_STORAGE_SOURCE_KEY ? undefined : def.key);
|
|
95
|
-
registry.register(def.key, source);
|
|
96
|
-
}
|
|
97
|
-
// "direct" sources must be registered manually
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return registry;
|
|
101
|
-
}
|
|
102
|
-
}
|