@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 +1 -1
- package/dist/contracts.d.ts +6 -14
- package/dist/publication-input.js +4 -18
- package/dist/remote-protocol.js +0 -4
- package/dist/remote.js +0 -2
- package/dist/sqlite-filesystem.js +1 -1
- package/dist/workspace.js +37 -6
- package/package.json +1 -1
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
|
|
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
|
|
package/dist/contracts.d.ts
CHANGED
|
@@ -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'
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
}
|
package/dist/remote-protocol.js
CHANGED
|
@@ -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),
|
|
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),
|
|
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) =>
|
|
423
|
-
const remove = async (input, context) => mutate(deletePublication(
|
|
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);
|