@hachej/boring-files 0.0.0-stage → 0.2.1

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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -2
  3. package/dist/client.d.ts +12 -0
  4. package/dist/client.js +1 -0
  5. package/dist/contracts.d.ts +194 -0
  6. package/dist/contracts.js +1 -0
  7. package/dist/git.d.ts +105 -0
  8. package/dist/git.js +205 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.js +1 -0
  11. package/dist/journal.d.ts +83 -0
  12. package/dist/journal.js +124 -0
  13. package/dist/node-http.d.ts +23 -0
  14. package/dist/node-http.js +102 -0
  15. package/dist/platform.d.ts +22 -0
  16. package/dist/platform.js +157 -0
  17. package/dist/publication-input.d.ts +15 -0
  18. package/dist/publication-input.js +164 -0
  19. package/dist/remote-handler.d.ts +11 -0
  20. package/dist/remote-handler.js +63 -0
  21. package/dist/remote-protocol.d.ts +34 -0
  22. package/dist/remote-protocol.js +154 -0
  23. package/dist/remote.d.ts +26 -0
  24. package/dist/remote.js +92 -0
  25. package/dist/request-guard.d.ts +20 -0
  26. package/dist/request-guard.js +75 -0
  27. package/dist/revision-contracts.d.ts +89 -0
  28. package/dist/revision-contracts.js +8 -0
  29. package/dist/revision-handler.d.ts +14 -0
  30. package/dist/revision-handler.js +213 -0
  31. package/dist/revision-protocol.d.ts +22 -0
  32. package/dist/revision-protocol.js +106 -0
  33. package/dist/revision.d.ts +8 -0
  34. package/dist/revision.js +145 -0
  35. package/dist/sqlite-batch.d.ts +19 -0
  36. package/dist/sqlite-batch.js +7 -0
  37. package/dist/sqlite-durable-object.d.ts +24 -0
  38. package/dist/sqlite-durable-object.js +49 -0
  39. package/dist/sqlite-filesystem.d.ts +16 -0
  40. package/dist/sqlite-filesystem.js +423 -0
  41. package/dist/sqlite-settings.d.ts +61 -0
  42. package/dist/sqlite-settings.js +104 -0
  43. package/dist/sqlite.d.ts +37 -0
  44. package/dist/sqlite.js +50 -0
  45. package/dist/text.d.ts +16 -0
  46. package/dist/text.js +29 -0
  47. package/dist/workspace-catalog.d.ts +17 -0
  48. package/dist/workspace-catalog.js +147 -0
  49. package/dist/workspace-identity.d.ts +8 -0
  50. package/dist/workspace-identity.js +1 -0
  51. package/dist/workspace.d.ts +108 -0
  52. package/dist/workspace.js +649 -0
  53. package/package.json +119 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Julien Hurault
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,216 @@
1
- # Temporary Holding Version
1
+ # @hachej/boring-files
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Versioned resources over a workspace's files: the contracts, and the workspace provider over Pi's own FileSystem (no Boring file system). Reader, publisher and lookup capabilities are independently injectable; the aggregate ResourceProvider advertises actual support/grants/availability/qualified guarantees. ResourceClient carries browser intent, not trusted host ResourceAccess.
4
+
5
+ Publication requires nonempty changes and explicit atomicity. The trusted boundary computes the canonical digest and checks approval; those are not client-authored authority fields. Receipts identify valid create/replace/delete transitions; partial outcomes retain operation, input index and full target/view. Reconciliation is optional and required explicitly where claimed; not-found is not proof of no effect.
6
+
7
+ Opaque resource view IDs are provider-scoped and bound to the actual backing/incarnation by the provider. Import contracts with `import type`.
8
+
9
+ `@hachej/boring-files/workspace` exports `createWorkspaceProvider({ identity, fs, journal })`: the one provider of a workspace's files ([One place for files](../../docs/architecture/FILES-GIT-EXEC.md#one-place-for-files)). It takes a Pi `FileSystem` (a disk environment, the virtual workspace or the SQLite backend below), the host's workspace identity and a journal (`createWorkspaceJournal(connection)` from `@hachej/boring-files/journal`, receipts, intents and the last 20 versions of each file in SQLite). Revisions are Git blob ids of the bytes. A conditional write checks every expected revision and precondition inside the workspace's queue, records its intent first and its receipt last; an intent without a completion looks up as `unknown` and is never replayed. Its owner closes the connection; the provider borrows it. Node currently marks `node:sqlite` experimental.
10
+
11
+ The provider accepts all-or-nothing create and replace. It refuses working views, per-change requests and deletes (files are removed with the workspace's own tools) before any effect. Logical paths are relative to the workspace root; a path whose real location leaves the root is denied. A single file is replaced atomically by every backend; a multi-file batch only by the SQLite backend, together with its receipt when the journal shares the database. Access control is the host's, at the workspace boundary: there are no per-file permissions inside a workspace, and external revocation fencing is not implemented.
12
+
13
+ ### Agent writes and the change feed
14
+
15
+ Agent writes are observed, never receipted ([owner ruling 2](../../docs/architecture/FILES-GIT-EXEC.md#one-place-for-files)). `provider.record({ path, before?, after, source: 'agent', conversationId? }, access)` is what the file guard (`@hachej/boring-agent/file-guard`) calls after a Pi `write` or `edit` succeeded: the file's history gains the written revision with `source: 'agent'` (and the replaced revision, `source: 'observed'`, when `before` carries its bytes), and the change feed an `agent` event. It never writes the publication journal and never mints a receipt. It does not enter the provider's queue (the guard already runs inside it), so calling it from queued work cannot deadlock. History entries (`saves`, `catalog.history`) carry `source`: `publish`, `agent` or `observed` (`keep` and replaced revisions).
16
+
17
+ `provider.changes({ since?, signal }, access)` is one change feed for every file view: an `AsyncIterable` of `{ kind: 'change', path, revision | null, source: 'publish' | 'agent' | 'poll' | 'watch', seq, at }`, ordered by `seq` and replayable from `since` (the last `seq` seen; `changeHead()` is "now"). The provider retains the last `changeBuffer` events (default 1024); a cursor older than that, from another provider lifetime or ahead of this one, and a listener that falls behind get `{ kind: 'resnapshot', seq, at }`: reload everything shown, then continue. Events are invalidation hints; bytes and the journal stay authoritative. Sources: conditional writes (`publish`), `record` (`agent`), `poll` and, while someone listens (and `watchLingerMs` after, default 30 s), the file system's own `watch` (`watch`; a reported directory is `{ revision: null, directory: true }`). The SQLite file system reports writes through its own connection at once and polls every second for other connections. When the file system cannot watch (the virtual and remote environments report `not_supported`), the host calls `provider.poll()` at the end of each agent turn: without paths it hashes every file (not `.git` or `node_modules`); the first call takes the baseline, later ones report changed, new and removed files. A `bash` write gets change events only, never history. After the last listener leaves the watcher lingers and then closes; changes made while no watcher was open are unknown, so the first listener afterwards makes the feed append a `resnapshot` (a client that stays connected is never affected). `changeIncarnation` names the provider lifetime: a cursor sent with another `incarnation` is a `resnapshot` whatever its number. `provider.close()` ends every change stream, closes the watcher and drops the listeners.
18
+
19
+ Receipts are scoped by workspace identity, principal, scope and initiator. Identical committed retries return the original receipt; changed arguments conflict. Refused operations do not reserve an ID. A receipt from an earlier incarnation of the workspace reads `unknown`. A not-found lookup is not permission to replay an uncertain operation.
20
+
21
+ `@hachej/boring-files/publication` is browser-safe. It captures request bytes, computes a versioned canonical SHA-256 digest with WebCrypto, and parses result shapes. Digests carry `boring-publication-v1:sha256:` and bind that format identifier in the hashed payload. Consumers must also check operation, target, scope and index association against their own request. This module confers no authorization. The SQLite connection (`openNodeConnection` from `@hachej/boring-files/sqlite`) is a separate server entry point.
22
+
23
+ `@hachej/boring-files/sqlite` exports `openNodeConnection(filename, settings?)` (a `node:sqlite` file as a `SqliteConnection`) and `openNodeDatabase(filename, settings?)` (the configured `DatabaseSync`, for Pi's public adapter: `SqliteStorage.open(new NodeSqliteDatabase(openNodeDatabase(file, settings)))`). `settings` is `SqliteSettings` `{ journalMode?: 'wal' | 'delete' | 'truncate' | 'persist', lockingMode?: 'normal' | 'exclusive', busyTimeoutMs?, synchronous?: 'off' | 'normal' | 'full' | 'extra', tempStore?: 'default' | 'file' | 'memory' }`; missing fields come from `sqliteSettings.localDisk` (WAL, normal locking, 5 s busy timeout, full sync: the behaviour before settings existed). `sqliteSettings.networkFilesystem` is for one process owning a file on NFS such as EFS: rollback journal, `exclusive` locking with the lock taken at open, 10 s busy timeout, full sync, temporary tables in memory; another connection to that file fails with `SqliteLockedError` (`code: 'sqlite-locked'`) after its busy timeout. Names and values are allow-listed (`TypeError` otherwise, before the file is opened); there is no free-form pragma. The settings live in `@hachej/boring-files/sqlite-settings` (pure, browser-safe; `sqlitePragmas(settings)` is what the browser's SQLite Wasm uses), and `npm run check` refuses `PRAGMA journal_mode`/`locking_mode` assignments anywhere else. The journal, the metering ledger and the SQLite file system borrow a connection and never set pragmas.
24
+
25
+ `@hachej/boring-files/sqlite-filesystem` exports `openSqliteFileSystem({ connection, workspace, cwd })`: a Pi `FileSystem` whose regular files and directories are rows of one workspace in a `SqliteConnection` (`openNodeConnection`, `durableObjectSqliteConnection`, or `openBrowserSqliteConnection` from the private, unpublished `@hachej/boring-browser/sqlite`, available only inside this monorepo), with the `incarnation` of those rows. Like Pi's `NodeExecutionEnv` (which Pi's `write` tool relies on), `writeFile` creates missing parent directories; a parent that is a file still fails. Given to `createWorkspaceProvider` it is the SQLite workspace backend ([One place for files](../../docs/architecture/FILES-GIT-EXEC.md#one-place-for-files)): multi-file conditional batches commit in one transaction, together with the receipt when the journal uses the same connection. It implements Pi 1.1's full `FileSystem` (positional readers whose `scanLines` is Pi's own `LineScanner`, paged directory readers, and a polling `watch`) and answers with Pi's own `FileError`s, so this entry loads `@earendil-works/pi-durable/env` (Pi's portable contracts, no engine): install `@earendil-works/pi-durable` with it. The other entries of this package do not need Pi.
26
+
27
+ See [scaffold guide](../../docs/contracts/SCAFFOLD.md) and [resource design](../../docs/architecture/FILES-GIT-EXEC.md).
28
+
29
+ ## Node hosts
30
+
31
+ `@hachej/boring-files/node-http` bridges `node:http` to the Fetch handlers of these packages: `webRequest(incoming, url, { signal, maxBytes })` reads the body first under a byte cap (never `Readable.toWeb` on a live request, whose cancellation kills the process) and `sendWebResponse(response, outgoing, { signal })` flushes the headers at once, keeps each `set-cookie`, waits for `drain` when the socket is full and cancels the web body when the client disconnects. The example servers use it; `npm run check` refuses hand-written response copies under `examples/`.
32
+
33
+ ## Optional authenticated resource transport
34
+
35
+ `@hachej/boring-files/remote` exports `createResourceClient` and `createResourceHandler`. Both are Fetch adapters over the existing resource contracts. They import no Pi runtime, filesystem or database. The client implements `ResourceClient`; `publication: true` and `reconciliation: true` independently select its optional methods. The handler borrows a reader, optional publisher and optional lookup. It never closes them.
36
+
37
+ ```ts
38
+ const handler = createResourceHandler({
39
+ authenticate: request => host.authenticateResourceRequest(request),
40
+ reader: provider,
41
+ publisher: provider.publication,
42
+ lookup: provider.reconciliation,
43
+ });
44
+ const client = createResourceClient({
45
+ identity: { principalId, scopeId, initiatorId },
46
+ endpoint: 'https://example.invalid/resources',
47
+ fetch: authenticatedFetch,
48
+ publication: true,
49
+ reconciliation: true,
50
+ });
51
+ ```
52
+
53
+ `publication: true` requires `reconciliation: true` (types and constructor): a save whose acknowledgement is lost is `unknown`, and only a lookup can resolve it. A read-only client sets neither.
54
+
55
+ Authentication returns trusted `ResourceAccess` or null. Its optional signal represents host revocation; the handler combines it with request cancellation. Client identity is expected response metadata, never authority. The host owns credentials, origin/CSRF/CORS policy, endpoint routing and provider confinement. The injected Fetch must honor `redirect: 'error'`, preserve cancellation and never retry effects. Bind a handler to the host's selected provider capabilities. Browser JSON cannot grant access or supply a trusted digest or approval.
56
+
57
+ The private versioned JSON envelope captures publication bytes before any asynchronous work. Protocol version 2 carries bytes as canonical base64 text (version 1 integer arrays are refused). Default limits are 4 MiB per serialized request and 8 MiB per serialized response, configurable up to 64 MiB. Incoming bodies use a bounded growing buffer. These are accepted-message limits, not limits on Fetch allocation, provider output, local request capture, JSON expansion or digest memory. Hosts must bound their trusted inputs and provider production separately.
58
+
59
+ All-or-nothing receipts must match the captured operation, canonical digest, actor and complete target transitions. Receipt order is preserved but does not determine association. Per-change requests are refused before dispatch; partial acknowledgements are unqualified and become unknown. A real partial provider must establish its receipt convention before that transport guarantee is added.
60
+
61
+ Transport errors, mismatched or oversized acknowledgements and aborted observation produce unknown publication/lookup outcomes. They never manufacture a known-no-commit denial or provider not-found, and never trigger replay. Read transport failures produce unavailable. Lookup checks operation and actor association; the caller still checks the digest and transitions against its captured original attempt. An operation ID alone cannot establish those arguments. Not-found does not authorize replay.
62
+
63
+ Revocation suppresses disclosure until the handler constructs its response; already delivered bytes cannot be retracted. Aborting observation does not prove provider termination, rollback or an atomic revocation fence. Provider exceptions and response serialization failures may follow a commit, so the client retains uncertainty. The workspace provider's declared guarantees remain unchanged.
64
+
65
+ Run `node examples/resource-transport.mjs` for a fictional exact-save and lookup journey through real Request/Response objects. Public-output tests also compose a native ToolTask and lost-acknowledgement Markdown reconciliation. The isolated document consumer exercises this adapter with strict types and a browser bundle without Pi, agent or React installed. These checks do not qualify deployed HTTP, browser identity policy or remote provider lifecycle.
66
+
67
+ `@hachej/boring-files/text` supplies the shared pure `applyTextEdits` transform and input parser. Edits run in order, require unique nonempty exact matches and preserve untouched whitespace. A failed edit refuses the whole transformation. It performs no I/O and grants no permission.
68
+
69
+ ## Optional working Git
70
+
71
+ `@hachej/boring-files/git` requires pinned `isomorphic-git@1.42.6`. `createGitRepository` borrows its public `PromiseFsClient`, an absolute normalized directory, a host author and a current `authorize(operation)` callback. The callback grants the bound caller access to the entire selected repository, including worktree and metadata. Only exact `true` grants access. It runs after queue acquisition and again before returning results. Share one repository instance across callers. Its queue serializes those calls only; other instances, raw filesystem writers and aliases require underlying host policy. Relative argument validation does not confine an unrestricted filesystem or symlink targets.
72
+
73
+ `createEnvGitFs(fileSystem, context)` is the `PromiseFsClient` over a Pi `FileSystem` (an `ExecutionEnv`'s files): every Git read and write goes through that environment, so its confinement applies to Git too. Files read as mode `0o100644` (Pi's file contract has no modes) and symbolic links are refused (`ENOSYS`), never followed.
74
+
75
+ Methods are `init`, matrix `status`, `add`, index-only `remove`, `commit`, twenty-entry `log`, `branches`, `branch`, `checkout` and `diff`. Diff compares exact tree refs/object IDs, index and worktree. It returns changed blob paths, modes, object IDs and byte arrays, including untracked worktree files. It excludes `.git`, rejects unsupported entry types and reports limit errors without truncated success. Default returned-content limits are 100 files and 1 MiB. Traversal is serial, but upstream can allocate an entire blob before the byte limit is checked. These limits do not bound peak memory or repository traversal cost. Status and worktree diff disable native index refresh.
76
+
77
+ The host owns filesystem confinement, credentials and disposal. No network transport is installed. Cancellation is checked around native operations and during diff traversal; it cannot roll back Git mutations. `GitOperationError.effects` distinguishes pre-execution refusal from possible prior mutation. Git operations have no durable operation receipts, conditional publication or crash recovery guarantee.
78
+
79
+
80
+ ### Local publication preparation failures
81
+
82
+ `PublicationNotDispatchedError` from `@hachej/boring-files/publication` identifies a valid publication request that the remote client could not dispatch. Its `operationId` identifies that invocation. Local byte-limit, unsupported atomicity, digest, request-construction and pre-dispatch cancellation failures use this error. The error does not establish the outcome of earlier attempts with the same operation ID. Retain and reconcile any earlier uncertain attempt.
83
+
84
+ Once the fetch callback is called, transport exceptions, HTTP errors and invalid acknowledgements remain `unknown`, including a callback that throws the same error class after committing. A lookup `not-found` still does not prove that an interrupted request had no effect. The text controllers use the typed error only for their freshly generated operation, releasing that attempt so the user can save a corrected draft.
85
+
86
+ SQLite reads and receipt lookups of the SQLite workspace backend use deferred read transactions; a conditional write keeps its `BEGIN IMMEDIATE` transaction for its files and receipt. Tests hold an independent WAL writer transaction and read the previous committed file and receipt through the provider. This qualifies that WAL contention case; opening the database and arbitrary SQLite locking modes can still require write access or wait.
87
+
88
+ ## Migrating from the removed resource store
89
+
90
+ The SQLite resource store of `@hachej/boring-files/sqlite` (`openSqliteResources`, `SqliteResourceProvider` and its option types) is
91
+ removed. Its documents become files of a workspace; `SqliteConnection` and `openNodeConnection` stay in `@hachej/boring-files/sqlite`.
92
+ A host that kept one store with rows per scope (the redaction and current-hub fixtures here) opens one SQLite-backed workspace per
93
+ scope in the same database:
94
+
95
+ | Old call | New call |
96
+ | --- | --- |
97
+ | `openSqliteResources({ filename, providerId, authorize })` | `connection = openNodeConnection(filename)`, `journal = createWorkspaceJournal(connection)`, then per scope `fs = openSqliteFileSystem({ connection, workspace: scopeId, cwd: '/workspace' })` and `createWorkspaceProvider({ identity: { providerId, instanceId: scopeId, incarnation: fs.incarnation, viewId: 'published' }, fs, journal })` |
98
+ | `openSqliteResources({ connection, ... })` (a Durable Object) | the same calls with `durableObjectSqliteConnection(ctx.storage)` from `@hachej/boring-files/sqlite-durable-object` |
99
+ | `authorize(action, target, access)` inside the store | the host checks its policy before calling the workspace's provider; [`examples/shared/sqlite-workspaces.mjs`](../../examples/shared/sqlite-workspaces.mjs) shows one way (a refused write whose operation committed and a refused lookup read `unknown`) |
100
+ | `provider.close()` | `connection.close()` by the connection's owner |
101
+ | a `delete` change | the workspace's own file tools (`fs.remove`, Pi's `bash`); publication refuses deletes |
102
+ | revisions were random ids; saving the same bytes made a new revision | revisions are Git blob ids: the same bytes keep their revision, and `before` equal to `after` is a valid committed change |
103
+ | every revision retained | the last 20 versions per file (`createWorkspaceJournal(connection, { historyLimit })`); older ones read `unavailable` |
104
+
105
+ Existing store databases are not migrated: their `boring_documents` rows are not read by the workspace backend. Copy each current
106
+ document into its workspace with a create, or start from the files.
107
+
108
+ ## Frontend revision provider
109
+
110
+ `@hachej/boring-files/revision-handler` serves an existing workspace provider. The
111
+ host resolves its OAuth session and selected workspace on every request:
112
+
113
+ ```ts
114
+ const handleFiles = createRevisionHandler({
115
+ resolve: async request => {
116
+ const session = await authenticate(request);
117
+ if (!session) return null;
118
+ return {
119
+ provider: workspaces.workspace(session.workspaceId),
120
+ access: {
121
+ principalId: session.userId,
122
+ scopeId: session.workspaceId,
123
+ initiatorId: session.userId,
124
+ },
125
+ };
126
+ },
127
+ });
128
+ ```
129
+
130
+ `authenticate` and `workspaces` are host objects. Import `createRevisionHandler`
131
+ from `@hachej/boring-files/revision-handler`. If acquisition returns a lease,
132
+ include `release`; the handler retains it until underlying operations settle,
133
+ including after request cancellation.
134
+
135
+ In the browser, import `connectRevisionProvider` from
136
+ `@hachej/boring-files/revision` and connect once per selected workspace:
137
+
138
+ ```ts
139
+ const revisionProvider = await connectRevisionProvider({
140
+ endpoint: new URL('/api/workspace', location.origin),
141
+ fetch: request => fetch(request),
142
+ });
143
+ ```
144
+
145
+ The binding discovers identity and workspace incarnation from the authenticated
146
+ server. Reconnect when the selected workspace changes. It exposes `list`,
147
+ filename `search`, retained `history`, `locate`, `read`, `publish`, `lookup` and
148
+ `changes({ since?, signal })`, the workspace's change feed.
149
+
150
+ The feed is `GET <endpoint>?op=changes[&since=<seq>&incarnation=<id>]` with the binding header:
151
+ server-sent events (`event: ready` with the current seq and the provider incarnation, then `event: change` or
152
+ `event: resnapshot` with `id:` the seq and the JSON event as `data:`), and a
153
+ comment line every `heartbeatMs` (`createRevisionHandler({ resolve, heartbeatMs })`,
154
+ default 15 s). It is a plain streamed response with no queue (pull-based), so a slow client
155
+ costs the provider's bounded replay buffer and a `resnapshot`, never memory in
156
+ the handler; revocation discards anything unread. It runs on Node and Workers
157
+ alike. The handler holds the workspace lease for the life of the stream and
158
+ releases it when the client disconnects or its access signal aborts; a request
159
+ the host refuses, or whose binding names another workspace or identity, gets no
160
+ event (403 or 409). The client reconnects after the last seq it delivered, with
161
+ backoff, until its `signal` aborts; a refusal ends it with a `RevisionError`.
162
+ All writes use conditional publication; uploading a new file requires absence,
163
+ and replacing one requires its exact resource revision. An unknown result must
164
+ be reconciled by operation ID before another upload to that path.
165
+
166
+ Catalog defaults hide Git-ignored files, dependency/build folders and internal
167
+ metadata. Configure `createWorkspaceProvider({ ..., catalog: { defaults: false,
168
+ filter: entry => ... } })` to override them. Filters govern presentation, not
169
+ access control. The host authorizes the whole selected workspace. Listings do
170
+ not follow symlinks. Pagination is a live, lexical view, not a frozen snapshot;
171
+ refresh after concurrent changes. Traversal over the configured `maxEntries`
172
+ (default 100,000) fails explicitly. History contains revisions retained by the
173
+ workspace journal, not Git commits or an imported filesystem's prior history.
174
+
175
+ The existing resource transport limits still apply: requests default to 4 MiB
176
+ and responses to 8 MiB, including their serialized envelope. This API does not
177
+ provide streaming uploads.
178
+
179
+ ### Durable Object apps
180
+
181
+ A Cloudflare Durable Object keeps its workspace in its own SQLite storage, and the Library binds to it the same way as a Node host. Each
182
+ piece is the one the Node example uses, with `durableObjectSqliteConnection(ctx.storage)` from
183
+ `@hachej/boring-files/sqlite-durable-object` in place of `openNodeConnection`. The workspace and its journal share that one connection,
184
+ so a multi-file save and its receipt commit in one `transactionSync`:
185
+
186
+ ```ts
187
+ import { durableObjectSqliteConnection } from '@hachej/boring-files/sqlite-durable-object';
188
+ import { openSqliteFileSystem } from '@hachej/boring-files/sqlite-filesystem';
189
+ import { createWorkspaceJournal } from '@hachej/boring-files/journal';
190
+ import { createWorkspaceProvider } from '@hachej/boring-files/workspace';
191
+ import { createRevisionHandler } from '@hachej/boring-files/revision-handler';
192
+
193
+ // In the object's constructor: one connection and journal, one provider per workspace the object serves.
194
+ const connection = durableObjectSqliteConnection(this.ctx.storage);
195
+ const journal = createWorkspaceJournal(connection);
196
+ const fs = openSqliteFileSystem({ connection, workspace: workspaceId, cwd: '/workspace' });
197
+ const provider = createWorkspaceProvider({
198
+ identity: { providerId: 'files', instanceId: workspaceId, incarnation: fs.incarnation, viewId: 'published' },
199
+ fs,
200
+ journal,
201
+ });
202
+
203
+ // The object's HTTP route: the host authenticates the request and picks the workspace it selected.
204
+ const handleFiles = createRevisionHandler({
205
+ resolve: async request => {
206
+ const session = await authenticate(request);
207
+ if (!session) return null;
208
+ return { provider: this.providerFor(session.workspaceId), access: { principalId: session.userId, scopeId: session.workspaceId, initiatorId: session.userId } };
209
+ },
210
+ });
211
+ ```
212
+
213
+ `history` is the journal's retained revisions for each file, newest first, with `savedAt` in milliseconds; a save made before this
214
+ journal recorded times reports `savedAt: 0`. The browser connects as in the Node case: `connectRevisionProvider({ endpoint, fetch })`
215
+ from `@hachej/boring-files/revision`. Each scope is its own workspace (rows keyed by `workspace`), so a request is served only from the
216
+ provider its `resolve` returns.
@@ -0,0 +1,12 @@
1
+ import type { ResourceRead, ReadResult, PublicationRequest, PublicationResult, PublicationLookupResult } from './contracts.js';
2
+ /** Authenticated host/client binding. No ResourceAccess, approval authority or
3
+ * trusted request digest is accepted from the browser. A request still requires
4
+ * real server validation. Uncertain saves can use optional reconciliation.
5
+ */
6
+ export interface ResourceClient {
7
+ readonly read: (request: ResourceRead, signal?: AbortSignal) => Promise<ReadResult>;
8
+ /** PublicationNotDispatchedError describes this call only; reconcile any earlier uncertain attempt separately. */
9
+ readonly publish?: (request: PublicationRequest, signal?: AbortSignal) => Promise<PublicationResult>;
10
+ readonly lookup?: (operationId: string, signal?: AbortSignal) => Promise<PublicationLookupResult>;
11
+ }
12
+ //# sourceMappingURL=client.d.ts.map
package/dist/client.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,194 @@
1
+ /** Application resource/publication contracts, not Pi working-filesystem
2
+ * methods. Types constrain shape; host authorization and provider guarantees
3
+ * still require runtime validation and qualification.
4
+ */
5
+ export type NonEmpty<Value> = readonly [Value, ...Value[]];
6
+ export interface ResourceId {
7
+ readonly providerId: string;
8
+ readonly path: string;
9
+ }
10
+ export type ResourceView = {
11
+ readonly kind: 'published';
12
+ } | {
13
+ readonly kind: 'working';
14
+ /** Opaque provider-scoped view identity. The provider binds it to the
15
+ * actual backing/incarnation; never reuse it for a different view.
16
+ * A resource view need not be a native workspace or remote machine.
17
+ */
18
+ readonly viewId: string;
19
+ };
20
+ export interface ResourceLocator {
21
+ readonly resource: ResourceId;
22
+ readonly view: ResourceView;
23
+ }
24
+ export interface ResourceRef extends ResourceLocator {
25
+ readonly revision: string;
26
+ }
27
+ export interface ResourceRead {
28
+ readonly target: ResourceLocator;
29
+ readonly revision: {
30
+ readonly kind: 'latest';
31
+ } | {
32
+ readonly kind: 'exact';
33
+ readonly value: string;
34
+ };
35
+ }
36
+ /** Trusted server context, never model/browser request authority. */
37
+ export interface ResourceAccess {
38
+ readonly principalId: string;
39
+ readonly scopeId: string;
40
+ readonly initiatorId: string;
41
+ readonly signal?: AbortSignal;
42
+ /** Host-resolved approval/policy binding, not a caller-provided grant. */
43
+ readonly authorizationRef?: string;
44
+ }
45
+ export interface ResourceSnapshot {
46
+ readonly ref: ResourceRef;
47
+ readonly bytes: Uint8Array;
48
+ readonly mediaType: string;
49
+ }
50
+ export type ReadResult = {
51
+ readonly kind: 'available';
52
+ readonly snapshot: ResourceSnapshot;
53
+ } | {
54
+ readonly kind: 'missing';
55
+ } | {
56
+ readonly kind: 'denied';
57
+ readonly reason: string;
58
+ } | {
59
+ readonly kind: 'unavailable';
60
+ readonly reason: string;
61
+ };
62
+ /** Reused for publication dependencies and a new/existing editor's save base. */
63
+ export type ResourceExpectation = {
64
+ readonly kind: 'absent';
65
+ readonly target: ResourceLocator;
66
+ } | {
67
+ readonly kind: 'revision';
68
+ readonly target: ResourceRef;
69
+ };
70
+ export type ResourceChange = {
71
+ readonly kind: 'create';
72
+ readonly target: ResourceLocator;
73
+ readonly expected: {
74
+ readonly kind: 'absent';
75
+ };
76
+ readonly bytes: Uint8Array;
77
+ readonly mediaType: string;
78
+ } | {
79
+ readonly kind: 'replace';
80
+ readonly target: ResourceRef;
81
+ readonly bytes: Uint8Array;
82
+ readonly mediaType: string;
83
+ } | {
84
+ readonly kind: 'delete';
85
+ readonly target: ResourceRef;
86
+ };
87
+ /** Caller expresses intent. The trusted boundary snapshots mutable bytes,
88
+ * validates the request and computes its canonical digest; the caller cannot
89
+ * assert that two different payloads have the same trusted digest.
90
+ */
91
+ export interface PublicationRequest {
92
+ readonly operationId: string;
93
+ readonly changes: NonEmpty<ResourceChange>;
94
+ readonly atomicity: 'all-or-nothing' | 'per-change';
95
+ /** Extra exact-content/absence dependencies, in addition to change targets.
96
+ * Search/listing predicate evidence needs its separately qualified provider
97
+ * integration; this minimal contract does not pretend to implement it.
98
+ */
99
+ readonly preconditions?: readonly ResourceExpectation[];
100
+ }
101
+ export type CommittedChange = {
102
+ readonly kind: 'create';
103
+ readonly before: null;
104
+ readonly after: ResourceRef;
105
+ } | {
106
+ readonly kind: 'replace';
107
+ readonly before: ResourceRef;
108
+ readonly after: ResourceRef;
109
+ } | {
110
+ readonly kind: 'delete';
111
+ readonly before: ResourceRef;
112
+ readonly after: null;
113
+ };
114
+ export interface PublicationReceipt {
115
+ readonly operationId: string;
116
+ /** Computed/checked by the trusted boundary, retained for reconciliation. */
117
+ readonly argumentDigest: string;
118
+ readonly principalId: string;
119
+ readonly scopeId: string;
120
+ readonly initiatorId: string;
121
+ readonly changes: NonEmpty<CommittedChange>;
122
+ readonly evidenceRef: string;
123
+ }
124
+ /** These are known no-commit refusals, not substitutes for an uncertain effect. */
125
+ export type PublicationRefusal = {
126
+ readonly kind: 'conflict';
127
+ readonly current: readonly ResourceRef[];
128
+ readonly reason: string;
129
+ } | {
130
+ readonly kind: 'denied';
131
+ readonly reason: string;
132
+ } | {
133
+ readonly kind: 'unavailable';
134
+ readonly reason: string;
135
+ };
136
+ export type PublicationOutcome = {
137
+ readonly kind: 'committed';
138
+ readonly receipt: PublicationReceipt;
139
+ } | PublicationRefusal | {
140
+ readonly kind: 'unknown';
141
+ readonly operationId: string;
142
+ readonly reason: string;
143
+ };
144
+ /** Result association survives multiple changes/views and lost responses.
145
+ * Providers validate index coverage/uniqueness and target/receipt agreement.
146
+ */
147
+ export interface PublicationItem {
148
+ readonly changeIndex: number;
149
+ readonly target: ResourceLocator;
150
+ readonly outcome: PublicationOutcome;
151
+ }
152
+ export type PublicationResult = PublicationOutcome | {
153
+ readonly kind: 'partial';
154
+ readonly operationId: string;
155
+ readonly items: NonEmpty<PublicationItem>;
156
+ };
157
+ export interface ProviderGuarantees {
158
+ readonly pinnedReads: boolean;
159
+ readonly conditionalPublication: boolean;
160
+ readonly atomicMutationAndReceipt: boolean;
161
+ readonly atomicBatch: boolean;
162
+ readonly operationLookup: boolean;
163
+ readonly revocationFencing: boolean;
164
+ }
165
+ export interface ResourceCapabilities {
166
+ readonly supported: readonly ('read' | 'create' | 'replace' | 'delete' | 'lookup')[];
167
+ readonly effective: readonly ('read' | 'create' | 'replace' | 'delete' | 'lookup')[];
168
+ readonly availability: 'available' | 'unavailable' | 'unknown';
169
+ readonly observedAt: string;
170
+ readonly guarantees: ProviderGuarantees;
171
+ }
172
+ /** Independently injectable read, publication and reconciliation capabilities. */
173
+ export interface ResourceReader {
174
+ readonly read: (request: ResourceRead, access: ResourceAccess) => Promise<ReadResult>;
175
+ }
176
+ export interface ResourcePublisher {
177
+ readonly publish: (request: PublicationRequest, access: ResourceAccess) => Promise<PublicationResult>;
178
+ }
179
+ export type PublicationLookupResult = PublicationResult | {
180
+ readonly kind: 'not-found';
181
+ };
182
+ export interface PublicationLookup {
183
+ /** Namespace includes the trusted caller/scope. Not-found means no retained
184
+ * receipt, not proof that a timed-out operation had no effect. No blind retry.
185
+ */
186
+ readonly lookup: (operationId: string, access: ResourceAccess) => Promise<PublicationLookupResult>;
187
+ }
188
+ export interface ResourceProvider extends ResourceReader {
189
+ readonly providerId: string;
190
+ readonly capabilities: (target: ResourceLocator, access: ResourceAccess) => Promise<ResourceCapabilities>;
191
+ readonly publication?: ResourcePublisher;
192
+ readonly reconciliation?: PublicationLookup;
193
+ }
194
+ //# sourceMappingURL=contracts.d.ts.map
@@ -0,0 +1 @@
1
+ export {};
package/dist/git.d.ts ADDED
@@ -0,0 +1,105 @@
1
+ import git from 'isomorphic-git';
2
+ import type { PromiseFsClient } from 'isomorphic-git';
3
+ import type { Context } from '@earendil-works/chord';
4
+ import type { FileSystem } from '@earendil-works/pi-durable/env';
5
+ export type GitOperation = 'init' | 'status' | 'add' | 'remove' | 'commit' | 'log' | 'branches' | 'branch' | 'checkout' | 'diff';
6
+ export type GitView = {
7
+ readonly kind: 'tree';
8
+ readonly ref: string;
9
+ } | {
10
+ readonly kind: 'index';
11
+ } | {
12
+ readonly kind: 'worktree';
13
+ };
14
+ export interface GitFileVersion {
15
+ readonly oid: string;
16
+ readonly mode: number;
17
+ readonly bytes: Uint8Array;
18
+ }
19
+ export interface GitFileChange {
20
+ readonly path: string;
21
+ readonly before: GitFileVersion | null;
22
+ readonly after: GitFileVersion | null;
23
+ }
24
+ export interface GitRepositoryOptions<FS extends PromiseFsClient> {
25
+ readonly fs: FS;
26
+ readonly directory: string;
27
+ readonly author: NonNullable<Parameters<typeof git.commit>[0]['author']>;
28
+ /** A current repository-wide grant for the host-bound caller, including all worktree and Git metadata. */
29
+ readonly authorize: (operation: GitOperation) => boolean | Promise<boolean>;
30
+ /** Returned diff limits. Upstream may allocate an entire blob before its size is checked. */
31
+ readonly diffLimits?: {
32
+ readonly files: number;
33
+ readonly bytes: number;
34
+ };
35
+ }
36
+ export declare class GitOperationError extends Error {
37
+ readonly effects: 'none' | 'possible';
38
+ constructor(message: string, effects: 'none' | 'possible', options?: ErrorOptions);
39
+ }
40
+ /** Borrow one filesystem and share this instance across callers. Raw filesystem writers do not take this queue. */
41
+ export declare function createGitRepository<FS extends PromiseFsClient>(options: GitRepositoryOptions<FS>): Readonly<{
42
+ fs: FS;
43
+ directory: string;
44
+ init: (signal?: AbortSignal) => Promise<void>;
45
+ status: (signal?: AbortSignal) => Promise<git.StatusRow[]>;
46
+ add: (path: string, signal?: AbortSignal) => Promise<void>;
47
+ remove: (path: string, signal?: AbortSignal) => Promise<void>;
48
+ commit: (message: string, signal?: AbortSignal) => Promise<string>;
49
+ log: (signal?: AbortSignal) => Promise<git.ReadCommitResult[]>;
50
+ branches: (signal?: AbortSignal) => Promise<string[]>;
51
+ branch: (ref: string, signal?: AbortSignal) => Promise<void>;
52
+ checkout: (ref: string, signal?: AbortSignal) => Promise<void>;
53
+ diff: (before: GitView, after: GitView, signal?: AbortSignal) => Promise<GitFileChange[]>;
54
+ }>;
55
+ export type GitRepository = ReturnType<typeof createGitRepository>;
56
+ /**
57
+ * isomorphic-git I/O over a Pi `FileSystem` (an `ExecutionEnv`'s files): every read and write goes through that environment, so its
58
+ * confinement (for example the AWS adapter's folder and symbolic link checks) also confines Git. No second checkout, no native Git.
59
+ * Modes are not part of Pi's file contract: files read as 0o100644. Symbolic links are refused (`ENOSYS`): Git fails on a worktree
60
+ * that contains one rather than following it.
61
+ */
62
+ export declare function createEnvGitFs(filesystem: FileSystem, context: Context): {
63
+ promises: {
64
+ readFile: (path: string, options?: string | {
65
+ encoding?: string;
66
+ }) => Promise<string | Uint8Array<ArrayBufferLike>>;
67
+ writeFile: (path: string, bytes: string | Uint8Array) => Promise<void>;
68
+ unlink: (path: string) => Promise<void>;
69
+ readdir: (path: string) => Promise<string[]>;
70
+ mkdir: (path: string) => Promise<void>;
71
+ rmdir: (path: string) => Promise<void>;
72
+ stat: (path: string) => Promise<{
73
+ type: string;
74
+ mode: number;
75
+ size: number;
76
+ mtimeMs: number;
77
+ ctimeMs: number;
78
+ uid: number;
79
+ gid: number;
80
+ dev: number;
81
+ ino: number;
82
+ isFile: () => boolean;
83
+ isDirectory: () => boolean;
84
+ isSymbolicLink: () => boolean;
85
+ }>;
86
+ lstat: (path: string) => Promise<{
87
+ type: string;
88
+ mode: number;
89
+ size: number;
90
+ mtimeMs: number;
91
+ ctimeMs: number;
92
+ uid: number;
93
+ gid: number;
94
+ dev: number;
95
+ ino: number;
96
+ isFile: () => boolean;
97
+ isDirectory: () => boolean;
98
+ isSymbolicLink: () => boolean;
99
+ }>;
100
+ readlink: (path: string) => Promise<never>;
101
+ symlink: (_target: string, path: string) => Promise<never>;
102
+ chmod: () => Promise<void>;
103
+ };
104
+ };
105
+ //# sourceMappingURL=git.d.ts.map