@koolbase/core 12.18.0 → 12.19.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.
@@ -0,0 +1,62 @@
1
+ import type { KoolbaseFileInfo, KoolbaseFilePage, KoolbaseFileSort, ListFilesOptions } from './storage-files.js';
2
+ export type FilesStatus = 'loading' | 'loaded' | 'error';
3
+ /** Which files, in what order. The bucket's view rule decides the rest. */
4
+ export interface FilesQuery {
5
+ /** Only files whose path starts with this, taken literally. */
6
+ prefix?: string;
7
+ /** By path unless set. */
8
+ sort?: KoolbaseFileSort;
9
+ /** Z to A, newest first, largest first. */
10
+ descending?: boolean;
11
+ /** Files per page: 50 unless set, at most 100. */
12
+ limit?: number;
13
+ }
14
+ export interface FilesState {
15
+ readonly status: FilesStatus;
16
+ readonly files: readonly KoolbaseFileInfo[];
17
+ /** Why the first load failed, while status is 'error'. */
18
+ readonly error: unknown;
19
+ /** True while refresh() runs. */
20
+ readonly refreshing: boolean;
21
+ /** True while loadMore() runs. */
22
+ readonly loadingMore: boolean;
23
+ /** Whether there is a page past the last one loaded. True before the first load. */
24
+ readonly hasMore: boolean;
25
+ }
26
+ export declare const initialFilesState: FilesState;
27
+ /** One string per distinct listing. */
28
+ export declare function filesQueryKey(bucket: string, query?: FilesQuery): string;
29
+ type FilesReader = {
30
+ listFiles(options: ListFilesOptions): Promise<KoolbaseFilePage>;
31
+ };
32
+ export declare class KoolbaseFilesController {
33
+ readonly bucket: string;
34
+ private readonly storage;
35
+ private readonly spec;
36
+ private state;
37
+ private readonly listeners;
38
+ private generation;
39
+ private disposed;
40
+ private pageOne;
41
+ private laterPages;
42
+ private cursor;
43
+ private unregister;
44
+ constructor(storage: FilesReader, bucket: string, query?: FilesQuery);
45
+ getState(): FilesState;
46
+ /** Called on every change. Returns the unsubscribe. */
47
+ subscribe(listener: () => void): () => void;
48
+ /** The first load. Call once; refresh() for later loads. */
49
+ load(): Promise<void>;
50
+ /** Page one again. What is shown stays while it runs, and stays if it fails. */
51
+ refresh(): Promise<void>;
52
+ /** The next page, appended. Does nothing unless loaded with a page to come. */
53
+ loadMore(): Promise<void>;
54
+ /** Stops all updates. Results still in flight are dropped when they land. */
55
+ dispose(): void;
56
+ private firstPage;
57
+ private publish;
58
+ private options;
59
+ private isStale;
60
+ private set;
61
+ }
62
+ export {};
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ // A bucket's files read as a list, framework-free (storage S4).
3
+ //
4
+ // The data half of a file gallery: loading, loaded or error, refresh, and
5
+ // the next page by cursor. No React here, so it is tested in this package;
6
+ // useFiles in @koolbase/react-native and @koolbase/js/react is a thin
7
+ // wrapper. Mirrors the Flutter SDK's KoolbaseFilesController.
8
+ //
9
+ // - only a first load with nothing to show is the error state; a failed
10
+ // refresh or loadMore keeps what is shown (stale beats blank)
11
+ // - loadMore appends the next page while there is one; a file already
12
+ // shown is never shown twice (matched by path)
13
+ // - an upload, delete, move, copy, metadata change or restore made through
14
+ // this SDK in the bucket reloads page one silently (files-refresh.ts)
15
+ // - every fetch belongs to a generation: a result from a fetch a later
16
+ // refresh superseded, or one landing after dispose(), is dropped
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.KoolbaseFilesController = exports.initialFilesState = void 0;
19
+ exports.filesQueryKey = filesQueryKey;
20
+ const files_refresh_js_1 = require("./files-refresh.js");
21
+ exports.initialFilesState = Object.freeze({
22
+ status: 'loading',
23
+ files: Object.freeze([]),
24
+ error: null,
25
+ refreshing: false,
26
+ loadingMore: false,
27
+ hasMore: true,
28
+ });
29
+ /** One string per distinct listing. */
30
+ function filesQueryKey(bucket, query = {}) {
31
+ return JSON.stringify([bucket, query.prefix ?? '', query.sort ?? 'path', query.descending === true, query.limit ?? null]);
32
+ }
33
+ /** Page one, then the later pages without any file already shown. */
34
+ function mergeByPath(first, rest) {
35
+ const seen = new Set(first.map((f) => f.path));
36
+ const out = [...first];
37
+ for (const f of rest) {
38
+ if (seen.has(f.path))
39
+ continue;
40
+ seen.add(f.path);
41
+ out.push(f);
42
+ }
43
+ return out;
44
+ }
45
+ class KoolbaseFilesController {
46
+ constructor(storage, bucket, query = {}) {
47
+ this.state = exports.initialFilesState;
48
+ this.listeners = new Set();
49
+ this.generation = 0;
50
+ this.disposed = false;
51
+ this.pageOne = [];
52
+ this.laterPages = [];
53
+ this.cursor = null;
54
+ this.unregister = null;
55
+ this.storage = storage;
56
+ this.bucket = bucket;
57
+ this.spec = query;
58
+ }
59
+ getState() {
60
+ return this.state;
61
+ }
62
+ /** Called on every change. Returns the unsubscribe. */
63
+ subscribe(listener) {
64
+ if (this.disposed)
65
+ return () => { };
66
+ this.listeners.add(listener);
67
+ return () => {
68
+ this.listeners.delete(listener);
69
+ };
70
+ }
71
+ /** The first load. Call once; refresh() for later loads. */
72
+ async load() {
73
+ if (this.disposed)
74
+ return;
75
+ this.unregister ?? (this.unregister = (0, files_refresh_js_1.registerFilesRefresher)(this.bucket, () => this.firstPage()));
76
+ await this.firstPage();
77
+ }
78
+ /** Page one again. What is shown stays while it runs, and stays if it fails. */
79
+ async refresh() {
80
+ if (this.disposed)
81
+ return;
82
+ this.set({ refreshing: true });
83
+ await this.firstPage();
84
+ }
85
+ /** The next page, appended. Does nothing unless loaded with a page to come. */
86
+ async loadMore() {
87
+ const s = this.state;
88
+ if (this.disposed || s.loadingMore || !s.hasMore || s.status !== 'loaded' || this.cursor === null)
89
+ return;
90
+ const gen = this.generation;
91
+ this.set({ loadingMore: true });
92
+ try {
93
+ const page = await this.storage.listFiles(this.options(this.cursor));
94
+ if (this.isStale(gen))
95
+ return;
96
+ this.laterPages = [...this.laterPages, ...page.files];
97
+ this.cursor = page.nextCursor;
98
+ this.publish({});
99
+ }
100
+ catch {
101
+ // Keep what is shown; the caller can try again.
102
+ }
103
+ finally {
104
+ if (!this.isStale(gen))
105
+ this.set({ loadingMore: false });
106
+ }
107
+ }
108
+ /** Stops all updates. Results still in flight are dropped when they land. */
109
+ dispose() {
110
+ this.disposed = true;
111
+ this.unregister?.();
112
+ this.unregister = null;
113
+ this.listeners.clear();
114
+ }
115
+ async firstPage() {
116
+ const gen = ++this.generation;
117
+ if (this.state.loadingMore)
118
+ this.set({ loadingMore: false });
119
+ try {
120
+ const page = await this.storage.listFiles(this.options(undefined));
121
+ if (!this.isStale(gen)) {
122
+ this.pageOne = page.files;
123
+ this.laterPages = [];
124
+ this.cursor = page.nextCursor;
125
+ this.publish({ status: 'loaded', error: null, refreshing: false });
126
+ }
127
+ }
128
+ catch (error) {
129
+ // Only a first load with nothing to show is the error state. Whichever
130
+ // fetch is newest ends a refresh, a silent one included.
131
+ if (!this.isStale(gen)) {
132
+ this.set(this.state.files.length === 0 ? { status: 'error', error, refreshing: false } : { refreshing: false });
133
+ }
134
+ }
135
+ }
136
+ publish(patch) {
137
+ this.set({ ...patch, files: mergeByPath(this.pageOne, this.laterPages), hasMore: this.cursor !== null });
138
+ }
139
+ options(cursor) {
140
+ return {
141
+ bucket: this.bucket,
142
+ ...(this.spec.prefix ? { prefix: this.spec.prefix } : {}),
143
+ ...(this.spec.limit != null ? { limit: this.spec.limit } : {}),
144
+ ...(this.spec.sort ? { sort: this.spec.sort } : {}),
145
+ ...(this.spec.descending ? { descending: true } : {}),
146
+ ...(cursor ? { cursor } : {}),
147
+ };
148
+ }
149
+ isStale(gen) {
150
+ return this.disposed || gen !== this.generation;
151
+ }
152
+ set(patch) {
153
+ if (this.disposed)
154
+ return;
155
+ this.state = Object.freeze({ ...this.state, ...patch });
156
+ for (const listener of [...this.listeners])
157
+ listener();
158
+ }
159
+ }
160
+ exports.KoolbaseFilesController = KoolbaseFilesController;
@@ -0,0 +1,9 @@
1
+ /** Registers one file list's reload; returns the unregister. */
2
+ export declare function registerFilesRefresher(bucket: string, refresh: () => Promise<void>): () => void;
3
+ /**
4
+ * Reloads every file list on [buckets]. One after another; a failure in one
5
+ * never stops the others, and never reaches the change that caused it.
6
+ */
7
+ export declare function refreshBucketFiles(...buckets: string[]): Promise<void>;
8
+ /** Tests only. */
9
+ export declare function clearFilesRefreshers(): void;
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ // File lists reloaded after a change made through this SDK (storage S4): an
3
+ // upload, a delete, a move or copy, a metadata change, a restore. Keyed by
4
+ // bucket; each list reloads ITSELF, with its own prefix and order.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.registerFilesRefresher = registerFilesRefresher;
7
+ exports.refreshBucketFiles = refreshBucketFiles;
8
+ exports.clearFilesRefreshers = clearFilesRefreshers;
9
+ const refreshers = new Map();
10
+ let next = 0;
11
+ /** Registers one file list's reload; returns the unregister. */
12
+ function registerFilesRefresher(bucket, refresh) {
13
+ const key = `${bucket}\u0000${++next}`;
14
+ refreshers.set(key, refresh);
15
+ return () => {
16
+ refreshers.delete(key);
17
+ };
18
+ }
19
+ /**
20
+ * Reloads every file list on [buckets]. One after another; a failure in one
21
+ * never stops the others, and never reaches the change that caused it.
22
+ */
23
+ async function refreshBucketFiles(...buckets) {
24
+ const prefixes = buckets.map((b) => `${b}\u0000`);
25
+ const due = [...refreshers.entries()].filter(([k]) => prefixes.some((p) => k.startsWith(p))).map(([, f]) => f);
26
+ for (const refresh of due) {
27
+ try {
28
+ await refresh();
29
+ }
30
+ catch (e) {
31
+ // eslint-disable-next-line no-console
32
+ console.warn('[Koolbase] A file list failed to reload after a change:', e);
33
+ }
34
+ }
35
+ }
36
+ /** Tests only. */
37
+ function clearFilesRefreshers() {
38
+ refreshers.clear();
39
+ }
@@ -23,6 +23,8 @@ export { KoolbaseStorage } from './storage.js';
23
23
  export { KoolbaseUploadTask } from './storage-transfer.js';
24
24
  export type { KoolbaseUploadProgress, KoolbaseUploadState } from './storage-transfer.js';
25
25
  export type { KoolbaseFileInfo, KoolbaseFilePage, KoolbaseFileSort, ListFilesOptions } from './storage-files.js';
26
+ export { KoolbaseFilesController, initialFilesState, filesQueryKey } from './files-controller.js';
27
+ export type { FilesQuery, FilesState, FilesStatus } from './files-controller.js';
26
28
  export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
27
29
  export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
28
30
  export type { FiscalIntentResult, FiscalStatus } from './fiscal.js';
package/dist/cjs/index.js CHANGED
@@ -19,7 +19,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
19
19
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
20
20
  };
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
- exports.memoryPlatform = exports.getPlatform = exports.setPlatform = exports.RestoreResult = exports.koolbaseSdkVersion = exports.getOrCreateDeviceId = exports.KoolbaseMessaging = exports.isPending = exports.isFiscalized = exports.decodeFiscalResponse = exports.KoolbaseFiscalError = exports.KoolbaseFiscal = exports.disabledAnalytics = exports.KoolbaseAnalytics = exports.KoolbaseUploadTask = exports.KoolbaseStorage = exports.KoolbaseConnectivity = exports.KoolbaseRealtime = exports.KoolbaseFunctions = exports.KoolbaseFlags = exports.initialRecordState = exports.KoolbaseRecordController = exports.initialCollectionState = exports.collectionQueryKey = exports.KoolbaseCollectionController = exports.KoolbaseDatabase = exports.KoolbaseAuth = void 0;
22
+ exports.memoryPlatform = exports.getPlatform = exports.setPlatform = exports.RestoreResult = exports.koolbaseSdkVersion = exports.getOrCreateDeviceId = exports.KoolbaseMessaging = exports.isPending = exports.isFiscalized = exports.decodeFiscalResponse = exports.KoolbaseFiscalError = exports.KoolbaseFiscal = exports.disabledAnalytics = exports.KoolbaseAnalytics = exports.filesQueryKey = exports.initialFilesState = exports.KoolbaseFilesController = exports.KoolbaseUploadTask = exports.KoolbaseStorage = exports.KoolbaseConnectivity = exports.KoolbaseRealtime = exports.KoolbaseFunctions = exports.KoolbaseFlags = exports.initialRecordState = exports.KoolbaseRecordController = exports.initialCollectionState = exports.collectionQueryKey = exports.KoolbaseCollectionController = exports.KoolbaseDatabase = exports.KoolbaseAuth = void 0;
23
23
  __exportStar(require("./types.js"), exports);
24
24
  __exportStar(require("./errors.js"), exports);
25
25
  __exportStar(require("./network.js"), exports);
@@ -53,6 +53,11 @@ var storage_js_1 = require("./storage.js");
53
53
  Object.defineProperty(exports, "KoolbaseStorage", { enumerable: true, get: function () { return storage_js_1.KoolbaseStorage; } });
54
54
  var storage_transfer_js_1 = require("./storage-transfer.js");
55
55
  Object.defineProperty(exports, "KoolbaseUploadTask", { enumerable: true, get: function () { return storage_transfer_js_1.KoolbaseUploadTask; } });
56
+ // A bucket's files as list state (storage S4).
57
+ var files_controller_js_1 = require("./files-controller.js");
58
+ Object.defineProperty(exports, "KoolbaseFilesController", { enumerable: true, get: function () { return files_controller_js_1.KoolbaseFilesController; } });
59
+ Object.defineProperty(exports, "initialFilesState", { enumerable: true, get: function () { return files_controller_js_1.initialFilesState; } });
60
+ Object.defineProperty(exports, "filesQueryKey", { enumerable: true, get: function () { return files_controller_js_1.filesQueryKey; } });
56
61
  var analytics_js_1 = require("./analytics.js");
57
62
  Object.defineProperty(exports, "KoolbaseAnalytics", { enumerable: true, get: function () { return analytics_js_1.KoolbaseAnalytics; } });
58
63
  Object.defineProperty(exports, "disabledAnalytics", { enumerable: true, get: function () { return analytics_js_1.disabledAnalytics; } });
@@ -1,2 +1,3 @@
1
1
  export { createUseCollection, type UseCollectionResult } from './use-collection.js';
2
2
  export { createUseRecord, type UseRecordResult } from './use-record.js';
3
+ export { createUseFiles, type UseFilesResult } from './use-files.js';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createUseRecord = exports.createUseCollection = void 0;
3
+ exports.createUseFiles = exports.createUseRecord = exports.createUseCollection = void 0;
4
4
  // React hooks over core's controllers: ONE implementation, exported to apps by
5
5
  // @koolbase/react-native (from its root, as always) and @koolbase/js/react.
6
6
  // React only -- nothing native, nothing DOM. Deliberately not exported from
@@ -9,3 +9,5 @@ var use_collection_js_1 = require("./use-collection.js");
9
9
  Object.defineProperty(exports, "createUseCollection", { enumerable: true, get: function () { return use_collection_js_1.createUseCollection; } });
10
10
  var use_record_js_1 = require("./use-record.js");
11
11
  Object.defineProperty(exports, "createUseRecord", { enumerable: true, get: function () { return use_record_js_1.createUseRecord; } });
12
+ var use_files_js_1 = require("./use-files.js");
13
+ Object.defineProperty(exports, "createUseFiles", { enumerable: true, get: function () { return use_files_js_1.createUseFiles; } });
@@ -0,0 +1,8 @@
1
+ import { type FilesQuery, type FilesState, type KoolbaseStorage } from '../index.js';
2
+ export interface UseFilesResult extends FilesState {
3
+ /** Page one again. What is shown stays while it runs, and stays if it fails. */
4
+ refresh: () => Promise<void>;
5
+ /** The next page, appended, while hasMore. */
6
+ loadMore: () => Promise<void>;
7
+ }
8
+ export declare function createUseFiles(getStorage: () => Pick<KoolbaseStorage, 'listFiles'>): (bucket: string, query?: FilesQuery) => UseFilesResult;
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ // useFiles: a bucket's files as React state (storage S4).
3
+ //
4
+ // A thin wrapper. Everything about getting the files right lives in
5
+ // @koolbase/core's KoolbaseFilesController, which is tested there; this
6
+ // file only ties one controller's lifetime to a component's.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.createUseFiles = createUseFiles;
9
+ const react_1 = require("react");
10
+ const index_js_1 = require("../index.js");
11
+ const noop = () => { };
12
+ const done = () => Promise.resolve();
13
+ function createUseFiles(getStorage) {
14
+ return function useFiles(bucket, query = {}) {
15
+ // The query as a value: an inline object is a new object every render,
16
+ // and must not start a new fetch every render.
17
+ const key = (0, index_js_1.filesQueryKey)(bucket, query);
18
+ const [current, setCurrent] = (0, react_1.useState)(null);
19
+ (0, react_1.useEffect)(() => {
20
+ // Created here, not during render, so React strict mode's second run
21
+ // never keeps a disposed controller.
22
+ const controller = new index_js_1.KoolbaseFilesController(getStorage(), bucket, query);
23
+ setCurrent({ key, controller });
24
+ void controller.load();
25
+ return () => controller.dispose();
26
+ }, [key]);
27
+ // A controller from the previous key reads as a fresh load, never as
28
+ // the previous listing's files.
29
+ const controller = current?.key === key ? current.controller : null;
30
+ const subscribe = (0, react_1.useCallback)((onChange) => (controller ? controller.subscribe(onChange) : noop), [controller]);
31
+ const getSnapshot = (0, react_1.useCallback)(() => (controller ? controller.getState() : index_js_1.initialFilesState), [controller]);
32
+ const state = (0, react_1.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
33
+ const refresh = (0, react_1.useCallback)(() => (controller ? controller.refresh() : done()), [controller]);
34
+ const loadMore = (0, react_1.useCallback)(() => (controller ? controller.loadMore() : done()), [controller]);
35
+ return { ...state, refresh, loadMore };
36
+ };
37
+ }
@@ -8,8 +8,14 @@ const storage_errors_js_2 = require("./storage-errors.js");
8
8
  const storage_transfer_js_1 = require("./storage-transfer.js");
9
9
  const storage_multipart_js_1 = require("./storage-multipart.js");
10
10
  const storage_errors_js_3 = require("./storage-errors.js");
11
+ const files_refresh_js_1 = require("./files-refresh.js");
11
12
  const network_js_2 = require("./network.js");
12
13
  const storage_files_js_1 = require("./storage-files.js");
14
+ /** An upload task whose success reloads the bucket's file lists (storage S4). */
15
+ function listsReload(task, bucket) {
16
+ task.result.then(() => { void (0, files_refresh_js_1.refreshBucketFiles)(bucket); }, () => { });
17
+ return task;
18
+ }
13
19
  // --- Cloudflare image-transform URL helpers -------------------------------
14
20
  // Module-private — callers use KoolbaseStorage.publicUrl / publicUrlForObject.
15
21
  function clampInt(v, min, max) {
@@ -132,7 +138,7 @@ class KoolbaseStorage {
132
138
  * const { object } = await task.result;
133
139
  */
134
140
  startUpload(options) {
135
- return (0, storage_transfer_js_1.runUploadTask)((task) => this.runUpload(options, task));
141
+ return listsReload((0, storage_transfer_js_1.runUploadTask)((task) => this.runUpload(options, task)), options.bucket);
136
142
  }
137
143
  async runUpload(options, task) {
138
144
  task.checkCancelled();
@@ -242,14 +248,14 @@ class KoolbaseStorage {
242
248
  * await Koolbase.storage.resumeUpload({ bucket: 'video', uploadId, file }).result;
243
249
  */
244
250
  resumeUpload(options) {
245
- return (0, storage_transfer_js_1.runUploadTask)(async (task) => {
251
+ return listsReload((0, storage_transfer_js_1.runUploadTask)(async (task) => {
246
252
  const isBlob = typeof Blob !== 'undefined' && options.file instanceof Blob;
247
253
  const blob = isBlob
248
254
  ? options.file
249
255
  : await (await (0, network_js_1.sessionFetch)(options.file.uri)).blob();
250
256
  task.setTotal(blob.size);
251
257
  return this.runInParts(options.bucket, options, task, blob, '', options.uploadId);
252
- });
258
+ }), options.bucket);
253
259
  }
254
260
  async runInParts(bucket, options, task, file, contentType, resumeId) {
255
261
  const base = `${this.config.baseUrl}/v1/sdk/storage/uploads`;
@@ -466,7 +472,9 @@ class KoolbaseStorage {
466
472
  if (!res.ok) {
467
473
  throw await this.error(res, op === 'move' ? 'Failed to move the file' : 'Failed to copy the file');
468
474
  }
469
- return mapObjectFromServer(await res.json());
475
+ const moved = mapObjectFromServer(await res.json());
476
+ void (0, files_refresh_js_1.refreshBucketFiles)(o.bucket, ...(o.toBucket && o.toBucket !== o.bucket ? [o.toBucket] : []));
477
+ return moved;
470
478
  }
471
479
  /**
472
480
  * Apply a partial metadata update to an existing object. Returns the
@@ -514,6 +522,7 @@ class KoolbaseStorage {
514
522
  throw await this.error(res, 'Failed to update metadata');
515
523
  }
516
524
  const raw = await res.json();
525
+ void (0, files_refresh_js_1.refreshBucketFiles)(bucket);
517
526
  return mapObjectFromServer(raw);
518
527
  }
519
528
  /**
@@ -645,11 +654,11 @@ class KoolbaseStorage {
645
654
  },
646
655
  body: JSON.stringify({ bucket, path }),
647
656
  });
648
- if (res.status === 204)
657
+ if (res.ok) {
658
+ void (0, files_refresh_js_1.refreshBucketFiles)(bucket);
649
659
  return;
650
- if (!res.ok) {
651
- throw await this.error(res, 'Failed to delete file');
652
660
  }
661
+ throw await this.error(res, 'Failed to delete file');
653
662
  }
654
663
  /**
655
664
  * List all versions of a file path, newest-first. Returns a flat list
@@ -706,7 +715,9 @@ class KoolbaseStorage {
706
715
  if (!res.ok) {
707
716
  throw await this.error(res, 'Failed to restore version');
708
717
  }
709
- return mapObjectFromServer(await res.json());
718
+ const restored = mapObjectFromServer(await res.json());
719
+ void (0, files_refresh_js_1.refreshBucketFiles)(bucket);
720
+ return restored;
710
721
  }
711
722
  /**
712
723
  * Hard-remove a single history version — both the metadata row and
@@ -0,0 +1,62 @@
1
+ import type { KoolbaseFileInfo, KoolbaseFilePage, KoolbaseFileSort, ListFilesOptions } from './storage-files.js';
2
+ export type FilesStatus = 'loading' | 'loaded' | 'error';
3
+ /** Which files, in what order. The bucket's view rule decides the rest. */
4
+ export interface FilesQuery {
5
+ /** Only files whose path starts with this, taken literally. */
6
+ prefix?: string;
7
+ /** By path unless set. */
8
+ sort?: KoolbaseFileSort;
9
+ /** Z to A, newest first, largest first. */
10
+ descending?: boolean;
11
+ /** Files per page: 50 unless set, at most 100. */
12
+ limit?: number;
13
+ }
14
+ export interface FilesState {
15
+ readonly status: FilesStatus;
16
+ readonly files: readonly KoolbaseFileInfo[];
17
+ /** Why the first load failed, while status is 'error'. */
18
+ readonly error: unknown;
19
+ /** True while refresh() runs. */
20
+ readonly refreshing: boolean;
21
+ /** True while loadMore() runs. */
22
+ readonly loadingMore: boolean;
23
+ /** Whether there is a page past the last one loaded. True before the first load. */
24
+ readonly hasMore: boolean;
25
+ }
26
+ export declare const initialFilesState: FilesState;
27
+ /** One string per distinct listing. */
28
+ export declare function filesQueryKey(bucket: string, query?: FilesQuery): string;
29
+ type FilesReader = {
30
+ listFiles(options: ListFilesOptions): Promise<KoolbaseFilePage>;
31
+ };
32
+ export declare class KoolbaseFilesController {
33
+ readonly bucket: string;
34
+ private readonly storage;
35
+ private readonly spec;
36
+ private state;
37
+ private readonly listeners;
38
+ private generation;
39
+ private disposed;
40
+ private pageOne;
41
+ private laterPages;
42
+ private cursor;
43
+ private unregister;
44
+ constructor(storage: FilesReader, bucket: string, query?: FilesQuery);
45
+ getState(): FilesState;
46
+ /** Called on every change. Returns the unsubscribe. */
47
+ subscribe(listener: () => void): () => void;
48
+ /** The first load. Call once; refresh() for later loads. */
49
+ load(): Promise<void>;
50
+ /** Page one again. What is shown stays while it runs, and stays if it fails. */
51
+ refresh(): Promise<void>;
52
+ /** The next page, appended. Does nothing unless loaded with a page to come. */
53
+ loadMore(): Promise<void>;
54
+ /** Stops all updates. Results still in flight are dropped when they land. */
55
+ dispose(): void;
56
+ private firstPage;
57
+ private publish;
58
+ private options;
59
+ private isStale;
60
+ private set;
61
+ }
62
+ export {};
@@ -0,0 +1,155 @@
1
+ // A bucket's files read as a list, framework-free (storage S4).
2
+ //
3
+ // The data half of a file gallery: loading, loaded or error, refresh, and
4
+ // the next page by cursor. No React here, so it is tested in this package;
5
+ // useFiles in @koolbase/react-native and @koolbase/js/react is a thin
6
+ // wrapper. Mirrors the Flutter SDK's KoolbaseFilesController.
7
+ //
8
+ // - only a first load with nothing to show is the error state; a failed
9
+ // refresh or loadMore keeps what is shown (stale beats blank)
10
+ // - loadMore appends the next page while there is one; a file already
11
+ // shown is never shown twice (matched by path)
12
+ // - an upload, delete, move, copy, metadata change or restore made through
13
+ // this SDK in the bucket reloads page one silently (files-refresh.ts)
14
+ // - every fetch belongs to a generation: a result from a fetch a later
15
+ // refresh superseded, or one landing after dispose(), is dropped
16
+ import { registerFilesRefresher } from './files-refresh.js';
17
+ export const initialFilesState = Object.freeze({
18
+ status: 'loading',
19
+ files: Object.freeze([]),
20
+ error: null,
21
+ refreshing: false,
22
+ loadingMore: false,
23
+ hasMore: true,
24
+ });
25
+ /** One string per distinct listing. */
26
+ export function filesQueryKey(bucket, query = {}) {
27
+ return JSON.stringify([bucket, query.prefix ?? '', query.sort ?? 'path', query.descending === true, query.limit ?? null]);
28
+ }
29
+ /** Page one, then the later pages without any file already shown. */
30
+ function mergeByPath(first, rest) {
31
+ const seen = new Set(first.map((f) => f.path));
32
+ const out = [...first];
33
+ for (const f of rest) {
34
+ if (seen.has(f.path))
35
+ continue;
36
+ seen.add(f.path);
37
+ out.push(f);
38
+ }
39
+ return out;
40
+ }
41
+ export class KoolbaseFilesController {
42
+ constructor(storage, bucket, query = {}) {
43
+ this.state = initialFilesState;
44
+ this.listeners = new Set();
45
+ this.generation = 0;
46
+ this.disposed = false;
47
+ this.pageOne = [];
48
+ this.laterPages = [];
49
+ this.cursor = null;
50
+ this.unregister = null;
51
+ this.storage = storage;
52
+ this.bucket = bucket;
53
+ this.spec = query;
54
+ }
55
+ getState() {
56
+ return this.state;
57
+ }
58
+ /** Called on every change. Returns the unsubscribe. */
59
+ subscribe(listener) {
60
+ if (this.disposed)
61
+ return () => { };
62
+ this.listeners.add(listener);
63
+ return () => {
64
+ this.listeners.delete(listener);
65
+ };
66
+ }
67
+ /** The first load. Call once; refresh() for later loads. */
68
+ async load() {
69
+ if (this.disposed)
70
+ return;
71
+ this.unregister ?? (this.unregister = registerFilesRefresher(this.bucket, () => this.firstPage()));
72
+ await this.firstPage();
73
+ }
74
+ /** Page one again. What is shown stays while it runs, and stays if it fails. */
75
+ async refresh() {
76
+ if (this.disposed)
77
+ return;
78
+ this.set({ refreshing: true });
79
+ await this.firstPage();
80
+ }
81
+ /** The next page, appended. Does nothing unless loaded with a page to come. */
82
+ async loadMore() {
83
+ const s = this.state;
84
+ if (this.disposed || s.loadingMore || !s.hasMore || s.status !== 'loaded' || this.cursor === null)
85
+ return;
86
+ const gen = this.generation;
87
+ this.set({ loadingMore: true });
88
+ try {
89
+ const page = await this.storage.listFiles(this.options(this.cursor));
90
+ if (this.isStale(gen))
91
+ return;
92
+ this.laterPages = [...this.laterPages, ...page.files];
93
+ this.cursor = page.nextCursor;
94
+ this.publish({});
95
+ }
96
+ catch {
97
+ // Keep what is shown; the caller can try again.
98
+ }
99
+ finally {
100
+ if (!this.isStale(gen))
101
+ this.set({ loadingMore: false });
102
+ }
103
+ }
104
+ /** Stops all updates. Results still in flight are dropped when they land. */
105
+ dispose() {
106
+ this.disposed = true;
107
+ this.unregister?.();
108
+ this.unregister = null;
109
+ this.listeners.clear();
110
+ }
111
+ async firstPage() {
112
+ const gen = ++this.generation;
113
+ if (this.state.loadingMore)
114
+ this.set({ loadingMore: false });
115
+ try {
116
+ const page = await this.storage.listFiles(this.options(undefined));
117
+ if (!this.isStale(gen)) {
118
+ this.pageOne = page.files;
119
+ this.laterPages = [];
120
+ this.cursor = page.nextCursor;
121
+ this.publish({ status: 'loaded', error: null, refreshing: false });
122
+ }
123
+ }
124
+ catch (error) {
125
+ // Only a first load with nothing to show is the error state. Whichever
126
+ // fetch is newest ends a refresh, a silent one included.
127
+ if (!this.isStale(gen)) {
128
+ this.set(this.state.files.length === 0 ? { status: 'error', error, refreshing: false } : { refreshing: false });
129
+ }
130
+ }
131
+ }
132
+ publish(patch) {
133
+ this.set({ ...patch, files: mergeByPath(this.pageOne, this.laterPages), hasMore: this.cursor !== null });
134
+ }
135
+ options(cursor) {
136
+ return {
137
+ bucket: this.bucket,
138
+ ...(this.spec.prefix ? { prefix: this.spec.prefix } : {}),
139
+ ...(this.spec.limit != null ? { limit: this.spec.limit } : {}),
140
+ ...(this.spec.sort ? { sort: this.spec.sort } : {}),
141
+ ...(this.spec.descending ? { descending: true } : {}),
142
+ ...(cursor ? { cursor } : {}),
143
+ };
144
+ }
145
+ isStale(gen) {
146
+ return this.disposed || gen !== this.generation;
147
+ }
148
+ set(patch) {
149
+ if (this.disposed)
150
+ return;
151
+ this.state = Object.freeze({ ...this.state, ...patch });
152
+ for (const listener of [...this.listeners])
153
+ listener();
154
+ }
155
+ }
@@ -0,0 +1,9 @@
1
+ /** Registers one file list's reload; returns the unregister. */
2
+ export declare function registerFilesRefresher(bucket: string, refresh: () => Promise<void>): () => void;
3
+ /**
4
+ * Reloads every file list on [buckets]. One after another; a failure in one
5
+ * never stops the others, and never reaches the change that caused it.
6
+ */
7
+ export declare function refreshBucketFiles(...buckets: string[]): Promise<void>;
8
+ /** Tests only. */
9
+ export declare function clearFilesRefreshers(): void;
@@ -0,0 +1,34 @@
1
+ // File lists reloaded after a change made through this SDK (storage S4): an
2
+ // upload, a delete, a move or copy, a metadata change, a restore. Keyed by
3
+ // bucket; each list reloads ITSELF, with its own prefix and order.
4
+ const refreshers = new Map();
5
+ let next = 0;
6
+ /** Registers one file list's reload; returns the unregister. */
7
+ export function registerFilesRefresher(bucket, refresh) {
8
+ const key = `${bucket}\u0000${++next}`;
9
+ refreshers.set(key, refresh);
10
+ return () => {
11
+ refreshers.delete(key);
12
+ };
13
+ }
14
+ /**
15
+ * Reloads every file list on [buckets]. One after another; a failure in one
16
+ * never stops the others, and never reaches the change that caused it.
17
+ */
18
+ export async function refreshBucketFiles(...buckets) {
19
+ const prefixes = buckets.map((b) => `${b}\u0000`);
20
+ const due = [...refreshers.entries()].filter(([k]) => prefixes.some((p) => k.startsWith(p))).map(([, f]) => f);
21
+ for (const refresh of due) {
22
+ try {
23
+ await refresh();
24
+ }
25
+ catch (e) {
26
+ // eslint-disable-next-line no-console
27
+ console.warn('[Koolbase] A file list failed to reload after a change:', e);
28
+ }
29
+ }
30
+ }
31
+ /** Tests only. */
32
+ export function clearFilesRefreshers() {
33
+ refreshers.clear();
34
+ }
@@ -23,6 +23,8 @@ export { KoolbaseStorage } from './storage.js';
23
23
  export { KoolbaseUploadTask } from './storage-transfer.js';
24
24
  export type { KoolbaseUploadProgress, KoolbaseUploadState } from './storage-transfer.js';
25
25
  export type { KoolbaseFileInfo, KoolbaseFilePage, KoolbaseFileSort, ListFilesOptions } from './storage-files.js';
26
+ export { KoolbaseFilesController, initialFilesState, filesQueryKey } from './files-controller.js';
27
+ export type { FilesQuery, FilesState, FilesStatus } from './files-controller.js';
26
28
  export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
27
29
  export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
28
30
  export type { FiscalIntentResult, FiscalStatus } from './fiscal.js';
package/dist/esm/index.js CHANGED
@@ -23,6 +23,8 @@ export { KoolbaseRealtime } from './realtime.js';
23
23
  export { KoolbaseConnectivity } from './connectivity.js';
24
24
  export { KoolbaseStorage } from './storage.js';
25
25
  export { KoolbaseUploadTask } from './storage-transfer.js';
26
+ // A bucket's files as list state (storage S4).
27
+ export { KoolbaseFilesController, initialFilesState, filesQueryKey } from './files-controller.js';
26
28
  export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
27
29
  export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
28
30
  export { KoolbaseMessaging } from './messaging.js';
@@ -1,2 +1,3 @@
1
1
  export { createUseCollection, type UseCollectionResult } from './use-collection.js';
2
2
  export { createUseRecord, type UseRecordResult } from './use-record.js';
3
+ export { createUseFiles, type UseFilesResult } from './use-files.js';
@@ -4,3 +4,4 @@
4
4
  // core's root entry, so importing @koolbase/core never loads React.
5
5
  export { createUseCollection } from './use-collection.js';
6
6
  export { createUseRecord } from './use-record.js';
7
+ export { createUseFiles } from './use-files.js';
@@ -0,0 +1,8 @@
1
+ import { type FilesQuery, type FilesState, type KoolbaseStorage } from '../index.js';
2
+ export interface UseFilesResult extends FilesState {
3
+ /** Page one again. What is shown stays while it runs, and stays if it fails. */
4
+ refresh: () => Promise<void>;
5
+ /** The next page, appended, while hasMore. */
6
+ loadMore: () => Promise<void>;
7
+ }
8
+ export declare function createUseFiles(getStorage: () => Pick<KoolbaseStorage, 'listFiles'>): (bucket: string, query?: FilesQuery) => UseFilesResult;
@@ -0,0 +1,34 @@
1
+ // useFiles: a bucket's files as React state (storage S4).
2
+ //
3
+ // A thin wrapper. Everything about getting the files right lives in
4
+ // @koolbase/core's KoolbaseFilesController, which is tested there; this
5
+ // file only ties one controller's lifetime to a component's.
6
+ import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
7
+ import { KoolbaseFilesController, filesQueryKey, initialFilesState, } from '../index.js';
8
+ const noop = () => { };
9
+ const done = () => Promise.resolve();
10
+ export function createUseFiles(getStorage) {
11
+ return function useFiles(bucket, query = {}) {
12
+ // The query as a value: an inline object is a new object every render,
13
+ // and must not start a new fetch every render.
14
+ const key = filesQueryKey(bucket, query);
15
+ const [current, setCurrent] = useState(null);
16
+ useEffect(() => {
17
+ // Created here, not during render, so React strict mode's second run
18
+ // never keeps a disposed controller.
19
+ const controller = new KoolbaseFilesController(getStorage(), bucket, query);
20
+ setCurrent({ key, controller });
21
+ void controller.load();
22
+ return () => controller.dispose();
23
+ }, [key]);
24
+ // A controller from the previous key reads as a fresh load, never as
25
+ // the previous listing's files.
26
+ const controller = current?.key === key ? current.controller : null;
27
+ const subscribe = useCallback((onChange) => (controller ? controller.subscribe(onChange) : noop), [controller]);
28
+ const getSnapshot = useCallback(() => (controller ? controller.getState() : initialFilesState), [controller]);
29
+ const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
30
+ const refresh = useCallback(() => (controller ? controller.refresh() : done()), [controller]);
31
+ const loadMore = useCallback(() => (controller ? controller.loadMore() : done()), [controller]);
32
+ return { ...state, refresh, loadMore };
33
+ };
34
+ }
@@ -5,8 +5,14 @@ import { KoolbaseStorageError, koolbaseStorageErrorFromResponse, } from './stora
5
5
  import { putWithProgress, runUploadTask } from './storage-transfer.js';
6
6
  import { sendParts } from './storage-multipart.js';
7
7
  import { KoolbaseDownloadCancelledError } from './storage-errors.js';
8
+ import { refreshBucketFiles } from './files-refresh.js';
8
9
  import { KoolbaseNetworkError } from './network.js';
9
10
  import { fileInfoFromJson, filePageFromJson, listFilesQuery, } from './storage-files.js';
11
+ /** An upload task whose success reloads the bucket's file lists (storage S4). */
12
+ function listsReload(task, bucket) {
13
+ task.result.then(() => { void refreshBucketFiles(bucket); }, () => { });
14
+ return task;
15
+ }
10
16
  // --- Cloudflare image-transform URL helpers -------------------------------
11
17
  // Module-private — callers use KoolbaseStorage.publicUrl / publicUrlForObject.
12
18
  function clampInt(v, min, max) {
@@ -129,7 +135,7 @@ export class KoolbaseStorage {
129
135
  * const { object } = await task.result;
130
136
  */
131
137
  startUpload(options) {
132
- return runUploadTask((task) => this.runUpload(options, task));
138
+ return listsReload(runUploadTask((task) => this.runUpload(options, task)), options.bucket);
133
139
  }
134
140
  async runUpload(options, task) {
135
141
  task.checkCancelled();
@@ -239,14 +245,14 @@ export class KoolbaseStorage {
239
245
  * await Koolbase.storage.resumeUpload({ bucket: 'video', uploadId, file }).result;
240
246
  */
241
247
  resumeUpload(options) {
242
- return runUploadTask(async (task) => {
248
+ return listsReload(runUploadTask(async (task) => {
243
249
  const isBlob = typeof Blob !== 'undefined' && options.file instanceof Blob;
244
250
  const blob = isBlob
245
251
  ? options.file
246
252
  : await (await sessionFetch(options.file.uri)).blob();
247
253
  task.setTotal(blob.size);
248
254
  return this.runInParts(options.bucket, options, task, blob, '', options.uploadId);
249
- });
255
+ }), options.bucket);
250
256
  }
251
257
  async runInParts(bucket, options, task, file, contentType, resumeId) {
252
258
  const base = `${this.config.baseUrl}/v1/sdk/storage/uploads`;
@@ -463,7 +469,9 @@ export class KoolbaseStorage {
463
469
  if (!res.ok) {
464
470
  throw await this.error(res, op === 'move' ? 'Failed to move the file' : 'Failed to copy the file');
465
471
  }
466
- return mapObjectFromServer(await res.json());
472
+ const moved = mapObjectFromServer(await res.json());
473
+ void refreshBucketFiles(o.bucket, ...(o.toBucket && o.toBucket !== o.bucket ? [o.toBucket] : []));
474
+ return moved;
467
475
  }
468
476
  /**
469
477
  * Apply a partial metadata update to an existing object. Returns the
@@ -511,6 +519,7 @@ export class KoolbaseStorage {
511
519
  throw await this.error(res, 'Failed to update metadata');
512
520
  }
513
521
  const raw = await res.json();
522
+ void refreshBucketFiles(bucket);
514
523
  return mapObjectFromServer(raw);
515
524
  }
516
525
  /**
@@ -642,11 +651,11 @@ export class KoolbaseStorage {
642
651
  },
643
652
  body: JSON.stringify({ bucket, path }),
644
653
  });
645
- if (res.status === 204)
654
+ if (res.ok) {
655
+ void refreshBucketFiles(bucket);
646
656
  return;
647
- if (!res.ok) {
648
- throw await this.error(res, 'Failed to delete file');
649
657
  }
658
+ throw await this.error(res, 'Failed to delete file');
650
659
  }
651
660
  /**
652
661
  * List all versions of a file path, newest-first. Returns a flat list
@@ -703,7 +712,9 @@ export class KoolbaseStorage {
703
712
  if (!res.ok) {
704
713
  throw await this.error(res, 'Failed to restore version');
705
714
  }
706
- return mapObjectFromServer(await res.json());
715
+ const restored = mapObjectFromServer(await res.json());
716
+ void refreshBucketFiles(bucket);
717
+ return restored;
707
718
  }
708
719
  /**
709
720
  * Hard-remove a single history version — both the metadata row and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koolbase/core",
3
- "version": "12.18.0",
3
+ "version": "12.19.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",