@howells/stow-server 0.1.1 → 0.1.2

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.d.mts CHANGED
@@ -31,19 +31,23 @@ interface StowServerConfig {
31
31
  baseUrl?: string;
32
32
  /** Default bucket name or ID. Required for global API keys. */
33
33
  bucket?: string;
34
+ /** Max retry attempts for 429/5xx errors (default 3, set 0 to disable) */
35
+ retries?: number;
36
+ /** Request timeout in ms (default 30000) */
37
+ timeout?: number;
34
38
  }
35
39
  interface UploadResult {
36
- key: string;
37
- url: string | null;
38
- size: number;
39
40
  contentType: string;
41
+ key: string;
40
42
  metadata?: Record<string, string>;
43
+ size: number;
44
+ url: string | null;
41
45
  }
42
46
  interface TransformOptions {
43
- width?: number;
47
+ format?: "webp" | "avif" | "jpeg" | "png";
44
48
  height?: number;
45
49
  quality?: number;
46
- format?: "webp" | "avif" | "jpeg" | "png";
50
+ width?: number;
47
51
  }
48
52
  interface ListFilesResult {
49
53
  files: Array<{
@@ -56,20 +60,20 @@ interface ListFilesResult {
56
60
  nextCursor: string | null;
57
61
  }
58
62
  interface DropResult {
59
- shortId: string;
60
- url: string;
63
+ contentType: string;
61
64
  filename: string;
65
+ shortId: string;
62
66
  size: number;
63
- contentType: string;
67
+ url: string;
64
68
  }
65
69
  interface Drop {
70
+ contentType: string;
71
+ createdAt: string;
72
+ filename: string;
66
73
  id: string;
67
74
  shortId: string;
68
- url: string;
69
- filename: string;
70
75
  size: number;
71
- contentType: string;
72
- createdAt: string;
76
+ url: string;
73
77
  }
74
78
  interface ListDropsResult {
75
79
  drops: Drop[];
@@ -78,46 +82,68 @@ interface ListDropsResult {
78
82
  limit: number;
79
83
  };
80
84
  }
81
- interface PresignResult {
85
+ /** Normal presign response — client must PUT to uploadUrl then call confirm. */
86
+ interface PresignNewResult {
87
+ /** URL to call to confirm the upload */
88
+ confirmUrl: string;
89
+ dedupe?: false;
82
90
  /** The file key that will be created */
83
91
  fileKey: string;
84
- /** URL to PUT the file to */
85
- uploadUrl: string;
86
92
  /** The R2 key for the file */
87
93
  r2Key: string;
88
- /** URL to call to confirm the upload */
89
- confirmUrl: string;
94
+ /** URL to PUT the file to */
95
+ uploadUrl: string;
90
96
  }
91
- interface PresignRequest {
92
- filename: string;
97
+ /** Dedup hit — file already exists in this bucket. No upload needed. */
98
+ interface PresignDedupeResult {
93
99
  contentType: string;
94
- /** File size in bytes */
100
+ dedupe: true;
101
+ key: string;
95
102
  size: number;
96
- /** Optional route/folder */
97
- route?: string;
103
+ url: string | null;
104
+ }
105
+ /** Presign response is either a new upload or a dedup hit. Check `result.dedupe` to differentiate. */
106
+ type PresignResult = PresignNewResult | PresignDedupeResult;
107
+ interface PresignRequest {
98
108
  /** Override the default bucket */
99
109
  bucket?: string;
110
+ /** SHA-256 hex digest of file content. When provided, Stow checks for an existing file with the same hash in the bucket and returns it immediately (dedup). */
111
+ contentHash?: string;
112
+ contentType: string;
113
+ filename: string;
100
114
  /** Custom metadata to attach to the file */
101
115
  metadata?: Record<string, string>;
116
+ /** Optional route/folder */
117
+ route?: string;
118
+ /** File size in bytes */
119
+ size: number;
102
120
  }
103
121
  interface ConfirmUploadRequest {
104
- fileKey: string;
105
- size: number;
106
- contentType: string;
107
122
  /** Override the default bucket */
108
123
  bucket?: string;
124
+ /** SHA-256 hex digest of file content. Stored with the file record for future dedup lookups. */
125
+ contentHash?: string;
126
+ contentType: string;
127
+ /** Fire KV sync in background instead of blocking. Saves ~100ms per upload. */
128
+ deferKvSync?: boolean;
129
+ fileKey: string;
109
130
  /** Custom metadata to attach to the file */
110
131
  metadata?: Record<string, string>;
132
+ size: number;
133
+ /** Skip R2 HEAD verification — trust the presign PUT. Saves ~100ms per upload. */
134
+ skipVerify?: boolean;
111
135
  }
112
136
  interface SimilarSearchRequest {
113
- /** Find files similar to this file key */
114
- fileKey?: string;
115
- /** Search directly with a vector (1024 dimensions) */
116
- vector?: number[];
117
137
  /** Bucket name or ID to scope search */
118
138
  bucket?: string;
139
+ /** Find files similar to this file key */
140
+ fileKey?: string;
119
141
  /** Max results (default 10, max 50) */
120
142
  limit?: number;
143
+ /** Search using a taste profile's vector */
144
+ profileId?: string;
145
+ /** Search directly with a vector (1024 dimensions) */
146
+ vector?: number[];
121
147
  }
122
148
  interface SimilarSearchResult {
123
149
  results: Array<{
@@ -133,10 +159,42 @@ interface SimilarSearchResult {
133
159
  createdAt: string;
134
160
  }>;
135
161
  }
162
+ interface FileResult {
163
+ contentType: string;
164
+ createdAt: string;
165
+ embeddingStatus: string | null;
166
+ key: string;
167
+ metadata: Record<string, string> | null;
168
+ size: number;
169
+ url: string | null;
170
+ }
171
+ interface ProfileCreateRequest {
172
+ bucket?: string;
173
+ fileKeys?: string[];
174
+ name?: string;
175
+ }
176
+ interface ProfileResult {
177
+ createdAt: string;
178
+ fileCount: number;
179
+ id: string;
180
+ name: string | null;
181
+ updatedAt: string;
182
+ }
183
+ interface ProfileFilesResult {
184
+ fileCount: number;
185
+ id: string;
186
+ }
187
+ interface TextSearchRequest {
188
+ bucket?: string;
189
+ limit?: number;
190
+ query: string;
191
+ }
136
192
  declare class StowServer {
137
193
  private readonly apiKey;
138
194
  private readonly baseUrl;
139
195
  private readonly bucket?;
196
+ private readonly timeout;
197
+ private readonly retries;
140
198
  constructor(config: StowServerConfig | string);
141
199
  /**
142
200
  * Get the base URL for this instance (used by client SDK)
@@ -152,9 +210,14 @@ declare class StowServer {
152
210
  */
153
211
  private withBucket;
154
212
  /**
155
- * Make an API request with proper error handling
213
+ * Make an API request with retry, timeout, and error handling.
214
+ *
215
+ * - Retries on 429 (rate limit) and 5xx with exponential backoff (1s, 2s, 4s).
216
+ * - AbortController timeout (default 30s).
217
+ * - Consumer can pass `signal` in options to cancel.
156
218
  */
157
219
  private request;
220
+ private sleep;
158
221
  /**
159
222
  * Upload a file directly from the server
160
223
  */
@@ -173,6 +236,8 @@ declare class StowServer {
173
236
  uploadFromUrl(url: string, filename: string, options?: {
174
237
  bucket?: string;
175
238
  metadata?: Record<string, string>;
239
+ /** Headers to forward when fetching the URL (e.g. User-Agent, Referer) */
240
+ headers?: Record<string, string>;
176
241
  }): Promise<UploadResult>;
177
242
  /**
178
243
  * Get a presigned URL for direct client-side upload.
@@ -215,6 +280,12 @@ declare class StowServer {
215
280
  key: string;
216
281
  metadata: Record<string, string>;
217
282
  }>;
283
+ /**
284
+ * Get a single file by key
285
+ */
286
+ getFile(key: string, options?: {
287
+ bucket?: string;
288
+ }): Promise<FileResult>;
218
289
  /**
219
290
  * Get a transform URL for an image.
220
291
  *
@@ -271,8 +342,10 @@ declare class StowServer {
271
342
  */
272
343
  get search(): {
273
344
  similar: (params: SimilarSearchRequest) => Promise<SimilarSearchResult>;
345
+ text: (params: TextSearchRequest) => Promise<SimilarSearchResult>;
274
346
  };
275
347
  private searchSimilar;
348
+ private searchText;
276
349
  /**
277
350
  * Upload a file as a drop (quick share)
278
351
  *
@@ -297,6 +370,21 @@ declare class StowServer {
297
370
  * Delete a drop by ID
298
371
  */
299
372
  deleteDrop(id: string): Promise<void>;
373
+ /**
374
+ * Profiles namespace for managing taste profiles
375
+ */
376
+ get profiles(): {
377
+ create: (params?: ProfileCreateRequest) => Promise<ProfileResult>;
378
+ get: (id: string) => Promise<ProfileResult>;
379
+ delete: (id: string) => Promise<void>;
380
+ addFiles: (id: string, fileKeys: string[], bucket?: string) => Promise<ProfileFilesResult>;
381
+ removeFiles: (id: string, fileKeys: string[], bucket?: string) => Promise<ProfileFilesResult>;
382
+ };
383
+ private createProfile;
384
+ private getProfile;
385
+ private deleteProfile;
386
+ private addProfileFiles;
387
+ private removeProfileFiles;
300
388
  }
301
389
 
302
- export { type ConfirmUploadRequest, type Drop, type DropResult, type ListDropsResult, type ListFilesResult, type PresignRequest, type PresignResult, type SimilarSearchRequest, type SimilarSearchResult, StowError, StowServer, type StowServerConfig, type TransformOptions, type UploadResult };
390
+ export { type ConfirmUploadRequest, type Drop, type DropResult, type FileResult, type ListDropsResult, type ListFilesResult, type PresignDedupeResult, type PresignNewResult, type PresignRequest, type PresignResult, type ProfileCreateRequest, type ProfileFilesResult, type ProfileResult, type SimilarSearchRequest, type SimilarSearchResult, StowError, StowServer, type StowServerConfig, type TextSearchRequest, type TransformOptions, type UploadResult };
package/dist/index.d.ts CHANGED
@@ -31,19 +31,23 @@ interface StowServerConfig {
31
31
  baseUrl?: string;
32
32
  /** Default bucket name or ID. Required for global API keys. */
33
33
  bucket?: string;
34
+ /** Max retry attempts for 429/5xx errors (default 3, set 0 to disable) */
35
+ retries?: number;
36
+ /** Request timeout in ms (default 30000) */
37
+ timeout?: number;
34
38
  }
35
39
  interface UploadResult {
36
- key: string;
37
- url: string | null;
38
- size: number;
39
40
  contentType: string;
41
+ key: string;
40
42
  metadata?: Record<string, string>;
43
+ size: number;
44
+ url: string | null;
41
45
  }
42
46
  interface TransformOptions {
43
- width?: number;
47
+ format?: "webp" | "avif" | "jpeg" | "png";
44
48
  height?: number;
45
49
  quality?: number;
46
- format?: "webp" | "avif" | "jpeg" | "png";
50
+ width?: number;
47
51
  }
48
52
  interface ListFilesResult {
49
53
  files: Array<{
@@ -56,20 +60,20 @@ interface ListFilesResult {
56
60
  nextCursor: string | null;
57
61
  }
58
62
  interface DropResult {
59
- shortId: string;
60
- url: string;
63
+ contentType: string;
61
64
  filename: string;
65
+ shortId: string;
62
66
  size: number;
63
- contentType: string;
67
+ url: string;
64
68
  }
65
69
  interface Drop {
70
+ contentType: string;
71
+ createdAt: string;
72
+ filename: string;
66
73
  id: string;
67
74
  shortId: string;
68
- url: string;
69
- filename: string;
70
75
  size: number;
71
- contentType: string;
72
- createdAt: string;
76
+ url: string;
73
77
  }
74
78
  interface ListDropsResult {
75
79
  drops: Drop[];
@@ -78,46 +82,68 @@ interface ListDropsResult {
78
82
  limit: number;
79
83
  };
80
84
  }
81
- interface PresignResult {
85
+ /** Normal presign response — client must PUT to uploadUrl then call confirm. */
86
+ interface PresignNewResult {
87
+ /** URL to call to confirm the upload */
88
+ confirmUrl: string;
89
+ dedupe?: false;
82
90
  /** The file key that will be created */
83
91
  fileKey: string;
84
- /** URL to PUT the file to */
85
- uploadUrl: string;
86
92
  /** The R2 key for the file */
87
93
  r2Key: string;
88
- /** URL to call to confirm the upload */
89
- confirmUrl: string;
94
+ /** URL to PUT the file to */
95
+ uploadUrl: string;
90
96
  }
91
- interface PresignRequest {
92
- filename: string;
97
+ /** Dedup hit — file already exists in this bucket. No upload needed. */
98
+ interface PresignDedupeResult {
93
99
  contentType: string;
94
- /** File size in bytes */
100
+ dedupe: true;
101
+ key: string;
95
102
  size: number;
96
- /** Optional route/folder */
97
- route?: string;
103
+ url: string | null;
104
+ }
105
+ /** Presign response is either a new upload or a dedup hit. Check `result.dedupe` to differentiate. */
106
+ type PresignResult = PresignNewResult | PresignDedupeResult;
107
+ interface PresignRequest {
98
108
  /** Override the default bucket */
99
109
  bucket?: string;
110
+ /** SHA-256 hex digest of file content. When provided, Stow checks for an existing file with the same hash in the bucket and returns it immediately (dedup). */
111
+ contentHash?: string;
112
+ contentType: string;
113
+ filename: string;
100
114
  /** Custom metadata to attach to the file */
101
115
  metadata?: Record<string, string>;
116
+ /** Optional route/folder */
117
+ route?: string;
118
+ /** File size in bytes */
119
+ size: number;
102
120
  }
103
121
  interface ConfirmUploadRequest {
104
- fileKey: string;
105
- size: number;
106
- contentType: string;
107
122
  /** Override the default bucket */
108
123
  bucket?: string;
124
+ /** SHA-256 hex digest of file content. Stored with the file record for future dedup lookups. */
125
+ contentHash?: string;
126
+ contentType: string;
127
+ /** Fire KV sync in background instead of blocking. Saves ~100ms per upload. */
128
+ deferKvSync?: boolean;
129
+ fileKey: string;
109
130
  /** Custom metadata to attach to the file */
110
131
  metadata?: Record<string, string>;
132
+ size: number;
133
+ /** Skip R2 HEAD verification — trust the presign PUT. Saves ~100ms per upload. */
134
+ skipVerify?: boolean;
111
135
  }
112
136
  interface SimilarSearchRequest {
113
- /** Find files similar to this file key */
114
- fileKey?: string;
115
- /** Search directly with a vector (1024 dimensions) */
116
- vector?: number[];
117
137
  /** Bucket name or ID to scope search */
118
138
  bucket?: string;
139
+ /** Find files similar to this file key */
140
+ fileKey?: string;
119
141
  /** Max results (default 10, max 50) */
120
142
  limit?: number;
143
+ /** Search using a taste profile's vector */
144
+ profileId?: string;
145
+ /** Search directly with a vector (1024 dimensions) */
146
+ vector?: number[];
121
147
  }
122
148
  interface SimilarSearchResult {
123
149
  results: Array<{
@@ -133,10 +159,42 @@ interface SimilarSearchResult {
133
159
  createdAt: string;
134
160
  }>;
135
161
  }
162
+ interface FileResult {
163
+ contentType: string;
164
+ createdAt: string;
165
+ embeddingStatus: string | null;
166
+ key: string;
167
+ metadata: Record<string, string> | null;
168
+ size: number;
169
+ url: string | null;
170
+ }
171
+ interface ProfileCreateRequest {
172
+ bucket?: string;
173
+ fileKeys?: string[];
174
+ name?: string;
175
+ }
176
+ interface ProfileResult {
177
+ createdAt: string;
178
+ fileCount: number;
179
+ id: string;
180
+ name: string | null;
181
+ updatedAt: string;
182
+ }
183
+ interface ProfileFilesResult {
184
+ fileCount: number;
185
+ id: string;
186
+ }
187
+ interface TextSearchRequest {
188
+ bucket?: string;
189
+ limit?: number;
190
+ query: string;
191
+ }
136
192
  declare class StowServer {
137
193
  private readonly apiKey;
138
194
  private readonly baseUrl;
139
195
  private readonly bucket?;
196
+ private readonly timeout;
197
+ private readonly retries;
140
198
  constructor(config: StowServerConfig | string);
141
199
  /**
142
200
  * Get the base URL for this instance (used by client SDK)
@@ -152,9 +210,14 @@ declare class StowServer {
152
210
  */
153
211
  private withBucket;
154
212
  /**
155
- * Make an API request with proper error handling
213
+ * Make an API request with retry, timeout, and error handling.
214
+ *
215
+ * - Retries on 429 (rate limit) and 5xx with exponential backoff (1s, 2s, 4s).
216
+ * - AbortController timeout (default 30s).
217
+ * - Consumer can pass `signal` in options to cancel.
156
218
  */
157
219
  private request;
220
+ private sleep;
158
221
  /**
159
222
  * Upload a file directly from the server
160
223
  */
@@ -173,6 +236,8 @@ declare class StowServer {
173
236
  uploadFromUrl(url: string, filename: string, options?: {
174
237
  bucket?: string;
175
238
  metadata?: Record<string, string>;
239
+ /** Headers to forward when fetching the URL (e.g. User-Agent, Referer) */
240
+ headers?: Record<string, string>;
176
241
  }): Promise<UploadResult>;
177
242
  /**
178
243
  * Get a presigned URL for direct client-side upload.
@@ -215,6 +280,12 @@ declare class StowServer {
215
280
  key: string;
216
281
  metadata: Record<string, string>;
217
282
  }>;
283
+ /**
284
+ * Get a single file by key
285
+ */
286
+ getFile(key: string, options?: {
287
+ bucket?: string;
288
+ }): Promise<FileResult>;
218
289
  /**
219
290
  * Get a transform URL for an image.
220
291
  *
@@ -271,8 +342,10 @@ declare class StowServer {
271
342
  */
272
343
  get search(): {
273
344
  similar: (params: SimilarSearchRequest) => Promise<SimilarSearchResult>;
345
+ text: (params: TextSearchRequest) => Promise<SimilarSearchResult>;
274
346
  };
275
347
  private searchSimilar;
348
+ private searchText;
276
349
  /**
277
350
  * Upload a file as a drop (quick share)
278
351
  *
@@ -297,6 +370,21 @@ declare class StowServer {
297
370
  * Delete a drop by ID
298
371
  */
299
372
  deleteDrop(id: string): Promise<void>;
373
+ /**
374
+ * Profiles namespace for managing taste profiles
375
+ */
376
+ get profiles(): {
377
+ create: (params?: ProfileCreateRequest) => Promise<ProfileResult>;
378
+ get: (id: string) => Promise<ProfileResult>;
379
+ delete: (id: string) => Promise<void>;
380
+ addFiles: (id: string, fileKeys: string[], bucket?: string) => Promise<ProfileFilesResult>;
381
+ removeFiles: (id: string, fileKeys: string[], bucket?: string) => Promise<ProfileFilesResult>;
382
+ };
383
+ private createProfile;
384
+ private getProfile;
385
+ private deleteProfile;
386
+ private addProfileFiles;
387
+ private removeProfileFiles;
300
388
  }
301
389
 
302
- export { type ConfirmUploadRequest, type Drop, type DropResult, type ListDropsResult, type ListFilesResult, type PresignRequest, type PresignResult, type SimilarSearchRequest, type SimilarSearchResult, StowError, StowServer, type StowServerConfig, type TransformOptions, type UploadResult };
390
+ export { type ConfirmUploadRequest, type Drop, type DropResult, type FileResult, type ListDropsResult, type ListFilesResult, type PresignDedupeResult, type PresignNewResult, type PresignRequest, type PresignResult, type ProfileCreateRequest, type ProfileFilesResult, type ProfileResult, type SimilarSearchRequest, type SimilarSearchResult, StowError, StowServer, type StowServerConfig, type TextSearchRequest, type TransformOptions, type UploadResult };
package/dist/index.js CHANGED
@@ -78,12 +78,24 @@ var listDropsSchema = import_zod.z.object({
78
78
  drops: import_zod.z.array(dropSchema),
79
79
  usage: import_zod.z.object({ bytes: import_zod.z.number(), limit: import_zod.z.number() })
80
80
  });
81
- var presignResultSchema = import_zod.z.object({
81
+ var presignNewResultSchema = import_zod.z.object({
82
82
  fileKey: import_zod.z.string(),
83
83
  uploadUrl: import_zod.z.string(),
84
84
  r2Key: import_zod.z.string(),
85
- confirmUrl: import_zod.z.string()
85
+ confirmUrl: import_zod.z.string(),
86
+ dedupe: import_zod.z.literal(false).optional()
86
87
  });
88
+ var presignDedupeResultSchema = import_zod.z.object({
89
+ dedupe: import_zod.z.literal(true),
90
+ key: import_zod.z.string(),
91
+ url: import_zod.z.string().nullable(),
92
+ size: import_zod.z.number(),
93
+ contentType: import_zod.z.string()
94
+ });
95
+ var presignResultSchema = import_zod.z.union([
96
+ presignDedupeResultSchema,
97
+ presignNewResultSchema
98
+ ]);
87
99
  var confirmResultSchema = import_zod.z.object({
88
100
  key: import_zod.z.string(),
89
101
  url: import_zod.z.string().nullable(),
@@ -91,18 +103,44 @@ var confirmResultSchema = import_zod.z.object({
91
103
  contentType: import_zod.z.string(),
92
104
  metadata: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional()
93
105
  });
106
+ var fileResultSchema = import_zod.z.object({
107
+ key: import_zod.z.string(),
108
+ size: import_zod.z.number(),
109
+ contentType: import_zod.z.string(),
110
+ url: import_zod.z.string().nullable(),
111
+ metadata: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).nullable(),
112
+ embeddingStatus: import_zod.z.string().nullable(),
113
+ createdAt: import_zod.z.string()
114
+ });
115
+ var profileResultSchema = import_zod.z.object({
116
+ id: import_zod.z.string(),
117
+ name: import_zod.z.string().nullable(),
118
+ fileCount: import_zod.z.number(),
119
+ createdAt: import_zod.z.string(),
120
+ updatedAt: import_zod.z.string()
121
+ });
122
+ var profileFilesResultSchema = import_zod.z.object({
123
+ id: import_zod.z.string(),
124
+ fileCount: import_zod.z.number()
125
+ });
94
126
  var StowServer = class {
95
127
  apiKey;
96
128
  baseUrl;
97
129
  bucket;
130
+ timeout;
131
+ retries;
98
132
  constructor(config) {
99
133
  if (typeof config === "string") {
100
134
  this.apiKey = config;
101
135
  this.baseUrl = "https://app.stow.sh";
136
+ this.timeout = 3e4;
137
+ this.retries = 3;
102
138
  } else {
103
139
  this.apiKey = config.apiKey;
104
140
  this.baseUrl = config.baseUrl || "https://app.stow.sh";
105
141
  this.bucket = config.bucket;
142
+ this.timeout = config.timeout ?? 3e4;
143
+ this.retries = config.retries ?? 3;
106
144
  }
107
145
  }
108
146
  /**
@@ -130,24 +168,66 @@ var StowServer = class {
130
168
  return `${path}${sep}bucket=${encodeURIComponent(b)}`;
131
169
  }
132
170
  /**
133
- * Make an API request with proper error handling
171
+ * Make an API request with retry, timeout, and error handling.
172
+ *
173
+ * - Retries on 429 (rate limit) and 5xx with exponential backoff (1s, 2s, 4s).
174
+ * - AbortController timeout (default 30s).
175
+ * - Consumer can pass `signal` in options to cancel.
134
176
  */
135
177
  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
178
+ const maxAttempts = this.retries + 1;
179
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
180
+ const controller = new AbortController();
181
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
182
+ if (options.signal) {
183
+ options.signal.addEventListener("abort", () => controller.abort());
184
+ }
185
+ try {
186
+ const response = await fetch(`${this.baseUrl}${path}`, {
187
+ ...options,
188
+ signal: controller.signal,
189
+ headers: {
190
+ ...options.headers,
191
+ "x-api-key": this.apiKey
192
+ }
193
+ });
194
+ const data = await response.json();
195
+ if (!response.ok) {
196
+ const error = errorSchema.safeParse(data);
197
+ const message = error.success ? error.data.error : "Request failed";
198
+ const code = error.success ? error.data.code : void 0;
199
+ const isRetryable = response.status === 429 || response.status >= 500;
200
+ if (isRetryable && attempt < maxAttempts - 1) {
201
+ await this.sleep(1e3 * 2 ** attempt);
202
+ continue;
203
+ }
204
+ throw new StowError(message, response.status, code);
205
+ }
206
+ return schema ? schema.parse(data) : data;
207
+ } catch (err) {
208
+ if (err instanceof StowError) {
209
+ throw err;
210
+ }
211
+ if (err instanceof DOMException || err instanceof Error && err.name === "AbortError") {
212
+ throw new StowError("Request timed out", 408, "TIMEOUT");
213
+ }
214
+ if (attempt < maxAttempts - 1) {
215
+ await this.sleep(1e3 * 2 ** attempt);
216
+ continue;
217
+ }
218
+ throw new StowError(
219
+ err instanceof Error ? err.message : "Network error",
220
+ 0,
221
+ "NETWORK_ERROR"
222
+ );
223
+ } finally {
224
+ clearTimeout(timeoutId);
141
225
  }
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
226
  }
150
- return schema ? schema.parse(data) : data;
227
+ throw new StowError("Max retries exceeded", 0, "MAX_RETRIES");
228
+ }
229
+ sleep(ms) {
230
+ return new Promise((resolve) => setTimeout(resolve, ms));
151
231
  }
152
232
  /**
153
233
  * Upload a file directly from the server
@@ -192,7 +272,8 @@ var StowServer = class {
192
272
  body: JSON.stringify({
193
273
  url,
194
274
  filename,
195
- ...options?.metadata ? { metadata: options.metadata } : {}
275
+ ...options?.metadata ? { metadata: options.metadata } : {},
276
+ ...options?.headers ? { headers: options.headers } : {}
196
277
  })
197
278
  },
198
279
  uploadResultSchema
@@ -215,7 +296,7 @@ var StowServer = class {
215
296
  * 4. Client calls confirmUpload to finalize
216
297
  */
217
298
  getPresignedUrl(request) {
218
- const { filename, contentType, size, route, bucket, metadata } = request;
299
+ const { filename, contentType, size, route, bucket, metadata, contentHash } = request;
219
300
  return this.request(
220
301
  this.withBucket("/api/presign", bucket),
221
302
  {
@@ -226,7 +307,8 @@ var StowServer = class {
226
307
  contentType,
227
308
  size,
228
309
  route,
229
- ...metadata ? { metadata } : {}
310
+ ...metadata ? { metadata } : {},
311
+ ...contentHash ? { contentHash } : {}
230
312
  })
231
313
  },
232
314
  presignResultSchema
@@ -237,7 +319,16 @@ var StowServer = class {
237
319
  * This creates the file record in the database.
238
320
  */
239
321
  confirmUpload(request) {
240
- const { fileKey, size, contentType, bucket, metadata } = request;
322
+ const {
323
+ fileKey,
324
+ size,
325
+ contentType,
326
+ bucket,
327
+ metadata,
328
+ skipVerify,
329
+ deferKvSync,
330
+ contentHash
331
+ } = request;
241
332
  return this.request(
242
333
  this.withBucket("/api/presign/confirm", bucket),
243
334
  {
@@ -247,7 +338,10 @@ var StowServer = class {
247
338
  fileKey,
248
339
  size,
249
340
  contentType,
250
- ...metadata ? { metadata } : {}
341
+ ...metadata ? { metadata } : {},
342
+ ...skipVerify ? { skipVerify } : {},
343
+ ...deferKvSync ? { deferKvSync } : {},
344
+ ...contentHash ? { contentHash } : {}
251
345
  })
252
346
  },
253
347
  confirmResultSchema
@@ -297,6 +391,17 @@ var StowServer = class {
297
391
  body: JSON.stringify({ metadata })
298
392
  });
299
393
  }
394
+ /**
395
+ * Get a single file by key
396
+ */
397
+ async getFile(key, options) {
398
+ const path = `/api/files/${encodeURIComponent(key)}`;
399
+ return this.request(
400
+ this.withBucket(path, options?.bucket),
401
+ { method: "GET" },
402
+ fileResultSchema
403
+ );
404
+ }
300
405
  /**
301
406
  * Get a transform URL for an image.
302
407
  *
@@ -387,7 +492,8 @@ var StowServer = class {
387
492
  */
388
493
  get search() {
389
494
  return {
390
- similar: (params) => this.searchSimilar(params)
495
+ similar: (params) => this.searchSimilar(params),
496
+ text: (params) => this.searchText(params)
391
497
  };
392
498
  }
393
499
  searchSimilar(params) {
@@ -397,6 +503,18 @@ var StowServer = class {
397
503
  body: JSON.stringify({
398
504
  ...params.fileKey ? { fileKey: params.fileKey } : {},
399
505
  ...params.vector ? { vector: params.vector } : {},
506
+ ...params.profileId ? { profileId: params.profileId } : {},
507
+ ...params.bucket ? { bucket: params.bucket } : {},
508
+ ...params.limit ? { limit: params.limit } : {}
509
+ })
510
+ });
511
+ }
512
+ searchText(params) {
513
+ return this.request("/api/search/text", {
514
+ method: "POST",
515
+ headers: { "Content-Type": "application/json" },
516
+ body: JSON.stringify({
517
+ query: params.query,
400
518
  ...params.bucket ? { bucket: params.bucket } : {},
401
519
  ...params.limit ? { limit: params.limit } : {}
402
520
  })
@@ -469,6 +587,76 @@ var StowServer = class {
469
587
  method: "DELETE"
470
588
  });
471
589
  }
590
+ // ============================================================
591
+ // PROFILES - Taste/preference profiles from file collections
592
+ // ============================================================
593
+ /**
594
+ * Profiles namespace for managing taste profiles
595
+ */
596
+ get profiles() {
597
+ return {
598
+ create: (params) => this.createProfile(params),
599
+ get: (id) => this.getProfile(id),
600
+ delete: (id) => this.deleteProfile(id),
601
+ addFiles: (id, fileKeys, bucket) => this.addProfileFiles(id, fileKeys, bucket),
602
+ removeFiles: (id, fileKeys, bucket) => this.removeProfileFiles(id, fileKeys, bucket)
603
+ };
604
+ }
605
+ createProfile(params) {
606
+ return this.request(
607
+ "/api/profiles",
608
+ {
609
+ method: "POST",
610
+ headers: { "Content-Type": "application/json" },
611
+ body: JSON.stringify({
612
+ ...params?.name ? { name: params.name } : {},
613
+ ...params?.fileKeys ? { fileKeys: params.fileKeys } : {},
614
+ ...params?.bucket ? { bucket: params.bucket } : {}
615
+ })
616
+ },
617
+ profileResultSchema
618
+ );
619
+ }
620
+ getProfile(id) {
621
+ return this.request(
622
+ `/api/profiles/${encodeURIComponent(id)}`,
623
+ { method: "GET" },
624
+ profileResultSchema
625
+ );
626
+ }
627
+ async deleteProfile(id) {
628
+ await this.request(`/api/profiles/${encodeURIComponent(id)}`, {
629
+ method: "DELETE"
630
+ });
631
+ }
632
+ addProfileFiles(id, fileKeys, bucket) {
633
+ return this.request(
634
+ `/api/profiles/${encodeURIComponent(id)}/files`,
635
+ {
636
+ method: "POST",
637
+ headers: { "Content-Type": "application/json" },
638
+ body: JSON.stringify({
639
+ fileKeys,
640
+ ...bucket ? { bucket } : {}
641
+ })
642
+ },
643
+ profileFilesResultSchema
644
+ );
645
+ }
646
+ removeProfileFiles(id, fileKeys, bucket) {
647
+ return this.request(
648
+ `/api/profiles/${encodeURIComponent(id)}/files`,
649
+ {
650
+ method: "DELETE",
651
+ headers: { "Content-Type": "application/json" },
652
+ body: JSON.stringify({
653
+ fileKeys,
654
+ ...bucket ? { bucket } : {}
655
+ })
656
+ },
657
+ profileFilesResultSchema
658
+ );
659
+ }
472
660
  };
473
661
  // Annotate the CommonJS export names for ESM import in node:
474
662
  0 && (module.exports = {
package/dist/index.mjs CHANGED
@@ -53,12 +53,24 @@ var listDropsSchema = z.object({
53
53
  drops: z.array(dropSchema),
54
54
  usage: z.object({ bytes: z.number(), limit: z.number() })
55
55
  });
56
- var presignResultSchema = z.object({
56
+ var presignNewResultSchema = z.object({
57
57
  fileKey: z.string(),
58
58
  uploadUrl: z.string(),
59
59
  r2Key: z.string(),
60
- confirmUrl: z.string()
60
+ confirmUrl: z.string(),
61
+ dedupe: z.literal(false).optional()
61
62
  });
63
+ var presignDedupeResultSchema = z.object({
64
+ dedupe: z.literal(true),
65
+ key: z.string(),
66
+ url: z.string().nullable(),
67
+ size: z.number(),
68
+ contentType: z.string()
69
+ });
70
+ var presignResultSchema = z.union([
71
+ presignDedupeResultSchema,
72
+ presignNewResultSchema
73
+ ]);
62
74
  var confirmResultSchema = z.object({
63
75
  key: z.string(),
64
76
  url: z.string().nullable(),
@@ -66,18 +78,44 @@ var confirmResultSchema = z.object({
66
78
  contentType: z.string(),
67
79
  metadata: z.record(z.string(), z.string()).optional()
68
80
  });
81
+ var fileResultSchema = z.object({
82
+ key: z.string(),
83
+ size: z.number(),
84
+ contentType: z.string(),
85
+ url: z.string().nullable(),
86
+ metadata: z.record(z.string(), z.string()).nullable(),
87
+ embeddingStatus: z.string().nullable(),
88
+ createdAt: z.string()
89
+ });
90
+ var profileResultSchema = z.object({
91
+ id: z.string(),
92
+ name: z.string().nullable(),
93
+ fileCount: z.number(),
94
+ createdAt: z.string(),
95
+ updatedAt: z.string()
96
+ });
97
+ var profileFilesResultSchema = z.object({
98
+ id: z.string(),
99
+ fileCount: z.number()
100
+ });
69
101
  var StowServer = class {
70
102
  apiKey;
71
103
  baseUrl;
72
104
  bucket;
105
+ timeout;
106
+ retries;
73
107
  constructor(config) {
74
108
  if (typeof config === "string") {
75
109
  this.apiKey = config;
76
110
  this.baseUrl = "https://app.stow.sh";
111
+ this.timeout = 3e4;
112
+ this.retries = 3;
77
113
  } else {
78
114
  this.apiKey = config.apiKey;
79
115
  this.baseUrl = config.baseUrl || "https://app.stow.sh";
80
116
  this.bucket = config.bucket;
117
+ this.timeout = config.timeout ?? 3e4;
118
+ this.retries = config.retries ?? 3;
81
119
  }
82
120
  }
83
121
  /**
@@ -105,24 +143,66 @@ var StowServer = class {
105
143
  return `${path}${sep}bucket=${encodeURIComponent(b)}`;
106
144
  }
107
145
  /**
108
- * Make an API request with proper error handling
146
+ * Make an API request with retry, timeout, and error handling.
147
+ *
148
+ * - Retries on 429 (rate limit) and 5xx with exponential backoff (1s, 2s, 4s).
149
+ * - AbortController timeout (default 30s).
150
+ * - Consumer can pass `signal` in options to cancel.
109
151
  */
110
152
  async request(path, options, schema) {
111
- const response = await fetch(`${this.baseUrl}${path}`, {
112
- ...options,
113
- headers: {
114
- ...options.headers,
115
- "x-api-key": this.apiKey
153
+ const maxAttempts = this.retries + 1;
154
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
155
+ const controller = new AbortController();
156
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
157
+ if (options.signal) {
158
+ options.signal.addEventListener("abort", () => controller.abort());
159
+ }
160
+ try {
161
+ const response = await fetch(`${this.baseUrl}${path}`, {
162
+ ...options,
163
+ signal: controller.signal,
164
+ headers: {
165
+ ...options.headers,
166
+ "x-api-key": this.apiKey
167
+ }
168
+ });
169
+ const data = await response.json();
170
+ if (!response.ok) {
171
+ const error = errorSchema.safeParse(data);
172
+ const message = error.success ? error.data.error : "Request failed";
173
+ const code = error.success ? error.data.code : void 0;
174
+ const isRetryable = response.status === 429 || response.status >= 500;
175
+ if (isRetryable && attempt < maxAttempts - 1) {
176
+ await this.sleep(1e3 * 2 ** attempt);
177
+ continue;
178
+ }
179
+ throw new StowError(message, response.status, code);
180
+ }
181
+ return schema ? schema.parse(data) : data;
182
+ } catch (err) {
183
+ if (err instanceof StowError) {
184
+ throw err;
185
+ }
186
+ if (err instanceof DOMException || err instanceof Error && err.name === "AbortError") {
187
+ throw new StowError("Request timed out", 408, "TIMEOUT");
188
+ }
189
+ if (attempt < maxAttempts - 1) {
190
+ await this.sleep(1e3 * 2 ** attempt);
191
+ continue;
192
+ }
193
+ throw new StowError(
194
+ err instanceof Error ? err.message : "Network error",
195
+ 0,
196
+ "NETWORK_ERROR"
197
+ );
198
+ } finally {
199
+ clearTimeout(timeoutId);
116
200
  }
117
- });
118
- const data = await response.json();
119
- if (!response.ok) {
120
- const error = errorSchema.safeParse(data);
121
- const message = error.success ? error.data.error : "Request failed";
122
- const code = error.success ? error.data.code : void 0;
123
- throw new StowError(message, response.status, code);
124
201
  }
125
- return schema ? schema.parse(data) : data;
202
+ throw new StowError("Max retries exceeded", 0, "MAX_RETRIES");
203
+ }
204
+ sleep(ms) {
205
+ return new Promise((resolve) => setTimeout(resolve, ms));
126
206
  }
127
207
  /**
128
208
  * Upload a file directly from the server
@@ -167,7 +247,8 @@ var StowServer = class {
167
247
  body: JSON.stringify({
168
248
  url,
169
249
  filename,
170
- ...options?.metadata ? { metadata: options.metadata } : {}
250
+ ...options?.metadata ? { metadata: options.metadata } : {},
251
+ ...options?.headers ? { headers: options.headers } : {}
171
252
  })
172
253
  },
173
254
  uploadResultSchema
@@ -190,7 +271,7 @@ var StowServer = class {
190
271
  * 4. Client calls confirmUpload to finalize
191
272
  */
192
273
  getPresignedUrl(request) {
193
- const { filename, contentType, size, route, bucket, metadata } = request;
274
+ const { filename, contentType, size, route, bucket, metadata, contentHash } = request;
194
275
  return this.request(
195
276
  this.withBucket("/api/presign", bucket),
196
277
  {
@@ -201,7 +282,8 @@ var StowServer = class {
201
282
  contentType,
202
283
  size,
203
284
  route,
204
- ...metadata ? { metadata } : {}
285
+ ...metadata ? { metadata } : {},
286
+ ...contentHash ? { contentHash } : {}
205
287
  })
206
288
  },
207
289
  presignResultSchema
@@ -212,7 +294,16 @@ var StowServer = class {
212
294
  * This creates the file record in the database.
213
295
  */
214
296
  confirmUpload(request) {
215
- const { fileKey, size, contentType, bucket, metadata } = request;
297
+ const {
298
+ fileKey,
299
+ size,
300
+ contentType,
301
+ bucket,
302
+ metadata,
303
+ skipVerify,
304
+ deferKvSync,
305
+ contentHash
306
+ } = request;
216
307
  return this.request(
217
308
  this.withBucket("/api/presign/confirm", bucket),
218
309
  {
@@ -222,7 +313,10 @@ var StowServer = class {
222
313
  fileKey,
223
314
  size,
224
315
  contentType,
225
- ...metadata ? { metadata } : {}
316
+ ...metadata ? { metadata } : {},
317
+ ...skipVerify ? { skipVerify } : {},
318
+ ...deferKvSync ? { deferKvSync } : {},
319
+ ...contentHash ? { contentHash } : {}
226
320
  })
227
321
  },
228
322
  confirmResultSchema
@@ -272,6 +366,17 @@ var StowServer = class {
272
366
  body: JSON.stringify({ metadata })
273
367
  });
274
368
  }
369
+ /**
370
+ * Get a single file by key
371
+ */
372
+ async getFile(key, options) {
373
+ const path = `/api/files/${encodeURIComponent(key)}`;
374
+ return this.request(
375
+ this.withBucket(path, options?.bucket),
376
+ { method: "GET" },
377
+ fileResultSchema
378
+ );
379
+ }
275
380
  /**
276
381
  * Get a transform URL for an image.
277
382
  *
@@ -362,7 +467,8 @@ var StowServer = class {
362
467
  */
363
468
  get search() {
364
469
  return {
365
- similar: (params) => this.searchSimilar(params)
470
+ similar: (params) => this.searchSimilar(params),
471
+ text: (params) => this.searchText(params)
366
472
  };
367
473
  }
368
474
  searchSimilar(params) {
@@ -372,6 +478,18 @@ var StowServer = class {
372
478
  body: JSON.stringify({
373
479
  ...params.fileKey ? { fileKey: params.fileKey } : {},
374
480
  ...params.vector ? { vector: params.vector } : {},
481
+ ...params.profileId ? { profileId: params.profileId } : {},
482
+ ...params.bucket ? { bucket: params.bucket } : {},
483
+ ...params.limit ? { limit: params.limit } : {}
484
+ })
485
+ });
486
+ }
487
+ searchText(params) {
488
+ return this.request("/api/search/text", {
489
+ method: "POST",
490
+ headers: { "Content-Type": "application/json" },
491
+ body: JSON.stringify({
492
+ query: params.query,
375
493
  ...params.bucket ? { bucket: params.bucket } : {},
376
494
  ...params.limit ? { limit: params.limit } : {}
377
495
  })
@@ -444,6 +562,76 @@ var StowServer = class {
444
562
  method: "DELETE"
445
563
  });
446
564
  }
565
+ // ============================================================
566
+ // PROFILES - Taste/preference profiles from file collections
567
+ // ============================================================
568
+ /**
569
+ * Profiles namespace for managing taste profiles
570
+ */
571
+ get profiles() {
572
+ return {
573
+ create: (params) => this.createProfile(params),
574
+ get: (id) => this.getProfile(id),
575
+ delete: (id) => this.deleteProfile(id),
576
+ addFiles: (id, fileKeys, bucket) => this.addProfileFiles(id, fileKeys, bucket),
577
+ removeFiles: (id, fileKeys, bucket) => this.removeProfileFiles(id, fileKeys, bucket)
578
+ };
579
+ }
580
+ createProfile(params) {
581
+ return this.request(
582
+ "/api/profiles",
583
+ {
584
+ method: "POST",
585
+ headers: { "Content-Type": "application/json" },
586
+ body: JSON.stringify({
587
+ ...params?.name ? { name: params.name } : {},
588
+ ...params?.fileKeys ? { fileKeys: params.fileKeys } : {},
589
+ ...params?.bucket ? { bucket: params.bucket } : {}
590
+ })
591
+ },
592
+ profileResultSchema
593
+ );
594
+ }
595
+ getProfile(id) {
596
+ return this.request(
597
+ `/api/profiles/${encodeURIComponent(id)}`,
598
+ { method: "GET" },
599
+ profileResultSchema
600
+ );
601
+ }
602
+ async deleteProfile(id) {
603
+ await this.request(`/api/profiles/${encodeURIComponent(id)}`, {
604
+ method: "DELETE"
605
+ });
606
+ }
607
+ addProfileFiles(id, fileKeys, bucket) {
608
+ return this.request(
609
+ `/api/profiles/${encodeURIComponent(id)}/files`,
610
+ {
611
+ method: "POST",
612
+ headers: { "Content-Type": "application/json" },
613
+ body: JSON.stringify({
614
+ fileKeys,
615
+ ...bucket ? { bucket } : {}
616
+ })
617
+ },
618
+ profileFilesResultSchema
619
+ );
620
+ }
621
+ removeProfileFiles(id, fileKeys, bucket) {
622
+ return this.request(
623
+ `/api/profiles/${encodeURIComponent(id)}/files`,
624
+ {
625
+ method: "DELETE",
626
+ headers: { "Content-Type": "application/json" },
627
+ body: JSON.stringify({
628
+ fileKeys,
629
+ ...bucket ? { bucket } : {}
630
+ })
631
+ },
632
+ profileFilesResultSchema
633
+ );
634
+ }
447
635
  };
448
636
  export {
449
637
  StowError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howells/stow-server",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Server-side SDK for Stow file storage",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,10 +40,10 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@stow/typescript-config": "workspace:*",
43
- "@types/node": "^25.2.1",
44
- "tsup": "^8.0.0",
45
- "typescript": "^5.0.0",
46
- "vitest": "^4.0.0",
43
+ "@types/node": "^25.2.3",
44
+ "tsup": "^8.5.1",
45
+ "typescript": "^5.9.3",
46
+ "vitest": "^4.0.18",
47
47
  "zod": "^4.3.6"
48
48
  }
49
49
  }