@koolbase/core 12.14.0 → 12.16.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.
@@ -1,4 +1,6 @@
1
1
  import { KoolbaseConfig, UploadOptions, UploadResult, KoolbaseObject, KoolbaseObjectVersion, KoolbaseImageTransform } from './types.js';
2
+ import { KoolbaseUploadTask } from './storage-transfer.js';
3
+ import { KoolbaseFileInfo, KoolbaseFilePage, ListFilesOptions } from './storage-files.js';
2
4
  /**
3
5
  * Koolbase storage client — uploads, downloads, and deletes via presigned
4
6
  * Cloudflare R2 URLs.
@@ -53,6 +55,45 @@ export declare class KoolbaseStorage {
53
55
  * overwriting silently, pass `overwrite: true` explicitly.
54
56
  */
55
57
  upload(options: UploadOptions): Promise<UploadResult>;
58
+ /**
59
+ * Starts an upload you can follow and cancel: the same upload as
60
+ * {@link upload}, returned at once as a {@link KoolbaseUploadTask}.
61
+ *
62
+ * The task goes pending -> uploading (bytes sent of total) -> verifying
63
+ * -> completed, or failed / cancelled. `cancel()` stops it until the
64
+ * server begins checking the bytes (verifying); nothing is stored. Only
65
+ * completed means the file can be referenced from a record.
66
+ *
67
+ * @example
68
+ * const task = Koolbase.storage.startUpload({ bucket: 'posters', path: 'gig.png', file });
69
+ * const stop = task.onProgress((p) => setPercent(Math.round(p.fraction * 100)));
70
+ * cancelButton.onclick = () => task.cancel();
71
+ * const { object } = await task.result;
72
+ */
73
+ startUpload(options: UploadOptions): KoolbaseUploadTask;
74
+ private runUpload;
75
+ /**
76
+ * One page of a bucket's files. The bucket's view rule is applied to every
77
+ * file: the page holds only files the signed-in person may view. Pass the
78
+ * page's `nextCursor` back as `cursor` for the next one; a page can hold
79
+ * fewer files than `limit` and still have a next.
80
+ *
81
+ * @example
82
+ * let cursor: string | undefined;
83
+ * do {
84
+ * const page = await Koolbase.storage.listFiles({ bucket: 'posters', prefix: 'events/', cursor });
85
+ * show(page.files);
86
+ * cursor = page.nextCursor ?? undefined;
87
+ * } while (cursor);
88
+ */
89
+ listFiles(options: ListFilesOptions): Promise<KoolbaseFilePage>;
90
+ /**
91
+ * One file's details -- size, type, metadata, who uploaded it, and its
92
+ * permanent link while its bucket is public. Throws
93
+ * `KoolbaseStorageNotFoundError` for a file that is not there or that
94
+ * the bucket's view rule hides from the caller.
95
+ */
96
+ getFileInfo(bucket: string, path: string): Promise<KoolbaseFileInfo>;
56
97
  /**
57
98
  * Apply a partial metadata update to an existing object. Returns the
58
99
  * post-update {@link KoolbaseObject} with the merged metadata.
@@ -1,7 +1,9 @@
1
1
  import { KoolbaseStorageProjectIdentityError } from './storage-errors.js';
2
- import { koolbaseFetch } from './network.js';
2
+ import { sessionFetch } from './network.js';
3
3
  import { KoolbaseUnauthenticatedError } from './errors.js';
4
4
  import { KoolbaseStorageError, koolbaseStorageErrorFromResponse, } from './storage-errors.js';
5
+ import { putWithProgress, runUploadTask } from './storage-transfer.js';
6
+ import { fileInfoFromJson, filePageFromJson, listFilesQuery, } from './storage-files.js';
5
7
  // --- Cloudflare image-transform URL helpers -------------------------------
6
8
  // Module-private — callers use KoolbaseStorage.publicUrl / publicUrlForObject.
7
9
  function clampInt(v, min, max) {
@@ -88,6 +90,46 @@ export class KoolbaseStorage {
88
90
  * overwriting silently, pass `overwrite: true` explicitly.
89
91
  */
90
92
  async upload(options) {
93
+ const task = this.startUpload(options);
94
+ const signal = options.signal;
95
+ const onAbort = () => task.cancel();
96
+ if (signal) {
97
+ if (signal.aborted)
98
+ task.cancel();
99
+ else
100
+ signal.addEventListener('abort', onAbort);
101
+ }
102
+ const stop = options.onProgress
103
+ ? task.onProgress((p) => options.onProgress?.(Math.round(p.fraction * 100), p))
104
+ : null;
105
+ try {
106
+ return await task.result;
107
+ }
108
+ finally {
109
+ stop?.();
110
+ signal?.removeEventListener('abort', onAbort);
111
+ }
112
+ }
113
+ /**
114
+ * Starts an upload you can follow and cancel: the same upload as
115
+ * {@link upload}, returned at once as a {@link KoolbaseUploadTask}.
116
+ *
117
+ * The task goes pending -> uploading (bytes sent of total) -> verifying
118
+ * -> completed, or failed / cancelled. `cancel()` stops it until the
119
+ * server begins checking the bytes (verifying); nothing is stored. Only
120
+ * completed means the file can be referenced from a record.
121
+ *
122
+ * @example
123
+ * const task = Koolbase.storage.startUpload({ bucket: 'posters', path: 'gig.png', file });
124
+ * const stop = task.onProgress((p) => setPercent(Math.round(p.fraction * 100)));
125
+ * cancelButton.onclick = () => task.cancel();
126
+ * const { object } = await task.result;
127
+ */
128
+ startUpload(options) {
129
+ return runUploadTask((task) => this.runUpload(options, task));
130
+ }
131
+ async runUpload(options, task) {
132
+ task.checkCancelled();
91
133
  const overwrite = options.overwrite ?? false;
92
134
  // A Blob (browser File included) knows its own type; the React Native
93
135
  // URI form carries it alongside. Falling back to octet-stream keeps a
@@ -97,8 +139,10 @@ export class KoolbaseStorage {
97
139
  const contentType = isBlob
98
140
  ? options.file.type || 'application/octet-stream'
99
141
  : options.file.type;
142
+ if (isBlob)
143
+ task.setTotal(options.file.size);
100
144
  // ─── Step 1: Get presigned upload URL ───
101
- const urlRes = await koolbaseFetch(`${this.config.baseUrl}/v1/sdk/storage/upload-url`, {
145
+ const urlRes = await sessionFetch(`${this.config.baseUrl}/v1/sdk/storage/upload-url`, {
102
146
  method: 'POST',
103
147
  headers: {
104
148
  ...(await this.buildHeaders()),
@@ -114,7 +158,8 @@ export class KoolbaseStorage {
114
158
  if (!urlRes.ok) {
115
159
  throw await this.error(urlRes, 'Failed to get upload URL');
116
160
  }
117
- const { upload_url } = (await urlRes.json());
161
+ const { upload_url, upload_id } = (await urlRes.json());
162
+ task.checkCancelled();
118
163
  // ─── Step 2: Upload directly to R2 ───
119
164
  // R2 presigned URLs expect raw binary, NOT multipart/form-data.
120
165
  //
@@ -124,19 +169,19 @@ export class KoolbaseStorage {
124
169
  // the two hosts differ here and nowhere else in this method.
125
170
  const fileBlob = isBlob
126
171
  ? options.file
127
- : await (await koolbaseFetch(options.file.uri)).blob();
172
+ : await (await sessionFetch(options.file.uri)).blob();
128
173
  const fileSize = fileBlob.size;
129
- const uploadRes = await koolbaseFetch(upload_url, {
130
- method: 'PUT',
131
- headers: { 'Content-Type': contentType },
132
- body: fileBlob,
133
- });
134
- if (!uploadRes.ok) {
174
+ task.setTotal(fileSize);
175
+ const uploadRes = await putWithProgress(task, upload_url, fileBlob, contentType);
176
+ if (uploadRes.status < 200 || uploadRes.status >= 300) {
135
177
  // R2 PUT errors don't follow the Koolbase error shape — surface as a
136
178
  // generic storage error rather than trying to decode a Koolbase body.
137
179
  throw new KoolbaseStorageError(`Upload to storage failed: ${uploadRes.status}`);
138
180
  }
139
- const etag = uploadRes.headers.get('etag') ?? '';
181
+ const etag = uploadRes.etag;
182
+ // From here the server checks the bytes and puts them in place; the
183
+ // upload can no longer be cancelled.
184
+ task.verifying();
140
185
  // ─── Step 3: Confirm upload ───
141
186
  // Build the body conditionally so the `metadata` field is only sent
142
187
  // when the caller passed it — keeps the wire shape clean for callers
@@ -150,10 +195,14 @@ export class KoolbaseStorage {
150
195
  etag,
151
196
  overwrite,
152
197
  };
198
+ // The confirm names its upload, so it takes exactly these bytes.
199
+ if (upload_id) {
200
+ confirmBody.upload_id = upload_id;
201
+ }
153
202
  if (options.metadata !== undefined) {
154
203
  confirmBody.metadata = options.metadata;
155
204
  }
156
- const confirmRes = await koolbaseFetch(`${this.config.baseUrl}/v1/sdk/storage/confirm`, {
205
+ const confirmRes = await sessionFetch(`${this.config.baseUrl}/v1/sdk/storage/confirm`, {
157
206
  method: 'POST',
158
207
  headers: {
159
208
  ...(await this.buildHeaders()),
@@ -170,6 +219,40 @@ export class KoolbaseStorage {
170
219
  const downloadUrl = await this.getDownloadUrl(options.bucket, options.path);
171
220
  return { object, downloadUrl };
172
221
  }
222
+ /**
223
+ * One page of a bucket's files. The bucket's view rule is applied to every
224
+ * file: the page holds only files the signed-in person may view. Pass the
225
+ * page's `nextCursor` back as `cursor` for the next one; a page can hold
226
+ * fewer files than `limit` and still have a next.
227
+ *
228
+ * @example
229
+ * let cursor: string | undefined;
230
+ * do {
231
+ * const page = await Koolbase.storage.listFiles({ bucket: 'posters', prefix: 'events/', cursor });
232
+ * show(page.files);
233
+ * cursor = page.nextCursor ?? undefined;
234
+ * } while (cursor);
235
+ */
236
+ async listFiles(options) {
237
+ const res = await sessionFetch(`${this.config.baseUrl}/v1/sdk/storage/files?${listFilesQuery(options)}`, { headers: await this.buildHeaders() });
238
+ if (!res.ok) {
239
+ throw await this.error(res, 'Failed to list files');
240
+ }
241
+ return filePageFromJson((await res.json()));
242
+ }
243
+ /**
244
+ * One file's details -- size, type, metadata, who uploaded it, and its
245
+ * permanent link while its bucket is public. Throws
246
+ * `KoolbaseStorageNotFoundError` for a file that is not there or that
247
+ * the bucket's view rule hides from the caller.
248
+ */
249
+ async getFileInfo(bucket, path) {
250
+ const res = await sessionFetch(`${this.config.baseUrl}/v1/sdk/storage/file?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`, { headers: await this.buildHeaders() });
251
+ if (!res.ok) {
252
+ throw await this.error(res, 'Failed to get the file');
253
+ }
254
+ return fileInfoFromJson((await res.json()));
255
+ }
173
256
  /**
174
257
  * Apply a partial metadata update to an existing object. Returns the
175
258
  * post-update {@link KoolbaseObject} with the merged metadata.
@@ -204,7 +287,7 @@ export class KoolbaseStorage {
204
287
  * // -> { category: 'landscape', tag: 'sunset' }
205
288
  */
206
289
  async updateMetadata(bucket, path, metadata) {
207
- const res = await koolbaseFetch(`${this.config.baseUrl}/v1/sdk/storage/objects/metadata`, {
290
+ const res = await sessionFetch(`${this.config.baseUrl}/v1/sdk/storage/objects/metadata`, {
208
291
  method: 'PATCH',
209
292
  headers: {
210
293
  ...(await this.buildHeaders()),
@@ -227,7 +310,7 @@ export class KoolbaseStorage {
227
310
  if (versionId) {
228
311
  url += `&version_id=${encodeURIComponent(versionId)}`;
229
312
  }
230
- const res = await koolbaseFetch(url, { headers: await this.buildHeaders() });
313
+ const res = await sessionFetch(url, { headers: await this.buildHeaders() });
231
314
  if (!res.ok) {
232
315
  throw await this.error(res, 'Failed to get download URL');
233
316
  }
@@ -339,7 +422,7 @@ export class KoolbaseStorage {
339
422
  const url = forcePurge
340
423
  ? `${this.config.baseUrl}/v1/sdk/storage/object?force_purge=true`
341
424
  : `${this.config.baseUrl}/v1/sdk/storage/object`;
342
- const res = await koolbaseFetch(url, {
425
+ const res = await sessionFetch(url, {
343
426
  method: 'DELETE',
344
427
  headers: {
345
428
  ...(await this.buildHeaders()),
@@ -366,7 +449,7 @@ export class KoolbaseStorage {
366
449
  async listVersions(bucket, path) {
367
450
  const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions` +
368
451
  `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
369
- const res = await koolbaseFetch(url, { headers: await this.buildHeaders() });
452
+ const res = await sessionFetch(url, { headers: await this.buildHeaders() });
370
453
  if (!res.ok) {
371
454
  throw await this.error(res, 'Failed to list versions');
372
455
  }
@@ -382,7 +465,7 @@ export class KoolbaseStorage {
382
465
  async getVersion(bucket, path, versionId) {
383
466
  const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}` +
384
467
  `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
385
- const res = await koolbaseFetch(url, { headers: await this.buildHeaders() });
468
+ const res = await sessionFetch(url, { headers: await this.buildHeaders() });
386
469
  if (!res.ok) {
387
470
  throw await this.error(res, 'Failed to fetch version');
388
471
  }
@@ -401,7 +484,7 @@ export class KoolbaseStorage {
401
484
  async restoreVersion(bucket, path, versionId) {
402
485
  const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}/restore` +
403
486
  `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
404
- const res = await koolbaseFetch(url, {
487
+ const res = await sessionFetch(url, {
405
488
  method: 'POST',
406
489
  headers: await this.buildHeaders(),
407
490
  });
@@ -419,7 +502,7 @@ export class KoolbaseStorage {
419
502
  async purgeVersion(bucket, path, versionId) {
420
503
  const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}` +
421
504
  `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
422
- const res = await koolbaseFetch(url, {
505
+ const res = await sessionFetch(url, {
423
506
  method: 'DELETE',
424
507
  headers: await this.buildHeaders(),
425
508
  });
@@ -1,4 +1,4 @@
1
- import { koolbaseFetch } from './network.js';
1
+ import { sessionFetch } from './network.js';
2
2
  import { readOfflineState, mutateOfflineState, migrateLegacyQueue, flushLockName, } from './offline-state.js';
3
3
  import { KoolbaseUnauthenticatedError } from './errors.js';
4
4
  import { getPlatform } from './platform.js';
@@ -256,7 +256,7 @@ export class SyncEngine {
256
256
  : `${this.config.baseUrl}/v1/sdk/db/records/${write.recordId}`;
257
257
  let res;
258
258
  if (write.operation === 'insert') {
259
- res = await koolbaseFetch(url, {
259
+ res = await sessionFetch(url, {
260
260
  method: 'POST',
261
261
  headers,
262
262
  // The write's own id: generated at enqueue, identical on every retry.
@@ -270,7 +270,7 @@ export class SyncEngine {
270
270
  });
271
271
  }
272
272
  else if (write.operation === 'update') {
273
- res = await koolbaseFetch(url, {
273
+ res = await sessionFetch(url, {
274
274
  method: 'PATCH',
275
275
  headers,
276
276
  // The revision the change was composed against. The server applies it
@@ -289,7 +289,7 @@ export class SyncEngine {
289
289
  const q = write.baseRevision !== undefined
290
290
  ? `?expected_revision=${write.baseRevision}`
291
291
  : '';
292
- res = await koolbaseFetch(`${url}${q}`, { method: 'DELETE', headers });
292
+ res = await sessionFetch(`${url}${q}`, { method: 'DELETE', headers });
293
293
  }
294
294
  if (res.status === 401)
295
295
  throw new KoolbaseUnauthenticatedError('unauthorized');
@@ -180,6 +180,13 @@ export declare enum RestoreResult {
180
180
  * broken listener cannot break propagation to others.
181
181
  */
182
182
  export type AuthStateListener = (user: KoolbaseUser | null) => void;
183
+ /**
184
+ * Why the last session ended (#104): the person signed out, or the session
185
+ * ended on its own -- revoked, expired past renewal, signed out elsewhere.
186
+ * An app tells them apart to say "You were signed out" only when they did
187
+ * not ask to be.
188
+ */
189
+ export type SignOutReason = 'signed_out' | 'session_ended';
183
190
  /**
184
191
  * Drop-in replacement for the global `fetch` function. Inject via
185
192
  * KoolbaseConfig.fetch for testing (mock fetch), proxying, or
@@ -362,7 +369,18 @@ export interface UploadOptions {
362
369
  * for post-upload merge changes.
363
370
  */
364
371
  metadata?: Record<string, string>;
365
- onProgress?: (percent: number) => void;
372
+ /**
373
+ * Called as the upload goes: `percent` is 0 to 100 of the bytes sent;
374
+ * `progress` says where the upload is (uploading, verifying, completed,
375
+ * ...). For a cancellable upload use `KoolbaseStorage.startUpload`.
376
+ */
377
+ onProgress?: (percent: number, progress: import('./storage-transfer.js').KoolbaseUploadProgress) => void;
378
+ /**
379
+ * Aborting this signal cancels the upload, if the server has not begun
380
+ * checking it yet; the upload then rejects with
381
+ * `KoolbaseUploadCancelledError`.
382
+ */
383
+ signal?: AbortSignal;
366
384
  }
367
385
  /**
368
386
  * A stored object's server-side metadata. Field names are camelCase here
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koolbase/core",
3
- "version": "12.14.0",
3
+ "version": "12.16.0",
4
4
  "description": "Koolbase SDK core \u2014 shared behaviour behind @koolbase/react-native and @koolbase/js. Install one of those, not this.",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "types": "./dist/esm/index.d.ts",