agentic-drive 0.0.0-stage → 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,935 @@
1
+ // src/drive.ts
2
+ var ROOT_ID = "root";
3
+ var NODES = "drive_nodes";
4
+ var NAMES = "drive_names";
5
+ var ACTOR_KINDS = ["human", "agent_of_human", "agent_of_agent", "agent", "user_token"];
6
+ var DriveError = class extends Error {
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = "DriveError";
11
+ }
12
+ code;
13
+ };
14
+ var MAX_PATH_SEGMENTS = 64;
15
+ var DEFAULT_MAX_DEPTH = 256;
16
+ var CHUNK = 25;
17
+ var DEFAULT_URL_SECONDS = 900;
18
+ var MAX_URL_SECONDS = 3600;
19
+ var OPAQUE = "application/octet-stream";
20
+ var MIME_SHAPE = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/;
21
+ var ACTIVE_TYPES = /* @__PURE__ */ new Set([
22
+ "text/html",
23
+ "application/xhtml+xml",
24
+ "image/svg+xml",
25
+ "text/xml",
26
+ "application/xml",
27
+ "text/javascript",
28
+ "application/javascript",
29
+ "application/x-javascript",
30
+ "text/ecmascript",
31
+ "application/ecmascript",
32
+ "text/css"
33
+ ]);
34
+ function safeMime(claimed) {
35
+ const type = (claimed ?? "").trim().toLowerCase();
36
+ return MIME_SHAPE.test(type) && !ACTIVE_TYPES.has(type) ? type : OPAQUE;
37
+ }
38
+ async function sha256Hex(data) {
39
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", data);
40
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
41
+ }
42
+ var REF = /^[a-z][a-z0-9_-]*:[^\s/]+$/;
43
+ function parseName(raw) {
44
+ const name = raw.normalize("NFC");
45
+ if (name.trim() === "" || /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/.test(name) || name === "." || name === ".." || /[/\u0000-\u001f\u007f]/.test(name) || new TextEncoder().encode(name).length > 255) {
46
+ throw new DriveError("INVALID_NAME", `invalid name ${JSON.stringify(raw)}`);
47
+ }
48
+ return { name, key: name.toLowerCase() };
49
+ }
50
+ function splitPath(path) {
51
+ if (!path.startsWith("/")) throw new DriveError("INVALID_PATH", `path must start with "/": ${path}`);
52
+ const rest = path.replace(/\/$/, "").slice(1);
53
+ if (rest === "") return [];
54
+ const parts = rest.split("/");
55
+ if (parts.includes("")) throw new DriveError("INVALID_PATH", `empty segment in path: ${path}`);
56
+ if (parts.length > MAX_PATH_SEGMENTS) throw new DriveError("TOO_DEEP", `a path has at most ${MAX_PATH_SEGMENTS} segments`);
57
+ return parts.map(parseName);
58
+ }
59
+ function batch() {
60
+ const writes = [];
61
+ const fresh = /* @__PURE__ */ new Set();
62
+ return {
63
+ fresh,
64
+ put: (collection, id, doc) => void writes.push({ collection, id, doc }),
65
+ remove: (collection, id) => void writes.push({ collection, id, doc: null }),
66
+ async apply(ops) {
67
+ for (const w of writes) await (w.doc ? ops.put(w.collection, w.id, w.doc) : ops.delete(w.collection, w.id));
68
+ }
69
+ };
70
+ }
71
+ var claimId = (owner, parentId, key) => sha256Hex(new TextEncoder().encode(`${owner}\0${parentId}\0${key}`));
72
+ function createDrive(deps) {
73
+ const { store, blobs } = deps;
74
+ const now = deps.now ?? Date.now;
75
+ const newId = deps.newId ?? (() => globalThis.crypto.randomUUID());
76
+ const maxDepth = deps.maxDepth ?? DEFAULT_MAX_DEPTH;
77
+ const onOrphan = deps.onOrphan ?? ((key, error) => console.error(`orphan blob ${key}: ${error instanceof Error ? error.name : "delete failed"}`));
78
+ const can = deps.can ?? ((ctx, _action, node) => node.owner === ctx.owner);
79
+ const rootNode = (ctx) => ({
80
+ id: ROOT_ID,
81
+ owner: ctx.owner,
82
+ parentId: "",
83
+ kind: "folder",
84
+ name: "",
85
+ nameKey: "",
86
+ size: 0,
87
+ mime: null,
88
+ hash: null,
89
+ ref: null,
90
+ blobKey: null,
91
+ createdAt: 0,
92
+ updatedAt: 0,
93
+ createdBy: ctx.actor,
94
+ updatedBy: ctx.actor,
95
+ trashed: false,
96
+ trashedAt: null,
97
+ purging: false
98
+ });
99
+ const who = (ctx) => ({ id: ctx.actor.id, kind: ctx.actor.kind });
100
+ async function fetch(ops, ctx, id, purgeAgain = false) {
101
+ const node = id === ROOT_ID ? rootNode(ctx) : await ops.get(NODES, id);
102
+ return node && (purgeAgain || node.purging !== true) && await can(ctx, "read", node) ? node : null;
103
+ }
104
+ async function need(ctx, action, node) {
105
+ if (!await can(ctx, action, node)) throw new DriveError("FORBIDDEN", `not allowed to ${action} ${node.id}`);
106
+ }
107
+ async function children(ops, owner, parentId, extra = {}) {
108
+ const out = [];
109
+ for (let cursor; ; ) {
110
+ const page = await ops.query(NODES, { where: { owner, parentId, ...extra }, limit: 100, cursor });
111
+ out.push(...page.items.map((item) => item.data));
112
+ if (!page.nextCursor) return out;
113
+ cursor = page.nextCursor;
114
+ }
115
+ }
116
+ async function claimed(ops, owner, parentId, key) {
117
+ const claim = await ops.get(NAMES, await claimId(owner, parentId, key));
118
+ const found = claim ? await ops.get(NODES, claim.nodeId) : null;
119
+ const node = found && !found.trashed && found.purging !== true && found.parentId === parentId && found.nameKey === key ? found : null;
120
+ return { taken: claim !== null, node };
121
+ }
122
+ const liveChild = async (ops, ctx, parentId, key) => (await claimed(ops, ctx.owner, parentId, key)).node;
123
+ async function isLive(ops, ctx, node) {
124
+ for (let n = node, depth = 0; n && depth < maxDepth; depth++) {
125
+ if (n.trashed) return false;
126
+ if (n.id === ROOT_ID) return true;
127
+ n = await fetch(ops, ctx, n.parentId);
128
+ }
129
+ return false;
130
+ }
131
+ async function resolve(ops, ctx, loc) {
132
+ if (!loc.startsWith("/")) {
133
+ const node2 = await fetch(ops, ctx, loc);
134
+ if (!node2) throw new DriveError("NOT_FOUND", `no item ${loc}`);
135
+ if (!await isLive(ops, ctx, node2)) throw new DriveError("TRASHED", `${loc} is in the trash`);
136
+ return node2;
137
+ }
138
+ let node = rootNode(ctx);
139
+ for (const seg of splitPath(loc)) {
140
+ if (node.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${node.name} is not a folder`);
141
+ const next = await liveChild(ops, ctx, node.id, seg.key);
142
+ if (!next) throw new DriveError("NOT_FOUND", `no item ${loc}`);
143
+ node = next;
144
+ }
145
+ if (!await can(ctx, "read", node)) throw new DriveError("NOT_FOUND", `no item ${loc}`);
146
+ return node;
147
+ }
148
+ async function freeName(ops, ctx, parentId, name) {
149
+ const dot = name.lastIndexOf(".");
150
+ const [base, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
151
+ for (let n = 2; ; n++) {
152
+ const candidate = `${base} (${n})${ext}`;
153
+ if (!(await claimed(ops, ctx.owner, parentId, parseName(candidate).key)).taken) return candidate;
154
+ }
155
+ }
156
+ async function folderAt(ops, w, ctx, segs, create) {
157
+ let node = rootNode(ctx);
158
+ for (const seg of segs) {
159
+ if (node.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${node.name} is not a folder`);
160
+ let next = w.fresh.has(node.id) ? null : await liveChild(ops, ctx, node.id, seg.key);
161
+ if (!next) {
162
+ if (!create) throw new DriveError("NOT_FOUND", `no folder ${seg.name}`);
163
+ next = (await insert(ops, w, ctx, node, seg.name, { kind: "folder" }, "fail")).node;
164
+ w.fresh.add(next.id);
165
+ }
166
+ node = next;
167
+ }
168
+ if (node.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${node.name} is not a folder`);
169
+ return node;
170
+ }
171
+ async function insert(ops, w, ctx, parent, rawName, fields, onConflict) {
172
+ let { name, key } = parseName(rawName);
173
+ const empty = w.fresh.has(parent.id);
174
+ if (!empty) await need(ctx, "write", parent);
175
+ const slot = empty ? null : await claimed(ops, ctx.owner, parent.id, key);
176
+ if (slot?.taken) {
177
+ if (onConflict === "replace" && slot.node?.kind === "file" && fields.kind === "file") {
178
+ const node2 = { ...slot.node, ...fields, updatedAt: now(), updatedBy: who(ctx) };
179
+ w.put(NODES, node2.id, node2);
180
+ return { node: node2, replacedBlob: slot.node.blobKey };
181
+ }
182
+ if (onConflict !== "rename") throw new DriveError("NAME_TAKEN", `"${name}" already exists`);
183
+ name = await freeName(ops, ctx, parent.id, name);
184
+ key = parseName(name).key;
185
+ }
186
+ const t = now();
187
+ const node = {
188
+ id: newId(),
189
+ owner: ctx.owner,
190
+ parentId: parent.id,
191
+ kind: "file",
192
+ name,
193
+ nameKey: key,
194
+ size: 0,
195
+ mime: null,
196
+ hash: null,
197
+ ref: null,
198
+ blobKey: null,
199
+ createdAt: t,
200
+ updatedAt: t,
201
+ createdBy: who(ctx),
202
+ updatedBy: who(ctx),
203
+ trashed: false,
204
+ trashedAt: null,
205
+ purging: false,
206
+ ...fields
207
+ };
208
+ w.put(NODES, node.id, node);
209
+ w.put(NAMES, await claimId(ctx.owner, parent.id, key), { nodeId: node.id, owner: ctx.owner, parentId: parent.id });
210
+ return { node, replacedBlob: null };
211
+ }
212
+ async function parentAndName(ops, w, ctx, path, parents) {
213
+ const segs = splitPath(path);
214
+ const last = segs.pop();
215
+ if (!last) throw new DriveError("IS_ROOT", "the root already exists");
216
+ return { parent: await folderAt(ops, w, ctx, segs, parents), name: last.name };
217
+ }
218
+ async function relocate(ops, w, ctx, node, destLoc, opts) {
219
+ if (node.id === ROOT_ID) throw new DriveError("IS_ROOT", "cannot change the root");
220
+ const dest = await resolve(ops, ctx, destLoc);
221
+ if (dest.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${dest.name} is not a folder`);
222
+ for (let n = dest, depth = 0; n.id !== ROOT_ID; depth++) {
223
+ if (n.id === node.id) throw new DriveError("MOVE_INTO_SELF", `cannot move ${node.name} into itself`);
224
+ if (depth >= maxDepth) throw new DriveError("TOO_DEEP", `the destination is deeper than ${maxDepth} folders`);
225
+ const parent = await fetch(ops, ctx, n.parentId);
226
+ if (!parent) throw new DriveError("NOT_FOUND", `a parent folder of ${dest.name} is missing`);
227
+ n = parent;
228
+ }
229
+ await need(ctx, "write", node);
230
+ await need(ctx, "write", dest);
231
+ let { name, key } = parseName(opts.name ?? node.name);
232
+ const slot = await claimed(ops, ctx.owner, dest.id, key);
233
+ if (slot.taken && slot.node?.id !== node.id) {
234
+ if (opts.onConflict !== "rename") throw new DriveError("NAME_TAKEN", `"${name}" already exists`);
235
+ name = await freeName(ops, ctx, dest.id, name);
236
+ key = parseName(name).key;
237
+ }
238
+ const moved = { ...node, parentId: dest.id, name, nameKey: key, updatedAt: now(), updatedBy: who(ctx) };
239
+ w.put(NODES, moved.id, moved);
240
+ const [from, to] = [await claimId(ctx.owner, node.parentId, node.nameKey), await claimId(ctx.owner, dest.id, key)];
241
+ if (from !== to) {
242
+ if ((await ops.get(NAMES, from))?.nodeId === node.id) w.remove(NAMES, from);
243
+ w.put(NAMES, to, { nodeId: moved.id, owner: ctx.owner, parentId: dest.id });
244
+ }
245
+ return moved;
246
+ }
247
+ async function isUnreferenced(ctx, blobKey) {
248
+ try {
249
+ return (await store.query(NODES, { where: { owner: ctx.owner, blobKey }, limit: 1 })).items.length === 0;
250
+ } catch (error) {
251
+ onOrphan(blobKey, error);
252
+ return false;
253
+ }
254
+ }
255
+ async function dropBlob(key) {
256
+ try {
257
+ await blobs.delete(key);
258
+ } catch (error) {
259
+ onOrphan(key, error);
260
+ }
261
+ }
262
+ async function ownerNodes(ctx, withMarked = false) {
263
+ const root = rootNode(ctx);
264
+ if (!await can(ctx, "read", root)) throw new DriveError("FORBIDDEN", "not allowed to read this drive");
265
+ const out = [];
266
+ for (let cursor; ; ) {
267
+ const page = await store.query(NODES, { where: { owner: ctx.owner }, limit: 100, cursor });
268
+ out.push(...page.items.map((item) => item.data).filter((node) => withMarked || node.purging !== true));
269
+ if (!page.nextCursor) return out;
270
+ cursor = page.nextCursor;
271
+ }
272
+ }
273
+ function pathIn(byId, node, liveOnly) {
274
+ const names = [];
275
+ for (let n = node, depth = 0; depth < 1e3; depth++) {
276
+ if (!n) return liveOnly ? null : `/(orphan)/${names.reverse().join("/")}`;
277
+ if (liveOnly && n.trashed) return null;
278
+ names.push(n.name);
279
+ if (n.parentId === ROOT_ID) return `/${names.reverse().join("/")}`;
280
+ n = byId.get(n.parentId);
281
+ }
282
+ return null;
283
+ }
284
+ async function transact(fn) {
285
+ try {
286
+ return await store.transaction(fn);
287
+ } catch (error) {
288
+ if (error instanceof Error && /transaction kept conflicting/.test(error.message)) throw new DriveError("BUSY", "the drive changed at the same time. Try again.");
289
+ throw error;
290
+ }
291
+ }
292
+ async function removeNodes(nodes, top) {
293
+ const unchanged = (now2, seen) => now2 !== null && now2.trashed === seen.trashed && now2.parentId === seen.parentId && now2.updatedAt === seen.updatedAt;
294
+ const marked = [];
295
+ const markedIds = /* @__PURE__ */ new Set();
296
+ for (let i = 0; i < nodes.length; i += CHUNK) {
297
+ const slice = nodes.slice(i, i + CHUNK);
298
+ for (const node of slice) {
299
+ if (node.purging === true) {
300
+ marked.push(node);
301
+ markedIds.add(node.id);
302
+ }
303
+ }
304
+ const found = await transact(async (tx) => {
305
+ const out = [];
306
+ const ids = new Set(markedIds);
307
+ for (const node of slice) {
308
+ if (node.purging === true) continue;
309
+ if (top && node.id !== top.id && !ids.has(node.parentId)) continue;
310
+ const current = await tx.get(NODES, node.id);
311
+ if (unchanged(current, node)) {
312
+ out.push(current);
313
+ ids.add(node.id);
314
+ }
315
+ }
316
+ for (const node of out) await tx.put(NODES, node.id, { ...node, purging: true });
317
+ return out;
318
+ });
319
+ for (const node of found) {
320
+ marked.push(node);
321
+ markedIds.add(node.id);
322
+ }
323
+ if (top && i === 0 && !markedIds.has(top.id)) throw new DriveError("BUSY", `${top.name} changed during the purge. Nothing was deleted.`);
324
+ }
325
+ const rows = marked.reverse();
326
+ for (let i = 0; i < rows.length; i += CHUNK) {
327
+ const chunk = rows.slice(i, i + CHUNK);
328
+ for (const node of chunk) if (node.blobKey) await blobs.delete(node.blobKey);
329
+ await transact(async (tx) => {
330
+ const held = [];
331
+ for (const node of chunk) {
332
+ const claim = node.trashed ? null : await claimId(node.owner, node.parentId, node.nameKey);
333
+ held.push({ node, claim, owns: claim !== null && (await tx.get(NAMES, claim))?.nodeId === node.id });
334
+ }
335
+ for (const { node, claim, owns } of held) {
336
+ await tx.delete(NODES, node.id);
337
+ if (claim && owns) await tx.delete(NAMES, claim);
338
+ }
339
+ });
340
+ }
341
+ return marked.length;
342
+ }
343
+ const api = {
344
+ async stat(ctx, loc) {
345
+ return resolve(store, ctx, loc);
346
+ },
347
+ /** Live children, sorted by name. */
348
+ async list(ctx, loc = "/") {
349
+ const folder = await resolve(store, ctx, loc);
350
+ if (folder.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${folder.name} is not a folder`);
351
+ return (await children(store, folder.owner, folder.id)).filter((n) => !n.trashed && n.purging !== true).sort((a, b) => a.nameKey < b.nameKey ? -1 : a.nameKey > b.nameKey ? 1 : a.name < b.name ? -1 : 1);
352
+ },
353
+ async mkdir(ctx, path, opts = {}) {
354
+ return transact(async (tx) => {
355
+ const w = batch();
356
+ const { parent, name } = await parentAndName(tx, w, ctx, path, opts.parents ?? false);
357
+ const existing = w.fresh.has(parent.id) ? null : await liveChild(tx, ctx, parent.id, parseName(name).key);
358
+ if (opts.parents && existing?.kind === "folder") return existing;
359
+ const { node } = await insert(tx, w, ctx, parent, name, { kind: "folder" }, "fail");
360
+ await w.apply(tx);
361
+ return node;
362
+ });
363
+ },
364
+ /**
365
+ * Stores one blob and one node. The blob is written first. The new node commits before
366
+ * any old blob goes. A failed old-blob delete leaves an orphan and never undoes the commit.
367
+ */
368
+ async putFile(ctx, path, data, opts = {}) {
369
+ const mime = opts.mime === void 0 ? null : safeMime(opts.mime);
370
+ const hash = await sha256Hex(data);
371
+ const blobKey = `${ctx.owner}/${newId()}`;
372
+ const stored = await blobs.put(blobKey, data, { ifNoneMatch: "*", ...mime ? { contentType: mime } : {} });
373
+ if (!stored) throw new DriveError("BLOB_EXISTS", "a blob with this key already exists");
374
+ let committed;
375
+ try {
376
+ committed = await transact(async (tx) => {
377
+ const w = batch();
378
+ const { parent, name } = await parentAndName(tx, w, ctx, path, opts.parents ?? false);
379
+ const result = await insert(tx, w, ctx, parent, name, { kind: "file", size: data.byteLength, mime, hash, blobKey }, opts.onConflict ?? "fail");
380
+ await w.apply(tx);
381
+ return result;
382
+ });
383
+ } catch (error) {
384
+ if (await isUnreferenced(ctx, blobKey)) await dropBlob(blobKey);
385
+ throw error;
386
+ }
387
+ if (committed.replacedBlob) await dropBlob(committed.replacedBlob);
388
+ return committed.node;
389
+ },
390
+ /** A reference points at a document in another app, such as `docs:<id>`. */
391
+ async putRef(ctx, path, ref, opts = {}) {
392
+ if (!REF.test(ref)) throw new DriveError("INVALID_REF", `a reference looks like "docs:<id>", got ${JSON.stringify(ref)}`);
393
+ return transact(async (tx) => {
394
+ const w = batch();
395
+ const { parent, name } = await parentAndName(tx, w, ctx, path, opts.parents ?? false);
396
+ const { node } = await insert(tx, w, ctx, parent, name, { kind: "ref", ref }, opts.onConflict === "replace" ? "fail" : opts.onConflict ?? "fail");
397
+ await w.apply(tx);
398
+ return node;
399
+ });
400
+ },
401
+ async read(ctx, loc) {
402
+ const node = await resolve(store, ctx, loc);
403
+ if (node.kind !== "file" || !node.blobKey) throw new DriveError("NOT_A_FILE", `${node.name} is not a file`);
404
+ const blob = await blobs.get(node.blobKey);
405
+ if (!blob) throw new DriveError("BLOB_MISSING", `the bytes of ${node.name} are missing`);
406
+ return { node, ...blob };
407
+ },
408
+ /**
409
+ * A time-limited URL to fetch the bytes. The URL signs an attachment header and the stored
410
+ * type, so the link holder cannot change how the bytes are served. Trashing an item does
411
+ * not revoke a link already issued.
412
+ */
413
+ async downloadUrl(ctx, loc, expiresInSeconds = DEFAULT_URL_SECONDS) {
414
+ if (!Number.isInteger(expiresInSeconds) || expiresInSeconds < 1 || expiresInSeconds > MAX_URL_SECONDS) {
415
+ throw new DriveError("INVALID_EXPIRY", `a link lasts 1 to ${MAX_URL_SECONDS} seconds`);
416
+ }
417
+ const node = await resolve(store, ctx, loc);
418
+ if (node.kind !== "file" || !node.blobKey) throw new DriveError("NOT_A_FILE", `${node.name} is not a file`);
419
+ const disposition = `attachment; filename*=UTF-8''${encodeURIComponent(node.name).replace(/['()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`)}`;
420
+ return blobs.signedUrl(node.blobKey, {
421
+ method: "GET",
422
+ expiresInSeconds,
423
+ responseContentDisposition: disposition.length <= 1024 ? disposition : "attachment",
424
+ responseContentType: node.mime ?? "application/octet-stream"
425
+ });
426
+ },
427
+ async move(ctx, src, destLoc, opts = {}) {
428
+ return transact(async (tx) => {
429
+ const w = batch();
430
+ const moved = await relocate(tx, w, ctx, await resolve(tx, ctx, src), destLoc, opts);
431
+ await w.apply(tx);
432
+ return moved;
433
+ });
434
+ },
435
+ async rename(ctx, loc, name) {
436
+ return transact(async (tx) => {
437
+ const w = batch();
438
+ const node = await resolve(tx, ctx, loc);
439
+ const renamed = await relocate(tx, w, ctx, node, node.parentId, { name });
440
+ await w.apply(tx);
441
+ return renamed;
442
+ });
443
+ },
444
+ /** Hides an item and, for a folder, everything under it. Nothing else changes. */
445
+ async trash(ctx, loc) {
446
+ return transact(async (tx) => {
447
+ const node = await resolve(tx, ctx, loc);
448
+ if (node.id === ROOT_ID) throw new DriveError("IS_ROOT", "cannot trash the root");
449
+ await need(ctx, "delete", node);
450
+ const claim = await claimId(node.owner, node.parentId, node.nameKey);
451
+ const held = await tx.get(NAMES, claim);
452
+ const t = now();
453
+ const trashed = { ...node, trashed: true, trashedAt: t, updatedAt: t, updatedBy: who(ctx) };
454
+ await tx.put(NODES, node.id, trashed);
455
+ if (held?.nodeId === node.id) await tx.delete(NAMES, claim);
456
+ return trashed;
457
+ });
458
+ },
459
+ /** Items trashed on their own, newest first. */
460
+ async listTrash(ctx) {
461
+ const page = await store.query(NODES, { where: { owner: ctx.owner, trashed: true }, limit: 1e3 });
462
+ const out = [];
463
+ for (const { data } of page.items) if (data.purging !== true && await can(ctx, "read", data)) out.push(data);
464
+ return out.sort((a, b) => (b.trashedAt ?? 0) - (a.trashedAt ?? 0));
465
+ },
466
+ /** Puts a trashed item back. It needs a live parent folder. */
467
+ async restore(ctx, id, opts = {}) {
468
+ return transact(async (tx) => {
469
+ const node = await fetch(tx, ctx, id);
470
+ if (!node || node.id === ROOT_ID) throw new DriveError("NOT_FOUND", `no item ${id}`);
471
+ if (!node.trashed) throw new DriveError("NOT_TRASHED", `${node.name} is not in the trash`);
472
+ await need(ctx, "write", node);
473
+ const parent = await fetch(tx, ctx, opts.parentId ?? node.parentId);
474
+ if (!parent || parent.kind !== "folder" || !await isLive(tx, ctx, parent)) {
475
+ throw new DriveError("PARENT_GONE", `the folder of ${node.name} is gone or in the trash`);
476
+ }
477
+ let { name, key } = parseName(node.name);
478
+ if ((await claimed(tx, ctx.owner, parent.id, key)).taken) {
479
+ if (opts.onConflict !== "rename") throw new DriveError("NAME_TAKEN", `"${name}" already exists`);
480
+ name = await freeName(tx, ctx, parent.id, name);
481
+ key = parseName(name).key;
482
+ }
483
+ const restored = { ...node, parentId: parent.id, name, nameKey: key, trashed: false, trashedAt: null, updatedAt: now(), updatedBy: who(ctx) };
484
+ await tx.put(NODES, restored.id, restored);
485
+ await tx.put(NAMES, await claimId(ctx.owner, parent.id, key), { nodeId: restored.id, owner: ctx.owner, parentId: parent.id });
486
+ return restored;
487
+ });
488
+ },
489
+ /** Live items that match every field given, sorted by path. */
490
+ async search(ctx, q = {}) {
491
+ const limit = q.limit ?? 100;
492
+ if (!Number.isInteger(limit) || limit < 1) throw new RangeError("limit must be an integer of 1 or more");
493
+ const all = await ownerNodes(ctx);
494
+ const byId = new Map(all.map((n) => [n.id, n]));
495
+ const needle = q.name?.normalize("NFC").toLowerCase();
496
+ const hits = [];
497
+ for (const node of all) {
498
+ const path = pathIn(byId, node, true);
499
+ if (path === null || node.purging) continue;
500
+ if (needle !== void 0 && !node.nameKey.includes(needle)) continue;
501
+ if (q.kind && node.kind !== q.kind) continue;
502
+ if (q.mime && !(q.mime.endsWith("/*") ? node.mime?.startsWith(q.mime.slice(0, -1)) : node.mime === q.mime)) continue;
503
+ if (q.minSize !== void 0 && node.size < q.minSize) continue;
504
+ if (q.maxSize !== void 0 && node.size > q.maxSize) continue;
505
+ if (q.createdByKind && node.createdBy.kind !== q.createdByKind) continue;
506
+ hits.push({ node, path });
507
+ }
508
+ return hits.sort((a, b) => a.path < b.path ? -1 : 1).slice(0, limit);
509
+ },
510
+ /** Every item of the owner, trashed or not, with its original path and bytes. For export. */
511
+ async exportData(ctx) {
512
+ const all = await ownerNodes(ctx);
513
+ const byId = new Map(all.map((n) => [n.id, n]));
514
+ const out = [];
515
+ for (const node of all.sort((a, b) => a.id < b.id ? -1 : 1)) {
516
+ const blob = node.blobKey ? await blobs.get(node.blobKey) : null;
517
+ out.push({ node, path: pathIn(byId, node, false), data: blob?.data ?? null, missing: node.blobKey !== null && !blob });
518
+ }
519
+ return out;
520
+ },
521
+ /** Deletes every item and blob of the owner for good. Only the owner may do this, never an agent for them. A failed call can run again. */
522
+ async deleteAll(ctx) {
523
+ if (ctx.actor.kind !== "human" || ctx.actor.id !== ctx.owner) throw new DriveError("FORBIDDEN", "only the owner in person can delete the whole drive");
524
+ await need(ctx, "delete", rootNode(ctx));
525
+ let removed = 0;
526
+ for (let pass = 0; pass < 5; pass++) {
527
+ const all = await ownerNodes(ctx, true);
528
+ if (all.length === 0) return { removed };
529
+ removed += await removeNodes(all);
530
+ }
531
+ throw new DriveError("BUSY", "the drive kept changing while it was deleted. Run it again.");
532
+ },
533
+ /** Deletes a trashed item for good, with everything under it and all their bytes. */
534
+ async purge(ctx, id) {
535
+ const top = await fetch(store, ctx, id, true);
536
+ if (!top || top.id === ROOT_ID) throw new DriveError("NOT_FOUND", `no item ${id}`);
537
+ if (!top.trashed) throw new DriveError("NOT_TRASHED", `${top.name} is not in the trash`);
538
+ await need(ctx, "delete", top);
539
+ const nodes = [];
540
+ for (const queue = [top]; queue.length; ) {
541
+ const node = queue.pop();
542
+ nodes.push(node);
543
+ if (node.kind === "folder") queue.push(...await children(store, node.owner, node.id));
544
+ }
545
+ return { removed: await removeNodes(nodes, top) };
546
+ }
547
+ };
548
+ return api;
549
+ }
550
+
551
+ // src/zip.ts
552
+ var CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
553
+ let c = n;
554
+ for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
555
+ return c >>> 0;
556
+ });
557
+ function crc32(data) {
558
+ let crc = 4294967295;
559
+ for (const byte of data) crc = CRC_TABLE[(crc ^ byte) & 255] ^ crc >>> 8;
560
+ return (crc ^ 4294967295) >>> 0;
561
+ }
562
+ function safeEntryName(name) {
563
+ if (name === "" || /[\\\u0000]/.test(name) || name.startsWith("/") || /^[a-zA-Z]:/.test(name)) return false;
564
+ return name.split("/").every((part) => part !== "" && part !== "." && part !== "..");
565
+ }
566
+ function writeZip(entries, at) {
567
+ for (const { name } of entries) if (!safeEntryName(name)) throw new Error(`zip: unsafe entry name ${JSON.stringify(name)}`);
568
+ const d = new Date(at);
569
+ const time = d.getUTCHours() << 11 | d.getUTCMinutes() << 5 | d.getUTCSeconds() >> 1;
570
+ const date = Math.max(d.getUTCFullYear(), 1980) - 1980 << 9 | d.getUTCMonth() + 1 << 5 | d.getUTCDate();
571
+ const parts = [];
572
+ const central = [];
573
+ let offset = 0;
574
+ const u16 = (v, o, n) => v.setUint16(o, n, true);
575
+ const u32 = (v, o, n) => v.setUint32(o, n, true);
576
+ for (const entry of entries) {
577
+ const name = new TextEncoder().encode(entry.name);
578
+ const crc = crc32(entry.data);
579
+ if (entry.data.byteLength >= 4294967295 || offset >= 4294967295 || entries.length >= 65535) throw new Error("zip: export is too large for a plain zip");
580
+ const local = new DataView(new ArrayBuffer(30));
581
+ u32(local, 0, 67324752);
582
+ u16(local, 4, 20);
583
+ u16(local, 6, 2048);
584
+ u16(local, 8, 0);
585
+ u16(local, 10, time);
586
+ u16(local, 12, date);
587
+ u32(local, 14, crc);
588
+ u32(local, 18, entry.data.byteLength);
589
+ u32(local, 22, entry.data.byteLength);
590
+ u16(local, 26, name.length);
591
+ u16(local, 28, 0);
592
+ const head = new DataView(new ArrayBuffer(46));
593
+ u32(head, 0, 33639248);
594
+ u16(head, 4, 20);
595
+ u16(head, 6, 20);
596
+ u16(head, 8, 2048);
597
+ u16(head, 10, 0);
598
+ u16(head, 12, time);
599
+ u16(head, 14, date);
600
+ u32(head, 16, crc);
601
+ u32(head, 20, entry.data.byteLength);
602
+ u32(head, 24, entry.data.byteLength);
603
+ u16(head, 28, name.length);
604
+ u32(head, 42, offset);
605
+ parts.push(new Uint8Array(local.buffer), name, entry.data);
606
+ central.push(new Uint8Array(head.buffer), name);
607
+ offset += 30 + name.length + entry.data.byteLength;
608
+ }
609
+ const size = central.reduce((n, p) => n + p.byteLength, 0);
610
+ const end = new DataView(new ArrayBuffer(22));
611
+ u32(end, 0, 101010256);
612
+ u16(end, 8, entries.length);
613
+ u16(end, 10, entries.length);
614
+ u32(end, 12, size);
615
+ u32(end, 16, offset);
616
+ const all = [...parts, ...central, new Uint8Array(end.buffer)];
617
+ const out = new Uint8Array(all.reduce((n, p) => n + p.byteLength, 0));
618
+ let at2 = 0;
619
+ for (const p of all) {
620
+ out.set(p, at2);
621
+ at2 += p.byteLength;
622
+ }
623
+ return out;
624
+ }
625
+ function readZip(bytes) {
626
+ const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
627
+ let end = bytes.byteLength - 22;
628
+ while (end >= 0 && v.getUint32(end, true) !== 101010256) end--;
629
+ if (end < 0) throw new Error("zip: no end signature");
630
+ const count = v.getUint16(end + 10, true);
631
+ const out = /* @__PURE__ */ new Map();
632
+ for (let i = 0, at = v.getUint32(end + 16, true); i < count; i++) {
633
+ if (v.getUint32(at, true) !== 33639248) throw new Error("zip: bad central signature");
634
+ if (v.getUint16(at + 10, true) !== 0) throw new Error("zip: compressed entries are not supported");
635
+ const crc = v.getUint32(at + 16, true);
636
+ const size = v.getUint32(at + 24, true);
637
+ const nameLen = v.getUint16(at + 28, true);
638
+ const local = v.getUint32(at + 42, true);
639
+ const name = new TextDecoder().decode(bytes.subarray(at + 46, at + 46 + nameLen));
640
+ if (v.getUint32(local, true) !== 67324752) throw new Error("zip: bad local signature");
641
+ const start = local + 30 + v.getUint16(local + 26, true) + v.getUint16(local + 28, true);
642
+ const data = bytes.slice(start, start + size);
643
+ if (crc32(data) !== crc) throw new Error(`zip: crc mismatch in ${name}`);
644
+ out.set(name, data);
645
+ at += 46 + nameLen + v.getUint16(at + 30, true) + v.getUint16(at + 32, true);
646
+ }
647
+ return out;
648
+ }
649
+
650
+ // src/export.ts
651
+ async function buildExport(drive, ctx, now = Date.now) {
652
+ const entries = [];
653
+ const items = (await drive.exportData(ctx)).map(({ node, path, data, missing }) => {
654
+ const file = data ? `files/${node.id}` : null;
655
+ if (data && file) entries.push({ name: file, data });
656
+ return {
657
+ id: node.id,
658
+ owner: node.owner,
659
+ path,
660
+ parentId: node.parentId,
661
+ kind: node.kind,
662
+ name: node.name,
663
+ size: node.size,
664
+ mime: node.mime,
665
+ hash: node.hash,
666
+ ref: node.ref,
667
+ createdAt: node.createdAt,
668
+ updatedAt: node.updatedAt,
669
+ createdBy: node.createdBy,
670
+ updatedBy: node.updatedBy,
671
+ trashed: node.trashed,
672
+ trashedAt: node.trashedAt,
673
+ file,
674
+ ...missing ? { missing: true } : {}
675
+ };
676
+ });
677
+ const manifest = { format: "agentic-drive-export", version: 1, exportedAt: now(), owner: ctx.owner, items };
678
+ return writeZip([{ name: "manifest.json", data: new TextEncoder().encode(JSON.stringify(manifest, null, 2)) }, ...entries], now());
679
+ }
680
+
681
+ // src/adapters/fs.ts
682
+ import { createHash } from "crypto";
683
+ import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, truncateSync, writeFileSync, writeSync } from "fs";
684
+ import { join } from "path";
685
+ import { assertBlobPutOptions, assertResponseHeaders, createMemoryStore, parseRegion } from "agentic-identity";
686
+ function createFileStore(dir, options = {}) {
687
+ mkdirSync(dir, { recursive: true });
688
+ const file = join(dir, "store.jsonl");
689
+ const inner = createMemoryStore(options);
690
+ const ready = (async () => {
691
+ if (!existsSync(file)) return;
692
+ const text = readFileSync(file, "utf8");
693
+ const lines = text.split("\n");
694
+ const tail = lines.pop();
695
+ const apply = async (changes2) => {
696
+ for (const [collection, id, doc] of changes2) await (doc ? inner.put(collection, id, doc) : inner.delete(collection, id));
697
+ };
698
+ for (const [i, line] of lines.entries()) {
699
+ if (line === "") continue;
700
+ try {
701
+ await apply(JSON.parse(line));
702
+ } catch {
703
+ throw new Error(`the journal ${file} has a bad line ${i + 1}`);
704
+ }
705
+ }
706
+ if (tail === "") return;
707
+ let changes;
708
+ try {
709
+ changes = JSON.parse(tail);
710
+ } catch {
711
+ truncateSync(file, Buffer.byteLength(text) - Buffer.byteLength(tail));
712
+ return;
713
+ }
714
+ try {
715
+ await apply(changes);
716
+ } catch {
717
+ throw new Error(`the journal ${file} has a bad last line`);
718
+ }
719
+ appendFileSync(file, "\n");
720
+ })();
721
+ let failed;
722
+ const keep = (changes) => {
723
+ if (changes.length === 0) return;
724
+ try {
725
+ const fd = openSync(file, "a");
726
+ try {
727
+ const line = Buffer.from(`${JSON.stringify(changes)}
728
+ `);
729
+ for (let done = 0; done < line.length; ) {
730
+ const wrote = writeSync(fd, line, done);
731
+ if (wrote <= 0) throw new Error("the journal write stopped short");
732
+ done += wrote;
733
+ }
734
+ fsyncSync(fd);
735
+ } finally {
736
+ closeSync(fd);
737
+ }
738
+ } catch (error) {
739
+ failed = error;
740
+ throw error;
741
+ }
742
+ };
743
+ const open = async () => {
744
+ await ready;
745
+ if (failed !== void 0) throw new Error("the journal could not be written, so the store is closed. Open it again.");
746
+ };
747
+ const recording = (ops, changes) => ({
748
+ ...ops,
749
+ put: async (collection, id, doc) => {
750
+ await ops.put(collection, id, doc);
751
+ changes.push([collection, id, doc]);
752
+ },
753
+ delete: async (collection, id) => {
754
+ await ops.delete(collection, id);
755
+ changes.push([collection, id, null]);
756
+ }
757
+ });
758
+ return {
759
+ region: inner.region,
760
+ get: async (c, id) => (await open(), inner.get(c, id)),
761
+ query: async (c, q) => (await open(), inner.query(c, q)),
762
+ async put(collection, id, doc) {
763
+ await open();
764
+ await inner.put(collection, id, doc);
765
+ keep([[collection, id, doc]]);
766
+ },
767
+ async delete(collection, id) {
768
+ await open();
769
+ await inner.delete(collection, id);
770
+ keep([[collection, id, null]]);
771
+ },
772
+ async transaction(fn) {
773
+ await open();
774
+ let changes = [];
775
+ const result = await inner.transaction((tx) => {
776
+ changes = [];
777
+ return fn(recording(tx, changes));
778
+ });
779
+ keep(changes);
780
+ return result;
781
+ }
782
+ };
783
+ }
784
+ var versionOf = (data) => createHash("sha256").update(data).digest("hex");
785
+ function createFileBlobs(dir, options = {}) {
786
+ mkdirSync(dir, { recursive: true });
787
+ const path = (key) => join(dir, createHash("sha256").update(key).digest("hex"));
788
+ const need = (key) => {
789
+ if (key === "") throw new Error("blob key must not be empty");
790
+ };
791
+ const now = options.now ?? Date.now;
792
+ return {
793
+ region: options.region === void 0 ? "unknown" : parseRegion(options.region),
794
+ async put(key, data, opts) {
795
+ need(key);
796
+ assertBlobPutOptions(opts);
797
+ const current = existsSync(path(key)) ? versionOf(readFileSync(path(key))) : void 0;
798
+ if (opts?.ifNoneMatch === "*" && current !== void 0 || opts?.ifMatch !== void 0 && current !== opts.ifMatch) return null;
799
+ const tmp = `${path(key)}.tmp`;
800
+ writeFileSync(tmp, data);
801
+ if (opts?.contentType) writeFileSync(`${path(key)}.type`, opts.contentType);
802
+ else rmSync(`${path(key)}.type`, { force: true });
803
+ renameSync(tmp, path(key));
804
+ return { version: versionOf(data) };
805
+ },
806
+ async get(key) {
807
+ need(key);
808
+ if (!existsSync(path(key))) return null;
809
+ const type = existsSync(`${path(key)}.type`) ? readFileSync(`${path(key)}.type`, "utf8") : void 0;
810
+ const data = new Uint8Array(readFileSync(path(key)));
811
+ return { data, version: versionOf(data), ...type ? { contentType: type } : {} };
812
+ },
813
+ async head(key) {
814
+ need(key);
815
+ if (!existsSync(path(key))) return null;
816
+ const type = existsSync(`${path(key)}.type`) ? readFileSync(`${path(key)}.type`, "utf8") : void 0;
817
+ const data = readFileSync(path(key));
818
+ return { size: data.length, version: versionOf(data), ...type ? { contentType: type } : {} };
819
+ },
820
+ async delete(key) {
821
+ need(key);
822
+ rmSync(path(key), { force: true });
823
+ rmSync(`${path(key)}.type`, { force: true });
824
+ },
825
+ /** A local path with its expiry. Nothing serves or checks it: it exists so the port is whole. */
826
+ async signedUrl(key, opts) {
827
+ need(key);
828
+ const { method, expiresInSeconds } = opts;
829
+ assertResponseHeaders(opts);
830
+ if (!Number.isInteger(expiresInSeconds) || expiresInSeconds < 1 || expiresInSeconds > 604800) {
831
+ throw new RangeError("expiresInSeconds must be an integer from 1 to 604800");
832
+ }
833
+ const extra = [
834
+ opts.responseContentDisposition === void 0 ? "" : `&disposition=${encodeURIComponent(opts.responseContentDisposition)}`,
835
+ opts.responseContentType === void 0 ? "" : `&type=${encodeURIComponent(opts.responseContentType)}`
836
+ ].join("");
837
+ return `file://${path(key)}?method=${method}&expires=${now() + expiresInSeconds * 1e3}${extra}`;
838
+ }
839
+ };
840
+ }
841
+
842
+ // src/local.ts
843
+ import { join as join2 } from "path";
844
+ function openLocal(options) {
845
+ const kind = options.kind ?? "human";
846
+ if (!ACTOR_KINDS.includes(kind)) throw new Error(`unknown actor kind "${kind}"`);
847
+ return {
848
+ drive: createDrive({ store: createFileStore(join2(options.dir, "db")), blobs: createFileBlobs(join2(options.dir, "blobs")) }),
849
+ ctx: { owner: options.owner, actor: { id: options.actor ?? options.owner, kind } }
850
+ };
851
+ }
852
+ function publicNode(node, path) {
853
+ const { nameKey: _nameKey, blobKey: _blobKey, purging: _purging, ...rest } = node;
854
+ return path === void 0 ? rest : { path, ...rest };
855
+ }
856
+
857
+ // src/upload.ts
858
+ var DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
859
+ var SIGNATURES = [
860
+ [[137, 80, 78, 71, 13, 10, 26, 10], "image/png"],
861
+ [[255, 216, 255], "image/jpeg"],
862
+ [[71, 73, 70, 56], "image/gif"],
863
+ [[37, 80, 68, 70, 45], "application/pdf"],
864
+ [[80, 75, 3, 4], "application/zip"],
865
+ [[31, 139], "application/gzip"]
866
+ ];
867
+ function sniffMime(head) {
868
+ const hit = SIGNATURES.find(([sig]) => sig.every((byte, i) => head[i] === byte));
869
+ if (hit) return hit[1];
870
+ const isWebp = [..."RIFF"].every((c, i) => head[i] === c.charCodeAt(0)) && [..."WEBP"].every((c, i) => head[8 + i] === c.charCodeAt(0));
871
+ return isWebp ? "image/webp" : null;
872
+ }
873
+ function looksLikeText(head) {
874
+ if (head.includes(0)) return false;
875
+ for (let cut = 0; cut < 4 && cut <= head.length; cut++) {
876
+ try {
877
+ new TextDecoder("utf-8", { fatal: true }).decode(head.subarray(0, head.length - cut));
878
+ return true;
879
+ } catch {
880
+ continue;
881
+ }
882
+ }
883
+ return false;
884
+ }
885
+ async function* iterate(source) {
886
+ if (Symbol.asyncIterator in source) {
887
+ yield* source;
888
+ return;
889
+ }
890
+ const reader = source.getReader();
891
+ try {
892
+ for (; ; ) {
893
+ const { done, value } = await reader.read();
894
+ if (done) return;
895
+ yield value;
896
+ }
897
+ } finally {
898
+ reader.releaseLock();
899
+ }
900
+ }
901
+ async function upload(drive, ctx, path, source, opts = {}) {
902
+ const max = opts.maxBytes ?? DEFAULT_MAX_BYTES;
903
+ const parts = [];
904
+ let size = 0;
905
+ for await (const chunk of iterate(source)) {
906
+ size += chunk.byteLength;
907
+ if (size > max) throw new DriveError("TOO_LARGE", `upload is over the limit of ${max} bytes`);
908
+ parts.push(chunk);
909
+ }
910
+ const data = new Uint8Array(size);
911
+ for (let at = 0, i = 0; i < parts.length; at += parts[i].byteLength, i++) data.set(parts[i], at);
912
+ const head = data.subarray(0, 512);
913
+ const mime = sniffMime(head) ?? (opts.mime !== void 0 ? safeMime(opts.mime) : looksLikeText(head) ? "text/plain" : "application/octet-stream");
914
+ return drive.putFile(ctx, path, data, { mime, parents: opts.parents, onConflict: opts.onConflict });
915
+ }
916
+
917
+ export {
918
+ ROOT_ID,
919
+ ACTOR_KINDS,
920
+ DriveError,
921
+ DEFAULT_URL_SECONDS,
922
+ safeMime,
923
+ parseName,
924
+ createDrive,
925
+ writeZip,
926
+ readZip,
927
+ buildExport,
928
+ createFileStore,
929
+ createFileBlobs,
930
+ openLocal,
931
+ publicNode,
932
+ DEFAULT_MAX_BYTES,
933
+ sniffMime,
934
+ upload
935
+ };