@hachej/boring-files 0.4.35 → 0.4.37

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,7 +8,7 @@ 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, 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.
11
+ The provider accepts all-or-nothing create, replace and conditional delete, plus conditional rename through `renaming.rename`. It refuses working views 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
 
@@ -91,7 +91,7 @@ export type ResourceChange = {
91
91
  export interface PublicationRequest {
92
92
  readonly operationId: string;
93
93
  readonly changes: NonEmpty<ResourceChange>;
94
- readonly atomicity: 'all-or-nothing' | 'per-change';
94
+ readonly atomicity: 'all-or-nothing';
95
95
  /** Extra exact-content/absence dependencies, in addition to change targets.
96
96
  * Search/listing predicate evidence needs its separately qualified provider
97
97
  * integration; this minimal contract does not pretend to implement it.
@@ -149,19 +149,7 @@ export type PublicationOutcome = {
149
149
  readonly operationId: string;
150
150
  readonly reason: string;
151
151
  };
152
- /** Result association survives multiple changes/views and lost responses.
153
- * Providers validate index coverage/uniqueness and target/receipt agreement.
154
- */
155
- export interface PublicationItem {
156
- readonly changeIndex: number;
157
- readonly target: ResourceLocator;
158
- readonly outcome: PublicationOutcome;
159
- }
160
- export type PublicationResult = PublicationOutcome | {
161
- readonly kind: 'partial';
162
- readonly operationId: string;
163
- readonly items: NonEmpty<PublicationItem>;
164
- };
152
+ export type PublicationResult = PublicationOutcome;
165
153
  export interface ProviderGuarantees {
166
154
  readonly pinnedReads: boolean;
167
155
  readonly conditionalPublication: boolean;
@@ -189,11 +177,15 @@ export interface ResourceRenameRequest {
189
177
  readonly to: ResourceLocator;
190
178
  readonly expectedRevision: string;
191
179
  readonly operationId: string;
180
+ /** Also remove the folders this leaves empty: a folder operation in the tree, never a single-file change, since another consumer may have its cwd there. */
181
+ readonly pruneEmptied?: true;
192
182
  }
193
183
  export interface ResourceDeleteRequest {
194
184
  readonly path: ResourceLocator;
195
185
  readonly expectedRevision: string;
196
186
  readonly operationId: string;
187
+ /** Also remove the folders this leaves empty: a folder operation in the tree, never a single-file change, since another consumer may have its cwd there. */
188
+ readonly pruneEmptied?: true;
197
189
  }
198
190
  /** Conditional regular-file mutations; receipts and lookup use the publication journal. */
199
191
  export interface ResourceRenamer {
@@ -79,7 +79,7 @@ export function publicationSnapshot(value) {
79
79
  const input = object(value);
80
80
  if (!Array.isArray(input.changes) || !input.changes.length)
81
81
  throw new TypeError('Publication requires changes');
82
- if (input.atomicity !== 'all-or-nothing' && input.atomicity !== 'per-change')
82
+ if (input.atomicity !== 'all-or-nothing')
83
83
  throw new TypeError('Invalid atomicity');
84
84
  const [first, ...rest] = input.changes;
85
85
  const changes = [change(first), ...rest.map(change)];
@@ -99,11 +99,11 @@ export function renameSnapshot(value) {
99
99
  throw new TypeError('Rename requires one provider and view');
100
100
  if (from.resource.path === to.resource.path || from.resource.path.startsWith(`${to.resource.path}/`) || to.resource.path.startsWith(`${from.resource.path}/`))
101
101
  throw new TypeError('Rename requires independent file paths');
102
- return { from, to, expectedRevision: identifier(input.expectedRevision), operationId: identifier(input.operationId) };
102
+ return { from, to, expectedRevision: identifier(input.expectedRevision), operationId: identifier(input.operationId), ...(input.pruneEmptied === true ? { pruneEmptied: true } : {}) };
103
103
  }
104
104
  export function deleteSnapshot(value) {
105
105
  const input = object(value);
106
- return { path: locator(input.path), expectedRevision: identifier(input.expectedRevision), operationId: identifier(input.operationId) };
106
+ return { path: locator(input.path), expectedRevision: identifier(input.expectedRevision), operationId: identifier(input.operationId), ...(input.pruneEmptied === true ? { pruneEmptied: true } : {}) };
107
107
  }
108
108
  export function deletePublication(request) {
109
109
  return { operationId: request.operationId, atomicity: 'all-or-nothing', changes: [{ kind: 'delete', target: { ...request.path, revision: request.expectedRevision } }] };
@@ -172,19 +172,5 @@ function publicationOutcome(value) {
172
172
  }
173
173
  /** Parse transport/provider output before treating it as acknowledgement. */
174
174
  export function parsePublicationResult(value) {
175
- const input = object(value);
176
- if (input.kind !== 'partial')
177
- return publicationOutcome(input);
178
- if (!Array.isArray(input.items) || !input.items.length)
179
- throw new TypeError('Partial publication requires items');
180
- const items = input.items.map(value => {
181
- const item = object(value);
182
- if (typeof item.changeIndex !== 'number' || !Number.isSafeInteger(item.changeIndex) || item.changeIndex < 0)
183
- throw new TypeError('Invalid change index');
184
- return { changeIndex: item.changeIndex, target: locator(item.target), outcome: publicationOutcome(item.outcome) };
185
- });
186
- const [first, ...rest] = items;
187
- if (!first || new Set(items.map(item => item.changeIndex)).size !== items.length)
188
- throw new TypeError('Invalid partial publication coverage');
189
- return { kind: 'partial', operationId: identifier(input.operationId), items: [first, ...rest] };
175
+ return publicationOutcome(object(value));
190
176
  }
@@ -76,8 +76,6 @@ export function decodeCall(input) {
76
76
  return change.kind === 'delete' ? change : { ...change, bytes: bytes(change.bytes) };
77
77
  });
78
78
  const request = publicationSnapshot({ ...value, changes });
79
- if (request.atomicity !== 'all-or-nothing')
80
- throw new TypeError('Remote per-change publication is not qualified');
81
79
  return { kind: 'publish', value: request };
82
80
  }
83
81
  export function encode(value, limit) {
@@ -148,8 +146,6 @@ function outcomeAgreement(result, operationId, selected, changes, digest) {
148
146
  }
149
147
  export function publicationResult(value, operationId, selected, request, digest) {
150
148
  const result = parsePublicationResult(value);
151
- if (result.kind === 'partial')
152
- throw new TypeError('Remote partial receipts are not qualified');
153
149
  outcomeAgreement(result, operationId, selected, request?.changes, digest);
154
150
  return result;
155
151
  }
package/dist/remote.js CHANGED
@@ -65,8 +65,6 @@ export function createResourceClient(options) {
65
65
  const captured = publicationSnapshot(request);
66
66
  let digest;
67
67
  try {
68
- if (captured.atomicity !== 'all-or-nothing')
69
- throw new TypeError('Remote per-change publication is not qualified');
70
68
  digest = await publicationDigest(captured);
71
69
  }
72
70
  catch {
@@ -439,7 +439,7 @@ 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(); },
442
+ rename: (from, to) => { const target = normalize(to); makeDirectory(dirname(target), true); rename(normalize(from), target); notify(); },
443
443
  remove: path => { remove(normalize(path)); notify(); },
444
444
  });
445
445
  return fs;
package/dist/workspace.js CHANGED
@@ -157,6 +157,20 @@ export function createWorkspaceProvider(options) {
157
157
  const bytes = Uint8Array.from(result.value);
158
158
  return { kind: 'file', bytes, revision: await blobRevision(bytes) };
159
159
  }
160
+ /**
161
+ * A folder exists through its files: the one a move or delete left empty goes, up to the first that still holds something. Best effort and
162
+ * never recursive, so a file landing meanwhile stays; a file system that cannot remove a directory that way (Pi's Node one) keeps the folder.
163
+ */
164
+ const pruneEmptied = async (path) => {
165
+ for (let folder = path.slice(0, Math.max(0, path.lastIndexOf('/'))); folder; folder = folder.slice(0, Math.max(0, folder.lastIndexOf('/')))) {
166
+ const listed = await fs.listDir(absolute(folder), fsContext()).catch(() => undefined);
167
+ if (!listed?.ok || listed.value.length)
168
+ return;
169
+ const gone = await fs.remove(absolute(folder), { recursive: false }, fsContext()).catch(() => undefined);
170
+ if (!gone?.ok)
171
+ return;
172
+ }
173
+ };
160
174
  /** Once per provider, before its first operation: remove temporary files that an earlier process left behind. */
161
175
  let swept;
162
176
  const sweep = () => swept ??= (async () => {
@@ -216,12 +230,10 @@ export function createWorkspaceProvider(options) {
216
230
  changes: committedChanges(stored.changes, ref),
217
231
  };
218
232
  }
219
- const mutate = async (request, access) => {
233
+ const mutate = async (request, access, prune = false) => {
220
234
  const moving = 'from' in request ? request : undefined;
221
235
  const publication = 'changes' in request ? request : undefined;
222
236
  const digest = moving ? await renameDigest(moving) : await publicationDigest(publication);
223
- if (publication && publication.atomicity !== 'all-or-nothing')
224
- return { kind: 'unavailable', reason: 'This provider accepts all-or-nothing publication only' };
225
237
  if (publication && publication.changes.length !== 1 && !batch)
226
238
  return { kind: 'unavailable', reason: 'This provider cannot apply a multi-file batch atomically; only a single-file change is accepted' };
227
239
  const changes = moving ? [{ kind: 'delete', target: { ...moving.from, revision: moving.expectedRevision } }] : publication.changes;
@@ -365,11 +377,28 @@ export function createWorkspaceProvider(options) {
365
377
  applying = true;
366
378
  if (!together)
367
379
  journal.complete(operation, record, versions);
380
+ if (prune && moving)
381
+ await pruneEmptied(moving.from.resource.path);
382
+ else if (prune)
383
+ for (const plan of plans)
384
+ if (plan.change.kind === 'delete')
385
+ await pruneEmptied(plan.path);
368
386
  }
369
387
  else if (moving || plans[0].change.kind === 'delete') {
370
388
  applying = true;
389
+ // Like a publication, a move creates the folder it lands in: a folder renames file by file, so the first file finds none.
390
+ const landing = moving ? absolute(moving.to.resource.path) : '';
391
+ if (moving) {
392
+ const made = await fs.createDir(landing.slice(0, landing.lastIndexOf('/')) || '/', { recursive: true }, fsContext(access.signal));
393
+ if (!made.ok) {
394
+ if (made.error.code === 'unknown' || made.error.code === 'aborted')
395
+ throw made.error;
396
+ return refuse(made.error.message);
397
+ }
398
+ }
399
+ const leaving = moving ? moving.from.resource.path : plans[0].path;
371
400
  const result = moving
372
- ? await fs.renameFile(absolute(moving.from.resource.path), absolute(moving.to.resource.path), fsContext(access.signal))
401
+ ? await fs.renameFile(absolute(moving.from.resource.path), landing, fsContext(access.signal))
373
402
  : await fs.remove(absolute(plans[0].path), { recursive: false }, fsContext(access.signal));
374
403
  if (!result.ok) {
375
404
  if (result.error.code === 'unknown' || result.error.code === 'aborted')
@@ -377,6 +406,8 @@ export function createWorkspaceProvider(options) {
377
406
  return refuse(result.error.message);
378
407
  }
379
408
  journal.complete(operation, record, versions);
409
+ if (prune)
410
+ await pruneEmptied(leaving);
380
411
  }
381
412
  else {
382
413
  const { change, path, unchanged } = plans[0];
@@ -419,8 +450,8 @@ export function createWorkspaceProvider(options) {
419
450
  });
420
451
  };
421
452
  const publish = async (input, context) => mutate(publicationSnapshot(input), accessSnapshot(context));
422
- const rename = async (input, context) => mutate(renameSnapshot(input), accessSnapshot(context));
423
- const remove = async (input, context) => mutate(deletePublication(deleteSnapshot(input)), accessSnapshot(context));
453
+ const rename = async (input, context) => { const request = renameSnapshot(input); return mutate(request, accessSnapshot(context), request.pruneEmptied === true); };
454
+ const remove = async (input, context) => { const request = deleteSnapshot(input); return mutate(deletePublication(request), accessSnapshot(context), request.pruneEmptied === true); };
424
455
  const lookup = async (operationId, context) => {
425
456
  const id = identifier(operationId);
426
457
  const access = accessSnapshot(context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-files",
3
- "version": "0.4.35",
3
+ "version": "0.4.37",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",