@koolbase/core 10.0.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 +19 -0
- package/dist/cjs/analytics.d.ts +24 -0
- package/dist/cjs/analytics.js +110 -0
- package/dist/cjs/auth-errors.d.ts +117 -0
- package/dist/cjs/auth-errors.js +250 -0
- package/dist/cjs/auth.d.ts +213 -0
- package/dist/cjs/auth.js +813 -0
- package/dist/cjs/cache-store.d.ts +50 -0
- package/dist/cjs/cache-store.js +194 -0
- package/dist/cjs/conflict.d.ts +80 -0
- package/dist/cjs/conflict.js +84 -0
- package/dist/cjs/database-errors.d.ts +101 -0
- package/dist/cjs/database-errors.js +200 -0
- package/dist/cjs/database.d.ts +298 -0
- package/dist/cjs/database.js +852 -0
- package/dist/cjs/device-id.d.ts +1 -0
- package/dist/cjs/device-id.js +57 -0
- package/dist/cjs/device-metadata.d.ts +36 -0
- package/dist/cjs/device-metadata.js +94 -0
- package/dist/cjs/errors.d.ts +64 -0
- package/dist/cjs/errors.js +85 -0
- package/dist/cjs/flags.d.ts +15 -0
- package/dist/cjs/flags.js +76 -0
- package/dist/cjs/function-errors.d.ts +51 -0
- package/dist/cjs/function-errors.js +103 -0
- package/dist/cjs/functions.d.ts +15 -0
- package/dist/cjs/functions.js +83 -0
- package/dist/cjs/index.d.ts +22 -0
- package/dist/cjs/index.js +56 -0
- package/dist/cjs/messaging.d.ts +13 -0
- package/dist/cjs/messaging.js +36 -0
- package/dist/cjs/offline-state.d.ts +97 -0
- package/dist/cjs/offline-state.js +198 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/pending-write.d.ts +47 -0
- package/dist/cjs/pending-write.js +22 -0
- package/dist/cjs/platform.d.ts +51 -0
- package/dist/cjs/platform.js +41 -0
- package/dist/cjs/realtime.d.ts +44 -0
- package/dist/cjs/realtime.js +195 -0
- package/dist/cjs/record.d.ts +2 -0
- package/dist/cjs/record.js +23 -0
- package/dist/cjs/shared.d.ts +9 -0
- package/dist/cjs/shared.js +43 -0
- package/dist/cjs/storage-errors.d.ts +163 -0
- package/dist/cjs/storage-errors.js +253 -0
- package/dist/cjs/storage.d.ts +198 -0
- package/dist/cjs/storage.js +451 -0
- package/dist/cjs/sync-engine.d.ts +30 -0
- package/dist/cjs/sync-engine.js +286 -0
- package/dist/cjs/types.d.ts +487 -0
- package/dist/cjs/types.js +40 -0
- package/dist/esm/analytics.d.ts +24 -0
- package/dist/esm/analytics.js +106 -0
- package/dist/esm/auth-errors.d.ts +117 -0
- package/dist/esm/auth-errors.js +222 -0
- package/dist/esm/auth.d.ts +213 -0
- package/dist/esm/auth.js +809 -0
- package/dist/esm/cache-store.d.ts +50 -0
- package/dist/esm/cache-store.js +179 -0
- package/dist/esm/conflict.d.ts +80 -0
- package/dist/esm/conflict.js +80 -0
- package/dist/esm/database-errors.d.ts +101 -0
- package/dist/esm/database-errors.js +189 -0
- package/dist/esm/database.d.ts +298 -0
- package/dist/esm/database.js +848 -0
- package/dist/esm/device-id.d.ts +1 -0
- package/dist/esm/device-id.js +54 -0
- package/dist/esm/device-metadata.d.ts +36 -0
- package/dist/esm/device-metadata.js +90 -0
- package/dist/esm/errors.d.ts +64 -0
- package/dist/esm/errors.js +79 -0
- package/dist/esm/flags.d.ts +15 -0
- package/dist/esm/flags.js +72 -0
- package/dist/esm/function-errors.d.ts +51 -0
- package/dist/esm/function-errors.js +93 -0
- package/dist/esm/functions.d.ts +15 -0
- package/dist/esm/functions.js +79 -0
- package/dist/esm/index.d.ts +22 -0
- package/dist/esm/index.js +25 -0
- package/dist/esm/messaging.d.ts +13 -0
- package/dist/esm/messaging.js +32 -0
- package/dist/esm/offline-state.d.ts +97 -0
- package/dist/esm/offline-state.js +189 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/pending-write.d.ts +47 -0
- package/dist/esm/pending-write.js +19 -0
- package/dist/esm/platform.d.ts +51 -0
- package/dist/esm/platform.js +36 -0
- package/dist/esm/realtime.d.ts +44 -0
- package/dist/esm/realtime.js +191 -0
- package/dist/esm/record.d.ts +2 -0
- package/dist/esm/record.js +20 -0
- package/dist/esm/shared.d.ts +9 -0
- package/dist/esm/shared.js +39 -0
- package/dist/esm/storage-errors.d.ts +163 -0
- package/dist/esm/storage-errors.js +239 -0
- package/dist/esm/storage.d.ts +198 -0
- package/dist/esm/storage.js +447 -0
- package/dist/esm/sync-engine.d.ts +30 -0
- package/dist/esm/sync-engine.js +282 -0
- package/dist/esm/types.d.ts +487 -0
- package/dist/esm/types.js +37 -0
- package/package.json +45 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { cacheRecord, removeCachedRecord } from './cache-store.js';
|
|
2
|
+
import { recordFromWire } from './record.js';
|
|
3
|
+
const EVENT_TYPE_MAP = {
|
|
4
|
+
'db.record.created': 'created',
|
|
5
|
+
'db.record.updated': 'updated',
|
|
6
|
+
'db.record.deleted': 'deleted',
|
|
7
|
+
};
|
|
8
|
+
function projectIdFromToken(token) {
|
|
9
|
+
try {
|
|
10
|
+
const part = token.split('.')[1];
|
|
11
|
+
if (!part)
|
|
12
|
+
return null;
|
|
13
|
+
const b64 = part.replace(/-/g, '+').replace(/_/g, '/');
|
|
14
|
+
const g = globalThis;
|
|
15
|
+
let json;
|
|
16
|
+
if (typeof g.atob === 'function') {
|
|
17
|
+
const bin = g.atob(b64);
|
|
18
|
+
json = decodeURIComponent(bin.split('').map((c) => '%' + c.charCodeAt(0).toString(16).padStart(2, '0')).join(''));
|
|
19
|
+
}
|
|
20
|
+
else if (g.Buffer) {
|
|
21
|
+
json = g.Buffer.from(b64, 'base64').toString('utf8');
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return JSON.parse(json).project_id ?? null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class KoolbaseRealtime {
|
|
33
|
+
constructor(config, getToken, getUserId) {
|
|
34
|
+
this.ws = null;
|
|
35
|
+
this.projectId = null;
|
|
36
|
+
this.listeners = new Map();
|
|
37
|
+
this.reconnectAttempts = 0;
|
|
38
|
+
this.reconnectTimer = null;
|
|
39
|
+
this.connecting = false;
|
|
40
|
+
this.config = config;
|
|
41
|
+
this.getToken = getToken;
|
|
42
|
+
this.getUserId = getUserId;
|
|
43
|
+
}
|
|
44
|
+
/** Files a record seen over the socket, if we know whose it is. */
|
|
45
|
+
async cacheSeenRecord(collection, record) {
|
|
46
|
+
const userId = this.getUserId?.();
|
|
47
|
+
if (!userId)
|
|
48
|
+
return;
|
|
49
|
+
await cacheRecord(userId, collection, record.id, record.data, record.revision);
|
|
50
|
+
}
|
|
51
|
+
async forgetSeenRecord(recordId) {
|
|
52
|
+
const userId = this.getUserId?.();
|
|
53
|
+
if (!userId)
|
|
54
|
+
return;
|
|
55
|
+
await removeCachedRecord(userId, recordId);
|
|
56
|
+
}
|
|
57
|
+
subscribe(collection, callback) {
|
|
58
|
+
if (!this.listeners.has(collection))
|
|
59
|
+
this.listeners.set(collection, []);
|
|
60
|
+
this.listeners.get(collection).push(callback);
|
|
61
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
62
|
+
this.sendSubscribe(collection);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
void this.connect();
|
|
66
|
+
}
|
|
67
|
+
return () => {
|
|
68
|
+
const callbacks = this.listeners.get(collection) ?? [];
|
|
69
|
+
const i = callbacks.indexOf(callback);
|
|
70
|
+
if (i > -1)
|
|
71
|
+
callbacks.splice(i, 1);
|
|
72
|
+
if (callbacks.length === 0) {
|
|
73
|
+
this.listeners.delete(collection);
|
|
74
|
+
this.sendUnsubscribe(collection);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
async connect() {
|
|
79
|
+
if (this.connecting)
|
|
80
|
+
return;
|
|
81
|
+
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING))
|
|
82
|
+
return;
|
|
83
|
+
const token = await this.getToken();
|
|
84
|
+
if (!token) {
|
|
85
|
+
this.scheduleReconnect(); // sign-in may be in flight
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.projectId = projectIdFromToken(token);
|
|
89
|
+
this.connecting = true;
|
|
90
|
+
const wsUrl = this.config.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://');
|
|
91
|
+
const ws = new WebSocket(`${wsUrl}/v1/realtime/ws?token=${encodeURIComponent(token)}`);
|
|
92
|
+
this.ws = ws;
|
|
93
|
+
ws.onopen = () => {
|
|
94
|
+
this.connecting = false;
|
|
95
|
+
// A connection that opened is the only proof the endpoint and the
|
|
96
|
+
// credentials are usable, so the backoff resets here rather than on a
|
|
97
|
+
// close — a flaky link should not accumulate delay.
|
|
98
|
+
this.reconnectAttempts = 0;
|
|
99
|
+
for (const collection of this.listeners.keys())
|
|
100
|
+
this.sendSubscribe(collection); // (re)subscribe all
|
|
101
|
+
};
|
|
102
|
+
ws.onmessage = (event) => {
|
|
103
|
+
let raw;
|
|
104
|
+
try {
|
|
105
|
+
raw = JSON.parse(event.data);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const mapped = EVENT_TYPE_MAP[raw?.type];
|
|
111
|
+
if (!mapped)
|
|
112
|
+
return; // ignore subscribed / unsubscribed / error / unknown
|
|
113
|
+
const payload = raw.payload;
|
|
114
|
+
if (!payload || !payload.collection)
|
|
115
|
+
return;
|
|
116
|
+
let msg;
|
|
117
|
+
if (mapped === 'deleted') {
|
|
118
|
+
msg = { type: 'deleted', collection: payload.collection, recordId: payload.record_id };
|
|
119
|
+
// Gone for everyone, so the cached copy is no longer a baseline for
|
|
120
|
+
// anything. An edit composed against it would be refused at replay
|
|
121
|
+
// regardless; removing it makes that a local refusal rather than a
|
|
122
|
+
// round trip.
|
|
123
|
+
void this.forgetSeenRecord(payload.record_id);
|
|
124
|
+
}
|
|
125
|
+
else if (payload.record) {
|
|
126
|
+
const record = recordFromWire(payload.record);
|
|
127
|
+
msg = { type: mapped, collection: payload.collection, record };
|
|
128
|
+
// A record seen over the socket is as freshly seen as one fetched, and
|
|
129
|
+
// a client watching a collection would otherwise hold a stale baseline
|
|
130
|
+
// while looking at the change. Writes already queued are unaffected:
|
|
131
|
+
// their baseline was copied in when they were made, so this cannot move
|
|
132
|
+
// ground beneath them.
|
|
133
|
+
void this.cacheSeenRecord(payload.collection, record);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
(this.listeners.get(payload.collection) ?? []).forEach((cb) => cb(msg));
|
|
139
|
+
};
|
|
140
|
+
ws.onclose = () => {
|
|
141
|
+
this.connecting = false;
|
|
142
|
+
if (this.ws === ws)
|
|
143
|
+
this.ws = null;
|
|
144
|
+
this.scheduleReconnect();
|
|
145
|
+
};
|
|
146
|
+
ws.onerror = () => { };
|
|
147
|
+
}
|
|
148
|
+
sendSubscribe(collection) {
|
|
149
|
+
if (!this.projectId || !this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
150
|
+
return;
|
|
151
|
+
this.ws.send(JSON.stringify({ action: 'subscribe', project_id: this.projectId, collection }));
|
|
152
|
+
}
|
|
153
|
+
sendUnsubscribe(collection) {
|
|
154
|
+
if (!this.projectId || !this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
155
|
+
return;
|
|
156
|
+
this.ws.send(JSON.stringify({ action: 'unsubscribe', project_id: this.projectId, collection }));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Reconnects with backoff, rather than every three seconds forever.
|
|
160
|
+
*
|
|
161
|
+
* A fixed interval is fine while a connection is merely interrupted and
|
|
162
|
+
* costly when it is not: a device with no network, a wrong URL, or a session
|
|
163
|
+
* the server will not accept retried indefinitely, draining battery and data
|
|
164
|
+
* the user cannot see or stop.
|
|
165
|
+
*
|
|
166
|
+
* Doubling from three seconds to a minute keeps a brief interruption
|
|
167
|
+
* recovering quickly while a lasting one settles into an interval that costs
|
|
168
|
+
* almost nothing. The counter resets when a connection opens, so a flaky link
|
|
169
|
+
* does not accumulate delay.
|
|
170
|
+
*/
|
|
171
|
+
scheduleReconnect() {
|
|
172
|
+
if (this.listeners.size === 0 || this.reconnectTimer)
|
|
173
|
+
return;
|
|
174
|
+
const delay = Math.min(3000 * Math.pow(2, this.reconnectAttempts), 60000);
|
|
175
|
+
this.reconnectAttempts += 1;
|
|
176
|
+
this.reconnectTimer = setTimeout(() => {
|
|
177
|
+
this.reconnectTimer = null;
|
|
178
|
+
void this.connect();
|
|
179
|
+
}, delay);
|
|
180
|
+
}
|
|
181
|
+
disconnect() {
|
|
182
|
+
if (this.reconnectTimer) {
|
|
183
|
+
clearTimeout(this.reconnectTimer);
|
|
184
|
+
this.reconnectTimer = null;
|
|
185
|
+
}
|
|
186
|
+
this.ws?.close();
|
|
187
|
+
this.ws = null;
|
|
188
|
+
this.projectId = null;
|
|
189
|
+
this.listeners.clear();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Converts the flat public wire shape into a KoolbaseRecord.
|
|
2
|
+
// Server sends: { $id, $createdAt, $updatedAt, $collection, $createdBy?, ...fields }
|
|
3
|
+
export function recordFromWire(raw) {
|
|
4
|
+
const data = {};
|
|
5
|
+
for (const key of Object.keys(raw)) {
|
|
6
|
+
if (!key.startsWith('$'))
|
|
7
|
+
data[key] = raw[key];
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
id: raw['$id'],
|
|
11
|
+
collection: raw['$collection'],
|
|
12
|
+
createdBy: raw['$createdBy'],
|
|
13
|
+
data,
|
|
14
|
+
createdAt: raw['$createdAt'],
|
|
15
|
+
updatedAt: raw['$updatedAt'],
|
|
16
|
+
revision: typeof raw['$revision'] === 'number'
|
|
17
|
+
? raw['$revision']
|
|
18
|
+
: undefined,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface Slot {
|
|
2
|
+
platform?: unknown;
|
|
3
|
+
locks?: Map<string, Promise<unknown>>;
|
|
4
|
+
}
|
|
5
|
+
/** Read a shared value, initialising it on first use. */
|
|
6
|
+
export declare function shared<K extends keyof Slot>(key: K, create: () => NonNullable<Slot[K]>): NonNullable<Slot[K]>;
|
|
7
|
+
/** Replace a shared value. */
|
|
8
|
+
export declare function setShared<K extends keyof Slot>(key: K, value: Slot[K]): void;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// State that must be singular, even when this package is loaded twice.
|
|
2
|
+
//
|
|
3
|
+
// A dual-published package can end up in one application twice: the app's own
|
|
4
|
+
// code resolves the ESM build while a dependency resolves the CommonJS one.
|
|
5
|
+
// Module-level variables are then duplicated, and anything that assumes it is
|
|
6
|
+
// the only copy quietly stops being true.
|
|
7
|
+
//
|
|
8
|
+
// Two things here cannot tolerate that:
|
|
9
|
+
//
|
|
10
|
+
// - the platform adapter, which a platform package installs once at
|
|
11
|
+
// initialize. A second copy would keep the in-memory default, so sessions
|
|
12
|
+
// would not persist and the offline queue would read empty.
|
|
13
|
+
//
|
|
14
|
+
// - the offline-state lock table, which serialises read-modify-write on one
|
|
15
|
+
// storage key per user. A second copy means two chains over the same key:
|
|
16
|
+
// a queued write or a conflict can be lost. That is data, not a feature.
|
|
17
|
+
//
|
|
18
|
+
// Both live on a well-known globalThis key instead, so every copy of the
|
|
19
|
+
// module shares one instance. Keyed by a symbol registered in the global
|
|
20
|
+
// symbol registry, which is the same symbol across module instances and
|
|
21
|
+
// cannot collide with an application's own globals.
|
|
22
|
+
const SLOT = Symbol.for('koolbase.sdk.shared.v1');
|
|
23
|
+
function slot() {
|
|
24
|
+
const g = globalThis;
|
|
25
|
+
if (!g[SLOT])
|
|
26
|
+
g[SLOT] = {};
|
|
27
|
+
return g[SLOT];
|
|
28
|
+
}
|
|
29
|
+
/** Read a shared value, initialising it on first use. */
|
|
30
|
+
export function shared(key, create) {
|
|
31
|
+
const s = slot();
|
|
32
|
+
if (s[key] === undefined)
|
|
33
|
+
s[key] = create();
|
|
34
|
+
return s[key];
|
|
35
|
+
}
|
|
36
|
+
/** Replace a shared value. */
|
|
37
|
+
export function setShared(key, value) {
|
|
38
|
+
slot()[key] = value;
|
|
39
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { KoolbaseError } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Base error type for all Koolbase storage errors. Catchable via
|
|
4
|
+
* `instanceof KoolbaseStorageError` to handle any storage-related failure
|
|
5
|
+
* generically; subclasses let you handle specific cases.
|
|
6
|
+
*/
|
|
7
|
+
export declare class KoolbaseStorageError extends KoolbaseError {
|
|
8
|
+
constructor(message: string, code?: string);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Thrown when an upload is rejected because an object already exists at
|
|
12
|
+
* the requested path — the server responds with 409 Conflict and code
|
|
13
|
+
* `path_conflict`. Catch it to give the user an "overwrite this file?"
|
|
14
|
+
* prompt, then retry the upload with `overwrite: true`.
|
|
15
|
+
*
|
|
16
|
+
* `path` is the colliding path the server rejected, surfaced from the
|
|
17
|
+
* response body for diagnostics and UI.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* try {
|
|
21
|
+
* await Koolbase.storage.upload({
|
|
22
|
+
* bucket: 'avatars',
|
|
23
|
+
* path: 'me.png',
|
|
24
|
+
* file: { uri, name, type: 'image/png' },
|
|
25
|
+
* });
|
|
26
|
+
* } catch (e) {
|
|
27
|
+
* if (e instanceof KoolbaseStorageConflictError) {
|
|
28
|
+
* const ok = await confirm(`${e.path} already exists. Overwrite?`);
|
|
29
|
+
* if (ok) {
|
|
30
|
+
* await Koolbase.storage.upload({
|
|
31
|
+
* bucket: 'avatars',
|
|
32
|
+
* path: 'me.png',
|
|
33
|
+
* file: { uri, name, type: 'image/png' },
|
|
34
|
+
* overwrite: true,
|
|
35
|
+
* });
|
|
36
|
+
* }
|
|
37
|
+
* }
|
|
38
|
+
* }
|
|
39
|
+
*/
|
|
40
|
+
export declare class KoolbaseStorageConflictError extends KoolbaseStorageError {
|
|
41
|
+
path?: string;
|
|
42
|
+
constructor(message?: string, path?: string);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Thrown when the requested bucket or object does not exist — the server
|
|
46
|
+
* responds with 404. Also surfaced for cross-tenant access attempts
|
|
47
|
+
* (Koolbase's 404-over-403 convention prevents enumeration in
|
|
48
|
+
* multi-tenant contexts).
|
|
49
|
+
*/
|
|
50
|
+
export declare class KoolbaseStorageNotFoundError extends KoolbaseStorageError {
|
|
51
|
+
constructor(message?: string);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Thrown when the request is rejected as invalid — the server responds
|
|
55
|
+
* with 400 (e.g. a malformed path, missing field, invalid bucket name).
|
|
56
|
+
*/
|
|
57
|
+
export declare class KoolbaseStorageValidationError extends KoolbaseStorageError {
|
|
58
|
+
constructor(message?: string);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Thrown when the caller is authenticated but not allowed to perform the
|
|
62
|
+
* storage operation — the server responds with 403.
|
|
63
|
+
*/
|
|
64
|
+
export declare class KoolbaseStoragePermissionError extends KoolbaseStorageError {
|
|
65
|
+
constructor(message?: string);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Thrown when an upload would push the bucket past its configured
|
|
69
|
+
* `max_size_bytes` quota — the server responds with 409 Conflict and code
|
|
70
|
+
* `quota_exceeded`. The server cleans up the underlying R2 object before
|
|
71
|
+
* returning; nothing leaks. Catch this to surface a "bucket is full"
|
|
72
|
+
* message or prompt the caller to delete older files. The per-bucket
|
|
73
|
+
* quota is set at bucket creation time and is currently immutable.
|
|
74
|
+
*
|
|
75
|
+
* Distinct from {@link KoolbaseStorageConflictError} (which also uses
|
|
76
|
+
* 409 but means "path collides"); branch on the error type via
|
|
77
|
+
* `instanceof`, not on status.
|
|
78
|
+
*/
|
|
79
|
+
export declare class KoolbaseStorageQuotaError extends KoolbaseStorageError {
|
|
80
|
+
constructor(message?: string);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Thrown when a single file exceeds the bucket's configured
|
|
84
|
+
* `max_file_size_bytes` — the server responds with 413 Payload Too Large
|
|
85
|
+
* and code `file_too_large`. The server cleans up the underlying R2
|
|
86
|
+
* object before returning. The configured per-file limit lives on the
|
|
87
|
+
* bucket record; check `Bucket.maxFileSizeBytes` to surface a clear
|
|
88
|
+
* "files must be under X MB" message at the call site.
|
|
89
|
+
*/
|
|
90
|
+
export declare class KoolbaseStorageFileTooLargeError extends KoolbaseStorageError {
|
|
91
|
+
constructor(message?: string);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Thrown when an upload's content-type isn't in the bucket's configured
|
|
95
|
+
* `allowed_mime_types` allowlist — the server responds with 415
|
|
96
|
+
* Unsupported Media Type and code `mime_not_allowed`. The check runs at
|
|
97
|
+
* presign time, so no bytes are transferred before rejection.
|
|
98
|
+
*
|
|
99
|
+
* Allowlists support `type/*` wildcards (e.g. `image/*` matches every
|
|
100
|
+
* image content-type). A bucket with no allowlist configured accepts
|
|
101
|
+
* every type.
|
|
102
|
+
*/
|
|
103
|
+
export declare class KoolbaseStorageMimeTypeError extends KoolbaseStorageError {
|
|
104
|
+
constructor(message?: string);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Thrown when an object metadata payload (either at upload-confirm time
|
|
108
|
+
* or via `updateMetadata`) fails server-side validation — the server
|
|
109
|
+
* responds with 400 and code `metadata_invalid`.
|
|
110
|
+
*
|
|
111
|
+
* The `detail` field carries the specific reason from the server — e.g.
|
|
112
|
+
* `'key "foo bar": must match [a-z0-9_]+'`, `'exceeds 50 keys (got 53)'`,
|
|
113
|
+
* or `'exceeds 8192 bytes total (sum of all key + value lengths)'`. The
|
|
114
|
+
* detail names the failing key and rule so callers can fix the offending
|
|
115
|
+
* entry without guessing what shape rule was violated.
|
|
116
|
+
*
|
|
117
|
+
* Validation rules (enforced server-side):
|
|
118
|
+
* - At most 50 keys per object.
|
|
119
|
+
* - At most 8KB total (sum of byte lengths across all keys + values).
|
|
120
|
+
* - Keys: 1–64 chars, must match `[a-z0-9_]+`.
|
|
121
|
+
* - Keys with a leading underscore are reserved for system use.
|
|
122
|
+
* - Values: at most 1024 chars each.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* try {
|
|
126
|
+
* await Koolbase.storage.updateMetadata('photos', 'sunset.jpg', {
|
|
127
|
+
* tag: 'sunset',
|
|
128
|
+
* 'BAD KEY': 'oops',
|
|
129
|
+
* });
|
|
130
|
+
* } catch (e) {
|
|
131
|
+
* if (e instanceof KoolbaseStorageMetadataInvalidError) {
|
|
132
|
+
* console.warn('Metadata rejected:', e.detail);
|
|
133
|
+
* // -> 'Metadata rejected: key "BAD KEY": must match [a-z0-9_]+'
|
|
134
|
+
* }
|
|
135
|
+
* }
|
|
136
|
+
*/
|
|
137
|
+
export declare class KoolbaseStorageMetadataInvalidError extends KoolbaseStorageError {
|
|
138
|
+
/**
|
|
139
|
+
* The specific validation failure reported by the server. Names the
|
|
140
|
+
* failing key (when applicable) and the rule that was violated.
|
|
141
|
+
* Surface this directly to developer logs or user-facing UI.
|
|
142
|
+
*/
|
|
143
|
+
detail?: string;
|
|
144
|
+
constructor(message?: string, detail?: string);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Maps a non-2xx storage-layer response to a typed
|
|
148
|
+
* {@link KoolbaseStorageError}, preferring the server's stable `code` and
|
|
149
|
+
* falling back to the HTTP status for older or uncoded responses. Always
|
|
150
|
+
* returns an error to throw.
|
|
151
|
+
*
|
|
152
|
+
* Status-fallback note: HTTP 409 covers both path_conflict and
|
|
153
|
+
* quota_exceeded. Without a `code` field, the mapper defaults 409 to
|
|
154
|
+
* {@link KoolbaseStorageConflictError} since path collisions are the more
|
|
155
|
+
* common case. Modern Koolbase servers always emit `code`, so this only
|
|
156
|
+
* matters for very old API responses or non-Koolbase 409s.
|
|
157
|
+
*/
|
|
158
|
+
export declare function koolbaseStorageError(status: number, body: any, fallbackMessage?: string): KoolbaseStorageError;
|
|
159
|
+
/**
|
|
160
|
+
* Convenience wrapper over {@link koolbaseStorageError} that decodes the
|
|
161
|
+
* response body for you. Use at call sites that have the raw `Response`.
|
|
162
|
+
*/
|
|
163
|
+
export declare function koolbaseStorageErrorFromResponse(res: Response, fallbackMessage?: string): Promise<KoolbaseStorageError>;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { KoolbaseError, KoolbaseUnauthenticatedError } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Base error type for all Koolbase storage errors. Catchable via
|
|
4
|
+
* `instanceof KoolbaseStorageError` to handle any storage-related failure
|
|
5
|
+
* generically; subclasses let you handle specific cases.
|
|
6
|
+
*/
|
|
7
|
+
export class KoolbaseStorageError extends KoolbaseError {
|
|
8
|
+
constructor(message, code) {
|
|
9
|
+
super(message, code);
|
|
10
|
+
this.name = 'KoolbaseStorageError';
|
|
11
|
+
Object.setPrototypeOf(this, KoolbaseStorageError.prototype);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Thrown when an upload is rejected because an object already exists at
|
|
16
|
+
* the requested path — the server responds with 409 Conflict and code
|
|
17
|
+
* `path_conflict`. Catch it to give the user an "overwrite this file?"
|
|
18
|
+
* prompt, then retry the upload with `overwrite: true`.
|
|
19
|
+
*
|
|
20
|
+
* `path` is the colliding path the server rejected, surfaced from the
|
|
21
|
+
* response body for diagnostics and UI.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* try {
|
|
25
|
+
* await Koolbase.storage.upload({
|
|
26
|
+
* bucket: 'avatars',
|
|
27
|
+
* path: 'me.png',
|
|
28
|
+
* file: { uri, name, type: 'image/png' },
|
|
29
|
+
* });
|
|
30
|
+
* } catch (e) {
|
|
31
|
+
* if (e instanceof KoolbaseStorageConflictError) {
|
|
32
|
+
* const ok = await confirm(`${e.path} already exists. Overwrite?`);
|
|
33
|
+
* if (ok) {
|
|
34
|
+
* await Koolbase.storage.upload({
|
|
35
|
+
* bucket: 'avatars',
|
|
36
|
+
* path: 'me.png',
|
|
37
|
+
* file: { uri, name, type: 'image/png' },
|
|
38
|
+
* overwrite: true,
|
|
39
|
+
* });
|
|
40
|
+
* }
|
|
41
|
+
* }
|
|
42
|
+
* }
|
|
43
|
+
*/
|
|
44
|
+
export class KoolbaseStorageConflictError extends KoolbaseStorageError {
|
|
45
|
+
constructor(message, path) {
|
|
46
|
+
super(message ?? 'An object already exists at this path', 'path_conflict');
|
|
47
|
+
this.path = path;
|
|
48
|
+
this.name = 'KoolbaseStorageConflictError';
|
|
49
|
+
Object.setPrototypeOf(this, KoolbaseStorageConflictError.prototype);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Thrown when the requested bucket or object does not exist — the server
|
|
54
|
+
* responds with 404. Also surfaced for cross-tenant access attempts
|
|
55
|
+
* (Koolbase's 404-over-403 convention prevents enumeration in
|
|
56
|
+
* multi-tenant contexts).
|
|
57
|
+
*/
|
|
58
|
+
export class KoolbaseStorageNotFoundError extends KoolbaseStorageError {
|
|
59
|
+
constructor(message) {
|
|
60
|
+
super(message ?? 'The requested bucket or object was not found', 'not_found');
|
|
61
|
+
this.name = 'KoolbaseStorageNotFoundError';
|
|
62
|
+
Object.setPrototypeOf(this, KoolbaseStorageNotFoundError.prototype);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Thrown when the request is rejected as invalid — the server responds
|
|
67
|
+
* with 400 (e.g. a malformed path, missing field, invalid bucket name).
|
|
68
|
+
*/
|
|
69
|
+
export class KoolbaseStorageValidationError extends KoolbaseStorageError {
|
|
70
|
+
constructor(message) {
|
|
71
|
+
super(message ?? 'The storage request was invalid', 'validation_error');
|
|
72
|
+
this.name = 'KoolbaseStorageValidationError';
|
|
73
|
+
Object.setPrototypeOf(this, KoolbaseStorageValidationError.prototype);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Thrown when the caller is authenticated but not allowed to perform the
|
|
78
|
+
* storage operation — the server responds with 403.
|
|
79
|
+
*/
|
|
80
|
+
export class KoolbaseStoragePermissionError extends KoolbaseStorageError {
|
|
81
|
+
constructor(message) {
|
|
82
|
+
super(message ?? 'You do not have permission to perform this storage action', 'permission_denied');
|
|
83
|
+
this.name = 'KoolbaseStoragePermissionError';
|
|
84
|
+
Object.setPrototypeOf(this, KoolbaseStoragePermissionError.prototype);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Thrown when an upload would push the bucket past its configured
|
|
89
|
+
* `max_size_bytes` quota — the server responds with 409 Conflict and code
|
|
90
|
+
* `quota_exceeded`. The server cleans up the underlying R2 object before
|
|
91
|
+
* returning; nothing leaks. Catch this to surface a "bucket is full"
|
|
92
|
+
* message or prompt the caller to delete older files. The per-bucket
|
|
93
|
+
* quota is set at bucket creation time and is currently immutable.
|
|
94
|
+
*
|
|
95
|
+
* Distinct from {@link KoolbaseStorageConflictError} (which also uses
|
|
96
|
+
* 409 but means "path collides"); branch on the error type via
|
|
97
|
+
* `instanceof`, not on status.
|
|
98
|
+
*/
|
|
99
|
+
export class KoolbaseStorageQuotaError extends KoolbaseStorageError {
|
|
100
|
+
constructor(message) {
|
|
101
|
+
super(message ?? 'Bucket quota exceeded', 'quota_exceeded');
|
|
102
|
+
this.name = 'KoolbaseStorageQuotaError';
|
|
103
|
+
Object.setPrototypeOf(this, KoolbaseStorageQuotaError.prototype);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Thrown when a single file exceeds the bucket's configured
|
|
108
|
+
* `max_file_size_bytes` — the server responds with 413 Payload Too Large
|
|
109
|
+
* and code `file_too_large`. The server cleans up the underlying R2
|
|
110
|
+
* object before returning. The configured per-file limit lives on the
|
|
111
|
+
* bucket record; check `Bucket.maxFileSizeBytes` to surface a clear
|
|
112
|
+
* "files must be under X MB" message at the call site.
|
|
113
|
+
*/
|
|
114
|
+
export class KoolbaseStorageFileTooLargeError extends KoolbaseStorageError {
|
|
115
|
+
constructor(message) {
|
|
116
|
+
super(message ?? 'File exceeds the bucket maximum file size', 'file_too_large');
|
|
117
|
+
this.name = 'KoolbaseStorageFileTooLargeError';
|
|
118
|
+
Object.setPrototypeOf(this, KoolbaseStorageFileTooLargeError.prototype);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Thrown when an upload's content-type isn't in the bucket's configured
|
|
123
|
+
* `allowed_mime_types` allowlist — the server responds with 415
|
|
124
|
+
* Unsupported Media Type and code `mime_not_allowed`. The check runs at
|
|
125
|
+
* presign time, so no bytes are transferred before rejection.
|
|
126
|
+
*
|
|
127
|
+
* Allowlists support `type/*` wildcards (e.g. `image/*` matches every
|
|
128
|
+
* image content-type). A bucket with no allowlist configured accepts
|
|
129
|
+
* every type.
|
|
130
|
+
*/
|
|
131
|
+
export class KoolbaseStorageMimeTypeError extends KoolbaseStorageError {
|
|
132
|
+
constructor(message) {
|
|
133
|
+
super(message ?? 'Content-type not allowed for this bucket', 'mime_not_allowed');
|
|
134
|
+
this.name = 'KoolbaseStorageMimeTypeError';
|
|
135
|
+
Object.setPrototypeOf(this, KoolbaseStorageMimeTypeError.prototype);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Thrown when an object metadata payload (either at upload-confirm time
|
|
140
|
+
* or via `updateMetadata`) fails server-side validation — the server
|
|
141
|
+
* responds with 400 and code `metadata_invalid`.
|
|
142
|
+
*
|
|
143
|
+
* The `detail` field carries the specific reason from the server — e.g.
|
|
144
|
+
* `'key "foo bar": must match [a-z0-9_]+'`, `'exceeds 50 keys (got 53)'`,
|
|
145
|
+
* or `'exceeds 8192 bytes total (sum of all key + value lengths)'`. The
|
|
146
|
+
* detail names the failing key and rule so callers can fix the offending
|
|
147
|
+
* entry without guessing what shape rule was violated.
|
|
148
|
+
*
|
|
149
|
+
* Validation rules (enforced server-side):
|
|
150
|
+
* - At most 50 keys per object.
|
|
151
|
+
* - At most 8KB total (sum of byte lengths across all keys + values).
|
|
152
|
+
* - Keys: 1–64 chars, must match `[a-z0-9_]+`.
|
|
153
|
+
* - Keys with a leading underscore are reserved for system use.
|
|
154
|
+
* - Values: at most 1024 chars each.
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* try {
|
|
158
|
+
* await Koolbase.storage.updateMetadata('photos', 'sunset.jpg', {
|
|
159
|
+
* tag: 'sunset',
|
|
160
|
+
* 'BAD KEY': 'oops',
|
|
161
|
+
* });
|
|
162
|
+
* } catch (e) {
|
|
163
|
+
* if (e instanceof KoolbaseStorageMetadataInvalidError) {
|
|
164
|
+
* console.warn('Metadata rejected:', e.detail);
|
|
165
|
+
* // -> 'Metadata rejected: key "BAD KEY": must match [a-z0-9_]+'
|
|
166
|
+
* }
|
|
167
|
+
* }
|
|
168
|
+
*/
|
|
169
|
+
export class KoolbaseStorageMetadataInvalidError extends KoolbaseStorageError {
|
|
170
|
+
constructor(message, detail) {
|
|
171
|
+
super(message ?? 'Metadata payload is invalid', 'metadata_invalid');
|
|
172
|
+
this.detail = detail;
|
|
173
|
+
this.name = 'KoolbaseStorageMetadataInvalidError';
|
|
174
|
+
Object.setPrototypeOf(this, KoolbaseStorageMetadataInvalidError.prototype);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Maps a non-2xx storage-layer response to a typed
|
|
179
|
+
* {@link KoolbaseStorageError}, preferring the server's stable `code` and
|
|
180
|
+
* falling back to the HTTP status for older or uncoded responses. Always
|
|
181
|
+
* returns an error to throw.
|
|
182
|
+
*
|
|
183
|
+
* Status-fallback note: HTTP 409 covers both path_conflict and
|
|
184
|
+
* quota_exceeded. Without a `code` field, the mapper defaults 409 to
|
|
185
|
+
* {@link KoolbaseStorageConflictError} since path collisions are the more
|
|
186
|
+
* common case. Modern Koolbase servers always emit `code`, so this only
|
|
187
|
+
* matters for very old API responses or non-Koolbase 409s.
|
|
188
|
+
*/
|
|
189
|
+
export function koolbaseStorageError(status, body, fallbackMessage = 'Storage request failed') {
|
|
190
|
+
const code = body?.code;
|
|
191
|
+
const message = body?.error ?? fallbackMessage;
|
|
192
|
+
// ─── code-first ───
|
|
193
|
+
switch (code) {
|
|
194
|
+
case 'path_conflict':
|
|
195
|
+
return new KoolbaseStorageConflictError(message, body?.path);
|
|
196
|
+
case 'quota_exceeded':
|
|
197
|
+
return new KoolbaseStorageQuotaError(message);
|
|
198
|
+
case 'file_too_large':
|
|
199
|
+
return new KoolbaseStorageFileTooLargeError(message);
|
|
200
|
+
case 'mime_not_allowed':
|
|
201
|
+
return new KoolbaseStorageMimeTypeError(message);
|
|
202
|
+
case 'metadata_invalid':
|
|
203
|
+
return new KoolbaseStorageMetadataInvalidError(message, body?.detail);
|
|
204
|
+
}
|
|
205
|
+
// ─── status fallback (pre-code servers or uncoded paths) ───
|
|
206
|
+
switch (status) {
|
|
207
|
+
case 409:
|
|
208
|
+
return new KoolbaseStorageConflictError(message);
|
|
209
|
+
case 413:
|
|
210
|
+
return new KoolbaseStorageFileTooLargeError(message);
|
|
211
|
+
case 415:
|
|
212
|
+
return new KoolbaseStorageMimeTypeError(message);
|
|
213
|
+
case 404:
|
|
214
|
+
return new KoolbaseStorageNotFoundError(message);
|
|
215
|
+
case 401:
|
|
216
|
+
// A rejected credential is not a storage problem: a session stops
|
|
217
|
+
// working for the whole SDK at once, so it raises the shared type.
|
|
218
|
+
return new KoolbaseUnauthenticatedError(message);
|
|
219
|
+
case 403:
|
|
220
|
+
return new KoolbaseStoragePermissionError(message);
|
|
221
|
+
case 400:
|
|
222
|
+
return new KoolbaseStorageValidationError(message);
|
|
223
|
+
}
|
|
224
|
+
return new KoolbaseStorageError(message, code);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Convenience wrapper over {@link koolbaseStorageError} that decodes the
|
|
228
|
+
* response body for you. Use at call sites that have the raw `Response`.
|
|
229
|
+
*/
|
|
230
|
+
export async function koolbaseStorageErrorFromResponse(res, fallbackMessage = 'Storage request failed') {
|
|
231
|
+
let body = {};
|
|
232
|
+
try {
|
|
233
|
+
body = await res.json();
|
|
234
|
+
}
|
|
235
|
+
catch (_) {
|
|
236
|
+
// body wasn't JSON — fall through with empty object
|
|
237
|
+
}
|
|
238
|
+
return koolbaseStorageError(res.status, body, fallbackMessage);
|
|
239
|
+
}
|