@howells/stow-server 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ __export(index_exports, {
24
24
  StowServer: () => StowServer
25
25
  });
26
26
  module.exports = __toCommonJS(index_exports);
27
+ var import_node_crypto = require("crypto");
27
28
  var import_zod = require("zod");
28
29
  var StowError = class extends Error {
29
30
  status;
@@ -35,12 +36,74 @@ var StowError = class extends Error {
35
36
  this.code = code;
36
37
  }
37
38
  };
39
+ var fileColorSchema = import_zod.z.object({
40
+ position: import_zod.z.number().int(),
41
+ proportion: import_zod.z.number(),
42
+ hex: import_zod.z.string(),
43
+ name: import_zod.z.string().nullable(),
44
+ hsl: import_zod.z.object({ h: import_zod.z.number(), s: import_zod.z.number(), l: import_zod.z.number() }),
45
+ oklab: import_zod.z.object({ L: import_zod.z.number(), a: import_zod.z.number(), b: import_zod.z.number() }).nullable(),
46
+ oklch: import_zod.z.object({ l: import_zod.z.number(), c: import_zod.z.number(), h: import_zod.z.number() }).nullable()
47
+ });
48
+ var fileColorProfileSchema = import_zod.z.object({
49
+ palette: import_zod.z.object({
50
+ mood: import_zod.z.string(),
51
+ brightness: import_zod.z.number(),
52
+ temperature: import_zod.z.number(),
53
+ vibrancy: import_zod.z.number(),
54
+ complexity: import_zod.z.number(),
55
+ dominantFamily: import_zod.z.string().nullable()
56
+ }),
57
+ backgroundHex: import_zod.z.string().nullable(),
58
+ accent: import_zod.z.object({
59
+ hex: import_zod.z.string(),
60
+ name: import_zod.z.string().nullable(),
61
+ oklab: import_zod.z.object({ L: import_zod.z.number(), a: import_zod.z.number(), b: import_zod.z.number() }).nullable(),
62
+ oklch: import_zod.z.object({ l: import_zod.z.number(), c: import_zod.z.number(), h: import_zod.z.number() }).nullable()
63
+ }).nullable(),
64
+ extractedAt: import_zod.z.string(),
65
+ colorCount: import_zod.z.number().int()
66
+ });
38
67
  var uploadResultSchema = import_zod.z.object({
39
68
  key: import_zod.z.string(),
40
69
  url: import_zod.z.string().nullable(),
41
70
  size: import_zod.z.number(),
42
71
  contentType: import_zod.z.string().optional(),
43
- metadata: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional()
72
+ metadata: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional(),
73
+ deduped: import_zod.z.boolean().optional()
74
+ });
75
+ var bucketSchema = import_zod.z.object({
76
+ id: import_zod.z.string(),
77
+ name: import_zod.z.string(),
78
+ description: import_zod.z.string().nullable().optional(),
79
+ isPublic: import_zod.z.boolean().optional(),
80
+ searchable: import_zod.z.boolean().optional(),
81
+ allowedTypes: import_zod.z.array(import_zod.z.string()).nullable().optional(),
82
+ maxFileSize: import_zod.z.coerce.number().nullable().optional(),
83
+ storageQuota: import_zod.z.coerce.number().nullable().optional(),
84
+ fileCountLimit: import_zod.z.coerce.number().nullable().optional(),
85
+ fileCount: import_zod.z.coerce.number().optional(),
86
+ usageBytes: import_zod.z.coerce.number().optional(),
87
+ createdAt: import_zod.z.string().optional()
88
+ });
89
+ var listBucketsSchema = import_zod.z.object({
90
+ buckets: import_zod.z.array(bucketSchema)
91
+ });
92
+ var bucketResponseSchema = import_zod.z.object({
93
+ bucket: bucketSchema
94
+ });
95
+ var whoamiSchema = import_zod.z.object({
96
+ user: import_zod.z.object({ email: import_zod.z.string() }),
97
+ stats: import_zod.z.object({
98
+ totalBytes: import_zod.z.coerce.number(),
99
+ totalFiles: import_zod.z.coerce.number(),
100
+ bucketCount: import_zod.z.coerce.number()
101
+ }),
102
+ key: import_zod.z.object({
103
+ name: import_zod.z.string(),
104
+ scope: import_zod.z.string(),
105
+ permissions: import_zod.z.record(import_zod.z.string(), import_zod.z.boolean())
106
+ }).optional()
44
107
  });
45
108
  var listFilesSchema = import_zod.z.object({
46
109
  files: import_zod.z.array(
@@ -49,11 +112,26 @@ var listFilesSchema = import_zod.z.object({
49
112
  size: import_zod.z.number(),
50
113
  lastModified: import_zod.z.string(),
51
114
  url: import_zod.z.string().nullable(),
52
- metadata: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional()
115
+ width: import_zod.z.number().nullable().optional(),
116
+ height: import_zod.z.number().nullable().optional(),
117
+ duration: import_zod.z.number().nullable().optional(),
118
+ metadata: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional(),
119
+ colorProfile: fileColorProfileSchema.nullable().optional(),
120
+ colors: import_zod.z.array(fileColorSchema).optional()
53
121
  })
54
122
  ),
55
123
  nextCursor: import_zod.z.string().nullable()
56
124
  });
125
+ var reprocessResultSchema = import_zod.z.object({
126
+ key: import_zod.z.string(),
127
+ triggered: import_zod.z.array(import_zod.z.string())
128
+ });
129
+ var replaceResultSchema = import_zod.z.object({
130
+ key: import_zod.z.string(),
131
+ size: import_zod.z.number(),
132
+ contentType: import_zod.z.string(),
133
+ triggered: import_zod.z.array(import_zod.z.string())
134
+ });
57
135
  var errorSchema = import_zod.z.object({
58
136
  error: import_zod.z.string(),
59
137
  code: import_zod.z.string().optional()
@@ -78,12 +156,23 @@ var listDropsSchema = import_zod.z.object({
78
156
  drops: import_zod.z.array(dropSchema),
79
157
  usage: import_zod.z.object({ bytes: import_zod.z.number(), limit: import_zod.z.number() })
80
158
  });
81
- var presignResultSchema = import_zod.z.object({
159
+ var presignNewResultSchema = import_zod.z.object({
82
160
  fileKey: import_zod.z.string(),
83
161
  uploadUrl: import_zod.z.string(),
84
- r2Key: import_zod.z.string(),
85
- confirmUrl: import_zod.z.string()
162
+ confirmUrl: import_zod.z.string(),
163
+ dedupe: import_zod.z.literal(false).optional()
86
164
  });
165
+ var presignDedupeResultSchema = import_zod.z.object({
166
+ dedupe: import_zod.z.literal(true),
167
+ key: import_zod.z.string(),
168
+ url: import_zod.z.string().nullable(),
169
+ size: import_zod.z.number(),
170
+ contentType: import_zod.z.string()
171
+ });
172
+ var presignResultSchema = import_zod.z.union([
173
+ presignDedupeResultSchema,
174
+ presignNewResultSchema
175
+ ]);
87
176
  var confirmResultSchema = import_zod.z.object({
88
177
  key: import_zod.z.string(),
89
178
  url: import_zod.z.string().nullable(),
@@ -91,18 +180,93 @@ var confirmResultSchema = import_zod.z.object({
91
180
  contentType: import_zod.z.string(),
92
181
  metadata: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional()
93
182
  });
183
+ var fileResultSchema = import_zod.z.object({
184
+ key: import_zod.z.string(),
185
+ size: import_zod.z.number(),
186
+ contentType: import_zod.z.string(),
187
+ url: import_zod.z.string().nullable(),
188
+ width: import_zod.z.number().nullable(),
189
+ height: import_zod.z.number().nullable(),
190
+ duration: import_zod.z.number().nullable(),
191
+ metadata: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).nullable(),
192
+ colorProfile: fileColorProfileSchema.nullable(),
193
+ colors: import_zod.z.array(fileColorSchema),
194
+ embeddingStatus: import_zod.z.string().nullable(),
195
+ createdAt: import_zod.z.string()
196
+ });
197
+ var profileClusterResultSchema = import_zod.z.object({
198
+ id: import_zod.z.string(),
199
+ index: import_zod.z.number().int(),
200
+ name: import_zod.z.string().nullable(),
201
+ description: import_zod.z.string().nullable(),
202
+ signalCount: import_zod.z.number().int(),
203
+ totalWeight: import_zod.z.number(),
204
+ nameGeneratedAt: import_zod.z.string().nullable()
205
+ });
206
+ var profileResultSchema = import_zod.z.object({
207
+ id: import_zod.z.string(),
208
+ name: import_zod.z.string().nullable(),
209
+ fileCount: import_zod.z.number(),
210
+ signalCount: import_zod.z.number(),
211
+ vector: import_zod.z.array(import_zod.z.number()).nullable(),
212
+ clusters: import_zod.z.array(profileClusterResultSchema).optional(),
213
+ createdAt: import_zod.z.string(),
214
+ updatedAt: import_zod.z.string()
215
+ });
216
+ var profileFilesResultSchema = import_zod.z.object({
217
+ id: import_zod.z.string(),
218
+ fileCount: import_zod.z.number()
219
+ });
220
+ var profileSignalResultSchema = import_zod.z.object({
221
+ id: import_zod.z.string(),
222
+ fileKey: import_zod.z.string(),
223
+ type: import_zod.z.enum([
224
+ "view",
225
+ "view_long",
226
+ "click",
227
+ "like",
228
+ "save",
229
+ "choose",
230
+ "purchase",
231
+ "share",
232
+ "dismiss",
233
+ "skip",
234
+ "reject",
235
+ "report",
236
+ "custom"
237
+ ]),
238
+ weight: import_zod.z.number()
239
+ });
240
+ var profileSignalsResponseSchema = import_zod.z.object({
241
+ profileId: import_zod.z.string(),
242
+ signals: import_zod.z.array(profileSignalResultSchema),
243
+ totalSignals: import_zod.z.number(),
244
+ vectorUpdated: import_zod.z.boolean()
245
+ });
246
+ var deleteProfileSignalsResponseSchema = import_zod.z.object({
247
+ profileId: import_zod.z.string(),
248
+ removed: import_zod.z.number(),
249
+ totalSignals: import_zod.z.number(),
250
+ vectorUpdated: import_zod.z.boolean()
251
+ });
94
252
  var StowServer = class {
95
253
  apiKey;
96
254
  baseUrl;
97
255
  bucket;
256
+ timeout;
257
+ retries;
98
258
  constructor(config) {
99
259
  if (typeof config === "string") {
100
260
  this.apiKey = config;
101
261
  this.baseUrl = "https://app.stow.sh";
262
+ this.timeout = 3e4;
263
+ this.retries = 3;
102
264
  } else {
103
265
  this.apiKey = config.apiKey;
104
266
  this.baseUrl = config.baseUrl || "https://app.stow.sh";
105
267
  this.bucket = config.bucket;
268
+ this.timeout = config.timeout ?? 3e4;
269
+ this.retries = config.retries ?? 3;
106
270
  }
107
271
  }
108
272
  /**
@@ -111,6 +275,88 @@ var StowServer = class {
111
275
  getBaseUrl() {
112
276
  return this.baseUrl;
113
277
  }
278
+ /**
279
+ * Return account usage and API key info for the current credential.
280
+ */
281
+ whoami() {
282
+ return this.request("/api/whoami", { method: "GET" }, whoamiSchema);
283
+ }
284
+ /**
285
+ * List all buckets available to the current organization.
286
+ */
287
+ listBuckets() {
288
+ return this.request("/api/buckets", { method: "GET" }, listBucketsSchema);
289
+ }
290
+ /**
291
+ * Create a new bucket.
292
+ */
293
+ async createBucket(request) {
294
+ const result = await this.request(
295
+ "/api/buckets",
296
+ {
297
+ method: "POST",
298
+ headers: { "Content-Type": "application/json" },
299
+ body: JSON.stringify(request)
300
+ },
301
+ bucketResponseSchema
302
+ );
303
+ return result.bucket;
304
+ }
305
+ /**
306
+ * Get bucket details by id.
307
+ */
308
+ async getBucket(id) {
309
+ const result = await this.request(
310
+ `/api/buckets/${encodeURIComponent(id)}`,
311
+ { method: "GET" },
312
+ bucketResponseSchema
313
+ );
314
+ return result.bucket;
315
+ }
316
+ /**
317
+ * Update bucket settings by id.
318
+ */
319
+ async updateBucket(id, updates) {
320
+ const result = await this.request(
321
+ `/api/buckets/${encodeURIComponent(id)}`,
322
+ {
323
+ method: "PATCH",
324
+ headers: { "Content-Type": "application/json" },
325
+ body: JSON.stringify(updates)
326
+ },
327
+ bucketResponseSchema
328
+ );
329
+ return result.bucket;
330
+ }
331
+ /**
332
+ * Rename/update a bucket by current bucket name.
333
+ */
334
+ async updateBucketByName(name, updates) {
335
+ const result = await this.request(
336
+ `/api/buckets/_?bucket=${encodeURIComponent(name)}`,
337
+ {
338
+ method: "PATCH",
339
+ headers: { "Content-Type": "application/json" },
340
+ body: JSON.stringify(updates)
341
+ },
342
+ bucketResponseSchema
343
+ );
344
+ return result.bucket;
345
+ }
346
+ /**
347
+ * Rename a bucket by current bucket name.
348
+ */
349
+ renameBucket(name, newName) {
350
+ return this.updateBucketByName(name, { name: newName });
351
+ }
352
+ /**
353
+ * Delete a bucket by id.
354
+ */
355
+ async deleteBucket(id) {
356
+ await this.request(`/api/buckets/${encodeURIComponent(id)}`, {
357
+ method: "DELETE"
358
+ });
359
+ }
114
360
  /**
115
361
  * Resolve the effective bucket for this request.
116
362
  * Per-call override > constructor default.
@@ -130,55 +376,131 @@ var StowServer = class {
130
376
  return `${path}${sep}bucket=${encodeURIComponent(b)}`;
131
377
  }
132
378
  /**
133
- * Make an API request with proper error handling
379
+ * Make an API request with retry, timeout, and error handling.
380
+ *
381
+ * - Retries on 429 (rate limit) and 5xx with exponential backoff (1s, 2s, 4s).
382
+ * - AbortController timeout (default 30s).
383
+ * - Consumer can pass `signal` in options to cancel.
134
384
  */
385
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: retry + timeout + error normalization intentionally handled in one request pipeline
135
386
  async request(path, options, schema) {
136
- const response = await fetch(`${this.baseUrl}${path}`, {
137
- ...options,
138
- headers: {
139
- ...options.headers,
140
- "x-api-key": this.apiKey
387
+ const maxAttempts = this.retries + 1;
388
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
389
+ const controller = new AbortController();
390
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
391
+ if (options.signal) {
392
+ options.signal.addEventListener("abort", () => controller.abort());
393
+ }
394
+ try {
395
+ const response = await fetch(`${this.baseUrl}${path}`, {
396
+ ...options,
397
+ signal: controller.signal,
398
+ headers: {
399
+ ...options.headers,
400
+ "x-api-key": this.apiKey
401
+ }
402
+ });
403
+ const data = await response.json();
404
+ if (!response.ok) {
405
+ const error = errorSchema.safeParse(data);
406
+ const message = error.success ? error.data.error : "Request failed";
407
+ const code = error.success ? error.data.code : void 0;
408
+ const isRetryable = response.status === 429 || response.status >= 500;
409
+ if (isRetryable && attempt < maxAttempts - 1) {
410
+ await this.sleep(1e3 * 2 ** attempt);
411
+ continue;
412
+ }
413
+ throw new StowError(message, response.status, code);
414
+ }
415
+ return schema ? schema.parse(data) : data;
416
+ } catch (err) {
417
+ if (err instanceof StowError) {
418
+ throw err;
419
+ }
420
+ if (err instanceof import_zod.z.ZodError) {
421
+ throw new StowError(
422
+ "Invalid response format",
423
+ 500,
424
+ "INVALID_RESPONSE"
425
+ );
426
+ }
427
+ if (err instanceof DOMException || err instanceof Error && err.name === "AbortError") {
428
+ throw new StowError("Request timed out", 408, "TIMEOUT");
429
+ }
430
+ if (attempt < maxAttempts - 1) {
431
+ await this.sleep(1e3 * 2 ** attempt);
432
+ continue;
433
+ }
434
+ throw new StowError(
435
+ err instanceof Error ? err.message : "Network error",
436
+ 0,
437
+ "NETWORK_ERROR"
438
+ );
439
+ } finally {
440
+ clearTimeout(timeoutId);
141
441
  }
142
- });
143
- const data = await response.json();
144
- if (!response.ok) {
145
- const error = errorSchema.safeParse(data);
146
- const message = error.success ? error.data.error : "Request failed";
147
- const code = error.success ? error.data.code : void 0;
148
- throw new StowError(message, response.status, code);
149
442
  }
150
- return schema ? schema.parse(data) : data;
443
+ throw new StowError("Max retries exceeded", 0, "MAX_RETRIES");
444
+ }
445
+ sleep(ms) {
446
+ return new Promise((resolve) => setTimeout(resolve, ms));
151
447
  }
152
448
  /**
153
449
  * Upload a file directly from the server
154
450
  */
155
451
  async uploadFile(file, options) {
156
- const formData = new FormData();
157
- const blob = Buffer.isBuffer(file) ? new Blob([new Uint8Array(file)], {
158
- type: options?.contentType || "application/octet-stream"
159
- }) : file;
160
- formData.append("file", blob, options?.filename || "file");
161
- if (options?.route) {
162
- formData.append("route", options.route);
452
+ const filename = options?.filename || "file";
453
+ const buffer = Buffer.isBuffer(file) ? file : Buffer.from(await file.arrayBuffer());
454
+ const contentType = options?.contentType || (!Buffer.isBuffer(file) && file.type ? file.type : "application/octet-stream");
455
+ const contentHash = (0, import_node_crypto.createHash)("sha256").update(buffer).digest("hex");
456
+ const presign = await this.getPresignedUrl({
457
+ filename,
458
+ contentType,
459
+ size: buffer.length,
460
+ ...options?.route ? { route: options.route } : {},
461
+ ...options?.bucket ? { bucket: options.bucket } : {},
462
+ ...options?.metadata ? { metadata: options.metadata } : {},
463
+ contentHash
464
+ });
465
+ if (presign.dedupe) {
466
+ return {
467
+ key: presign.key,
468
+ url: presign.url,
469
+ size: presign.size,
470
+ contentType: presign.contentType,
471
+ deduped: true
472
+ };
163
473
  }
164
- if (options?.metadata) {
165
- formData.append("metadata", JSON.stringify(options.metadata));
474
+ const uploadRes = await fetch(presign.uploadUrl, {
475
+ method: "PUT",
476
+ headers: { "Content-Type": contentType },
477
+ body: new Uint8Array(buffer)
478
+ });
479
+ if (!uploadRes.ok) {
480
+ throw new StowError("Failed to upload to storage", uploadRes.status);
166
481
  }
167
- const result = await this.request(
168
- this.withBucket("/api/upload", options?.bucket),
482
+ return this.request(
483
+ this.withBucket(
484
+ presign.confirmUrl || "/api/presign/confirm",
485
+ options?.bucket
486
+ ),
169
487
  {
170
488
  method: "POST",
171
- body: formData
489
+ headers: { "Content-Type": "application/json" },
490
+ body: JSON.stringify({
491
+ fileKey: presign.fileKey,
492
+ size: buffer.length,
493
+ contentType,
494
+ ...options?.metadata ? { metadata: options.metadata } : {},
495
+ contentHash,
496
+ skipVerify: true,
497
+ ...options?.title ? { title: true } : {},
498
+ ...options?.describe ? { describe: true } : {},
499
+ ...options?.altText ? { altText: true } : {}
500
+ })
172
501
  },
173
- uploadResultSchema
502
+ confirmResultSchema
174
503
  );
175
- return {
176
- key: result.key,
177
- url: result.url,
178
- size: result.size,
179
- contentType: result.contentType || options?.contentType || "application/octet-stream",
180
- ...result.metadata ? { metadata: result.metadata } : {}
181
- };
182
504
  }
183
505
  /**
184
506
  * Upload a file from a URL (server-side fetch + upload)
@@ -192,7 +514,11 @@ var StowServer = class {
192
514
  body: JSON.stringify({
193
515
  url,
194
516
  filename,
195
- ...options?.metadata ? { metadata: options.metadata } : {}
517
+ ...options?.metadata ? { metadata: options.metadata } : {},
518
+ ...options?.headers ? { headers: options.headers } : {},
519
+ ...options?.title ? { title: true } : {},
520
+ ...options?.describe ? { describe: true } : {},
521
+ ...options?.altText ? { altText: true } : {}
196
522
  })
197
523
  },
198
524
  uploadResultSchema
@@ -215,7 +541,15 @@ var StowServer = class {
215
541
  * 4. Client calls confirmUpload to finalize
216
542
  */
217
543
  getPresignedUrl(request) {
218
- const { filename, contentType, size, route, bucket, metadata } = request;
544
+ const {
545
+ filename,
546
+ contentType,
547
+ size,
548
+ route,
549
+ bucket,
550
+ metadata,
551
+ contentHash
552
+ } = request;
219
553
  return this.request(
220
554
  this.withBucket("/api/presign", bucket),
221
555
  {
@@ -226,7 +560,8 @@ var StowServer = class {
226
560
  contentType,
227
561
  size,
228
562
  route,
229
- ...metadata ? { metadata } : {}
563
+ ...metadata ? { metadata } : {},
564
+ ...contentHash ? { contentHash } : {}
230
565
  })
231
566
  },
232
567
  presignResultSchema
@@ -237,7 +572,19 @@ var StowServer = class {
237
572
  * This creates the file record in the database.
238
573
  */
239
574
  confirmUpload(request) {
240
- const { fileKey, size, contentType, bucket, metadata } = request;
575
+ const {
576
+ fileKey,
577
+ size,
578
+ contentType,
579
+ bucket,
580
+ metadata,
581
+ skipVerify,
582
+ deferKvSync,
583
+ contentHash,
584
+ title,
585
+ describe,
586
+ altText
587
+ } = request;
241
588
  return this.request(
242
589
  this.withBucket("/api/presign/confirm", bucket),
243
590
  {
@@ -247,7 +594,13 @@ var StowServer = class {
247
594
  fileKey,
248
595
  size,
249
596
  contentType,
250
- ...metadata ? { metadata } : {}
597
+ ...metadata ? { metadata } : {},
598
+ ...skipVerify ? { skipVerify } : {},
599
+ ...deferKvSync ? { deferKvSync } : {},
600
+ ...contentHash ? { contentHash } : {},
601
+ ...title ? { title } : {},
602
+ ...describe ? { describe } : {},
603
+ ...altText ? { altText } : {}
251
604
  })
252
605
  },
253
606
  confirmResultSchema
@@ -289,7 +642,7 @@ var StowServer = class {
289
642
  /**
290
643
  * Update metadata on an existing file
291
644
  */
292
- async updateFileMetadata(key, metadata, options) {
645
+ updateFileMetadata(key, metadata, options) {
293
646
  const path = `/api/files/${encodeURIComponent(key)}`;
294
647
  return this.request(this.withBucket(path, options?.bucket), {
295
648
  method: "PATCH",
@@ -297,6 +650,51 @@ var StowServer = class {
297
650
  body: JSON.stringify({ metadata })
298
651
  });
299
652
  }
653
+ /**
654
+ * Get a single file by key
655
+ */
656
+ getFile(key, options) {
657
+ const path = `/api/files/${encodeURIComponent(key)}`;
658
+ return this.request(
659
+ this.withBucket(path, options?.bucket),
660
+ { method: "GET" },
661
+ fileResultSchema
662
+ );
663
+ }
664
+ /**
665
+ * Reprocess a file: reset all derived data (embeddings, colors, dimensions,
666
+ * AI metadata, taxonomies) and re-trigger processing tasks.
667
+ */
668
+ reprocessFile(key, options) {
669
+ const path = `/api/files/${encodeURIComponent(key)}/reprocess`;
670
+ return this.request(
671
+ this.withBucket(path, options?.bucket),
672
+ { method: "POST" },
673
+ reprocessResultSchema
674
+ );
675
+ }
676
+ /**
677
+ * Replace a file's content by fetching from a new URL.
678
+ *
679
+ * Keeps the same file key but replaces the stored object and resets all
680
+ * derived data (dimensions, embeddings, colors, AI metadata). Processing
681
+ * tasks are re-dispatched as if the file were newly uploaded.
682
+ */
683
+ replaceFile(key, url, options) {
684
+ const path = `/api/files/${encodeURIComponent(key)}/replace`;
685
+ return this.request(
686
+ this.withBucket(path, options?.bucket),
687
+ {
688
+ method: "PUT",
689
+ headers: { "Content-Type": "application/json" },
690
+ body: JSON.stringify({
691
+ url,
692
+ ...options?.headers ? { headers: options.headers } : {}
693
+ })
694
+ },
695
+ replaceResultSchema
696
+ );
697
+ }
300
698
  /**
301
699
  * Get a transform URL for an image.
302
700
  *
@@ -308,25 +706,23 @@ var StowServer = class {
308
706
  * @param options - Transform options (width, height, quality, format)
309
707
  */
310
708
  getTransformUrl(url, options) {
311
- const params = new URLSearchParams();
312
- if (options?.width) {
313
- params.set("w", options.width.toString());
709
+ if (!(options && (options.width || options.height || options.quality || options.format))) {
710
+ return url;
314
711
  }
315
- if (options?.height) {
316
- params.set("h", options.height.toString());
712
+ const parsed = new URL(url);
713
+ if (options.width) {
714
+ parsed.searchParams.set("w", String(options.width));
317
715
  }
318
- if (options?.quality) {
319
- params.set("q", options.quality.toString());
716
+ if (options.height) {
717
+ parsed.searchParams.set("h", String(options.height));
320
718
  }
321
- if (options?.format) {
322
- params.set("f", options.format);
719
+ if (options.quality) {
720
+ parsed.searchParams.set("q", String(options.quality));
323
721
  }
324
- const query = params.toString();
325
- if (!query) {
326
- return url;
722
+ if (options.format) {
723
+ parsed.searchParams.set("f", options.format);
327
724
  }
328
- const separator = url.includes("?") ? "&" : "?";
329
- return `${url}${separator}${query}`;
725
+ return parsed.toString();
330
726
  }
331
727
  // ============================================================
332
728
  // TAGS - Org-scoped labels for file organization
@@ -387,18 +783,77 @@ var StowServer = class {
387
783
  */
388
784
  get search() {
389
785
  return {
390
- similar: (params) => this.searchSimilar(params)
786
+ similar: (params) => this.searchSimilar(params),
787
+ diverse: (params) => this.searchDiverse(params ?? {}),
788
+ text: (params) => this.searchText(params),
789
+ color: (params) => this.searchColor(params)
391
790
  };
392
791
  }
393
792
  searchSimilar(params) {
793
+ const bucket = this.resolveBucket(params.bucket);
394
794
  return this.request("/api/search/similar", {
395
795
  method: "POST",
396
796
  headers: { "Content-Type": "application/json" },
397
797
  body: JSON.stringify({
398
798
  ...params.fileKey ? { fileKey: params.fileKey } : {},
399
799
  ...params.vector ? { vector: params.vector } : {},
400
- ...params.bucket ? { bucket: params.bucket } : {},
401
- ...params.limit ? { limit: params.limit } : {}
800
+ ...params.profileId ? { profileId: params.profileId } : {},
801
+ ...params.clusterId ? { clusterId: params.clusterId } : {},
802
+ ...params.clusterIds?.length ? { clusterIds: params.clusterIds } : {},
803
+ ...bucket ? { bucket } : {},
804
+ ...params.limit ? { limit: params.limit } : {},
805
+ ...params.excludeKeys?.length ? { excludeKeys: params.excludeKeys } : {},
806
+ ...params.filters ? { filters: params.filters } : {},
807
+ ...params.include?.length ? { include: params.include } : {}
808
+ })
809
+ });
810
+ }
811
+ searchDiverse(params) {
812
+ const bucket = this.resolveBucket(params.bucket);
813
+ return this.request("/api/search/diverse", {
814
+ method: "POST",
815
+ headers: { "Content-Type": "application/json" },
816
+ body: JSON.stringify({
817
+ ...params.fileKey ? { fileKey: params.fileKey } : {},
818
+ ...params.vector ? { vector: params.vector } : {},
819
+ ...params.profileId ? { profileId: params.profileId } : {},
820
+ ...params.clusterId ? { clusterId: params.clusterId } : {},
821
+ ...params.clusterIds?.length ? { clusterIds: params.clusterIds } : {},
822
+ ...bucket ? { bucket } : {},
823
+ ...params.limit ? { limit: params.limit } : {},
824
+ ...params.lambda !== void 0 ? { lambda: params.lambda } : {},
825
+ ...params.excludeKeys?.length ? { excludeKeys: params.excludeKeys } : {},
826
+ ...params.filters ? { filters: params.filters } : {},
827
+ ...params.include?.length ? { include: params.include } : {}
828
+ })
829
+ });
830
+ }
831
+ searchText(params) {
832
+ const bucket = this.resolveBucket(params.bucket);
833
+ return this.request("/api/search/text", {
834
+ method: "POST",
835
+ headers: { "Content-Type": "application/json" },
836
+ body: JSON.stringify({
837
+ query: params.query,
838
+ ...bucket ? { bucket } : {},
839
+ ...params.limit ? { limit: params.limit } : {},
840
+ ...params.filters ? { filters: params.filters } : {},
841
+ ...params.include?.length ? { include: params.include } : {}
842
+ })
843
+ });
844
+ }
845
+ searchColor(params) {
846
+ const bucket = this.resolveBucket(params.bucket);
847
+ return this.request("/api/search/color", {
848
+ method: "POST",
849
+ headers: { "Content-Type": "application/json" },
850
+ body: JSON.stringify({
851
+ ...params.hex ? { hex: params.hex } : {},
852
+ ...params.oklab ? { oklab: params.oklab } : {},
853
+ ...bucket ? { bucket } : {},
854
+ ...params.limit ? { limit: params.limit } : {},
855
+ ...params.minProportion !== void 0 ? { minProportion: params.minProportion } : {},
856
+ ...params.dominantOnly ? { dominantOnly: params.dominantOnly } : {}
402
857
  })
403
858
  });
404
859
  }
@@ -439,7 +894,7 @@ var StowServer = class {
439
894
  throw new StowError("Failed to upload to storage", putRes.status);
440
895
  }
441
896
  return this.request(
442
- "/api/drops/presign/confirm",
897
+ presign.confirmUrl || "/api/drops/presign/confirm",
443
898
  {
444
899
  method: "POST",
445
900
  headers: { "Content-Type": "application/json" },
@@ -469,6 +924,130 @@ var StowServer = class {
469
924
  method: "DELETE"
470
925
  });
471
926
  }
927
+ // ============================================================
928
+ // PROFILES - Taste/preference profiles from file collections
929
+ // ============================================================
930
+ /**
931
+ * Profiles namespace for managing taste profiles
932
+ */
933
+ get profiles() {
934
+ return {
935
+ create: (params) => this.createProfile(params),
936
+ get: (id) => this.getProfile(id),
937
+ delete: (id) => this.deleteProfile(id),
938
+ addFiles: (id, fileKeys, bucket) => this.addProfileFiles(id, fileKeys, bucket),
939
+ removeFiles: (id, fileKeys, bucket) => this.removeProfileFiles(id, fileKeys, bucket),
940
+ signal: (id, signals, bucket) => this.signalProfile(id, signals, bucket),
941
+ deleteSignals: (id, signalIds) => this.deleteProfileSignals(id, signalIds),
942
+ clusters: (id) => this.getProfileClusters(id),
943
+ recluster: (id, params) => this.reclusterProfile(id, params),
944
+ renameCluster: (profileId, clusterId, params) => this.renameProfileCluster(profileId, clusterId, params)
945
+ };
946
+ }
947
+ createProfile(params) {
948
+ return this.request(
949
+ "/api/profiles",
950
+ {
951
+ method: "POST",
952
+ headers: { "Content-Type": "application/json" },
953
+ body: JSON.stringify({
954
+ ...params?.name ? { name: params.name } : {},
955
+ ...params?.fileKeys ? { fileKeys: params.fileKeys } : {},
956
+ ...params?.bucket ? { bucket: params.bucket } : {}
957
+ })
958
+ },
959
+ profileResultSchema
960
+ );
961
+ }
962
+ getProfile(id) {
963
+ return this.request(
964
+ `/api/profiles/${encodeURIComponent(id)}`,
965
+ { method: "GET" },
966
+ profileResultSchema
967
+ );
968
+ }
969
+ async deleteProfile(id) {
970
+ await this.request(`/api/profiles/${encodeURIComponent(id)}`, {
971
+ method: "DELETE"
972
+ });
973
+ }
974
+ addProfileFiles(id, fileKeys, bucket) {
975
+ return this.request(
976
+ `/api/profiles/${encodeURIComponent(id)}/files`,
977
+ {
978
+ method: "POST",
979
+ headers: { "Content-Type": "application/json" },
980
+ body: JSON.stringify({
981
+ fileKeys,
982
+ ...bucket ? { bucket } : {}
983
+ })
984
+ },
985
+ profileFilesResultSchema
986
+ );
987
+ }
988
+ removeProfileFiles(id, fileKeys, bucket) {
989
+ return this.request(
990
+ `/api/profiles/${encodeURIComponent(id)}/files`,
991
+ {
992
+ method: "DELETE",
993
+ headers: { "Content-Type": "application/json" },
994
+ body: JSON.stringify({
995
+ fileKeys,
996
+ ...bucket ? { bucket } : {}
997
+ })
998
+ },
999
+ profileFilesResultSchema
1000
+ );
1001
+ }
1002
+ signalProfile(id, signals, bucket) {
1003
+ return this.request(
1004
+ `/api/profiles/${encodeURIComponent(id)}/signals`,
1005
+ {
1006
+ method: "POST",
1007
+ headers: { "Content-Type": "application/json" },
1008
+ body: JSON.stringify({
1009
+ signals,
1010
+ ...bucket ? { bucket } : {}
1011
+ })
1012
+ },
1013
+ profileSignalsResponseSchema
1014
+ );
1015
+ }
1016
+ deleteProfileSignals(id, signalIds) {
1017
+ return this.request(
1018
+ `/api/profiles/${encodeURIComponent(id)}/signals`,
1019
+ {
1020
+ method: "DELETE",
1021
+ headers: { "Content-Type": "application/json" },
1022
+ body: JSON.stringify({ signalIds })
1023
+ },
1024
+ deleteProfileSignalsResponseSchema
1025
+ );
1026
+ }
1027
+ getProfileClusters(id) {
1028
+ return this.request(`/api/profiles/${encodeURIComponent(id)}/clusters`, {
1029
+ method: "GET"
1030
+ });
1031
+ }
1032
+ reclusterProfile(id, params) {
1033
+ return this.request(`/api/profiles/${encodeURIComponent(id)}/clusters`, {
1034
+ method: "POST",
1035
+ headers: { "Content-Type": "application/json" },
1036
+ body: JSON.stringify({
1037
+ ...params?.clusterCount !== void 0 ? { clusterCount: params.clusterCount } : {}
1038
+ })
1039
+ });
1040
+ }
1041
+ renameProfileCluster(profileId, clusterId, params) {
1042
+ return this.request(
1043
+ `/api/profiles/${encodeURIComponent(profileId)}/clusters/${encodeURIComponent(clusterId)}`,
1044
+ {
1045
+ method: "PUT",
1046
+ headers: { "Content-Type": "application/json" },
1047
+ body: JSON.stringify(params)
1048
+ }
1049
+ );
1050
+ }
472
1051
  };
473
1052
  // Annotate the CommonJS export names for ESM import in node:
474
1053
  0 && (module.exports = {