@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.
Files changed (104) hide show
  1. package/README.md +19 -0
  2. package/dist/cjs/analytics.d.ts +24 -0
  3. package/dist/cjs/analytics.js +110 -0
  4. package/dist/cjs/auth-errors.d.ts +117 -0
  5. package/dist/cjs/auth-errors.js +250 -0
  6. package/dist/cjs/auth.d.ts +213 -0
  7. package/dist/cjs/auth.js +813 -0
  8. package/dist/cjs/cache-store.d.ts +50 -0
  9. package/dist/cjs/cache-store.js +194 -0
  10. package/dist/cjs/conflict.d.ts +80 -0
  11. package/dist/cjs/conflict.js +84 -0
  12. package/dist/cjs/database-errors.d.ts +101 -0
  13. package/dist/cjs/database-errors.js +200 -0
  14. package/dist/cjs/database.d.ts +298 -0
  15. package/dist/cjs/database.js +852 -0
  16. package/dist/cjs/device-id.d.ts +1 -0
  17. package/dist/cjs/device-id.js +57 -0
  18. package/dist/cjs/device-metadata.d.ts +36 -0
  19. package/dist/cjs/device-metadata.js +94 -0
  20. package/dist/cjs/errors.d.ts +64 -0
  21. package/dist/cjs/errors.js +85 -0
  22. package/dist/cjs/flags.d.ts +15 -0
  23. package/dist/cjs/flags.js +76 -0
  24. package/dist/cjs/function-errors.d.ts +51 -0
  25. package/dist/cjs/function-errors.js +103 -0
  26. package/dist/cjs/functions.d.ts +15 -0
  27. package/dist/cjs/functions.js +83 -0
  28. package/dist/cjs/index.d.ts +22 -0
  29. package/dist/cjs/index.js +56 -0
  30. package/dist/cjs/messaging.d.ts +13 -0
  31. package/dist/cjs/messaging.js +36 -0
  32. package/dist/cjs/offline-state.d.ts +97 -0
  33. package/dist/cjs/offline-state.js +198 -0
  34. package/dist/cjs/package.json +3 -0
  35. package/dist/cjs/pending-write.d.ts +47 -0
  36. package/dist/cjs/pending-write.js +22 -0
  37. package/dist/cjs/platform.d.ts +51 -0
  38. package/dist/cjs/platform.js +41 -0
  39. package/dist/cjs/realtime.d.ts +44 -0
  40. package/dist/cjs/realtime.js +195 -0
  41. package/dist/cjs/record.d.ts +2 -0
  42. package/dist/cjs/record.js +23 -0
  43. package/dist/cjs/shared.d.ts +9 -0
  44. package/dist/cjs/shared.js +43 -0
  45. package/dist/cjs/storage-errors.d.ts +163 -0
  46. package/dist/cjs/storage-errors.js +253 -0
  47. package/dist/cjs/storage.d.ts +198 -0
  48. package/dist/cjs/storage.js +451 -0
  49. package/dist/cjs/sync-engine.d.ts +30 -0
  50. package/dist/cjs/sync-engine.js +286 -0
  51. package/dist/cjs/types.d.ts +487 -0
  52. package/dist/cjs/types.js +40 -0
  53. package/dist/esm/analytics.d.ts +24 -0
  54. package/dist/esm/analytics.js +106 -0
  55. package/dist/esm/auth-errors.d.ts +117 -0
  56. package/dist/esm/auth-errors.js +222 -0
  57. package/dist/esm/auth.d.ts +213 -0
  58. package/dist/esm/auth.js +809 -0
  59. package/dist/esm/cache-store.d.ts +50 -0
  60. package/dist/esm/cache-store.js +179 -0
  61. package/dist/esm/conflict.d.ts +80 -0
  62. package/dist/esm/conflict.js +80 -0
  63. package/dist/esm/database-errors.d.ts +101 -0
  64. package/dist/esm/database-errors.js +189 -0
  65. package/dist/esm/database.d.ts +298 -0
  66. package/dist/esm/database.js +848 -0
  67. package/dist/esm/device-id.d.ts +1 -0
  68. package/dist/esm/device-id.js +54 -0
  69. package/dist/esm/device-metadata.d.ts +36 -0
  70. package/dist/esm/device-metadata.js +90 -0
  71. package/dist/esm/errors.d.ts +64 -0
  72. package/dist/esm/errors.js +79 -0
  73. package/dist/esm/flags.d.ts +15 -0
  74. package/dist/esm/flags.js +72 -0
  75. package/dist/esm/function-errors.d.ts +51 -0
  76. package/dist/esm/function-errors.js +93 -0
  77. package/dist/esm/functions.d.ts +15 -0
  78. package/dist/esm/functions.js +79 -0
  79. package/dist/esm/index.d.ts +22 -0
  80. package/dist/esm/index.js +25 -0
  81. package/dist/esm/messaging.d.ts +13 -0
  82. package/dist/esm/messaging.js +32 -0
  83. package/dist/esm/offline-state.d.ts +97 -0
  84. package/dist/esm/offline-state.js +189 -0
  85. package/dist/esm/package.json +3 -0
  86. package/dist/esm/pending-write.d.ts +47 -0
  87. package/dist/esm/pending-write.js +19 -0
  88. package/dist/esm/platform.d.ts +51 -0
  89. package/dist/esm/platform.js +36 -0
  90. package/dist/esm/realtime.d.ts +44 -0
  91. package/dist/esm/realtime.js +191 -0
  92. package/dist/esm/record.d.ts +2 -0
  93. package/dist/esm/record.js +20 -0
  94. package/dist/esm/shared.d.ts +9 -0
  95. package/dist/esm/shared.js +39 -0
  96. package/dist/esm/storage-errors.d.ts +163 -0
  97. package/dist/esm/storage-errors.js +239 -0
  98. package/dist/esm/storage.d.ts +198 -0
  99. package/dist/esm/storage.js +447 -0
  100. package/dist/esm/sync-engine.d.ts +30 -0
  101. package/dist/esm/sync-engine.js +282 -0
  102. package/dist/esm/types.d.ts +487 -0
  103. package/dist/esm/types.js +37 -0
  104. package/package.json +45 -0
@@ -0,0 +1,198 @@
1
+ import { KoolbaseConfig, UploadOptions, UploadResult, KoolbaseObject, KoolbaseObjectVersion, KoolbaseImageTransform } from './types.js';
2
+ /**
3
+ * Koolbase storage client — uploads, downloads, and deletes via presigned
4
+ * Cloudflare R2 URLs.
5
+ *
6
+ * Uploads are **safe-by-default** (v5+): an upload to a path where an object
7
+ * already exists is rejected with {@link KoolbaseStorageConflictError} unless
8
+ * `overwrite: true` is passed.
9
+ */
10
+ export declare class KoolbaseStorage {
11
+ private config;
12
+ private getToken;
13
+ /**
14
+ * Called when the server rejects the caller's credentials.
15
+ *
16
+ * A session stops working for the whole SDK at once, not one subsystem at a
17
+ * time, so a 401 met during an upload has to clear it just as one met during a
18
+ * query does. Otherwise an app whose failing call happens to be a file upload
19
+ * keeps believing it is signed in.
20
+ */
21
+ private onSessionExpired?;
22
+ constructor(config: KoolbaseConfig, getToken: () => Promise<string | null>, onSessionExpired?: () => Promise<void>);
23
+ /**
24
+ * Builds the error for a failed response and clears the session when the
25
+ * credentials were refused, before the error reaches the caller.
26
+ */
27
+ private error;
28
+ private buildHeaders;
29
+ /**
30
+ * Upload a file to a bucket. Returns the object metadata and a download URL.
31
+ *
32
+ * By default (`overwrite: false`), uploads to a path where an object
33
+ * already exists are **rejected** with a {@link KoolbaseStorageConflictError}.
34
+ * Catch it to prompt the user, then retry with `overwrite: true` to replace
35
+ * the existing object — or with a different `path`.
36
+ *
37
+ * Set `overwrite: true` for true upsert semantics — silently replace any
38
+ * existing object at this path.
39
+ *
40
+ * Pass `options.metadata` to attach arbitrary user-defined key/value pairs
41
+ * to the object at confirm time. Subject to the limits documented on
42
+ * {@link KoolbaseObject.metadata}; violations throw
43
+ * `KoolbaseStorageMetadataInvalidError`. On the `overwrite: true` path the
44
+ * metadata REPLACES any prior metadata at this path (matches GCS semantics).
45
+ * Use {@link updateMetadata} for post-upload merge changes.
46
+ *
47
+ * **Breaking change in v5.0.0**: the default flipped from silent overwrite
48
+ * (legacy behavior) to safe-by-default. If you previously relied on uploads
49
+ * overwriting silently, pass `overwrite: true` explicitly.
50
+ */
51
+ upload(options: UploadOptions): Promise<UploadResult>;
52
+ /**
53
+ * Apply a partial metadata update to an existing object. Returns the
54
+ * post-update {@link KoolbaseObject} with the merged metadata.
55
+ *
56
+ * **Merge semantics** (mirrors the server's JSONB merge):
57
+ *
58
+ * - Keys with a non-null string value are SET — added if missing,
59
+ * replacing any existing value at the key otherwise.
60
+ * - Keys with `null` are DELETED from the stored metadata.
61
+ * - Keys ABSENT from `metadata` are untouched — pre-existing entries
62
+ * for those keys remain unchanged.
63
+ *
64
+ * Validation runs server-side against the same rules as upload-time
65
+ * metadata; violations throw `KoolbaseStorageMetadataInvalidError`,
66
+ * whose `detail` field names the failing key and rule. The check is
67
+ * performed against the projected post-merge state, so adding a key
68
+ * that would push the object past the 50-key or 8KB ceiling is
69
+ * rejected before the row is mutated.
70
+ *
71
+ * @example
72
+ * // Add a tag, update an existing key, and drop another in one call:
73
+ * const updated = await Koolbase.storage.updateMetadata(
74
+ * 'photos',
75
+ * 'sunset.jpg',
76
+ * {
77
+ * category: 'landscape', // SET or UPDATE
78
+ * tag: 'sunset', // SET or UPDATE
79
+ * owner: null, // DELETE
80
+ * }
81
+ * );
82
+ * console.log(updated.metadata);
83
+ * // -> { category: 'landscape', tag: 'sunset' }
84
+ */
85
+ updateMetadata(bucket: string, path: string, metadata: Record<string, string | null>): Promise<KoolbaseObject>;
86
+ /**
87
+ * Get a signed download URL for a file.
88
+ */
89
+ getDownloadUrl(bucket: string, path: string, versionId?: string): Promise<string>;
90
+ /**
91
+ * Build the stable public CDN URL for a file in a public bucket.
92
+ *
93
+ * Returns the URL unconditionally — no check on whether the file
94
+ * exists or whether the bucket is actually public. Use when you
95
+ * know the file is in a public bucket and want the URL without a
96
+ * network round-trip (build-time URL generation, server-side
97
+ * rendering, batch image processing, etc.).
98
+ *
99
+ * For safer construction from an Object you already have, use
100
+ * {@link KoolbaseStorage.publicUrlForObject} — it checks the stored
101
+ * `r2Bucket` value and returns `null` when the object isn't in the
102
+ * public R2 bucket.
103
+ */
104
+ static publicUrl(args: {
105
+ projectId: string;
106
+ bucket: string;
107
+ path: string;
108
+ /**
109
+ * Optional Cloudflare Image Transformations. Adds a `/cdn-cgi/image/`
110
+ * URL prefix; billed against the koolbase.com zone's free monthly
111
+ * allocation (5,000 unique transforms/month). Each unique combination
112
+ * of `path` + options is cached and billed only once per calendar month.
113
+ */
114
+ transform?: KoolbaseImageTransform;
115
+ }): string;
116
+ /**
117
+ * Returns the stable CDN URL for an object when its bytes physically
118
+ * live in the public R2 bucket, `null` otherwise.
119
+ *
120
+ * Returns `null` for:
121
+ * - Files in private buckets (no public URL ever)
122
+ * - Legacy files in public buckets whose bytes still live in the
123
+ * private R2 bucket from before Gap #2 (no permanent URL until
124
+ * they're re-uploaded)
125
+ *
126
+ * The bucket name must be supplied because {@link KoolbaseObject}
127
+ * carries only the bucket ID, not its name. Typically the caller
128
+ * already knows which bucket they queried.
129
+ */
130
+ static publicUrlForObject(obj: KoolbaseObject, bucket: string, options?: {
131
+ transform?: KoolbaseImageTransform;
132
+ }): string | null;
133
+ /**
134
+ * Builds a named-preset CDN URL. The preset is resolved at the Cloudflare
135
+ * edge by the koolbase-cdn-worker, which looks up
136
+ * `preset:{project_id}:{preset_name}` in Workers KV and applies the stored
137
+ * transformation options. Presets are managed in the dashboard under
138
+ * Storage → Presets.
139
+ *
140
+ * Unknown preset names yield a 404 at the edge — the URL itself always
141
+ * constructs successfully without a network round-trip.
142
+ *
143
+ * For safer construction from an Object you already have, use
144
+ * {@link KoolbaseStorage.publicUrlForObjectWithPreset} — it checks the
145
+ * stored `r2Bucket` value and returns `null` when the object isn't in the
146
+ * public R2 bucket.
147
+ */
148
+ static publicUrlWithPreset(args: {
149
+ projectId: string;
150
+ presetName: string;
151
+ bucket: string;
152
+ path: string;
153
+ }): string;
154
+ /**
155
+ * Returns the named-preset CDN URL for the given object, or `null` if the
156
+ * object isn't in the public R2 bucket.
157
+ */
158
+ static publicUrlForObjectWithPreset(obj: KoolbaseObject, bucket: string, presetName: string): string | null;
159
+ /**
160
+ * Delete a file from a bucket.
161
+ */
162
+ delete(bucket: string, path: string, forcePurge?: boolean): Promise<void>;
163
+ /**
164
+ * List all versions of a file path, newest-first. Returns a flat list
165
+ * mixing the current row (with `isCurrent: true`) and all history
166
+ * rows. Delete markers are included so callers can render the full
167
+ * timeline; filter client-side to hide them if the UI only wants
168
+ * restorable versions.
169
+ *
170
+ * Returns an empty array (not an error) when the path has no history
171
+ * and no current row.
172
+ */
173
+ listVersions(bucket: string, path: string): Promise<KoolbaseObjectVersion[]>;
174
+ /**
175
+ * Fetch metadata for a single version by id. Works against both the
176
+ * current row and any history row — the response's `isCurrent` tells
177
+ * you which.
178
+ */
179
+ getVersion(bucket: string, path: string, versionId: string): Promise<KoolbaseObjectVersion>;
180
+ /**
181
+ * Bring a history version back as the current version. The
182
+ * previously-current row (if any) is snapshotted into history first,
183
+ * so this operation is itself a versioned event you can undo. The
184
+ * restored row gets a freshly-minted version_id; the target stays in
185
+ * history at its original version_id.
186
+ *
187
+ * Throws if the bucket has versioning off, if the target is the
188
+ * already-current version, or if the target is a delete marker.
189
+ */
190
+ restoreVersion(bucket: string, path: string, versionId: string): Promise<KoolbaseObject>;
191
+ /**
192
+ * Hard-remove a single history version — both the metadata row and
193
+ * the .versions/ R2 bytes (or just the row, for delete markers).
194
+ * Refuses to operate on the current version; use {@link delete} with
195
+ * `forcePurge: true` to wipe everything for a path.
196
+ */
197
+ purgeVersion(bucket: string, path: string, versionId: string): Promise<void>;
198
+ }
@@ -0,0 +1,447 @@
1
+ import { KoolbaseUnauthenticatedError } from './errors.js';
2
+ import { KoolbaseStorageError, koolbaseStorageErrorFromResponse, } from './storage-errors.js';
3
+ // --- Cloudflare image-transform URL helpers -------------------------------
4
+ // Module-private — callers use KoolbaseStorage.publicUrl / publicUrlForObject.
5
+ function clampInt(v, min, max) {
6
+ return Math.max(min, Math.min(max, Math.floor(v)));
7
+ }
8
+ /**
9
+ * Serializes a transform spec to Cloudflare's comma-separated key=value
10
+ * options segment (e.g. `width=400,format=webp,quality=80`). Returns the
11
+ * empty string when no fields are set — callers can use that to skip the
12
+ * `/cdn-cgi/image/` URL prefix entirely.
13
+ */
14
+ function serializeTransform(t) {
15
+ const parts = [];
16
+ if (t.width != null)
17
+ parts.push(`width=${clampInt(t.width, 1, 2000)}`);
18
+ if (t.height != null)
19
+ parts.push(`height=${clampInt(t.height, 1, 2000)}`);
20
+ if (t.format)
21
+ parts.push(`format=${t.format}`);
22
+ if (t.quality != null)
23
+ parts.push(`quality=${clampInt(t.quality, 1, 100)}`);
24
+ if (t.fit)
25
+ parts.push(`fit=${t.fit}`);
26
+ if (t.dpr != null)
27
+ parts.push(`dpr=${clampInt(t.dpr, 1, 3)}`);
28
+ if (t.gravity)
29
+ parts.push(`gravity=${t.gravity}`);
30
+ return parts.join(',');
31
+ }
32
+ /**
33
+ * Koolbase storage client — uploads, downloads, and deletes via presigned
34
+ * Cloudflare R2 URLs.
35
+ *
36
+ * Uploads are **safe-by-default** (v5+): an upload to a path where an object
37
+ * already exists is rejected with {@link KoolbaseStorageConflictError} unless
38
+ * `overwrite: true` is passed.
39
+ */
40
+ export class KoolbaseStorage {
41
+ constructor(config, getToken, onSessionExpired) {
42
+ this.config = config;
43
+ this.getToken = getToken;
44
+ this.onSessionExpired = onSessionExpired;
45
+ }
46
+ /**
47
+ * Builds the error for a failed response and clears the session when the
48
+ * credentials were refused, before the error reaches the caller.
49
+ */
50
+ async error(res, fallback) {
51
+ const err = await koolbaseStorageErrorFromResponse(res, fallback);
52
+ if (err instanceof KoolbaseUnauthenticatedError) {
53
+ await this.onSessionExpired?.();
54
+ }
55
+ return err;
56
+ }
57
+ async buildHeaders() {
58
+ const token = await this.getToken();
59
+ return {
60
+ 'x-api-key': this.config.publicKey,
61
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
62
+ };
63
+ }
64
+ /**
65
+ * Upload a file to a bucket. Returns the object metadata and a download URL.
66
+ *
67
+ * By default (`overwrite: false`), uploads to a path where an object
68
+ * already exists are **rejected** with a {@link KoolbaseStorageConflictError}.
69
+ * Catch it to prompt the user, then retry with `overwrite: true` to replace
70
+ * the existing object — or with a different `path`.
71
+ *
72
+ * Set `overwrite: true` for true upsert semantics — silently replace any
73
+ * existing object at this path.
74
+ *
75
+ * Pass `options.metadata` to attach arbitrary user-defined key/value pairs
76
+ * to the object at confirm time. Subject to the limits documented on
77
+ * {@link KoolbaseObject.metadata}; violations throw
78
+ * `KoolbaseStorageMetadataInvalidError`. On the `overwrite: true` path the
79
+ * metadata REPLACES any prior metadata at this path (matches GCS semantics).
80
+ * Use {@link updateMetadata} for post-upload merge changes.
81
+ *
82
+ * **Breaking change in v5.0.0**: the default flipped from silent overwrite
83
+ * (legacy behavior) to safe-by-default. If you previously relied on uploads
84
+ * overwriting silently, pass `overwrite: true` explicitly.
85
+ */
86
+ async upload(options) {
87
+ const overwrite = options.overwrite ?? false;
88
+ const contentType = options.file.type;
89
+ // ─── Step 1: Get presigned upload URL ───
90
+ const urlRes = await fetch(`${this.config.baseUrl}/v1/sdk/storage/upload-url`, {
91
+ method: 'POST',
92
+ headers: {
93
+ ...(await this.buildHeaders()),
94
+ 'Content-Type': 'application/json',
95
+ },
96
+ body: JSON.stringify({
97
+ bucket: options.bucket,
98
+ path: options.path,
99
+ content_type: contentType,
100
+ overwrite,
101
+ }),
102
+ });
103
+ if (!urlRes.ok) {
104
+ throw await this.error(urlRes, 'Failed to get upload URL');
105
+ }
106
+ const { upload_url } = (await urlRes.json());
107
+ // ─── Step 2: Upload directly to R2 ───
108
+ // RN's fetch resolves local file URIs and Blob bodies on a raw PUT.
109
+ // R2 presigned URLs expect raw binary, NOT multipart/form-data.
110
+ const fileResp = await fetch(options.file.uri);
111
+ const fileBlob = await fileResp.blob();
112
+ const fileSize = fileBlob.size;
113
+ const uploadRes = await fetch(upload_url, {
114
+ method: 'PUT',
115
+ headers: { 'Content-Type': contentType },
116
+ body: fileBlob,
117
+ });
118
+ if (!uploadRes.ok) {
119
+ // R2 PUT errors don't follow the Koolbase error shape — surface as a
120
+ // generic storage error rather than trying to decode a Koolbase body.
121
+ throw new KoolbaseStorageError(`Upload to storage failed: ${uploadRes.status}`);
122
+ }
123
+ const etag = uploadRes.headers.get('etag') ?? '';
124
+ // ─── Step 3: Confirm upload ───
125
+ // Build the body conditionally so the `metadata` field is only sent
126
+ // when the caller passed it — keeps the wire shape clean for callers
127
+ // that don't care, and lets the server's omitempty path treat absent
128
+ // as "no metadata."
129
+ const confirmBody = {
130
+ bucket: options.bucket,
131
+ path: options.path,
132
+ size: fileSize,
133
+ content_type: contentType,
134
+ etag,
135
+ overwrite,
136
+ };
137
+ if (options.metadata !== undefined) {
138
+ confirmBody.metadata = options.metadata;
139
+ }
140
+ const confirmRes = await fetch(`${this.config.baseUrl}/v1/sdk/storage/confirm`, {
141
+ method: 'POST',
142
+ headers: {
143
+ ...(await this.buildHeaders()),
144
+ 'Content-Type': 'application/json',
145
+ },
146
+ body: JSON.stringify(confirmBody),
147
+ });
148
+ if (!confirmRes.ok) {
149
+ throw await this.error(confirmRes, 'Failed to confirm upload');
150
+ }
151
+ const raw = await confirmRes.json();
152
+ const object = mapObjectFromServer(raw);
153
+ // ─── Step 4: Get download URL ───
154
+ const downloadUrl = await this.getDownloadUrl(options.bucket, options.path);
155
+ return { object, downloadUrl };
156
+ }
157
+ /**
158
+ * Apply a partial metadata update to an existing object. Returns the
159
+ * post-update {@link KoolbaseObject} with the merged metadata.
160
+ *
161
+ * **Merge semantics** (mirrors the server's JSONB merge):
162
+ *
163
+ * - Keys with a non-null string value are SET — added if missing,
164
+ * replacing any existing value at the key otherwise.
165
+ * - Keys with `null` are DELETED from the stored metadata.
166
+ * - Keys ABSENT from `metadata` are untouched — pre-existing entries
167
+ * for those keys remain unchanged.
168
+ *
169
+ * Validation runs server-side against the same rules as upload-time
170
+ * metadata; violations throw `KoolbaseStorageMetadataInvalidError`,
171
+ * whose `detail` field names the failing key and rule. The check is
172
+ * performed against the projected post-merge state, so adding a key
173
+ * that would push the object past the 50-key or 8KB ceiling is
174
+ * rejected before the row is mutated.
175
+ *
176
+ * @example
177
+ * // Add a tag, update an existing key, and drop another in one call:
178
+ * const updated = await Koolbase.storage.updateMetadata(
179
+ * 'photos',
180
+ * 'sunset.jpg',
181
+ * {
182
+ * category: 'landscape', // SET or UPDATE
183
+ * tag: 'sunset', // SET or UPDATE
184
+ * owner: null, // DELETE
185
+ * }
186
+ * );
187
+ * console.log(updated.metadata);
188
+ * // -> { category: 'landscape', tag: 'sunset' }
189
+ */
190
+ async updateMetadata(bucket, path, metadata) {
191
+ const res = await fetch(`${this.config.baseUrl}/v1/sdk/storage/objects/metadata`, {
192
+ method: 'PATCH',
193
+ headers: {
194
+ ...(await this.buildHeaders()),
195
+ 'Content-Type': 'application/json',
196
+ },
197
+ body: JSON.stringify({ bucket, path, metadata }),
198
+ });
199
+ if (!res.ok) {
200
+ throw await this.error(res, 'Failed to update metadata');
201
+ }
202
+ const raw = await res.json();
203
+ return mapObjectFromServer(raw);
204
+ }
205
+ /**
206
+ * Get a signed download URL for a file.
207
+ */
208
+ async getDownloadUrl(bucket, path, versionId) {
209
+ let url = `${this.config.baseUrl}/v1/sdk/storage/download-url` +
210
+ `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
211
+ if (versionId) {
212
+ url += `&version_id=${encodeURIComponent(versionId)}`;
213
+ }
214
+ const res = await fetch(url, { headers: await this.buildHeaders() });
215
+ if (!res.ok) {
216
+ throw await this.error(res, 'Failed to get download URL');
217
+ }
218
+ const data = (await res.json());
219
+ return data.url;
220
+ }
221
+ /**
222
+ * Build the stable public CDN URL for a file in a public bucket.
223
+ *
224
+ * Returns the URL unconditionally — no check on whether the file
225
+ * exists or whether the bucket is actually public. Use when you
226
+ * know the file is in a public bucket and want the URL without a
227
+ * network round-trip (build-time URL generation, server-side
228
+ * rendering, batch image processing, etc.).
229
+ *
230
+ * For safer construction from an Object you already have, use
231
+ * {@link KoolbaseStorage.publicUrlForObject} — it checks the stored
232
+ * `r2Bucket` value and returns `null` when the object isn't in the
233
+ * public R2 bucket.
234
+ */
235
+ static publicUrl(args) {
236
+ // Encode each path segment individually so slashes are preserved
237
+ // while spaces, parens, hashes, and query characters are escaped.
238
+ const encoded = args.path.split('/').map(encodeURIComponent).join('/');
239
+ const opts = args.transform ? serializeTransform(args.transform) : '';
240
+ if (!opts) {
241
+ return `https://cdn.koolbase.com/${args.projectId}/${args.bucket}/${encoded}`;
242
+ }
243
+ return `https://cdn.koolbase.com/cdn-cgi/image/${opts}/${args.projectId}/${args.bucket}/${encoded}`;
244
+ }
245
+ /**
246
+ * Returns the stable CDN URL for an object when its bytes physically
247
+ * live in the public R2 bucket, `null` otherwise.
248
+ *
249
+ * Returns `null` for:
250
+ * - Files in private buckets (no public URL ever)
251
+ * - Legacy files in public buckets whose bytes still live in the
252
+ * private R2 bucket from before Gap #2 (no permanent URL until
253
+ * they're re-uploaded)
254
+ *
255
+ * The bucket name must be supplied because {@link KoolbaseObject}
256
+ * carries only the bucket ID, not its name. Typically the caller
257
+ * already knows which bucket they queried.
258
+ */
259
+ static publicUrlForObject(obj, bucket, options) {
260
+ if (obj.r2Bucket !== 'koolbase-storage-public')
261
+ return null;
262
+ return KoolbaseStorage.publicUrl({
263
+ projectId: obj.projectId,
264
+ bucket,
265
+ path: obj.path,
266
+ transform: options?.transform,
267
+ });
268
+ }
269
+ /**
270
+ * Builds a named-preset CDN URL. The preset is resolved at the Cloudflare
271
+ * edge by the koolbase-cdn-worker, which looks up
272
+ * `preset:{project_id}:{preset_name}` in Workers KV and applies the stored
273
+ * transformation options. Presets are managed in the dashboard under
274
+ * Storage → Presets.
275
+ *
276
+ * Unknown preset names yield a 404 at the edge — the URL itself always
277
+ * constructs successfully without a network round-trip.
278
+ *
279
+ * For safer construction from an Object you already have, use
280
+ * {@link KoolbaseStorage.publicUrlForObjectWithPreset} — it checks the
281
+ * stored `r2Bucket` value and returns `null` when the object isn't in the
282
+ * public R2 bucket.
283
+ */
284
+ static publicUrlWithPreset(args) {
285
+ const encoded = args.path.split('/').map(encodeURIComponent).join('/');
286
+ return `https://cdn.koolbase.com/p/${args.projectId}/${args.presetName}/${args.bucket}/${encoded}`;
287
+ }
288
+ /**
289
+ * Returns the named-preset CDN URL for the given object, or `null` if the
290
+ * object isn't in the public R2 bucket.
291
+ */
292
+ static publicUrlForObjectWithPreset(obj, bucket, presetName) {
293
+ if (obj.r2Bucket !== 'koolbase-storage-public')
294
+ return null;
295
+ return KoolbaseStorage.publicUrlWithPreset({
296
+ projectId: obj.projectId,
297
+ presetName,
298
+ bucket,
299
+ path: obj.path,
300
+ });
301
+ }
302
+ /**
303
+ * Delete a file from a bucket.
304
+ */
305
+ async delete(bucket, path, forcePurge) {
306
+ const url = forcePurge
307
+ ? `${this.config.baseUrl}/v1/sdk/storage/object?force_purge=true`
308
+ : `${this.config.baseUrl}/v1/sdk/storage/object`;
309
+ const res = await fetch(url, {
310
+ method: 'DELETE',
311
+ headers: {
312
+ ...(await this.buildHeaders()),
313
+ 'Content-Type': 'application/json',
314
+ },
315
+ body: JSON.stringify({ bucket, path }),
316
+ });
317
+ if (res.status === 204)
318
+ return;
319
+ if (!res.ok) {
320
+ throw await this.error(res, 'Failed to delete file');
321
+ }
322
+ }
323
+ /**
324
+ * List all versions of a file path, newest-first. Returns a flat list
325
+ * mixing the current row (with `isCurrent: true`) and all history
326
+ * rows. Delete markers are included so callers can render the full
327
+ * timeline; filter client-side to hide them if the UI only wants
328
+ * restorable versions.
329
+ *
330
+ * Returns an empty array (not an error) when the path has no history
331
+ * and no current row.
332
+ */
333
+ async listVersions(bucket, path) {
334
+ const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions` +
335
+ `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
336
+ const res = await fetch(url, { headers: await this.buildHeaders() });
337
+ if (!res.ok) {
338
+ throw await this.error(res, 'Failed to list versions');
339
+ }
340
+ const data = (await res.json());
341
+ const list = Array.isArray(data.versions) ? data.versions : [];
342
+ return list.map((v) => fromVersionJson(v));
343
+ }
344
+ /**
345
+ * Fetch metadata for a single version by id. Works against both the
346
+ * current row and any history row — the response's `isCurrent` tells
347
+ * you which.
348
+ */
349
+ async getVersion(bucket, path, versionId) {
350
+ const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}` +
351
+ `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
352
+ const res = await fetch(url, { headers: await this.buildHeaders() });
353
+ if (!res.ok) {
354
+ throw await this.error(res, 'Failed to fetch version');
355
+ }
356
+ return fromVersionJson((await res.json()));
357
+ }
358
+ /**
359
+ * Bring a history version back as the current version. The
360
+ * previously-current row (if any) is snapshotted into history first,
361
+ * so this operation is itself a versioned event you can undo. The
362
+ * restored row gets a freshly-minted version_id; the target stays in
363
+ * history at its original version_id.
364
+ *
365
+ * Throws if the bucket has versioning off, if the target is the
366
+ * already-current version, or if the target is a delete marker.
367
+ */
368
+ async restoreVersion(bucket, path, versionId) {
369
+ const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}/restore` +
370
+ `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
371
+ const res = await fetch(url, {
372
+ method: 'POST',
373
+ headers: await this.buildHeaders(),
374
+ });
375
+ if (!res.ok) {
376
+ throw await this.error(res, 'Failed to restore version');
377
+ }
378
+ return mapObjectFromServer(await res.json());
379
+ }
380
+ /**
381
+ * Hard-remove a single history version — both the metadata row and
382
+ * the .versions/ R2 bytes (or just the row, for delete markers).
383
+ * Refuses to operate on the current version; use {@link delete} with
384
+ * `forcePurge: true` to wipe everything for a path.
385
+ */
386
+ async purgeVersion(bucket, path, versionId) {
387
+ const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}` +
388
+ `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
389
+ const res = await fetch(url, {
390
+ method: 'DELETE',
391
+ headers: await this.buildHeaders(),
392
+ });
393
+ if (res.status === 204)
394
+ return;
395
+ if (!res.ok) {
396
+ throw await this.error(res, 'Failed to purge version');
397
+ }
398
+ }
399
+ }
400
+ /**
401
+ * Maps the snake_case server JSON to the camelCase {@link KoolbaseObject}.
402
+ * Defensive: missing or null `metadata` (older / non-Koolbase responses)
403
+ * is coerced to an empty object so callers always see a typed
404
+ * `Record<string, string>` rather than null.
405
+ */
406
+ function mapObjectFromServer(raw) {
407
+ return {
408
+ id: raw.id,
409
+ projectId: raw.project_id,
410
+ bucketId: raw.bucket_id,
411
+ userId: raw.user_id ?? null,
412
+ path: raw.path,
413
+ size: raw.size ?? 0,
414
+ contentType: raw.content_type ?? null,
415
+ metadata: raw.metadata ?? {},
416
+ r2Bucket: raw.r2_bucket ?? 'koolbase-storage',
417
+ createdAt: raw.created_at,
418
+ updatedAt: raw.updated_at,
419
+ };
420
+ }
421
+ /**
422
+ * Maps the snake_case server JSON of a version row to the camelCase
423
+ * {@link KoolbaseObjectVersion}. Mirrors `fromObjectJson` shape.
424
+ */
425
+ function fromVersionJson(j) {
426
+ const rawMeta = j.metadata;
427
+ const metadata = {};
428
+ if (rawMeta && typeof rawMeta === 'object') {
429
+ for (const [k, v] of Object.entries(rawMeta)) {
430
+ if (typeof v === 'string')
431
+ metadata[k] = v;
432
+ }
433
+ }
434
+ return {
435
+ versionId: j.version_id ?? null,
436
+ path: j.path,
437
+ size: Number(j.size ?? 0),
438
+ contentType: j.content_type ?? null,
439
+ etag: j.etag ?? null,
440
+ metadata,
441
+ r2Bucket: j.r2_bucket ?? '',
442
+ userId: j.user_id ?? null,
443
+ isDeleteMarker: Boolean(j.is_delete_marker),
444
+ isCurrent: Boolean(j.is_current),
445
+ createdAt: j.created_at,
446
+ };
447
+ }
@@ -0,0 +1,30 @@
1
+ import { KoolbaseConfig } from './types.js';
2
+ type SyncCallback = () => void;
3
+ export declare class SyncEngine {
4
+ private config;
5
+ private getUserId;
6
+ private getToken;
7
+ private onSyncComplete?;
8
+ /**
9
+ * Called when the server rejects the caller's credentials during replay.
10
+ *
11
+ * Background sync is the likeliest place to meet a dead session: the queue
12
+ * replays writes made long before the session stopped being honoured.
13
+ */
14
+ private onSessionExpired?;
15
+ private unsubscribe?;
16
+ private isSyncing;
17
+ constructor(config: KoolbaseConfig, getUserId: () => string | null, getToken: () => Promise<string | null>, onSyncComplete?: SyncCallback, onSessionExpired?: () => Promise<void>);
18
+ start(): void;
19
+ stop(): void;
20
+ flush(): Promise<void>;
21
+ /**
22
+ * Sends one queued write, returning the revision the record now carries.
23
+ *
24
+ * The revision matters to whatever is queued behind this for the same record:
25
+ * those were composed against this one's result and cannot know its revision
26
+ * until the server assigns it.
27
+ */
28
+ private executeWrite;
29
+ }
30
+ export {};