agentic-drive 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/README.md +32 -12
- package/SKILL.md +1 -1
- package/dist/chunk-3LISWG7H.js +65 -0
- package/dist/{chunk-QRGKXPLX.js → chunk-6CWBHSJ7.js} +489 -73
- package/dist/{chunk-4QE7E5UQ.js → chunk-RWMBGIAS.js} +1 -1
- package/dist/cli.js +22 -5
- package/dist/drive-DKOs1FyM.d.ts +325 -0
- package/dist/index.d.ts +12 -4
- package/dist/index.js +8 -2
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +20 -3
- package/dist/migrate-B79gRPMu.d.ts +29 -0
- package/dist/worker.d.ts +10 -10
- package/dist/worker.js +115 -38
- package/package.json +4 -2
- package/dist/drive-DRQ8LiHl.d.ts +0 -144
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
Read the breaking changes before you upgrade. The migration steps follow them.
|
|
6
|
+
|
|
7
|
+
### Breaking changes
|
|
8
|
+
|
|
9
|
+
- **Sign-in uses agentic-identity.** The Worker no longer reads `DRIVE_API_TOKENS`. A bearer token is now a credential in the identity store. It names a principal, and the whole delegation chain must be live. Tokens in `DRIVE_API_TOKENS` stop working when you deploy this release.
|
|
10
|
+
- **The owner of a drive is the root of the principal's chain.** Before, the owner was the `owner` string of the token. Now a person's drive has the id of that person's principal.
|
|
11
|
+
- **Items from before sharing need a backfill.** Sharing is on in the Worker. Each item needs a `resources` doc, and items from 0.1.0 do not have one. Until you run `backfillResources`, their owner gets `NOT_FOUND`. See the migration steps.
|
|
12
|
+
- **Folders nest 15 deep, not 256, in a drive with sharing on.** The Worker turns sharing on. Sharing checks at most 16 resources on a path, the drive root included. A deeper item, move, or restore fails with `TOO_DEEP`. A local drive without sharing keeps 256. An item already deeper than 15 folders cannot be reached with sharing. Move it up in 0.1.0 before you upgrade.
|
|
13
|
+
- **A locator with a `/` is a path.** A path is `/x` from the root, or `<folder id>/x` from a folder. Before, only a leading `/` made a path. Ids that the drive makes never contain `/`. A path without an id starts in the caller's own drive.
|
|
14
|
+
- **Trash asks for `write`, not `delete`.** An editor can trash an item. A custom `can` hook that allowed trash only for `delete` must change.
|
|
15
|
+
- **Big folders may not move.** In a drive with sharing on, a move or restore that must count the height of a folder with about 200 or more subfolders fails with `TOO_MANY_ITEMS`.
|
|
16
|
+
- **Restore into another folder needs `write` on that folder.** Before, a restore checked only the item.
|
|
17
|
+
- **`authenticate` has a new signature.** It was `authenticate(request, env)`. It is now `authenticate(request, store, now?)`, and it returns a promise. The `Ctx` it returns has a `principal` field.
|
|
18
|
+
- **Agent tokens follow their grant scopes.** An agent used to act with the full rights of its owner in that drive. Now each action needs a scope in the agent's grant. An agent without the scope gets `FORBIDDEN`.
|
|
19
|
+
- **`DELETE /account` and `delete-all` erase the person.** Before, they deleted the drive. Now they call `erasePerson`. That also deletes the drives of the person's agents that have their own identity, the shares they hold, and the invites they sent. What they made in other people's drives stays. Those items keep their kind, and the id of the person on them becomes `erased`. The result is `{ removed, grants, invites, anonymized }`. Before, it was `{ removed }`.
|
|
20
|
+
- **The export manifest is version 2.** Version 2 adds `subject`, `partial`, `authored`, `agents`, `shares`, and `audit`. The item entries do not change. An agent gets `partial: true` and none of the person-level parts. A reader that accepts only version 1 must accept version 2.
|
|
21
|
+
- **Items carry `createdById` and `updatedById`.** Erase and export use these top-level fields to find a person's work. Items written before 0.2.0 do not have them. Code that builds a `Node` value must set both fields.
|
|
22
|
+
- **New error codes.** `CROSS_DRIVE`, `INVALID_SHARE`, and `TOO_MANY_ITEMS` are new. Export and erase also fail with `TOO_MANY_ITEMS` when a person has more than 1000 agents. Code that handles every `ErrorCode` in a `Record` or a `switch` must handle them.
|
|
23
|
+
- **agentic-identity 0.3.0.** `grant` refuses to lower the last owner of a resource, which fails with `INVALID_SHARE`. `listAudit` is paged. It returns `{ entries, nextCursor }`, newest first, 50 entries a page by default and 200 at most. It used to return an array. Drive does not expose `listAudit`. Code that calls it directly must read `entries`, and must pass `nextCursor` as `cursor` to read on.
|
|
24
|
+
|
|
25
|
+
### Migration
|
|
26
|
+
|
|
27
|
+
Read the known limits before you move a live deployment. Run the steps in this order. The old Worker keeps writing until step 4, so the backfills in step 5 must come after the deploy.
|
|
28
|
+
|
|
29
|
+
The steps 3, 5, 6, and 8 run code that needs the D1 database. Run it in one one-off Worker, and delete it after the last step. The Worker checks a secret on every call, so nobody else can call it. It binds the same database as the main Worker, with the same `DRIVE_REGION`, and it has `compatibility_flags: ["nodejs_compat"]`, as the main Worker has:
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
import { createD1Store } from "agentic-identity/cloudflare";
|
|
33
|
+
import { issueCredential, parseRegion } from "agentic-identity";
|
|
34
|
+
import { backfillAuthorIds, backfillResources } from "agentic-drive/worker";
|
|
35
|
+
|
|
36
|
+
// Compares two strings in a time that does not depend on where they differ.
|
|
37
|
+
function same(a, b) {
|
|
38
|
+
const x = new TextEncoder().encode(a);
|
|
39
|
+
const y = new TextEncoder().encode(b);
|
|
40
|
+
let diff = x.length ^ y.length;
|
|
41
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
|
|
42
|
+
return diff === 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Each step takes one call. The body of a POST is JSON.
|
|
46
|
+
const steps = {
|
|
47
|
+
// POST a principal document, such as { "id": "u1", "kind": "human", "scopes": ["drive:admin"] }.
|
|
48
|
+
principal: (store, body) => store.put("principals", body.id, body),
|
|
49
|
+
// Runs one page. Pass the `nextCursor` of the last call as `cursor`.
|
|
50
|
+
authors: (store, body, cursor) => backfillAuthorIds(store, { cursor, maxPages: 1 }),
|
|
51
|
+
resources: (store, body, cursor) => backfillResources(store, { cursor, maxPages: 1 }),
|
|
52
|
+
// POST { "principalId": "u1", "label": "laptop" }. The token comes back once, so send it only to your terminal.
|
|
53
|
+
token: async (store, body) => ({ token: await issueCredential(store, { principalId: body.principalId, kind: "api_key", label: body.label }) }),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export default {
|
|
57
|
+
async fetch(request, env) {
|
|
58
|
+
const sent = /^Bearer (.+)$/.exec(request.headers.get("authorization") ?? "")?.[1] ?? "";
|
|
59
|
+
if (!env.MIGRATE_TOKEN || !same(sent, env.MIGRATE_TOKEN)) return new Response("forbidden", { status: 403 });
|
|
60
|
+
const url = new URL(request.url);
|
|
61
|
+
const step = url.searchParams.get("step");
|
|
62
|
+
if (!Object.hasOwn(steps, step)) return new Response("unknown step", { status: 400 });
|
|
63
|
+
const body = request.method === "POST" ? await request.json() : {};
|
|
64
|
+
const store = createD1Store(env.DB, { region: parseRegion(env.DRIVE_REGION ?? "eu") });
|
|
65
|
+
return Response.json((await steps[step](store, body, url.searchParams.get("cursor") ?? undefined)) ?? { ok: true });
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Set the secret with `wrangler secret put MIGRATE_TOKEN`. Send it as `authorization: Bearer <token>` with each call.
|
|
71
|
+
|
|
72
|
+
1. Check the ids. Every owner and actor id in `DRIVE_API_TOKENS` must match `^[A-Za-z0-9_-]{1,128}$`, the rule that agentic-identity 0.3.0 applies to share subjects. 0.1.0 allowed any text, such as an email address. A deployment with an id that fails this rule cannot move to 0.2.0 in this release, because there is no re-key step. Stay on 0.1.0 for it.
|
|
73
|
+
2. Install the new versions: `npm install agentic-drive@0.2.0 agentic-identity@^0.3.0`.
|
|
74
|
+
3. Write the principals, with the old ids. The owner of a drive is the principal id, and the old data keeps the old ids. Old tokens had no scopes. Give `drive:admin` to keep the old rights. The root principal of each owner must keep `drive:write` at least, because its first call creates the drive root. Narrow the agents, not the owner. The 0.1.0 data does not say what kind an owner is. Use `human` for a person. Use `agent` only for a machine that acts with no person. A wrong kind breaks the chains under that owner. POST each principal to `?step=principal`:
|
|
75
|
+
- An owner of kind `human` is a root `human`. Its agents are `agent_of_human`, with `parentId` set to the owner. Its `user_token` actors are `user_token`, with `parentId` set to the owner. An `agent_of_agent` sits under an `agent_of_human`. 0.1.0 recorded no parent ids, so choose one, and keep the chain to 5 links (`MAX_CHAIN_DEPTH`).
|
|
76
|
+
- An owner of kind `agent` is a root `agent`. Its actors are `agent_of_agent`, with `parentId` set to the owner. The same 5-link limit applies.
|
|
77
|
+
- A human actor who is not the owner keeps its old actor id, as a root `human`. Step 7 shares the folders with it. It keeps no path access to the owner's drive. See the known limits.
|
|
78
|
+
4. Deploy the 0.2.0 Worker. From this point, nobody can sign in until step 6.
|
|
79
|
+
5. Run the backfills once the old Worker has stopped writing. Call `?step=authors`, then `?step=resources`, and pass each `nextCursor` as `cursor`. Stop when a call returns no `nextCursor`. A second run of either step changes nothing. If a call fails, run it again with the last cursor that worked. A damaged item stops a step. Fix or delete that item, then run it again.
|
|
80
|
+
6. Issue a bearer token for each owner: POST `{ "principalId": "u1", "label": "..." }` to `?step=token`. Give it to the owner. The token is shown only once. Then make one call as each owner, for example `GET /api/v1/drive/items?path=/`. That call creates the drive's root, which the items inherit their access from. Until a person makes it, an agent without `drive:write` that calls first gets `NOT_FOUND`.
|
|
81
|
+
7. Share with each human actor that is not the owner. As the owner, share the root `/` and each folder that the actor used. Use the role `owner`, which keeps its old rights. Before that, share each folder with the owner as `owner`, so that the last-owner rule still leaves an owner when the actor's grant changes later. Do this after step 6, because a share needs the item's `resources` doc and the drive root.
|
|
82
|
+
8. Issue the tokens for the agents and the other human actors: POST to `?step=token` for each, and give them out.
|
|
83
|
+
9. Remove the secrets: `npx wrangler secret delete DRIVE_API_TOKENS`, and delete the one-off Worker.
|
|
84
|
+
10. If your code calls `authenticate`, await it and pass the store: `await authenticate(request, store)`.
|
|
85
|
+
11. Tell your users that `DELETE /account?confirm=yes` and `agentic-drive delete-all --yes` now erase their agents and their shares too. Neither can be undone.
|
|
86
|
+
|
|
87
|
+
Known limits of this release:
|
|
88
|
+
|
|
89
|
+
- **OIDC sign-in gets a new id.** agentic-identity gives an OIDC person the id `u_` plus a hash of the issuer and the subject. That id is not the old owner string. A person who signs in through OIDC does not see a drive made under the old owner string. Use bearer tokens for those drives until a re-key step ships.
|
|
90
|
+
- **There is no re-key step.** This release does not move items from one owner id to another. Keep the old ids as principal ids, as step 3 does.
|
|
91
|
+
- **An id that fails the share rule blocks the upgrade.** See step 1.
|
|
92
|
+
- **Old items keep the kind they had in 0.1.0.** New items carry the kind of their new principal, which can differ, as step 3 shows. So a search by `--created-by-kind` sees the old and the new work under different kinds.
|
|
93
|
+
- **A human who is not the owner has no path access to the owner's drive.** Its paths resolve in its own drive. It reaches the owner's items by id, once they are shared with it.
|
|
94
|
+
|
|
95
|
+
### Added
|
|
96
|
+
|
|
97
|
+
- App documents. An app puts a document in the drive with `registerDocument`. `forgetDocument` moves every reference to it to the trash. `home` lists files and app documents together. `search` takes an `app` filter.
|
|
98
|
+
- Sharing with roles (`viewer`, `commenter`, `editor`, `owner`). Invites by email or handle, share links with an optional expiry, and a signed-in-only option.
|
|
99
|
+
- `exportPerson`, `erasePerson`, `backfillAuthorIds`, and `backfillResources`. The two backfills are exported from `agentic-drive` and `agentic-drive/worker`.
|
|
100
|
+
- HTTP: `GET /home`, `POST /documents`, `DELETE /documents`, and the `/shares`, `/invites`, `/links`, and `/shared` routes.
|
|
101
|
+
- CLI: `home`, `register`, and `forget`.
|
|
102
|
+
- MCP: `drive_home`, `drive_register_document`, and `drive_forget_document`.
|
|
103
|
+
|
|
104
|
+
### Changed
|
|
105
|
+
|
|
106
|
+
- CLI `delete-all` erases the person, not only the drive. See the breaking changes.
|
|
107
|
+
- The package builds before it is packed (`prepack`). The tarball holds the built files, the docs, and this changelog. The publish workflow passes `--ignore-scripts`, so it packs the `dist/` that its build step made. Keep that build step before publish.
|
|
108
|
+
- The publish workflow runs the build, the typecheck, and the tests on Node 22 with no `--if-present`, as CI does. Both workflows set `WRANGLER_SEND_METRICS` to `false`.
|
|
109
|
+
|
|
110
|
+
## 0.1.0
|
|
111
|
+
|
|
112
|
+
- First release. A drive of files and folders, with uploads to R2, search, export, hard delete, the CLI, the MCP server, and the Worker API.
|
package/README.md
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
Store, sort, and share files and folders.
|
|
9
9
|
|
|
10
|
-
> Status: early
|
|
11
|
-
>
|
|
10
|
+
> Status: early. The library, CLI, MCP server, and HTTP API work, with sharing in the SDK and the HTTP API.
|
|
11
|
+
> The web UI is not built yet.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -51,7 +51,6 @@ A Cloudflare Worker (Hono) serves `/api/v1/drive` and mirrors the CLI. It uses D
|
|
|
51
51
|
through the shared adapters of `agentic-identity`.
|
|
52
52
|
|
|
53
53
|
```sh
|
|
54
|
-
npx wrangler secret put DRIVE_API_TOKENS # {"<token of 32+ characters>":{"owner":"u1","actor":"u1","kind":"human"}}
|
|
55
54
|
npx wrangler secret put DRIVE_URL_SECRET # 32 bytes or more. It signs download URLs.
|
|
56
55
|
npx wrangler secret put DRIVE_PUBLIC_URL # The origin of the Worker, such as https://drive.example.com
|
|
57
56
|
npx wrangler deploy
|
|
@@ -60,7 +59,17 @@ curl -H "authorization: Bearer <token>" "$URL/api/v1/drive/items?path=/"
|
|
|
60
59
|
|
|
61
60
|
Routes: `GET /items`, `GET /stat`, `POST /folders`, `PUT /files`, `GET /files`, `GET /url`,
|
|
62
61
|
`POST /move`, `POST /trash`, `GET /trash`, `POST /restore`, `POST /purge`, `GET /search`,
|
|
63
|
-
`GET /export`, `DELETE /account?confirm=yes
|
|
62
|
+
`GET /export`, `DELETE /account?confirm=yes`, `GET /home`, `POST /documents`, `DELETE /documents?ref=`.
|
|
63
|
+
Sign in with a bearer token from agentic-identity. The token is a credential in the `credentials` collection of
|
|
64
|
+
the same D1 database, and it names a principal in `principals`. The Worker resolves the whole delegation chain on
|
|
65
|
+
every request. A missing, expired, or revoked credential, or a revoked or expired principal (or one above it), gets 401.
|
|
66
|
+
A session cookie is refused: only a bearer token counts. An agent acts in its owner's drive with the narrower scopes of its grant.
|
|
67
|
+
Scopes are `drive:read`, `drive:write`, and `drive:admin`.
|
|
68
|
+
|
|
69
|
+
Sharing routes: `GET /shares?path=`, `POST /shares`, `DELETE /shares?path=&subjectId=`, `POST /invites`,
|
|
70
|
+
`POST /invites/accept`, `POST /links`, `DELETE /links?id=`. A link visitor reads with the header
|
|
71
|
+
`x-link-token` on `GET /shared/:id`, `GET /shared/:id/items`, and `GET /shared/:id/content`.
|
|
72
|
+
Name a shared item by its id. A path can start at a folder id: `<folder id>/notes/a.txt`.
|
|
64
73
|
Without `DRIVE_URL_SECRET` or `DRIVE_PUBLIC_URL`, every request gets 503.
|
|
65
74
|
Create the R2 bucket with `--jurisdiction eu` and the D1 database with `--location weur`.
|
|
66
75
|
The Worker declares region `eu` and refuses a D1 query that a non-EU region served. `DRIVE_REGION=unknown` turns this off for local runs.
|
|
@@ -73,16 +82,24 @@ The Worker declares region `eu` and refuses a D1 query that a non-EU region serv
|
|
|
73
82
|
- Names compare without regard to case or Unicode form. A clash fails, or renames, or replaces.
|
|
74
83
|
- Each item records its size, type, owner, and who made and last changed it, with the actor kind.
|
|
75
84
|
- Storage sits behind the `Store` and `Blobs` ports of `agentic-identity`. Tests use its memory adapters. The CLI uses a local file adapter that passes the same contract suites. The Worker uses its D1 and R2 adapters.
|
|
85
|
+
- Roles: viewer, commenter, editor, owner. A role on a folder reaches everything under it, and follows an item that moves. Identity decides every request through `can()`, which also writes the audit entry. A caller who cannot read an item gets `NOT_FOUND`. A caller who can read it but not change it gets `FORBIDDEN`. Editors write and trash. An item takes the roles of its new folder, so moving an item into a folder where the caller is owner needs owner on the item. Only owners share, purge, and read the share list.
|
|
86
|
+
- Share by person or agent id, by invite (an email for a person, a handle for an agent), or by link with a role, an expiry, and an optional sign-in. The SDK calls are `shareWith`, `unshare`, `invite`, `acceptInvite`, `createLink`, `revokeLink`, `shares`, `openShared`, `listShared`, and `readShared`.
|
|
76
87
|
- Two live items never share a name in one folder. A claim document holds each name. It is written in the same transaction as the item, and it is deleted when the item goes to the trash.
|
|
77
88
|
- `upload()` reads a stream with a size cap, finds the type from the first bytes, and stores a SHA-256 hash.
|
|
78
89
|
- `downloadUrl()` returns a signed URL. The URL signs an attachment header with the file name and the stored type. The Worker verifies it and serves the bytes with those headers, so the link holder cannot change how the bytes are served. A link lasts 15 minutes by default and 1 hour at most. Trashing a file does not revoke a link already issued.
|
|
79
90
|
- `search()` finds live items by name, type, kind, size, and who made them.
|
|
80
|
-
- `
|
|
81
|
-
- `
|
|
82
|
-
-
|
|
91
|
+
- App documents: an app calls `registerDocument({ ref: "docs:<id>", name, folder })` to put a document in the drive, and `forgetDocument(ref)` when it deletes the document. `home({ app })` lists files and every app's documents together, newest change first, with a count per app. `search({ app })` filters the same way. The CLI has `home`, `register`, and `forget`. The MCP server has `drive_home`, `drive_register_document`, and `drive_forget_document`.
|
|
92
|
+
- `buildExport()` writes everything one person has to a zip with a `manifest.json` (format version 2). It names the person by id and kind. It lists the person's own items (trashed ones are flagged, and a file whose bytes are gone is marked `missing`). For the person in person it also lists the ids of what they or their agents made or changed in other drives (no names, and nothing of the people who own them), the shares they hold, the invites they sent, and the audit entries of what they and their agents did. An agent gets `partial: true` and none of that. Invitee emails and link tokens are never in it. Entries are named by item id.
|
|
93
|
+
- `erasePerson()` erases a person for good, and only the person in person can call it (the CLI `delete-all` and `DELETE /account` call it). It deletes their drive with all its shares, the shares they hold, and the invites they or their agents sent, on drive items only: the grants of other apps stay for those apps and for identity. What they or their agents made in other drives stays, because it belongs to those drives: the id becomes `erased`, and the kind stays, so machine-made work keeps its label. Agents are found through the `principals` collection, as identity finds them: an agent with its own identity names the person as `ownerId`, and a delegated agent names its parent. An agent with its own identity belongs to the person, and so does its drive: erase deletes it, and the export lists its items (no bytes). A person with more than 1000 agents cannot be erased here: the call fails and deletes nothing more. The audit trail stays: it holds ids and kinds only. A failed call can run again. `deleteAll()` is the drive part alone.
|
|
94
|
+
- A `can()` hook decides who may read, write, or delete. Without `sharing`, the default allows the owner only.
|
|
83
95
|
|
|
84
96
|
## Limits
|
|
85
97
|
|
|
98
|
+
- Erase does not delete what identity keeps about the person: their principal, credentials, and sign-in record. Identity erases those. An invite that names only an email, sent by someone else, stays until it expires.
|
|
99
|
+
- Items made before drive kept `createdById` and `updatedById` have no such fields, so an export or an erase cannot find them. Run `backfillAuthorIds(store, { maxPages: 1 })` on a store of that age, once the old Worker has stopped writing. Each call runs `maxPages` pages of 10 items, and returns a `nextCursor` while items remain. Pass the cursor to the next call until none is returned. Without `maxPages`, one call runs every page, which can exceed a Worker's query cap on a large store.
|
|
100
|
+
- There are no retention settings yet, and no per-workspace export. An export covers one person.
|
|
101
|
+
- `registerDocument` makes one item per document in a drive. Two calls at the same moment can make two.
|
|
102
|
+
|
|
86
103
|
- Drive reads the `store_docs` table in D1 and `store.jsonl` in the CLI folder. It does not read older layouts. There is no import and no claim backfill.
|
|
87
104
|
- Uploads buffer the stream in memory up to the cap. The default cap is 25 MiB.
|
|
88
105
|
- Local file blobs return a `file:` URL that nothing serves or checks.
|
|
@@ -91,7 +108,7 @@ The Worker declares region `eu` and refuses a D1 query that a non-EU region serv
|
|
|
91
108
|
- A claimed type that a browser would run (HTML, SVG, scripts) is stored as `application/octet-stream`.
|
|
92
109
|
- The file adapters allow one process at a time.
|
|
93
110
|
- The MCP `drive_export` tool writes a new `.zip` into `<dir>/exports` and never overwrites a file. It takes a plain file name, not a path.
|
|
94
|
-
- A purge or an erase works in chunks of 25 items, and each chunk commits. A Worker call has a cap on database queries, so a very large tree can need several calls. A failed or cut-off call leaves marked items hidden, and the same call finishes the job.
|
|
111
|
+
- A purge or an erase works in chunks of 25 items (10 with sharing on), and each chunk commits. A Worker call has a cap on database queries, so a very large tree can need several calls. A failed or cut-off call leaves marked items hidden, and the same call finishes the job.
|
|
95
112
|
- Hard delete removes blobs first and then rows. If a blob delete fails, the call throws and the same call can run again.
|
|
96
113
|
- The export holds every file in memory. A very large drive needs a streaming export, which is not built yet.
|
|
97
114
|
- The zip export stores entries without compression and has no Zip64 support.
|
|
@@ -99,10 +116,13 @@ The Worker declares region `eu` and refuses a D1 query that a non-EU region serv
|
|
|
99
116
|
- The D1 store checks every document a transaction read. If another Worker changed one, the transaction runs again, and after 50 tries it fails with a `BUSY` error.
|
|
100
117
|
- The D1 store keeps one generic table. A busy deployment needs an expression index on `owner` and `parentId`.
|
|
101
118
|
- `region: "eu"` on the D1 and R2 adapters is a promise made at deploy time. The D1 adapter refuses a query that a non-EU region served. The code cannot check where the bucket lives.
|
|
102
|
-
-
|
|
103
|
-
-
|
|
104
|
-
-
|
|
105
|
-
|
|
119
|
+
- Sharing is on in the Worker and in the SDK (`createDrive({ sharing })`). The CLI and the MCP server work on a local drive with one owner, so they do not share yet.
|
|
120
|
+
- Drive writes the `resources` doc of each item itself, in the same transaction, because identity has no call that does it in a transaction. A drive with `sharing` off keeps no such docs. Turn sharing on at the start, or run `backfillResources` after you turn it on.
|
|
121
|
+
- An invite sends no message. The caller passes the invite id on.
|
|
122
|
+
- Sharing follows a path of 16 resources, the drive root included, so an item sits at most 15 folders deep. A deeper path, move, or restore fails with `TOO_DEEP`. A move or a restore of a folder spends up to 200 queries on its subfolders (never on files), once, outside the transaction, to count its height. A move up or sideways, a rename, and a file need no count. A bigger folder fails with `TOO_MANY_ITEMS`: move its parts one by one. An edit inside the folder while it moves can leave items past the cap. Nobody can then reach them, and moving the folder back up repairs it.
|
|
123
|
+
- Every access check writes one audit row, and a transaction that runs again writes its rows again. The audit holds ids and kinds only: no emails and no content.
|
|
124
|
+
- Data made before sharing has no `resources` docs, so its owner gets `NOT_FOUND`. Run `backfillResources(store, { maxPages: 1 })` on that store, paged the same way, once the old Worker has stopped writing. See CHANGELOG.md for the order of the upgrade steps.
|
|
125
|
+
- A person who is not an owner cannot move an item into their own drive: the move fails with `CROSS_DRIVE`.
|
|
106
126
|
## Develop
|
|
107
127
|
|
|
108
128
|
```sh
|
package/SKILL.md
CHANGED
|
@@ -20,6 +20,6 @@ await drive.mkdir(me, "/notes");
|
|
|
20
20
|
|
|
21
21
|
## Rules
|
|
22
22
|
|
|
23
|
-
- A path
|
|
23
|
+
- A locator with a `/` is a path: `/x` from the root, or `<folder id>/x` from a folder. Any other locator is an id.
|
|
24
24
|
- Trash before purge.
|
|
25
25
|
- Report a `DriveError` as a failure.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ROOT_ID,
|
|
3
|
+
rootResource
|
|
4
|
+
} from "./chunk-6CWBHSJ7.js";
|
|
5
|
+
|
|
6
|
+
// src/migrate.ts
|
|
7
|
+
var PAGE = 10;
|
|
8
|
+
var isText = (v) => typeof v === "string" && v !== "";
|
|
9
|
+
async function walk(store, options, fix) {
|
|
10
|
+
const { maxPages } = options;
|
|
11
|
+
if (maxPages !== void 0 && !(Number.isSafeInteger(maxPages) && maxPages > 0)) {
|
|
12
|
+
throw new Error("maxPages must be a positive whole number");
|
|
13
|
+
}
|
|
14
|
+
let fixed = 0;
|
|
15
|
+
let cursor = options.cursor;
|
|
16
|
+
for (let pages = 0; maxPages === void 0 || pages < maxPages; pages++) {
|
|
17
|
+
const page = await store.query("drive_nodes", { limit: PAGE, cursor });
|
|
18
|
+
fixed += await store.transaction(async (tx) => {
|
|
19
|
+
const items = [];
|
|
20
|
+
for (const item of page.items) {
|
|
21
|
+
const data = await tx.get("drive_nodes", item.id);
|
|
22
|
+
if (data) items.push({ id: item.id, data });
|
|
23
|
+
}
|
|
24
|
+
return fix(tx, items);
|
|
25
|
+
});
|
|
26
|
+
cursor = page.nextCursor;
|
|
27
|
+
if (!cursor) return { fixed };
|
|
28
|
+
}
|
|
29
|
+
return { fixed, nextCursor: cursor };
|
|
30
|
+
}
|
|
31
|
+
function backfillAuthorIds(store, options = {}) {
|
|
32
|
+
return walk(store, options, async (tx, items) => {
|
|
33
|
+
let count = 0;
|
|
34
|
+
for (const { id, data } of items) {
|
|
35
|
+
const created = data.createdBy?.id;
|
|
36
|
+
const updated = data.updatedBy?.id;
|
|
37
|
+
if (!isText(created) || !isText(updated)) {
|
|
38
|
+
throw new Error(`item ${id} has no author. Fix it before you run the backfill again.`);
|
|
39
|
+
}
|
|
40
|
+
if (data.createdById === created && data.updatedById === updated) continue;
|
|
41
|
+
await tx.put("drive_nodes", id, { ...data, createdById: created, updatedById: updated });
|
|
42
|
+
count++;
|
|
43
|
+
}
|
|
44
|
+
return count;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function backfillResources(store, options = {}) {
|
|
48
|
+
return walk(store, options, async (tx, items) => {
|
|
49
|
+
const missing = [];
|
|
50
|
+
for (const { id, data } of items) {
|
|
51
|
+
if (!isText(data.owner) || !isText(data.parentId)) {
|
|
52
|
+
throw new Error(`item ${id} has no owner or parent. Fix it before you run the backfill again.`);
|
|
53
|
+
}
|
|
54
|
+
if (await tx.get("resources", id)) continue;
|
|
55
|
+
missing.push({ id, parentId: data.parentId === ROOT_ID ? await rootResource(data.owner) : data.parentId });
|
|
56
|
+
}
|
|
57
|
+
for (const { id, parentId } of missing) await tx.put("resources", id, { id, type: "drive", parentId });
|
|
58
|
+
return missing.length;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
backfillAuthorIds,
|
|
64
|
+
backfillResources
|
|
65
|
+
};
|