@hachej/boring-files 0.4.34 → 0.4.36

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.
@@ -189,11 +189,15 @@ export interface ResourceRenameRequest {
189
189
  readonly to: ResourceLocator;
190
190
  readonly expectedRevision: string;
191
191
  readonly operationId: string;
192
+ /** 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. */
193
+ readonly pruneEmptied?: true;
192
194
  }
193
195
  export interface ResourceDeleteRequest {
194
196
  readonly path: ResourceLocator;
195
197
  readonly expectedRevision: string;
196
198
  readonly operationId: string;
199
+ /** 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. */
200
+ readonly pruneEmptied?: true;
197
201
  }
198
202
  /** Conditional regular-file mutations; receipts and lookup use the publication journal. */
199
203
  export interface ResourceRenamer {
@@ -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 } }] };
@@ -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,7 +230,7 @@ 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);
@@ -365,11 +379,28 @@ export function createWorkspaceProvider(options) {
365
379
  applying = true;
366
380
  if (!together)
367
381
  journal.complete(operation, record, versions);
382
+ if (prune && moving)
383
+ await pruneEmptied(moving.from.resource.path);
384
+ else if (prune)
385
+ for (const plan of plans)
386
+ if (plan.change.kind === 'delete')
387
+ await pruneEmptied(plan.path);
368
388
  }
369
389
  else if (moving || plans[0].change.kind === 'delete') {
370
390
  applying = true;
391
+ // Like a publication, a move creates the folder it lands in: a folder renames file by file, so the first file finds none.
392
+ const landing = moving ? absolute(moving.to.resource.path) : '';
393
+ if (moving) {
394
+ const made = await fs.createDir(landing.slice(0, landing.lastIndexOf('/')) || '/', { recursive: true }, fsContext(access.signal));
395
+ if (!made.ok) {
396
+ if (made.error.code === 'unknown' || made.error.code === 'aborted')
397
+ throw made.error;
398
+ return refuse(made.error.message);
399
+ }
400
+ }
401
+ const leaving = moving ? moving.from.resource.path : plans[0].path;
371
402
  const result = moving
372
- ? await fs.renameFile(absolute(moving.from.resource.path), absolute(moving.to.resource.path), fsContext(access.signal))
403
+ ? await fs.renameFile(absolute(moving.from.resource.path), landing, fsContext(access.signal))
373
404
  : await fs.remove(absolute(plans[0].path), { recursive: false }, fsContext(access.signal));
374
405
  if (!result.ok) {
375
406
  if (result.error.code === 'unknown' || result.error.code === 'aborted')
@@ -377,6 +408,8 @@ export function createWorkspaceProvider(options) {
377
408
  return refuse(result.error.message);
378
409
  }
379
410
  journal.complete(operation, record, versions);
411
+ if (prune)
412
+ await pruneEmptied(leaving);
380
413
  }
381
414
  else {
382
415
  const { change, path, unchanged } = plans[0];
@@ -419,8 +452,8 @@ export function createWorkspaceProvider(options) {
419
452
  });
420
453
  };
421
454
  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));
455
+ const rename = async (input, context) => { const request = renameSnapshot(input); return mutate(request, accessSnapshot(context), request.pruneEmptied === true); };
456
+ const remove = async (input, context) => { const request = deleteSnapshot(input); return mutate(deletePublication(request), accessSnapshot(context), request.pruneEmptied === true); };
424
457
  const lookup = async (operationId, context) => {
425
458
  const id = identifier(operationId);
426
459
  const access = accessSnapshot(context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-files",
3
- "version": "0.4.34",
3
+ "version": "0.4.36",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",