@hachej/boring-files 0.4.6 → 0.4.8

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/README.md CHANGED
@@ -8,11 +8,11 @@ Opaque resource view IDs are provider-scoped and bound to the actual backing/inc
8
8
 
9
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
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.
11
+ The provider accepts all-or-nothing create, replace and conditional delete, plus conditional rename through `renaming.rename`. It refuses working views, per-change requests and folder mutations 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
12
 
13
13
  ### Agent writes and the change feed
14
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).
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`, `observed` (`keep` and replaced revisions), `rename` or `delete` (the affected retained version). History remains one row per content revision, not an operation log; receipts retain every conditional mutation. A rename retains the moved version at both paths; a delete retains its removed version without inventing a tombstone revision. `savedAt` remains the version's first recorded save time at that path, not a separate deletion timestamp.
16
16
 
17
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
18
 
@@ -43,7 +43,7 @@ A large or binary file (up to 100 MB) can live in the host's blob store as an im
43
43
 
44
44
  ## Optional authenticated resource transport
45
45
 
46
- `@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.
46
+ `@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` selects `publish`, `rename` and `delete`, and `reconciliation: true` selects `lookup`. The handler borrows a reader, optional publisher, renamer, deleter and lookup. It never closes them.
47
47
 
48
48
  ```ts
49
49
  const handler = createResourceHandler({
@@ -51,6 +51,8 @@ const handler = createResourceHandler({
51
51
  reader: provider,
52
52
  publisher: provider.publication,
53
53
  lookup: provider.reconciliation,
54
+ renamer: provider.renaming,
55
+ deleter: provider.deletion,
54
56
  });
55
57
  const client = createResourceClient({
56
58
  identity: { principalId, scopeId, initiatorId },
@@ -61,7 +63,7 @@ const client = createResourceClient({
61
63
  });
62
64
  ```
63
65
 
64
- `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.
66
+ `publication: true` requires `reconciliation: true` (types and constructor): a mutation whose acknowledgement is lost is `unknown`, and only a lookup can resolve it. A read-only client sets neither. The handler answers 405 for an uninstalled capability; client opt-ins grant no server authority.
65
67
 
66
68
  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.
67
69
 
@@ -109,7 +111,7 @@ scope in the same database:
109
111
  | `openSqliteResources({ connection, ... })` (a Durable Object) | the same calls with `durableObjectSqliteConnection(ctx.storage)` from `@hachej/boring-files/sqlite-durable-object` |
110
112
  | `authorize(action, target, access)` inside the store | the host checks its policy before calling the workspace's provider; [`shared/sqlite-workspaces.mjs`](../../shared/sqlite-workspaces.mjs) shows one way (a refused write whose operation committed and a refused lookup read `unknown`) |
111
113
  | `provider.close()` | `connection.close()` by the connection's owner |
112
- | a `delete` change | the workspace's own file tools (`fs.remove`, Pi's `bash`); publication refuses deletes |
114
+ | a `delete` change | conditional publication with the observed revision, or `provider.deletion.delete({ path: locator, expectedRevision, operationId }, access)` |
113
115
  | 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 |
114
116
  | every revision retained | the last 20 versions per file (`createWorkspaceJournal(connection, { historyLimit })`); older ones read `unavailable` |
115
117
 
@@ -155,8 +157,43 @@ const revisionProvider = await connectRevisionProvider({
155
157
 
156
158
  The binding discovers identity and workspace incarnation from the authenticated
157
159
  server. Reconnect when the selected workspace changes. It exposes `list`,
158
- filename `search`, retained `history`, `locate`, `read`, `publish`, `lookup` and
159
- `changes({ since?, signal })`, the workspace's change feed.
160
+ filename `search`, retained `history`, `locate`, `read`, `publish`, `lookup`,
161
+ `rename`, `delete` and `changes({ since?, signal })`, the workspace's change feed.
162
+
163
+ ```ts
164
+ const moved = await revisionProvider.rename?.({
165
+ from: revisionProvider.locate('draft.md'),
166
+ to: revisionProvider.locate('final.md'),
167
+ expectedRevision: loaded.snapshot.ref.revision,
168
+ operationId: 'move-draft-1',
169
+ });
170
+ const removed = await revisionProvider.delete?.({
171
+ path: revisionProvider.locate('final.md'),
172
+ expectedRevision: loaded.snapshot.ref.revision,
173
+ operationId: 'remove-final-1',
174
+ });
175
+ ```
176
+
177
+ These methods send `rename` and `delete` kinds on the same authenticated files
178
+ endpoint. `from`, `to` and `path` are `ResourceLocator`s, not strings. Both require
179
+ the current source revision; rename also requires an absent target in the same
180
+ provider/view and an existing parent directory. Direct workspace callers use
181
+ `provider.renaming.rename(request, access)` and `provider.deletion.delete(request, access)`.
182
+ A rename receipt is a delete/create pair with the same content revision; its feed
183
+ emits old-path `revision: null` then the new-path revision, both `source: 'publish'`.
184
+ Delete emits `revision: null`. Stale revisions and existing file targets conflict.
185
+ Identical operation-ID retries and `lookup` return the original receipt; changed
186
+ arguments conflict. A lost reply stays `unknown`, never an automatic retry.
187
+ `createRevisionHandler` installs these capabilities from its selected provider
188
+ and holds the same workspace lease until they settle. Generic resource handlers
189
+ install `renamer`/`deleter` explicitly. Folders, recursive deletion and file-tree
190
+ Rename/Delete controls are not part of this endpoint increment.
191
+
192
+ Both operations use the workspace queue and Pi's native `renameFile`/`remove`.
193
+ SQLite (including the Durable Object adapter) reuses those filesystem mutations
194
+ inside the bytes/history/receipt transaction when the journal shares its connection.
195
+ Disk/remote backends retain their existing crash window: an effect without a
196
+ completed journal receipt is `unknown`. Shell writers remain outside the queue.
160
197
 
161
198
  The feed is `GET <endpoint>?op=changes[&since=<seq>&incarnation=<id>]` with the binding header:
162
199
  server-sent events (`event: ready` with the current seq and the provider incarnation, then `event: change` or
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ResourceRead, ReadResult, PublicationRequest, PublicationResult, PublicationLookupResult } from './contracts.js';
1
+ import type { ResourceRead, ReadResult, PublicationRequest, PublicationResult, PublicationLookupResult, ResourceRenameRequest, ResourceDeleteRequest } from './contracts.js';
2
2
  /** Authenticated host/client binding. No ResourceAccess, approval authority or
3
3
  * trusted request digest is accepted from the browser. A request still requires
4
4
  * real server validation. Uncertain saves can use optional reconciliation.
@@ -8,5 +8,7 @@ export interface ResourceClient {
8
8
  /** PublicationNotDispatchedError describes this call only; reconcile any earlier uncertain attempt separately. */
9
9
  readonly publish?: (request: PublicationRequest, signal?: AbortSignal) => Promise<PublicationResult>;
10
10
  readonly lookup?: (operationId: string, signal?: AbortSignal) => Promise<PublicationLookupResult>;
11
+ readonly rename?: (request: ResourceRenameRequest, signal?: AbortSignal) => Promise<PublicationResult>;
12
+ readonly delete?: (request: ResourceDeleteRequest, signal?: AbortSignal) => Promise<PublicationResult>;
11
13
  }
12
14
  //# sourceMappingURL=client.d.ts.map
@@ -171,8 +171,8 @@ export interface ProviderGuarantees {
171
171
  readonly revocationFencing: boolean;
172
172
  }
173
173
  export interface ResourceCapabilities {
174
- readonly supported: readonly ('read' | 'create' | 'replace' | 'delete' | 'lookup')[];
175
- readonly effective: readonly ('read' | 'create' | 'replace' | 'delete' | 'lookup')[];
174
+ readonly supported: readonly ('read' | 'create' | 'replace' | 'delete' | 'rename' | 'lookup')[];
175
+ readonly effective: readonly ('read' | 'create' | 'replace' | 'delete' | 'rename' | 'lookup')[];
176
176
  readonly availability: 'available' | 'unavailable' | 'unknown';
177
177
  readonly observedAt: string;
178
178
  readonly guarantees: ProviderGuarantees;
@@ -184,6 +184,24 @@ export interface ResourceReader {
184
184
  export interface ResourcePublisher {
185
185
  readonly publish: (request: PublicationRequest, access: ResourceAccess) => Promise<PublicationResult>;
186
186
  }
187
+ export interface ResourceRenameRequest {
188
+ readonly from: ResourceLocator;
189
+ readonly to: ResourceLocator;
190
+ readonly expectedRevision: string;
191
+ readonly operationId: string;
192
+ }
193
+ export interface ResourceDeleteRequest {
194
+ readonly path: ResourceLocator;
195
+ readonly expectedRevision: string;
196
+ readonly operationId: string;
197
+ }
198
+ /** Conditional regular-file mutations; receipts and lookup use the publication journal. */
199
+ export interface ResourceRenamer {
200
+ readonly rename: (request: ResourceRenameRequest, access: ResourceAccess) => Promise<PublicationResult>;
201
+ }
202
+ export interface ResourceDeleter {
203
+ readonly delete: (request: ResourceDeleteRequest, access: ResourceAccess) => Promise<PublicationResult>;
204
+ }
187
205
  export type PublicationLookupResult = PublicationResult | {
188
206
  readonly kind: 'not-found';
189
207
  };
@@ -198,5 +216,7 @@ export interface ResourceProvider extends ResourceReader {
198
216
  readonly capabilities: (target: ResourceLocator, access: ResourceAccess) => Promise<ResourceCapabilities>;
199
217
  readonly publication?: ResourcePublisher;
200
218
  readonly reconciliation?: PublicationLookup;
219
+ readonly renaming?: ResourceRenamer;
220
+ readonly deletion?: ResourceDeleter;
201
221
  }
202
222
  //# sourceMappingURL=contracts.d.ts.map
package/dist/journal.d.ts CHANGED
@@ -44,7 +44,7 @@ export interface StoredVersion {
44
44
  */
45
45
  readonly source?: HistorySource;
46
46
  }
47
- export type HistorySource = 'publish' | 'agent' | 'observed';
47
+ export type HistorySource = 'publish' | 'agent' | 'observed' | 'rename' | 'delete';
48
48
  /**
49
49
  * The operation journal plus what a workspace needs beyond receipts: an intent row written before an operation's first effect
50
50
  * (an intent without a completion is an operation of unknown outcome), and the last versions of each file when there is no Git.
package/dist/journal.js CHANGED
@@ -59,7 +59,7 @@ export function committedChanges(changes, ref) {
59
59
  throw new Error('Stored operation has no changes');
60
60
  return [first, ...rest];
61
61
  }
62
- const historySource = (value) => value === 'agent' || value === 'observed' ? value : 'publish';
62
+ const historySource = (value) => value === 'agent' || value === 'observed' || value === 'rename' || value === 'delete' ? value : 'publish';
63
63
  export function createWorkspaceJournal(db, options = {}) {
64
64
  const limit = options.historyLimit ?? 20;
65
65
  if (!Number.isSafeInteger(limit) || limit < 1)
@@ -1,4 +1,4 @@
1
- import type { PublicationRequest, PublicationResult, ResourceAccess, ResourceLocator, ResourceRef } from './contracts.js';
1
+ import type { PublicationRequest, PublicationResult, ResourceAccess, ResourceLocator, ResourceRef, ResourceRenameRequest, ResourceDeleteRequest } from './contracts.js';
2
2
  /** This invocation did not dispatch. Earlier attempts with the same ID may still have effects. */
3
3
  export declare class PublicationNotDispatchedError extends Error {
4
4
  readonly operationId: string;
@@ -9,6 +9,10 @@ export declare function locator(value: unknown): ResourceLocator;
9
9
  export declare function reference(value: unknown): ResourceRef;
10
10
  export declare function accessSnapshot(value: ResourceAccess): ResourceAccess;
11
11
  export declare function publicationSnapshot(value: unknown): PublicationRequest;
12
+ export declare function renameSnapshot(value: unknown): ResourceRenameRequest;
13
+ export declare function deleteSnapshot(value: unknown): ResourceDeleteRequest;
14
+ export declare function deletePublication(request: ResourceDeleteRequest): PublicationRequest;
15
+ export declare function renameDigest(input: ResourceRenameRequest): Promise<string>;
12
16
  export declare function publicationDigest(input: PublicationRequest): Promise<string>;
13
17
  /** Parse transport/provider output before treating it as acknowledgement. */
14
18
  export declare function parsePublicationResult(value: unknown): PublicationResult;
@@ -93,6 +93,26 @@ export function publicationSnapshot(value) {
93
93
  ...(input.preconditions === undefined ? {} : { preconditions: input.preconditions.map(expectation) }),
94
94
  };
95
95
  }
96
+ export function renameSnapshot(value) {
97
+ const input = object(value), from = locator(input.from), to = locator(input.to);
98
+ if (from.resource.providerId !== to.resource.providerId || JSON.stringify(from.view) !== JSON.stringify(to.view))
99
+ throw new TypeError('Rename requires one provider and view');
100
+ if (from.resource.path === to.resource.path || from.resource.path.startsWith(`${to.resource.path}/`) || to.resource.path.startsWith(`${from.resource.path}/`))
101
+ throw new TypeError('Rename requires independent file paths');
102
+ return { from, to, expectedRevision: identifier(input.expectedRevision), operationId: identifier(input.operationId) };
103
+ }
104
+ export function deleteSnapshot(value) {
105
+ const input = object(value);
106
+ return { path: locator(input.path), expectedRevision: identifier(input.expectedRevision), operationId: identifier(input.operationId) };
107
+ }
108
+ export function deletePublication(request) {
109
+ return { operationId: request.operationId, atomicity: 'all-or-nothing', changes: [{ kind: 'delete', target: { ...request.path, revision: request.expectedRevision } }] };
110
+ }
111
+ export async function renameDigest(input) {
112
+ const { from, to, expectedRevision } = renameSnapshot(input);
113
+ const digest = await sha256(new TextEncoder().encode(JSON.stringify({ format: 'boring-rename-v1', from, to, expectedRevision })));
114
+ return 'boring-rename-v1:sha256:' + Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('');
115
+ }
96
116
  export async function publicationDigest(input) {
97
117
  const request = publicationSnapshot(input);
98
118
  const canonical = JSON.stringify({
@@ -1,9 +1,11 @@
1
- import type { PublicationLookup, ResourceAccess, ResourcePublisher, ResourceReader } from './contracts.js';
1
+ import type { PublicationLookup, ResourceAccess, ResourcePublisher, ResourceReader, ResourceRenamer, ResourceDeleter } from './contracts.js';
2
2
  export interface ResourceHandlerOptions {
3
3
  readonly authenticate: (request: Request) => Promise<ResourceAccess | null>;
4
4
  readonly reader: ResourceReader;
5
5
  readonly publisher?: ResourcePublisher;
6
6
  readonly lookup?: PublicationLookup;
7
+ readonly renamer?: ResourceRenamer;
8
+ readonly deleter?: ResourceDeleter;
7
9
  readonly maxRequestBytes?: number;
8
10
  readonly maxResponseBytes?: number;
9
11
  }
@@ -1,8 +1,8 @@
1
1
  import { guardStatus, readJsonBody } from './request-guard.js';
2
- import { accessSnapshot, publicationDigest } from './publication-input.js';
3
- import { contentType, decodeCall, encode, envelope, identity, limit, lookupResult, observe, publicationResult, readResult, sameIdentity, schema, version } from './remote-protocol.js';
2
+ import { accessSnapshot, publicationDigest, renameDigest, deletePublication } from './publication-input.js';
3
+ import { contentType, decodeCall, encode, envelope, identity, limit, lookupResult, observe, publicationResult, renameResult, readResult, sameIdentity, schema, version } from './remote-protocol.js';
4
4
  export function createResourceHandler(options) {
5
- const { authenticate, reader, publisher, lookup } = options;
5
+ const { authenticate, reader, publisher, lookup, renamer, deleter } = options;
6
6
  const requestLimit = limit(options.maxRequestBytes ?? 4_194_304), responseLimit = limit(options.maxResponseBytes ?? 8_388_608);
7
7
  return async (request) => {
8
8
  const refuse = (status) => new Response(null, { status, headers: { 'cache-control': 'no-store' } });
@@ -38,6 +38,25 @@ export function createResourceHandler(options) {
38
38
  value = publicationResult(await observe(publisher.publish(call.value, access), signal), call.value.operationId, captured, call.value, digest);
39
39
  break;
40
40
  }
41
+ case 'rename': {
42
+ if (!renamer)
43
+ return refuse(405);
44
+ const digest = await observe(renameDigest(call.value), signal);
45
+ if (signal.aborted)
46
+ return refuse(403);
47
+ value = renameResult(await observe(renamer.rename(call.value, access), signal), call.value, captured, digest);
48
+ break;
49
+ }
50
+ case 'delete': {
51
+ if (!deleter)
52
+ return refuse(405);
53
+ const publication = deletePublication(call.value);
54
+ const digest = await observe(publicationDigest(publication), signal);
55
+ if (signal.aborted)
56
+ return refuse(403);
57
+ value = publicationResult(await observe(deleter.delete(call.value, access), signal), call.value.operationId, captured, publication, digest);
58
+ break;
59
+ }
41
60
  case 'lookup': {
42
61
  if (!lookup)
43
62
  return refuse(405);
@@ -1,4 +1,4 @@
1
- import type { PublicationLookupResult, PublicationRequest, PublicationResult, ReadResult, ResourceAccess, ResourceRead } from './contracts.js';
1
+ import type { PublicationLookupResult, PublicationRequest, PublicationResult, ReadResult, ResourceAccess, ResourceRead, ResourceRenameRequest, ResourceDeleteRequest } from './contracts.js';
2
2
  export type ResourceIdentity = Pick<ResourceAccess, 'principalId' | 'scopeId' | 'initiatorId'>;
3
3
  export type ResourceCall = {
4
4
  readonly kind: 'read';
@@ -6,6 +6,12 @@ export type ResourceCall = {
6
6
  } | {
7
7
  readonly kind: 'publish';
8
8
  readonly value: PublicationRequest;
9
+ } | {
10
+ readonly kind: 'rename';
11
+ readonly value: ResourceRenameRequest;
12
+ } | {
13
+ readonly kind: 'delete';
14
+ readonly value: ResourceDeleteRequest;
9
15
  } | {
10
16
  readonly kind: 'lookup';
11
17
  readonly value: string;
@@ -30,5 +36,6 @@ export declare function observe<Value>(pending: Promise<Value>, signal?: AbortSi
30
36
  export { readJsonBody as jsonBody } from './request-guard.js';
31
37
  export declare function readResult(value: unknown, request: ResourceRead, wire?: boolean): ReadResult;
32
38
  export declare function publicationResult(value: unknown, operationId: string, selected: ResourceIdentity, request?: PublicationRequest, digest?: string): PublicationResult;
39
+ export declare function renameResult(value: unknown, request: ResourceRenameRequest, selected: ResourceIdentity, digest: string): PublicationResult;
33
40
  export declare function lookupResult(value: unknown, operationId: string, selected: ResourceIdentity): PublicationLookupResult;
34
41
  //# sourceMappingURL=remote-protocol.d.ts.map
@@ -1,4 +1,4 @@
1
- import { accessSnapshot, identifier, locator, parsePublicationResult, publicationSnapshot, reference } from './publication-input.js';
1
+ import { accessSnapshot, identifier, locator, parsePublicationResult, publicationSnapshot, reference, renameSnapshot, deleteSnapshot } from './publication-input.js';
2
2
  export const schema = 'boring-resource';
3
3
  export const version = 2;
4
4
  export const contentType = 'application/json';
@@ -22,7 +22,7 @@ export function envelope(value) {
22
22
  const input = object(value);
23
23
  if (input.schema !== schema || input.version !== version)
24
24
  throw new TypeError('Unsupported resource protocol');
25
- if (input.kind !== 'read' && input.kind !== 'publish' && input.kind !== 'lookup')
25
+ if (input.kind !== 'read' && input.kind !== 'publish' && input.kind !== 'lookup' && input.kind !== 'rename' && input.kind !== 'delete')
26
26
  throw new TypeError('Unsupported resource method');
27
27
  return { requestId: identifier(input.requestId), identity: wireIdentity(input.identity), kind: input.kind, value: input.value };
28
28
  }
@@ -64,6 +64,10 @@ export function decodeCall(input) {
64
64
  return { kind: 'read', value: readSnapshot(input.value) };
65
65
  if (input.kind === 'lookup')
66
66
  return { kind: 'lookup', value: identifier(input.value) };
67
+ if (input.kind === 'rename')
68
+ return { kind: 'rename', value: renameSnapshot(input.value) };
69
+ if (input.kind === 'delete')
70
+ return { kind: 'delete', value: deleteSnapshot(input.value) };
67
71
  const value = object(input.value);
68
72
  if (!Array.isArray(value.changes))
69
73
  throw new TypeError('Missing changes');
@@ -149,6 +153,18 @@ export function publicationResult(value, operationId, selected, request, digest)
149
153
  outcomeAgreement(result, operationId, selected, request?.changes, digest);
150
154
  return result;
151
155
  }
156
+ export function renameResult(value, request, selected, digest) {
157
+ const result = publicationResult(value, request.operationId, selected, undefined, digest);
158
+ if (result.kind !== 'committed')
159
+ return result;
160
+ const changes = result.receipt.changes;
161
+ const removed = changes.find(change => change.kind === 'delete');
162
+ const created = changes.find(change => change.kind === 'create');
163
+ if (changes.length !== 2 || !removed || !created || !sameTarget(removed.before, request.from) || removed.before.revision !== request.expectedRevision
164
+ || !sameTarget(created.after, request.to) || created.after.revision !== request.expectedRevision)
165
+ throw new TypeError('Rename receipt mismatch');
166
+ return result;
167
+ }
152
168
  export function lookupResult(value, operationId, selected) {
153
169
  return object(value).kind === 'not-found' ? { kind: 'not-found' } : publicationResult(value, operationId, selected);
154
170
  }
package/dist/remote.js CHANGED
@@ -1,5 +1,5 @@
1
- import { PublicationNotDispatchedError, identifier, publicationDigest, publicationSnapshot } from './publication-input.js';
2
- import { contentType, encode, envelope, identity, jsonBody, limit, lookupResult, observe, publicationResult, readResult, readSnapshot, sameIdentity, schema, version } from './remote-protocol.js';
1
+ import { PublicationNotDispatchedError, identifier, publicationDigest, publicationSnapshot, renameSnapshot, renameDigest, deleteSnapshot, deletePublication } from './publication-input.js';
2
+ import { contentType, encode, envelope, identity, jsonBody, limit, lookupResult, observe, publicationResult, renameResult, readResult, readSnapshot, sameIdentity, schema, version } from './remote-protocol.js';
3
3
  import { randomUUID } from './platform.js';
4
4
  export { createResourceHandler } from './remote-handler.js';
5
5
  export function createResourceClient(options) {
@@ -37,7 +37,7 @@ export function createResourceClient(options) {
37
37
  return result.value;
38
38
  }
39
39
  catch {
40
- if (call.kind === 'publish' && !dispatched)
40
+ if ((call.kind === 'publish' || call.kind === 'rename' || call.kind === 'delete') && !dispatched)
41
41
  throw new PublicationNotDispatchedError(call.value.operationId);
42
42
  throw new Error('Remote resource operation is unconfirmed');
43
43
  }
@@ -76,6 +76,42 @@ export function createResourceClient(options) {
76
76
  return { kind: 'unknown', operationId: captured.operationId, reason: 'Remote publication is unconfirmed; reconcile the original operation without automatic replay' };
77
77
  }
78
78
  },
79
+ rename: async (request, signal) => {
80
+ const captured = renameSnapshot(request);
81
+ let digest;
82
+ try {
83
+ digest = await renameDigest(captured);
84
+ }
85
+ catch {
86
+ throw new PublicationNotDispatchedError(captured.operationId);
87
+ }
88
+ try {
89
+ return renameResult(await invoke({ kind: 'rename', value: captured }, signal), captured, selected, digest);
90
+ }
91
+ catch (error) {
92
+ if (error instanceof PublicationNotDispatchedError)
93
+ throw error;
94
+ return { kind: 'unknown', operationId: captured.operationId, reason: 'Remote rename is unconfirmed; reconcile the original operation without automatic replay' };
95
+ }
96
+ },
97
+ delete: async (request, signal) => {
98
+ const captured = deleteSnapshot(request), publication = deletePublication(captured);
99
+ let digest;
100
+ try {
101
+ digest = await publicationDigest(publication);
102
+ }
103
+ catch {
104
+ throw new PublicationNotDispatchedError(captured.operationId);
105
+ }
106
+ try {
107
+ return publicationResult(await invoke({ kind: 'delete', value: captured }, signal), captured.operationId, selected, publication, digest);
108
+ }
109
+ catch (error) {
110
+ if (error instanceof PublicationNotDispatchedError)
111
+ throw error;
112
+ return { kind: 'unknown', operationId: captured.operationId, reason: 'Remote delete is unconfirmed; reconcile the original operation without automatic replay' };
113
+ }
114
+ },
79
115
  } : {}),
80
116
  ...(options.reconciliation ? {
81
117
  lookup: async (operationId, signal) => {
@@ -39,8 +39,8 @@ export interface FileSearchRequest {
39
39
  export interface SavedRevision {
40
40
  readonly revision: string;
41
41
  readonly savedAt: number;
42
- /** Who wrote it: `publish` (a conditional write), `agent` (a Pi `write` or `edit`: observed, never receipted) or `observed` (retained as found). */
43
- readonly source?: 'publish' | 'agent' | 'observed';
42
+ /** `publish`, `agent` or `observed` bytes; `rename`/`delete` retain the affected version. Receipts record each operation. */
43
+ readonly source?: 'publish' | 'agent' | 'observed' | 'rename' | 'delete';
44
44
  }
45
45
  /**
46
46
  * One entry of a workspace's change feed (ruling 2 of 2026-10-10): an invalidation hint, never content or a receipt. Bytes and
@@ -163,6 +163,8 @@ export function createRevisionHandler(options) {
163
163
  reader: { read: (value, context) => hold(provider.read(value, context)) },
164
164
  publisher: { publish: (value, context) => hold(provider.publication.publish(value, context)) },
165
165
  lookup: { lookup: (value, context) => hold(provider.reconciliation.lookup(value, context)) },
166
+ ...(provider.renaming ? { renamer: { rename: (value, context) => hold(provider.renaming.rename(value, context)) } } : {}),
167
+ ...(provider.deletion ? { deleter: { delete: (value, context) => hold(provider.deletion.delete(value, context)) } } : {}),
166
168
  })(request);
167
169
  const input = record(await readJsonBody(request, 32_768, access.signal));
168
170
  const requestId = identifier(input.requestId);
@@ -37,7 +37,7 @@ export function savedRevisions(value) {
37
37
  const entry = record(item);
38
38
  if (typeof entry.savedAt !== 'number' || !Number.isSafeInteger(entry.savedAt) || entry.savedAt < 0)
39
39
  throw new RevisionError('invalid', 'Invalid save timestamp');
40
- const source = entry.source === 'publish' || entry.source === 'agent' || entry.source === 'observed' ? entry.source : undefined;
40
+ const source = entry.source === 'publish' || entry.source === 'agent' || entry.source === 'observed' || entry.source === 'rename' || entry.source === 'delete' ? entry.source : undefined;
41
41
  return { revision: identifier(entry.revision), savedAt: entry.savedAt, ...(source ? { source } : {}) };
42
42
  });
43
43
  }
@@ -2,7 +2,7 @@ import type { SqliteConnection } from './sqlite.js';
2
2
  /**
3
3
  * Internal (not a package export): what the workspace provider needs from the SQLite file system to commit a multi-file batch in
4
4
  * one transaction. Registered by `openSqliteFileSystem` against the file system object it returns, so the provider keeps taking a
5
- * plain Pi `FileSystem`. Both functions are synchronous and run inside `connection.transaction`.
5
+ * plain Pi `FileSystem`. Its mutations are synchronous and run inside `connection.transaction`.
6
6
  */
7
7
  export interface SqliteBatch {
8
8
  readonly connection: SqliteConnection;
@@ -10,6 +10,8 @@ export interface SqliteBatch {
10
10
  readonly bytes: (path: string) => Uint8Array | null | undefined;
11
11
  /** Write a regular file at an absolute path, creating its missing parent directories. Throws a Pi `FileError` on failure. */
12
12
  readonly write: (path: string, bytes: Uint8Array) => void;
13
+ readonly rename: (from: string, to: string) => void;
14
+ readonly remove: (path: string) => void;
13
15
  /** The one byte limit of this file system: native writes and publications both enforce it. */
14
16
  readonly maxFileBytes: () => number;
15
17
  readonly setMaxFileBytes: (limit: number) => void;
@@ -202,6 +202,45 @@ export function openSqliteFileSystem(options) {
202
202
  return seen;
203
203
  }
204
204
  const encode = (content) => typeof content === 'string' ? new TextEncoder().encode(content) : Uint8Array.from(content);
205
+ function rename(from, to) {
206
+ const moved = existing(from);
207
+ if (from === to)
208
+ return;
209
+ const oversized = db.get(`SELECT path, length(bytes) AS size FROM boring_workspace_files WHERE ${subtree(from)} AND kind = 'file' AND length(bytes) > ? LIMIT 1`, ...subtreeArgs(from), maxFileBytes);
210
+ if (oversized)
211
+ throw fileTooLarge(oversized.path, Number(oversized.size), maxFileBytes);
212
+ touched.add(from);
213
+ touched.add(to);
214
+ if (from === '/' || within(from, to))
215
+ throw failure('invalid', 'EINVAL', to, 'cannot move a directory into itself');
216
+ const target = row(to);
217
+ if (target) {
218
+ if (moved.kind !== 'directory' && target.kind === 'directory')
219
+ throw failure('is_directory', 'EISDIR', to, 'illegal operation on a directory');
220
+ if (moved.kind === 'directory' && target.kind !== 'directory')
221
+ throw failure('not_directory', 'ENOTDIR', to, 'not a directory');
222
+ if (target.kind === 'directory' && children(to).length)
223
+ throw failure('invalid', 'ENOTEMPTY', to, 'directory not empty');
224
+ db.run('DELETE FROM boring_workspace_files WHERE workspace = ? AND path = ?', workspace, to);
225
+ }
226
+ else
227
+ parent(to);
228
+ db.run(`UPDATE boring_workspace_files SET path = ? || substr(path, ?), parent = CASE WHEN path = ? THEN ? ELSE ? || substr(parent, ?) END WHERE ${subtree(from)}`, to, from.length + 1, from, dirname(to), to, from.length + 1, ...subtreeArgs(from));
229
+ }
230
+ function remove(absolute, options) {
231
+ const found = row(absolute);
232
+ if (!found) {
233
+ if (options?.force)
234
+ return;
235
+ throw absent(absolute);
236
+ }
237
+ if (absolute === '/' || absolute === fs.cwd)
238
+ throw failure('permission_denied', 'EPERM', absolute, 'the workspace root cannot be removed');
239
+ if (found.kind === 'directory' && !options?.recursive && children(absolute).length)
240
+ throw failure('invalid', 'ENOTEMPTY', absolute, 'directory not empty');
241
+ touched.add(absolute);
242
+ db.run(`DELETE FROM boring_workspace_files WHERE ${subtree(absolute)}`, ...subtreeArgs(absolute));
243
+ }
205
244
  const fs = {
206
245
  id: `sqlite:${workspace}`, cwd: normalize(options.cwd), incarnation,
207
246
  absolutePath: (path, context) => run(path, context, 'read', absolute => absolute),
@@ -298,33 +337,7 @@ export function openSqliteFileSystem(options) {
298
337
  // Every write is already a committed SQLite transaction.
299
338
  flushFile: (path, context) => run(path, context, 'read', absolute => { existing(absolute); }),
300
339
  // One transaction: a reader sees the old or the new path, never both or neither. A directory moves with everything below it.
301
- renameFile: (source, destination, context) => run(source, context, 'write', from => {
302
- const to = normalize(destination.startsWith('/') ? destination : `${fs.cwd}/${destination}`);
303
- const moved = existing(from);
304
- if (from === to)
305
- return;
306
- // A moved file keeps its bytes, so a file (or a file below a moved directory) over the limit cannot replace another file by renaming.
307
- const oversized = db.get(`SELECT path, length(bytes) AS size FROM boring_workspace_files WHERE ${subtree(from)} AND kind = 'file' AND length(bytes) > ? LIMIT 1`, ...subtreeArgs(from), maxFileBytes);
308
- if (oversized)
309
- throw fileTooLarge(oversized.path, Number(oversized.size), maxFileBytes);
310
- touched.add(from);
311
- touched.add(to);
312
- if (from === '/' || within(from, to))
313
- throw failure('invalid', 'EINVAL', to, 'cannot move a directory into itself');
314
- const target = row(to);
315
- if (target) {
316
- if (moved.kind !== 'directory' && target.kind === 'directory')
317
- throw failure('is_directory', 'EISDIR', to, 'illegal operation on a directory');
318
- if (moved.kind === 'directory' && target.kind !== 'directory')
319
- throw failure('not_directory', 'ENOTDIR', to, 'not a directory');
320
- if (target.kind === 'directory' && children(to).length)
321
- throw failure('invalid', 'ENOTEMPTY', to, 'directory not empty');
322
- db.run('DELETE FROM boring_workspace_files WHERE workspace = ? AND path = ?', workspace, to);
323
- }
324
- else
325
- parent(to);
326
- db.run(`UPDATE boring_workspace_files SET path = ? || substr(path, ?), parent = CASE WHEN path = ? THEN ? ELSE ? || substr(parent, ?) END WHERE ${subtree(from)}`, to, from.length + 1, from, dirname(to), to, from.length + 1, ...subtreeArgs(from));
327
- }),
340
+ renameFile: (source, destination, context) => run(source, context, 'write', from => rename(from, normalize(destination.startsWith('/') ? destination : `${fs.cwd}/${destination}`))),
328
341
  fileInfo: (path, context) => run(path, context, 'read', absolute => info(absolute, existing(absolute))),
329
342
  listDir: (path, context) => run(path, context, 'read', absolute => {
330
343
  directory(absolute);
@@ -415,20 +428,7 @@ export function openSqliteFileSystem(options) {
415
428
  return;
416
429
  makeDirectory(absolute, options?.recursive !== false);
417
430
  }),
418
- remove: (path, options, context) => run(path, context, 'write', absolute => {
419
- const found = row(absolute);
420
- if (!found) {
421
- if (options?.force)
422
- return;
423
- throw absent(absolute);
424
- }
425
- if (absolute === '/' || absolute === fs.cwd)
426
- throw failure('permission_denied', 'EPERM', absolute, 'the workspace root cannot be removed');
427
- if (found.kind === 'directory' && !options?.recursive && children(absolute).length)
428
- throw failure('invalid', 'ENOTEMPTY', absolute, 'directory not empty');
429
- touched.add(absolute);
430
- db.run(`DELETE FROM boring_workspace_files WHERE ${subtree(absolute)}`, ...subtreeArgs(absolute));
431
- }),
431
+ remove: (path, options, context) => run(path, context, 'write', absolute => remove(absolute, options)),
432
432
  createTempDir: (prefix, context) => temporary(prefix ?? 'tmp-', '', 'directory', context),
433
433
  createTempFile: (options, context) => temporary(options?.prefix ?? 'tmp-', options?.suffix ?? '', 'file', context),
434
434
  cleanup: async () => { },
@@ -439,6 +439,8 @@ export function openSqliteFileSystem(options) {
439
439
  setMaxFileBytes: limit => { assertLimit(limit); maxFileBytes = limit; },
440
440
  bytes: path => { const found = row(normalize(path)); return !found ? null : found.kind === 'directory' ? undefined : bytes(normalize(path)); },
441
441
  write: (path, content) => { const absolute = normalize(path); makeDirectory(dirname(absolute), true); write(absolute, content); notify(); },
442
+ rename: (from, to) => { rename(normalize(from), normalize(to)); notify(); },
443
+ remove: path => { remove(normalize(path)); notify(); },
442
444
  });
443
445
  return fs;
444
446
  }
@@ -1,5 +1,5 @@
1
1
  import type { FileSystem } from '@earendil-works/pi-durable/env';
2
- import type { PublicationLookupResult, PublicationResult, ReadResult, ResourceAccess, ResourceProvider } from './contracts.js';
2
+ import type { PublicationLookupResult, PublicationResult, ReadResult, ResourceAccess, ResourceProvider, ResourceRenamer, ResourceDeleter } from './contracts.js';
3
3
  import type { WorkspaceCatalogOptions } from './workspace-catalog.js';
4
4
  import type { ChangeFeedRequest, WorkspaceCatalog, WorkspaceChangeEvent } from './revision-contracts.js';
5
5
  export type { ChangeFeedRequest, WorkspaceChangeEvent } from './revision-contracts.js';
@@ -42,6 +42,8 @@ export interface WorkspaceResourceProvider extends ResourceProvider {
42
42
  readonly reconciliation: {
43
43
  readonly lookup: (operationId: string, access: ResourceAccess) => Promise<PublicationLookupResult>;
44
44
  };
45
+ readonly renaming: ResourceRenamer;
46
+ readonly deletion: ResourceDeleter;
45
47
  /** The workspace's mutation queue. Native tool calls that must not interleave with a conditional write run through it. */
46
48
  readonly queue: WorkspaceQueue;
47
49
  /**
package/dist/workspace.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createWorkspaceCatalog } from './workspace-catalog.js';
2
2
  import { committedChanges } from './journal.js';
3
3
  import { randomUUID, sha1 } from './platform.js';
4
- import { accessSnapshot, identifier, locator, publicationDigest, publicationSnapshot } from './publication-input.js';
4
+ import { accessSnapshot, identifier, locator, publicationDigest, publicationSnapshot, renameSnapshot, renameDigest, deleteSnapshot, deletePublication } from './publication-input.js';
5
5
  import { assertLimit, fileTooLarge } from './file-limits.js';
6
6
  import { journalConnectionOf, sqliteBatchOf } from './sqlite-batch.js';
7
7
  import { isTemporary, mediaTypeOf, temporaryFileName } from './file-types.js';
@@ -215,23 +215,18 @@ export function createWorkspaceProvider(options) {
215
215
  changes: committedChanges(stored.changes, ref),
216
216
  };
217
217
  }
218
- const publish = async (input, context) => {
219
- const request = publicationSnapshot(input);
220
- const access = accessSnapshot(context);
221
- const digest = await publicationDigest(request);
222
- if (request.atomicity !== 'all-or-nothing')
218
+ const mutate = async (request, access) => {
219
+ const moving = 'from' in request ? request : undefined;
220
+ const publication = 'changes' in request ? request : undefined;
221
+ const digest = moving ? await renameDigest(moving) : await publicationDigest(publication);
222
+ if (publication && publication.atomicity !== 'all-or-nothing')
223
223
  return { kind: 'unavailable', reason: 'This provider accepts all-or-nothing publication only' };
224
- if (request.changes.length !== 1 && !batch)
224
+ if (publication && publication.changes.length !== 1 && !batch)
225
225
  return { kind: 'unavailable', reason: 'This provider cannot apply a multi-file batch atomically; only a single-file change is accepted' };
226
- const changes = [];
227
- for (const change of request.changes) {
228
- if (change.kind === 'delete')
229
- return { kind: 'unavailable', reason: 'This provider does not delete files through publication' };
230
- changes.push(change);
231
- }
226
+ const changes = moving ? [{ kind: 'delete', target: { ...moving.from, revision: moving.expectedRevision } }] : publication.changes;
232
227
  const expectations = [
233
228
  ...changes.map((change) => change.kind === 'create' ? { kind: 'absent', target: change.target } : { kind: 'revision', target: change.target }),
234
- ...(request.preconditions ?? []),
229
+ ...(moving ? [{ kind: 'absent', target: moving.to }] : publication.preconditions ?? []),
235
230
  ];
236
231
  if (expectations.some(item => !supported(item.target)))
237
232
  return { kind: 'unavailable', reason: 'This provider serves its published view only' };
@@ -253,7 +248,7 @@ export function createWorkspaceProvider(options) {
253
248
  // After the receipt and intent checks, so a replay of a committed operation keeps its result under a lower limit.
254
249
  const limit = limitOf();
255
250
  if (limit !== undefined) {
256
- const oversized = changes.find(change => change.bytes.length > limit);
251
+ const oversized = changes.find((change) => change.kind !== 'delete' && change.bytes.length > limit);
257
252
  if (oversized) {
258
253
  const size = oversized.bytes.length, path = oversized.target.resource.path;
259
254
  return { kind: 'file_too_large', path, size, limit, reason: fileTooLarge(path, size, limit).message };
@@ -300,23 +295,39 @@ export function createWorkspaceProvider(options) {
300
295
  journal.cancel(operation);
301
296
  return { kind: 'denied', reason: forbidden };
302
297
  }
303
- const after = await blobRevision(change.bytes);
298
+ const after = change.kind === 'delete' ? null : await blobRevision(change.bytes);
304
299
  const unchanged = change.kind === 'replace' && after === change.target.revision;
305
300
  // The replaced revision was saved when its file was last modified, before this write changes that.
306
- plans.push({ change, path, before: observed.get(path), after, unchanged, beforeSavedAt: change.kind === 'replace' && !unchanged ? await savedAtOf(path, access.signal) : 0 });
301
+ plans.push({ change, path, before: observed.get(path), after, unchanged, beforeSavedAt: change.kind !== 'create' && !unchanged ? await savedAtOf(path, access.signal) : 0 });
302
+ }
303
+ if (moving) {
304
+ const path = moving.to.resource.path;
305
+ const forbidden = await escape(path, true, access.signal);
306
+ if (forbidden !== undefined) {
307
+ journal.cancel(operation);
308
+ return { kind: 'denied', reason: forbidden };
309
+ }
310
+ const before = observed.get(moving.from.resource.path);
311
+ if (before?.kind !== 'file')
312
+ return refuse('Rename requires a regular file');
313
+ if (limit !== undefined && before.bytes.length > limit) {
314
+ journal.cancel(operation);
315
+ return { kind: 'file_too_large', path, size: before.bytes.length, limit, reason: fileTooLarge(path, before.bytes.length, limit).message };
316
+ }
317
+ plans.push({ change: { kind: 'create', target: moving.to, expected: { kind: 'absent' }, bytes: before.bytes, mediaType: journal.version(historyScope, moving.from.resource.path, before.revision)?.mediaType ?? mediaTypeOf(moving.from.resource.path) }, path, before: observed.get(path), after: before.revision, unchanged: false, beforeSavedAt: 0 });
307
318
  }
308
319
  const evidence = `${key.incarnation}:${randomUUID()}`;
309
320
  const versions = plans.flatMap(({ change, path, before, after, unchanged, beforeSavedAt }) => {
310
321
  const kept = (revision, bytes, mediaType, savedAt, source) => ({ scope: historyScope, path, version: { revision, bytes, mediaType, savedAt, source } });
311
322
  return unchanged ? [] : [
312
- ...(change.kind === 'replace' && before?.kind === 'file'
313
- ? [kept(before.revision, before.bytes, journal.version(historyScope, path, before.revision)?.mediaType ?? mediaTypeOf(path), beforeSavedAt, 'observed')] : []),
314
- kept(after, change.bytes, change.mediaType, Date.now(), 'publish'),
323
+ ...(change.kind !== 'create' && before?.kind === 'file'
324
+ ? [kept(before.revision, before.bytes, journal.version(historyScope, path, before.revision)?.mediaType ?? mediaTypeOf(path), beforeSavedAt, moving ? 'rename' : change.kind === 'delete' ? 'delete' : 'observed')] : []),
325
+ ...(change.kind !== 'delete' && after !== null ? [kept(after, change.bytes, change.mediaType, Date.now(), moving ? 'rename' : 'publish')] : []),
315
326
  ];
316
327
  });
317
328
  const record = {
318
329
  digest, evidence,
319
- changes: plans.map(({ change, path, after }) => change.kind === 'create' ? { kind: 'create', path, before: null, after } : { kind: 'replace', path, before: change.target.revision, after }),
330
+ changes: plans.map(({ change, path, after }) => change.kind === 'create' ? { kind: 'create', path, before: null, after } : { kind: change.kind, path, before: change.target.revision, after }),
320
331
  };
321
332
  if (access.signal?.aborted)
322
333
  return refuse('The save was cancelled before any change');
@@ -331,9 +342,16 @@ export function createWorkspaceProvider(options) {
331
342
  }).map(([path]) => path);
332
343
  if (changed.length)
333
344
  return changed;
334
- for (const plan of plans)
335
- if (!plan.unchanged)
336
- batch.write(absolute(plan.path), plan.change.bytes);
345
+ if (moving)
346
+ batch.rename(absolute(moving.from.resource.path), absolute(moving.to.resource.path));
347
+ else
348
+ for (const plan of plans)
349
+ if (!plan.unchanged) {
350
+ if (plan.change.kind === 'delete')
351
+ batch.remove(absolute(plan.path));
352
+ else
353
+ batch.write(absolute(plan.path), plan.change.bytes);
354
+ }
337
355
  if (together)
338
356
  journal.complete(operation, record, versions);
339
357
  return [];
@@ -347,8 +365,22 @@ export function createWorkspaceProvider(options) {
347
365
  if (!together)
348
366
  journal.complete(operation, record, versions);
349
367
  }
368
+ else if (moving || plans[0].change.kind === 'delete') {
369
+ applying = true;
370
+ const result = moving
371
+ ? await fs.renameFile(absolute(moving.from.resource.path), absolute(moving.to.resource.path), fsContext(access.signal))
372
+ : await fs.remove(absolute(plans[0].path), { recursive: false }, fsContext(access.signal));
373
+ if (!result.ok) {
374
+ if (result.error.code === 'unknown' || result.error.code === 'aborted')
375
+ throw result.error;
376
+ return refuse(result.error.message);
377
+ }
378
+ journal.complete(operation, record, versions);
379
+ }
350
380
  else {
351
381
  const { change, path, unchanged } = plans[0];
382
+ if (change.kind === 'delete')
383
+ return refuse('Invalid file replacement');
352
384
  if (!unchanged) {
353
385
  applying = true;
354
386
  const target = absolute(path);
@@ -385,6 +417,9 @@ export function createWorkspaceProvider(options) {
385
417
  }
386
418
  });
387
419
  };
420
+ const publish = async (input, context) => mutate(publicationSnapshot(input), accessSnapshot(context));
421
+ const rename = async (input, context) => mutate(renameSnapshot(input), accessSnapshot(context));
422
+ const remove = async (input, context) => mutate(deletePublication(deleteSnapshot(input)), accessSnapshot(context));
388
423
  const lookup = async (operationId, context) => {
389
424
  const id = identifier(operationId);
390
425
  const access = accessSnapshot(context);
@@ -626,7 +661,7 @@ export function createWorkspaceProvider(options) {
626
661
  return {
627
662
  providerId, read, queue, keep, identity: Object.freeze(key),
628
663
  catalog: createWorkspaceCatalog({ fs, ...(options.catalog ? { config: options.catalog } : {}), saves: path => journal.saves(historyScope, path) }),
629
- publication: { publish }, reconciliation: { lookup }, poll, record, changes, changeHead: () => head, changeIncarnation: incarnationOfFeed,
664
+ publication: { publish }, reconciliation: { lookup }, renaming: { rename }, deletion: { delete: remove }, poll, record, changes, changeHead: () => head, changeIncarnation: incarnationOfFeed,
630
665
  lifetime: lifetime.signal,
631
666
  close: () => {
632
667
  // Every change stream ends, the watcher closes and the listeners are dropped: nothing of this provider keeps running.
@@ -646,7 +681,7 @@ export function createWorkspaceProvider(options) {
646
681
  capabilities: async (input) => {
647
682
  const selected = locator(input);
648
683
  const ok = supported(selected);
649
- const actions = ['read', 'create', 'replace', 'lookup'];
684
+ const actions = ['read', 'create', 'replace', 'delete', 'rename', 'lookup'];
650
685
  return {
651
686
  supported: ok ? actions : [], effective: ok ? actions : [], availability: ok ? 'available' : 'unavailable', observedAt: new Date().toISOString(),
652
687
  guarantees: { pinnedReads: true, conditionalPublication: true, atomicMutationAndReceipt: batch !== undefined && journalConnectionOf(journal) === batch.connection, atomicBatch: batch !== undefined, operationLookup: true, revocationFencing: false },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-files",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",