@idapt/browser-app-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,862 @@
1
+ 'use strict';
2
+
3
+ // ../sdk/src/errors.ts
4
+ var IdaptError = class extends Error {
5
+ constructor(init) {
6
+ super(init.message, init.cause ? { cause: init.cause } : void 0);
7
+ this.name = "IdaptError";
8
+ this.code = init.code;
9
+ this.type = init.type;
10
+ this.subCode = init.subCode;
11
+ this.status = init.status ?? 0;
12
+ this.body = init.body;
13
+ }
14
+ };
15
+ var NetworkError = class extends IdaptError {
16
+ constructor(init) {
17
+ super({ ...init, code: "network_error" });
18
+ this.name = "NetworkError";
19
+ }
20
+ };
21
+ var AuthError = class extends IdaptError {
22
+ constructor(init) {
23
+ super({ ...init, code: init.code ?? "auth_expired" });
24
+ this.name = "AuthError";
25
+ }
26
+ };
27
+ var PermissionError = class extends IdaptError {
28
+ constructor(init) {
29
+ super({ ...init, code: "permission_denied" });
30
+ this.name = "PermissionError";
31
+ }
32
+ };
33
+ var NotFoundError = class extends IdaptError {
34
+ constructor(init) {
35
+ super({ ...init, code: "not_found" });
36
+ this.name = "NotFoundError";
37
+ }
38
+ };
39
+ var ConflictError = class extends IdaptError {
40
+ constructor(init) {
41
+ super({ ...init, code: "conflict" });
42
+ this.name = "ConflictError";
43
+ }
44
+ };
45
+ var RateLimitError = class extends IdaptError {
46
+ constructor(init) {
47
+ super({ ...init, code: "rate_limited" });
48
+ this.name = "RateLimitError";
49
+ this.retryAfter = init.retryAfter;
50
+ }
51
+ };
52
+ var InvalidRequestError = class extends IdaptError {
53
+ constructor(init) {
54
+ super({ ...init, code: "invalid_request" });
55
+ this.name = "InvalidRequestError";
56
+ }
57
+ };
58
+ var ServiceUnavailableError = class extends IdaptError {
59
+ constructor(init) {
60
+ super({ ...init, code: "service_unavailable" });
61
+ /** Always `true` — a 503 means "retry later", the request was fine. */
62
+ this.retryable = true;
63
+ this.name = "ServiceUnavailableError";
64
+ this.retryAfter = init.retryAfter;
65
+ }
66
+ };
67
+ var ServerError = class extends IdaptError {
68
+ constructor(init) {
69
+ super({ ...init, code: "server_error" });
70
+ this.name = "ServerError";
71
+ }
72
+ };
73
+ function errorFromStatus(status, message, body) {
74
+ const { type, subCode } = extractErrorFields(body);
75
+ const base = { message, status, body, type, subCode };
76
+ if (status === 401) return new AuthError(base);
77
+ if (status === 403) return new PermissionError(base);
78
+ if (status === 404) return new NotFoundError(base);
79
+ if (status === 409) return new ConflictError(base);
80
+ if (status === 422) {
81
+ return new InvalidRequestError(base);
82
+ }
83
+ if (status === 429) {
84
+ const retryAfter = extractRetryAfter(body);
85
+ return new RateLimitError({ ...base, retryAfter });
86
+ }
87
+ if (status === 503) {
88
+ const retryAfter = extractRetryAfter(body);
89
+ return new ServiceUnavailableError({ ...base, retryAfter });
90
+ }
91
+ if (status >= 500) return new ServerError(base);
92
+ return new IdaptError({ ...base, code: "unknown" });
93
+ }
94
+ function extractErrorFields(body) {
95
+ if (!body || typeof body !== "object" || !("error" in body)) return {};
96
+ const err = body.error;
97
+ if (!err || typeof err !== "object") return {};
98
+ const out = {};
99
+ const t = err.type;
100
+ if (typeof t === "string") out.type = t;
101
+ const c = err.code;
102
+ if (typeof c === "string") out.subCode = c;
103
+ return out;
104
+ }
105
+ function extractRetryAfter(body) {
106
+ if (body && typeof body === "object" && "retry_after" in body) {
107
+ const v = body.retry_after;
108
+ if (typeof v === "number") return v;
109
+ }
110
+ return void 0;
111
+ }
112
+
113
+ // ../sdk/src/http.ts
114
+ async function request(ctx, req) {
115
+ const res = await send(ctx, req);
116
+ return handleResponse(res, {
117
+ expectJson: req.expectJson !== false,
118
+ expectBlob: req.expectBlob === true
119
+ });
120
+ }
121
+ async function requestRaw(ctx, req) {
122
+ const res = await send(ctx, req);
123
+ if (!res.ok) {
124
+ await handleResponse(res, {
125
+ expectJson: req.expectJson !== false,
126
+ expectBlob: false
127
+ });
128
+ }
129
+ return res;
130
+ }
131
+ async function send(ctx, req) {
132
+ const doFetch = ctx.fetch ?? globalThis.fetch;
133
+ if (!doFetch) {
134
+ throw new NetworkError({
135
+ message: "No fetch implementation available"
136
+ });
137
+ }
138
+ const url = buildUrl(ctx.apiUrl, req.path, req.query);
139
+ const callerSetsAuth = hasAuthorizationHeader(req.headers);
140
+ const mintedBearer = !!ctx.getToken && !callerSetsAuth;
141
+ const cookieMode = !mintedBearer && ctx.auth === "cookie";
142
+ const body = buildBody(req);
143
+ const doSend = async (authHeader) => {
144
+ const headers = {
145
+ ...authHeader ? { Authorization: authHeader } : {},
146
+ // Context defaults (e.g. User-Agent), then per-request overrides win.
147
+ ...ctx.headers ?? {},
148
+ ...req.headers ?? {}
149
+ };
150
+ if (body.contentType) headers["Content-Type"] ?? (headers["Content-Type"] = body.contentType);
151
+ try {
152
+ return await doFetch(url, {
153
+ method: req.method,
154
+ headers,
155
+ body: body.value,
156
+ signal: req.signal,
157
+ // Send the httpOnly app-key cookie ONLY in cookie mode (same-origin on
158
+ // the app subdomain). Bearer / minted-bearer omit it to avoid leaking
159
+ // ambient cookies — the minted JWT is the sole credential there.
160
+ ...cookieMode ? { credentials: "include" } : {}
161
+ });
162
+ } catch (err) {
163
+ if (err instanceof Error && err.name === "AbortError") throw err;
164
+ throw new NetworkError({
165
+ message: err instanceof Error ? err.message : "Network failure",
166
+ cause: err
167
+ });
168
+ }
169
+ };
170
+ if (mintedBearer && ctx.getToken) {
171
+ let res = await doSend(`Bearer ${await ctx.getToken()}`);
172
+ if (res.status === 401 && ctx.onUnauthorized) {
173
+ ctx.onUnauthorized();
174
+ res = await doSend(`Bearer ${await ctx.getToken()}`);
175
+ }
176
+ return res;
177
+ }
178
+ return doSend(cookieMode ? null : `Bearer ${ctx.key}`);
179
+ }
180
+ function hasAuthorizationHeader(headers) {
181
+ if (!headers) return false;
182
+ return Object.keys(headers).some((k) => k.toLowerCase() === "authorization");
183
+ }
184
+ function buildBody(req) {
185
+ if (req.bodyRaw !== void 0) return { value: req.bodyRaw };
186
+ if (req.body !== void 0) {
187
+ return {
188
+ value: JSON.stringify(req.body),
189
+ contentType: "application/json"
190
+ };
191
+ }
192
+ return { value: void 0 };
193
+ }
194
+ async function handleResponse(res, mode) {
195
+ const ct = res.headers.get("content-type") ?? "";
196
+ const isJson = ct.includes("application/json");
197
+ if (!res.ok) {
198
+ let parsed;
199
+ let message = `API error: ${res.status}`;
200
+ try {
201
+ if (isJson) {
202
+ parsed = await res.json();
203
+ if (parsed && typeof parsed === "object" && "error" in parsed && parsed.error && typeof parsed.error === "object" && "message" in parsed.error && typeof parsed.error.message === "string") {
204
+ message = parsed.error.message;
205
+ }
206
+ } else {
207
+ parsed = await res.text();
208
+ }
209
+ } catch {
210
+ }
211
+ throw errorFromStatus(res.status, message, parsed);
212
+ }
213
+ if (res.status === 204) {
214
+ return void 0;
215
+ }
216
+ try {
217
+ if (mode.expectBlob) return await res.blob();
218
+ if (mode.expectJson && isJson) return await res.json();
219
+ return await res.text();
220
+ } catch (err) {
221
+ throw new IdaptError({
222
+ code: "server_error",
223
+ message: "Invalid response body",
224
+ status: res.status,
225
+ cause: err
226
+ });
227
+ }
228
+ }
229
+ function buildUrl(apiUrl, path, query) {
230
+ const base = apiUrl.replace(/\/+$/, "");
231
+ const cleanPath = path.startsWith("/") ? path : `/${path}`;
232
+ const qs = buildQuery(query);
233
+ return base + cleanPath + qs;
234
+ }
235
+ function buildQuery(query) {
236
+ if (!query) return "";
237
+ const entries = [];
238
+ for (const [k, v] of Object.entries(query)) {
239
+ if (v === null || v === void 0) continue;
240
+ if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") {
241
+ continue;
242
+ }
243
+ entries.push([k, String(v)]);
244
+ }
245
+ if (entries.length === 0) return "";
246
+ const sp = new URLSearchParams(entries);
247
+ return `?${sp.toString()}`;
248
+ }
249
+
250
+ // ../sdk/src/api/_files-core.ts
251
+ async function listFiles(ctx, query = {}, opts = {}) {
252
+ const res = await request(ctx, {
253
+ method: "GET",
254
+ path: "/api/v1/drive/files",
255
+ query,
256
+ signal: opts.signal
257
+ });
258
+ return res.data ?? [];
259
+ }
260
+ async function getFileText(ctx, id, opts = {}) {
261
+ return request(ctx, {
262
+ method: "GET",
263
+ path: `/api/v1/drive/files/${id}`,
264
+ signal: opts.signal,
265
+ expectJson: false
266
+ });
267
+ }
268
+ async function getFileBlob(ctx, id, opts = {}) {
269
+ return request(ctx, {
270
+ method: "GET",
271
+ path: `/api/v1/drive/files/${id}`,
272
+ signal: opts.signal,
273
+ expectBlob: true
274
+ });
275
+ }
276
+
277
+ // src/api/app.ts
278
+ var RemoteBundleReader = class {
279
+ constructor(ctx, id) {
280
+ this.ctx = ctx;
281
+ this.id = id;
282
+ }
283
+ list(opts = {}) {
284
+ return listFiles(this.ctx, { parent_id: this.id }, opts);
285
+ }
286
+ listFolder(parentId, opts = {}) {
287
+ return listFiles(this.ctx, { parent_id: parentId }, opts);
288
+ }
289
+ read(fileId, opts = {}) {
290
+ return getFileText(this.ctx, fileId, opts);
291
+ }
292
+ async readJSON(fileId, opts = {}) {
293
+ const text = await this.read(fileId, opts);
294
+ return JSON.parse(text);
295
+ }
296
+ readBlob(fileId, opts = {}) {
297
+ return getFileBlob(this.ctx, fileId, opts);
298
+ }
299
+ };
300
+ var AppFolder = class {
301
+ /**
302
+ * Two construction shapes:
303
+ *
304
+ * - `new AppFolder(ctx, folderId)` — builds a {@link RemoteBundleReader}
305
+ * internally. The normal path.
306
+ * - `new AppFolder(reader)` — inject any {@link BundleReader} (e.g. a fake
307
+ * in tests).
308
+ */
309
+ constructor(ctxOrReader, folderId) {
310
+ if (isBundleReader(ctxOrReader)) {
311
+ this.reader = ctxOrReader;
312
+ } else {
313
+ this.reader = new RemoteBundleReader(ctxOrReader, folderId ?? "");
314
+ }
315
+ }
316
+ /** The code folder's id. */
317
+ get id() {
318
+ return this.reader.id ?? "";
319
+ }
320
+ /** List files in the app folder. */
321
+ list(opts = {}) {
322
+ return this.reader.list(opts);
323
+ }
324
+ /** List a specific subfolder of the app folder. */
325
+ listFolder(parentId, opts = {}) {
326
+ return this.reader.listFolder(parentId, opts);
327
+ }
328
+ /**
329
+ * Read a file's text content (by Drive file id). UTF-8 decoded; use
330
+ * `readBlob` for binary.
331
+ */
332
+ read(fileId, opts = {}) {
333
+ return this.reader.read(fileId, opts);
334
+ }
335
+ /** Read + parse JSON in one call. */
336
+ readJSON(fileId, opts = {}) {
337
+ return this.reader.readJSON(fileId, opts);
338
+ }
339
+ /** Read a file's raw bytes as a Blob. Preserves the server's MIME type. */
340
+ readBlob(fileId, opts = {}) {
341
+ return this.reader.readBlob(fileId, opts);
342
+ }
343
+ };
344
+ function isBundleReader(x) {
345
+ return typeof x.read === "function" && typeof x.readBlob === "function";
346
+ }
347
+
348
+ // src/api/data-remote.ts
349
+ var RemoteDataStore = class {
350
+ /**
351
+ * @param ctx HTTP context (carries the app's `ap_` bearer + api origin).
352
+ * @param id The app's **resource id** (the `{appResId}` path segment) — this
353
+ * is the boot's `appResourceId`, NOT a Drive folder id.
354
+ */
355
+ constructor(ctx, id) {
356
+ this.ctx = ctx;
357
+ this.id = id;
358
+ }
359
+ /** Idapt origin this store talks to — surfaced by `DataFolder` for tooling. */
360
+ get apiUrl() {
361
+ return this.ctx.apiUrl;
362
+ }
363
+ /** URL-encode a single key segment for the `{...key}` catch-all route. */
364
+ keyPath(key) {
365
+ const encoded = key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
366
+ return `/api/browser-app/data/${encodeURIComponent(this.id)}/${encoded}`;
367
+ }
368
+ /** List the principal's keys for this app → `File[]` with `id = name = key`. */
369
+ async list(opts = {}) {
370
+ const res = await request(this.ctx, {
371
+ method: "GET",
372
+ // Trailing slash → empty `[...key]` → the route's "list keys" branch.
373
+ path: `/api/browser-app/data/${encodeURIComponent(this.id)}/`,
374
+ signal: opts.signal
375
+ });
376
+ const keys = Array.isArray(res?.keys) ? res.keys : [];
377
+ return keys.map(keyToFile);
378
+ }
379
+ /**
380
+ * No KV analogue — the app-data KV is a flat per-principal namespace with no
381
+ * subfolders. Throws so apps that try to enumerate a folder get a clear error.
382
+ */
383
+ listFolder(_parentId, _opts = {}) {
384
+ return Promise.reject(unsupported("listFolder"));
385
+ }
386
+ /** Metadata for one key — GET it; 404 surfaces as `NotFoundError`. */
387
+ async getMetadata(key, opts = {}) {
388
+ await requestRaw(this.ctx, {
389
+ method: "GET",
390
+ path: this.keyPath(key),
391
+ signal: opts.signal
392
+ });
393
+ return keyToFile(key);
394
+ }
395
+ /** Read a key's stored string. Inline JSON → `{value}`; blob → raw text. */
396
+ async read(key, opts = {}) {
397
+ const res = await requestRaw(this.ctx, {
398
+ method: "GET",
399
+ path: this.keyPath(key),
400
+ signal: opts.signal
401
+ });
402
+ const ct = res.headers.get("content-type") ?? "";
403
+ if (ct.includes("application/json")) {
404
+ const body = await res.json();
405
+ return valueToString(body?.value);
406
+ }
407
+ return res.text();
408
+ }
409
+ /** Read a key's raw bytes as a Blob (handles blob-backed binary entries). */
410
+ async readBlob(key, opts = {}) {
411
+ const res = await requestRaw(this.ctx, {
412
+ method: "GET",
413
+ path: this.keyPath(key),
414
+ signal: opts.signal
415
+ });
416
+ const ct = res.headers.get("content-type") ?? "";
417
+ if (ct.includes("application/json")) {
418
+ const body = await res.json();
419
+ return new Blob([valueToString(body?.value)], { type: "text/plain" });
420
+ }
421
+ return res.blob();
422
+ }
423
+ /** Upsert a key's value. `content` is stored verbatim as `{value: content}`. */
424
+ async write(key, content, opts = {}) {
425
+ await request(this.ctx, {
426
+ method: "PUT",
427
+ path: this.keyPath(key),
428
+ body: { value: content },
429
+ signal: opts.signal
430
+ });
431
+ return keyToFile(key);
432
+ }
433
+ /** Delete a key (idempotent — DELETE on a missing key still resolves). */
434
+ async delete(key, opts = {}) {
435
+ await request(this.ctx, {
436
+ method: "DELETE",
437
+ path: this.keyPath(key),
438
+ signal: opts.signal
439
+ });
440
+ return { deleted: true, id: key };
441
+ }
442
+ /**
443
+ * Store a blob under `name` (the KV key). This is the create path behind
444
+ * `DataFolder.set()` / `setJSON()`, which always upload a `text/plain` blob —
445
+ * we read the blob as **text** and PUT it as the key's `{value}`. Binary
446
+ * blobs are stored as their UTF-8 text (the KV PUT route is JSON-only); use
447
+ * `set()`/`setJSON()` for structured data.
448
+ */
449
+ async upload(input, opts = {}) {
450
+ if (input.parent_id && input.parent_id !== this.id) {
451
+ throw unsupported("upload into a subfolder");
452
+ }
453
+ const name = input.name ?? (input.file instanceof globalThis.File ? input.file.name : void 0);
454
+ if (!name) {
455
+ throw new InvalidRequestError({
456
+ message: "upload requires a name (the KV key)",
457
+ status: 422,
458
+ body: null
459
+ });
460
+ }
461
+ const text = await input.file.text();
462
+ await request(this.ctx, {
463
+ method: "PUT",
464
+ path: this.keyPath(name),
465
+ body: { value: text },
466
+ signal: opts.signal
467
+ });
468
+ return { id: name, name };
469
+ }
470
+ /** No KV analogue — the namespace is flat. */
471
+ createFolder(_input, _opts = {}) {
472
+ return Promise.reject(unsupported("createFolder"));
473
+ }
474
+ /** No KV analogue — there are no folders to move between. */
475
+ move(_key, _parentId, _opts = {}) {
476
+ return Promise.reject(unsupported("move"));
477
+ }
478
+ };
479
+ function keyToFile(key) {
480
+ return { id: key, name: key };
481
+ }
482
+ function valueToString(value) {
483
+ if (typeof value === "string") return value;
484
+ if (value === null || value === void 0) return "";
485
+ return JSON.stringify(value);
486
+ }
487
+ function unsupported(surface) {
488
+ return new InvalidRequestError({
489
+ message: `\`${surface}\` is not supported by the browser-app data KV \u2014 it is a flat per-app key/value store with no folders. Use the name-based API (set/get/has/setJSON/getJSON/remove).`,
490
+ status: 422,
491
+ body: null
492
+ });
493
+ }
494
+
495
+ // src/api/data.ts
496
+ var DataFolder = class {
497
+ /**
498
+ * Two construction shapes:
499
+ *
500
+ * - `new DataFolder(ctx, appResourceId)` — builds a {@link RemoteDataStore}
501
+ * internally, scoped by the app's RESOURCE id (the `{appResId}` segment of
502
+ * the KV path), NOT a Drive folder id. The normal path.
503
+ * - `new DataFolder(store)` — inject any {@link DataStore} (e.g. a fake in
504
+ * tests).
505
+ */
506
+ constructor(ctxOrStore, appResourceId) {
507
+ /** name (lowercased) → fileId, populated lazily by list() / set(). */
508
+ this.nameCache = /* @__PURE__ */ new Map();
509
+ this.nameCacheLoaded = false;
510
+ if (isDataStore(ctxOrStore)) {
511
+ this.store = ctxOrStore;
512
+ } else {
513
+ this.store = new RemoteDataStore(ctxOrStore, appResourceId ?? "");
514
+ }
515
+ }
516
+ /** The data folder's id — useful for passing to other SDK calls. */
517
+ get id() {
518
+ return this.store.id;
519
+ }
520
+ // ---------------------------------------------------------------------------
521
+ // Raw fileId-based API.
522
+ // ---------------------------------------------------------------------------
523
+ /** List every file in the data folder (top level only, excluding subfolders' contents). */
524
+ async list(opts = {}) {
525
+ const files = await this.store.list(opts);
526
+ this.rebuildNameCache(files);
527
+ return files;
528
+ }
529
+ /** List a specific subfolder within the data folder. */
530
+ listFolder(parentId, opts = {}) {
531
+ return this.store.listFolder(parentId, opts);
532
+ }
533
+ /** Read a file's raw text content by id. UTF-8 decoded. */
534
+ read(fileId, opts = {}) {
535
+ return this.store.read(fileId, opts);
536
+ }
537
+ /** Read and parse as JSON. */
538
+ async readJSON(fileId, opts = {}) {
539
+ const text = await this.read(fileId, opts);
540
+ return JSON.parse(text);
541
+ }
542
+ /** Read raw bytes as a Blob. Use for binary files stored in data folder. */
543
+ readBlob(fileId, opts = {}) {
544
+ return this.store.readBlob(fileId, opts);
545
+ }
546
+ /** PATCH content on an existing file id (optimistic concurrency via opts). */
547
+ write(fileId, content, opts = {}) {
548
+ return this.store.write(fileId, content, opts);
549
+ }
550
+ /** Stringify and PATCH. */
551
+ writeJSON(fileId, data, opts = {}) {
552
+ return this.write(fileId, JSON.stringify(data, null, 2), opts);
553
+ }
554
+ /** Move the file to trash. */
555
+ async delete(fileId, opts = {}) {
556
+ await this.store.delete(fileId, opts);
557
+ for (const [k, v] of this.nameCache) {
558
+ if (v === fileId) this.nameCache.delete(k);
559
+ }
560
+ }
561
+ // ---------------------------------------------------------------------------
562
+ // Upload / folder / move — scoped to the data folder.
563
+ // ---------------------------------------------------------------------------
564
+ /**
565
+ * Multipart upload a Blob or File into the data folder (or a subfolder of
566
+ * it). Returns the upload record. Use this for binary data; for text/JSON
567
+ * prefer the name-based `set()` / `setJSON()`.
568
+ */
569
+ upload(input, opts = {}) {
570
+ return this.store.upload(
571
+ {
572
+ file: input.file,
573
+ name: input.name,
574
+ parent_id: input.parent_id,
575
+ skip_if_exists: input.skip_if_exists
576
+ },
577
+ opts
578
+ );
579
+ }
580
+ /**
581
+ * Create (or get) a subfolder inside the data folder. Returns the folder
582
+ * record — store the `id` to use as `parent_id` for nested uploads /
583
+ * `createFolder` calls. Idempotent by name at the given parent.
584
+ */
585
+ createFolder(input, opts = {}) {
586
+ return this.store.createFolder(
587
+ {
588
+ name: input.name,
589
+ parent_id: input.parent_id,
590
+ icon: input.icon
591
+ },
592
+ opts
593
+ );
594
+ }
595
+ /**
596
+ * Move a file or subfolder within the data folder. Pass the data folder's
597
+ * own id (or omit `parentId`) to move it back to the data-folder root.
598
+ */
599
+ move(fileId, parentId, opts = {}) {
600
+ return this.store.move(
601
+ fileId,
602
+ parentId === void 0 ? this.store.id : parentId,
603
+ opts
604
+ );
605
+ }
606
+ // ---------------------------------------------------------------------------
607
+ // Name-based convenience API.
608
+ // ---------------------------------------------------------------------------
609
+ /**
610
+ * Upsert a file by name. Creates it on first call, PATCHes it on
611
+ * subsequent calls. Returns the resolved file metadata.
612
+ */
613
+ async set(name, content, opts = {}) {
614
+ const existing = await this.resolve(name, opts);
615
+ if (existing) {
616
+ return this.write(existing.id, content, opts);
617
+ }
618
+ return this.createByName(name, content, opts);
619
+ }
620
+ /** Stringify-and-upsert. */
621
+ setJSON(name, data, opts = {}) {
622
+ return this.set(name, JSON.stringify(data, null, 2), opts);
623
+ }
624
+ /**
625
+ * Read a file by name. Returns `null` if the file doesn't exist (rather
626
+ * than throwing) — this is the common "first-run preferences" case.
627
+ */
628
+ async get(name, opts = {}) {
629
+ const ref = await this.resolve(name, opts);
630
+ if (!ref) return null;
631
+ try {
632
+ return await this.read(ref.id, opts);
633
+ } catch (err) {
634
+ if (err instanceof NotFoundError) {
635
+ this.nameCache.delete(name.toLowerCase());
636
+ const retry = await this.resolve(name, opts);
637
+ if (!retry) return null;
638
+ return this.read(retry.id, opts);
639
+ }
640
+ throw err;
641
+ }
642
+ }
643
+ /** Read + parse JSON, or null if the file doesn't exist. */
644
+ async getJSON(name, opts = {}) {
645
+ const text = await this.get(name, opts);
646
+ if (text === null) return null;
647
+ return JSON.parse(text);
648
+ }
649
+ /** Does a file with this name exist? */
650
+ async has(name, opts = {}) {
651
+ return await this.resolve(name, opts) !== null;
652
+ }
653
+ /**
654
+ * Delete a file by name. Returns `true` if something was deleted, `false`
655
+ * if the file didn't exist.
656
+ */
657
+ async remove(name, opts = {}) {
658
+ const ref = await this.resolve(name, opts);
659
+ if (!ref) return false;
660
+ await this.delete(ref.id, opts);
661
+ return true;
662
+ }
663
+ /**
664
+ * Find the file id for a given name, or null. Populates the name→id cache
665
+ * on first call.
666
+ */
667
+ async resolve(name, opts = {}) {
668
+ const normalized = name.toLowerCase();
669
+ if (!this.nameCacheLoaded) {
670
+ const files = await this.list(opts);
671
+ const hit = files.find(
672
+ (f) => (f.name ?? "").toLowerCase() === normalized
673
+ );
674
+ return hit ?? null;
675
+ }
676
+ const cachedId = this.nameCache.get(normalized);
677
+ if (!cachedId) return null;
678
+ try {
679
+ return await this.store.getMetadata(cachedId, opts);
680
+ } catch (err) {
681
+ if (err instanceof NotFoundError) {
682
+ this.nameCache.delete(normalized);
683
+ return null;
684
+ }
685
+ throw err;
686
+ }
687
+ }
688
+ /** Drop the name→id cache. Next call will re-list. */
689
+ invalidate() {
690
+ this.nameCache.clear();
691
+ this.nameCacheLoaded = false;
692
+ }
693
+ // ---------------------------------------------------------------------------
694
+ // Internal helpers.
695
+ // ---------------------------------------------------------------------------
696
+ /**
697
+ * Create a new file at the data-folder root via multipart upload. The
698
+ * `skip_if_exists` flag hedges against concurrent creates; if another tab
699
+ * wins the race, we re-list and PATCH instead.
700
+ */
701
+ async createByName(name, content, opts) {
702
+ try {
703
+ const res = await this.store.upload(
704
+ {
705
+ file: new Blob([content], { type: "text/plain" }),
706
+ name,
707
+ parent_id: this.store.id,
708
+ skip_if_exists: true
709
+ },
710
+ opts
711
+ );
712
+ this.nameCache.set(name.toLowerCase(), res.id);
713
+ return res;
714
+ } catch (err) {
715
+ const normalized = name.toLowerCase();
716
+ const files = await this.list(opts);
717
+ const winner = files.find(
718
+ (f) => (f.name ?? "").toLowerCase() === normalized
719
+ );
720
+ if (winner) return this.write(winner.id, content, opts);
721
+ throw err;
722
+ }
723
+ }
724
+ rebuildNameCache(files) {
725
+ this.nameCache.clear();
726
+ for (const f of files) {
727
+ if (!f.name || !f.id) continue;
728
+ this.nameCache.set(f.name.toLowerCase(), f.id);
729
+ }
730
+ this.nameCacheLoaded = true;
731
+ }
732
+ };
733
+ function isDataStore(x) {
734
+ return typeof x.write === "function" && typeof x.upload === "function" && typeof x.getMetadata === "function";
735
+ }
736
+
737
+ // src/testing.ts
738
+ function byteLength(s) {
739
+ return new TextEncoder().encode(s).length;
740
+ }
741
+ var InMemoryDataStore = class {
742
+ constructor(id, seed) {
743
+ this.id = id;
744
+ this.apiUrl = null;
745
+ /** key (== id == name) → content. */
746
+ this.entries = /* @__PURE__ */ new Map();
747
+ if (seed) {
748
+ for (const [k, v] of Object.entries(seed)) this.entries.set(k, v);
749
+ }
750
+ }
751
+ /** Snapshot of the current KV contents — handy for assertions. */
752
+ snapshot() {
753
+ return Object.fromEntries(this.entries);
754
+ }
755
+ toFile(key) {
756
+ return {
757
+ id: key,
758
+ name: key,
759
+ mime_type: "text/plain",
760
+ file_size: byteLength(this.entries.get(key) ?? "")
761
+ };
762
+ }
763
+ async list() {
764
+ return [...this.entries.keys()].map((k) => this.toFile(k));
765
+ }
766
+ async listFolder() {
767
+ return [];
768
+ }
769
+ async getMetadata(fileId) {
770
+ if (!this.entries.has(fileId)) {
771
+ throw new NotFoundError({ message: `No such data file: ${fileId}` });
772
+ }
773
+ return this.toFile(fileId);
774
+ }
775
+ async read(fileId) {
776
+ const v = this.entries.get(fileId);
777
+ if (v === void 0) {
778
+ throw new NotFoundError({ message: `No such data file: ${fileId}` });
779
+ }
780
+ return v;
781
+ }
782
+ async readBlob(fileId) {
783
+ return new Blob([await this.read(fileId)], { type: "text/plain" });
784
+ }
785
+ async write(fileId, content) {
786
+ this.entries.set(fileId, content);
787
+ return this.toFile(fileId);
788
+ }
789
+ async delete(fileId) {
790
+ this.entries.delete(fileId);
791
+ return { deleted: true, id: fileId };
792
+ }
793
+ async upload(input) {
794
+ const name = input.name ?? "untitled.txt";
795
+ const content = typeof input.file.text === "function" ? await input.file.text() : "";
796
+ this.entries.set(name, content);
797
+ return {
798
+ id: name,
799
+ name,
800
+ mime_type: "text/plain",
801
+ file_size: byteLength(content)
802
+ };
803
+ }
804
+ async createFolder(_input) {
805
+ throw new Error("the mock data KV is flat \u2014 folders are not supported");
806
+ }
807
+ async move() {
808
+ throw new Error("the mock data KV is flat \u2014 folders are not supported");
809
+ }
810
+ };
811
+ var InMemoryBundleReader = class {
812
+ constructor(files) {
813
+ this.id = "mock-app-folder";
814
+ this.files = new Map(Object.entries(files ?? {}));
815
+ }
816
+ toFile(path) {
817
+ return {
818
+ id: path,
819
+ name: path,
820
+ mime_type: "text/plain",
821
+ file_size: byteLength(this.files.get(path) ?? "")
822
+ };
823
+ }
824
+ async list() {
825
+ return [...this.files.keys()].map((p) => this.toFile(p));
826
+ }
827
+ async listFolder() {
828
+ return [];
829
+ }
830
+ async read(fileId, _opts) {
831
+ const v = this.files.get(fileId);
832
+ if (v === void 0) {
833
+ throw new NotFoundError({ message: `No such bundle file: ${fileId}` });
834
+ }
835
+ return v;
836
+ }
837
+ async readJSON(fileId) {
838
+ return JSON.parse(await this.read(fileId));
839
+ }
840
+ async readBlob(fileId) {
841
+ return new Blob([await this.read(fileId)], { type: "text/plain" });
842
+ }
843
+ };
844
+ function createMockIdapt(options = {}) {
845
+ const appId = options.appId ?? "mock-app";
846
+ const apiUrl = options.apiUrl ?? "https://mock.idapt.app";
847
+ const dataStore = new InMemoryDataStore(appId, options.data);
848
+ const bundleReader = new InMemoryBundleReader(options.files);
849
+ return {
850
+ data: new DataFolder(dataStore),
851
+ app: new AppFolder(bundleReader),
852
+ meta: { appId, apiUrl, mode: "mock" },
853
+ mode: "mock",
854
+ _stores: { data: dataStore, bundle: bundleReader }
855
+ };
856
+ }
857
+
858
+ exports.InMemoryBundleReader = InMemoryBundleReader;
859
+ exports.InMemoryDataStore = InMemoryDataStore;
860
+ exports.createMockIdapt = createMockIdapt;
861
+ //# sourceMappingURL=testing.cjs.map
862
+ //# sourceMappingURL=testing.cjs.map