@hachej/boring-files 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +195 -2
- package/dist/client.d.ts +12 -0
- package/dist/client.js +1 -0
- package/dist/contracts.d.ts +194 -0
- package/dist/contracts.js +1 -0
- package/dist/git.d.ts +105 -0
- package/dist/git.js +205 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/journal.d.ts +76 -0
- package/dist/journal.js +119 -0
- package/dist/node-http.d.ts +23 -0
- package/dist/node-http.js +102 -0
- package/dist/platform.d.ts +22 -0
- package/dist/platform.js +157 -0
- package/dist/publication-input.d.ts +15 -0
- package/dist/publication-input.js +164 -0
- package/dist/remote-handler.d.ts +11 -0
- package/dist/remote-handler.js +63 -0
- package/dist/remote-protocol.d.ts +34 -0
- package/dist/remote-protocol.js +154 -0
- package/dist/remote.d.ts +26 -0
- package/dist/remote.js +92 -0
- package/dist/request-guard.d.ts +20 -0
- package/dist/request-guard.js +75 -0
- package/dist/revision-contracts.d.ts +48 -0
- package/dist/revision-contracts.js +8 -0
- package/dist/revision-handler.d.ts +11 -0
- package/dist/revision-handler.js +105 -0
- package/dist/revision-protocol.d.ts +15 -0
- package/dist/revision-protocol.js +42 -0
- package/dist/revision.d.ts +8 -0
- package/dist/revision.js +57 -0
- package/dist/sqlite-batch.d.ts +19 -0
- package/dist/sqlite-batch.js +7 -0
- package/dist/sqlite-durable-object.d.ts +24 -0
- package/dist/sqlite-durable-object.js +49 -0
- package/dist/sqlite-filesystem.d.ts +16 -0
- package/dist/sqlite-filesystem.js +381 -0
- package/dist/sqlite-settings.d.ts +61 -0
- package/dist/sqlite-settings.js +104 -0
- package/dist/sqlite.d.ts +37 -0
- package/dist/sqlite.js +50 -0
- package/dist/text.d.ts +16 -0
- package/dist/text.js +29 -0
- package/dist/workspace-catalog.d.ts +17 -0
- package/dist/workspace-catalog.js +147 -0
- package/dist/workspace-identity.d.ts +8 -0
- package/dist/workspace-identity.js +1 -0
- package/dist/workspace.d.ts +55 -0
- package/dist/workspace.js +411 -0
- package/package.json +119 -4
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { createWorkspaceCatalog } from './workspace-catalog.js';
|
|
2
|
+
import { committedChanges } from './journal.js';
|
|
3
|
+
import { randomUUID, sha1 } from './platform.js';
|
|
4
|
+
import { accessSnapshot, identifier, locator, publicationDigest, publicationSnapshot } from './publication-input.js';
|
|
5
|
+
import { journalConnectionOf, sqliteBatchOf } from './sqlite-batch.js';
|
|
6
|
+
const sameBytes = (left, right) => left.length === right.length && left.every((byte, index) => byte === right[index]);
|
|
7
|
+
const queues = new WeakMap();
|
|
8
|
+
/** One queue per file system object: two providers over the same `fs` share it. */
|
|
9
|
+
function queueFor(fs) {
|
|
10
|
+
const existing = queues.get(fs);
|
|
11
|
+
if (existing)
|
|
12
|
+
return existing;
|
|
13
|
+
let tail = Promise.resolve();
|
|
14
|
+
const queue = {
|
|
15
|
+
run: work => {
|
|
16
|
+
const result = tail.then(work, work);
|
|
17
|
+
tail = result.catch(() => undefined);
|
|
18
|
+
return result;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
queues.set(fs, queue);
|
|
22
|
+
return queue;
|
|
23
|
+
}
|
|
24
|
+
const hex = (bytes) => Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
25
|
+
/** The Git blob id of `bytes` (`sha1("blob <length>\0" + bytes)`), computed without Git. */
|
|
26
|
+
export async function blobRevision(bytes) {
|
|
27
|
+
const header = new TextEncoder().encode(`blob ${bytes.length}\0`);
|
|
28
|
+
const whole = new Uint8Array(header.length + bytes.length);
|
|
29
|
+
whole.set(header);
|
|
30
|
+
whole.set(bytes, header.length);
|
|
31
|
+
return hex(await sha1(whole));
|
|
32
|
+
}
|
|
33
|
+
const mediaTypes = {
|
|
34
|
+
md: 'text/markdown', markdown: 'text/markdown', html: 'text/html', htm: 'text/html', json: 'application/json', svg: 'image/svg+xml', txt: 'text/plain', tldraw: 'application/vnd.tldraw+json',
|
|
35
|
+
};
|
|
36
|
+
function mediaTypeOf(path) {
|
|
37
|
+
return mediaTypes[path.slice(path.lastIndexOf('.') + 1).toLowerCase()] ?? 'application/octet-stream';
|
|
38
|
+
}
|
|
39
|
+
/** The provider's own temporary files. They are removed when the provider opens and are never a resource. */
|
|
40
|
+
const temporaryPattern = /^\.boring-[0-9a-f-]{36}\.tmp$/;
|
|
41
|
+
/** Whether a path is one of the provider's temporary files (a lister hides them). */
|
|
42
|
+
export const isTemporary = (path) => temporaryPattern.test(path.slice(path.lastIndexOf('/') + 1));
|
|
43
|
+
export function createWorkspaceProvider(options) {
|
|
44
|
+
const { fs, journal } = options;
|
|
45
|
+
const identity = options.identity;
|
|
46
|
+
if (!identity || typeof identity !== 'object')
|
|
47
|
+
throw new TypeError('A workspace provider needs the host workspace identity');
|
|
48
|
+
const key = {
|
|
49
|
+
providerId: identifier(identity.providerId), instanceId: identifier(identity.instanceId),
|
|
50
|
+
incarnation: identifier(identity.incarnation), viewId: identifier(identity.viewId),
|
|
51
|
+
};
|
|
52
|
+
if (!fs || typeof fs.readBinaryFile !== 'function' || typeof fs.renameFile !== 'function')
|
|
53
|
+
throw new TypeError('A workspace provider needs a Pi FileSystem');
|
|
54
|
+
const providerId = key.providerId;
|
|
55
|
+
const root = fs.cwd;
|
|
56
|
+
if (typeof root !== 'string' || !root.startsWith('/'))
|
|
57
|
+
throw new TypeError('The file system needs an absolute working directory');
|
|
58
|
+
const queue = queueFor(fs);
|
|
59
|
+
/** Present when `fs` is the SQLite file system (`@hachej/boring-files/sqlite-filesystem`): multi-file batches then commit in one transaction. */
|
|
60
|
+
const batch = sqliteBatchOf(fs);
|
|
61
|
+
const historyScope = JSON.stringify([key.providerId, key.instanceId, key.incarnation, key.viewId]);
|
|
62
|
+
const listeners = new Set();
|
|
63
|
+
const known = new Map();
|
|
64
|
+
const startedAt = Date.now();
|
|
65
|
+
const fsContext = (signal) => ({ abortSignal: signal, value: () => undefined, toString: () => 'boring-workspace' });
|
|
66
|
+
const absolute = (path) => root.endsWith('/') ? root + path : `${root}/${path}`;
|
|
67
|
+
const ref = (path, revision) => ({ resource: { providerId, path }, view: { kind: 'published' }, revision });
|
|
68
|
+
const supported = (value) => value.resource.providerId === providerId && value.view.kind === 'published';
|
|
69
|
+
const operationKey = (operationId, access) => ({
|
|
70
|
+
scope: JSON.stringify([key.providerId, key.instanceId, key.viewId, access.scopeId]),
|
|
71
|
+
principal: access.principalId, initiator: access.initiatorId, operation: operationId,
|
|
72
|
+
});
|
|
73
|
+
const ownEvidence = (evidence) => evidence.startsWith(`${key.incarnation}:`);
|
|
74
|
+
function emit(change) {
|
|
75
|
+
known.set(change.path, change.revision);
|
|
76
|
+
for (const listener of [...listeners]) {
|
|
77
|
+
try {
|
|
78
|
+
listener(change);
|
|
79
|
+
}
|
|
80
|
+
catch { /* a listener failure cannot undo a write */ }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const dirname = (path) => path.slice(0, path.lastIndexOf('/')) || '/';
|
|
84
|
+
const inside = (base, path) => path === base || path.startsWith(base === '/' ? '/' : `${base}/`);
|
|
85
|
+
let rootReal;
|
|
86
|
+
const realRoot = () => rootReal ??= fs.canonicalPath(root, fsContext()).then(result => result.ok ? result.value : undefined);
|
|
87
|
+
/**
|
|
88
|
+
* Refuses a path whose real location, after resolving symbolic links, is outside the workspace root. The target is checked when it
|
|
89
|
+
* exists, else its deepest existing ancestor. `forWrite` also refuses a symbolic link as the target itself, so a replacement never
|
|
90
|
+
* writes through a link. Returns a reason, or undefined when the path is contained.
|
|
91
|
+
*/
|
|
92
|
+
async function escape(path, forWrite, signal) {
|
|
93
|
+
const base = await realRoot();
|
|
94
|
+
if (base === undefined)
|
|
95
|
+
return 'The workspace root could not be resolved';
|
|
96
|
+
const target = absolute(path);
|
|
97
|
+
if (forWrite) {
|
|
98
|
+
const info = await fs.fileInfo(target, fsContext(signal));
|
|
99
|
+
if (info.ok && info.value.kind === 'symlink')
|
|
100
|
+
return 'A symbolic link cannot be written through';
|
|
101
|
+
}
|
|
102
|
+
let candidate = target;
|
|
103
|
+
for (;;) {
|
|
104
|
+
const real = await fs.canonicalPath(candidate, fsContext(signal));
|
|
105
|
+
if (real.ok)
|
|
106
|
+
return inside(base, real.value) ? undefined : 'The path resolves outside the workspace';
|
|
107
|
+
if (real.error.code !== 'not_found' && real.error.code !== 'not_directory')
|
|
108
|
+
return real.error.message;
|
|
109
|
+
// A dangling link has no canonical path but lstat finds it: never treat it as a missing file.
|
|
110
|
+
const link = await fs.fileInfo(candidate, fsContext(signal));
|
|
111
|
+
if (link.ok && link.value.kind === 'symlink')
|
|
112
|
+
return 'The path is a dangling symbolic link';
|
|
113
|
+
if (candidate === root || candidate === '/')
|
|
114
|
+
return 'The path resolves outside the workspace';
|
|
115
|
+
candidate = dirname(candidate);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** When the file was last modified, or now when the file system cannot say. */
|
|
119
|
+
const savedAtOf = async (path, signal) => {
|
|
120
|
+
const info = await fs.fileInfo(absolute(path), fsContext(signal));
|
|
121
|
+
return info.ok && Number.isFinite(info.value.mtimeMs) ? Math.round(info.value.mtimeMs) : Date.now();
|
|
122
|
+
};
|
|
123
|
+
/** Read the bytes and hash exactly those bytes. */
|
|
124
|
+
async function observe(path, signal) {
|
|
125
|
+
if (isTemporary(path))
|
|
126
|
+
return { kind: 'missing' };
|
|
127
|
+
const reason = await escape(path, false, signal);
|
|
128
|
+
if (reason !== undefined)
|
|
129
|
+
return { kind: 'outside', reason };
|
|
130
|
+
const result = await fs.readBinaryFile(absolute(path), fsContext(signal));
|
|
131
|
+
if (!result.ok) {
|
|
132
|
+
return result.error.code === 'not_found' || result.error.code === 'not_directory' ? { kind: 'missing' } : { kind: 'error', reason: result.error.message };
|
|
133
|
+
}
|
|
134
|
+
const bytes = Uint8Array.from(result.value);
|
|
135
|
+
return { kind: 'file', bytes, revision: await blobRevision(bytes) };
|
|
136
|
+
}
|
|
137
|
+
/** Once per provider, before its first operation: remove temporary files that an earlier process left behind. */
|
|
138
|
+
let swept;
|
|
139
|
+
const sweep = () => swept ??= (async () => {
|
|
140
|
+
const walk = async (directory) => {
|
|
141
|
+
const listing = await fs.listDir(directory, fsContext());
|
|
142
|
+
if (!listing.ok)
|
|
143
|
+
return;
|
|
144
|
+
for (const entry of listing.value) {
|
|
145
|
+
if (entry.kind === 'directory') {
|
|
146
|
+
if (entry.name !== '.git' && entry.name !== 'node_modules')
|
|
147
|
+
await walk(entry.path);
|
|
148
|
+
}
|
|
149
|
+
else if (entry.kind === 'file' && isTemporary(entry.path) && entry.mtimeMs < startedAt)
|
|
150
|
+
await fs.remove(entry.path, { force: true }, fsContext());
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
await walk(root);
|
|
154
|
+
})();
|
|
155
|
+
const read = async (request, context) => {
|
|
156
|
+
const access = accessSnapshot(context);
|
|
157
|
+
const selected = locator(request.target);
|
|
158
|
+
if (request.revision.kind !== 'exact' && request.revision.kind !== 'latest')
|
|
159
|
+
throw new TypeError('Invalid read revision');
|
|
160
|
+
const wanted = request.revision.kind === 'exact' ? identifier(request.revision.value) : undefined;
|
|
161
|
+
if (!supported(selected))
|
|
162
|
+
return { kind: 'unavailable', reason: 'This provider serves its published view only' };
|
|
163
|
+
const path = selected.resource.path;
|
|
164
|
+
await sweep();
|
|
165
|
+
const current = await observe(path, access.signal);
|
|
166
|
+
if (current.kind === 'outside')
|
|
167
|
+
return { kind: 'denied', reason: current.reason };
|
|
168
|
+
if (current.kind === 'error')
|
|
169
|
+
return { kind: 'unavailable', reason: current.reason };
|
|
170
|
+
if (current.kind === 'file' && (wanted === undefined || wanted === current.revision)) {
|
|
171
|
+
const mediaType = journal.version(historyScope, path, current.revision)?.mediaType ?? mediaTypeOf(path);
|
|
172
|
+
return { kind: 'available', snapshot: { ref: ref(path, current.revision), bytes: current.bytes, mediaType } };
|
|
173
|
+
}
|
|
174
|
+
if (wanted === undefined)
|
|
175
|
+
return { kind: 'missing' };
|
|
176
|
+
const retained = journal.version(historyScope, path, wanted);
|
|
177
|
+
if (!retained)
|
|
178
|
+
return { kind: 'unavailable', reason: 'The requested revision is not retained' };
|
|
179
|
+
return { kind: 'available', snapshot: { ref: ref(path, wanted), bytes: retained.bytes, mediaType: retained.mediaType } };
|
|
180
|
+
};
|
|
181
|
+
const keep = (path, access) => queue.run(async () => {
|
|
182
|
+
const result = await read({ target: { resource: { providerId, path }, view: { kind: 'published' } }, revision: { kind: 'latest' } }, access);
|
|
183
|
+
if (result.kind === 'available') {
|
|
184
|
+
const path = result.snapshot.ref.resource.path;
|
|
185
|
+
journal.remember(historyScope, path, { revision: result.snapshot.ref.revision, bytes: result.snapshot.bytes, mediaType: result.snapshot.mediaType, savedAt: await savedAtOf(path) });
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
});
|
|
189
|
+
function receipt(operationId, access, stored) {
|
|
190
|
+
return {
|
|
191
|
+
operationId, argumentDigest: stored.digest, evidenceRef: stored.evidence,
|
|
192
|
+
principalId: access.principalId, scopeId: access.scopeId, initiatorId: access.initiatorId,
|
|
193
|
+
changes: committedChanges(stored.changes, ref),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const publish = async (input, context) => {
|
|
197
|
+
const request = publicationSnapshot(input);
|
|
198
|
+
const access = accessSnapshot(context);
|
|
199
|
+
const digest = await publicationDigest(request);
|
|
200
|
+
if (request.atomicity !== 'all-or-nothing')
|
|
201
|
+
return { kind: 'unavailable', reason: 'This provider accepts all-or-nothing publication only' };
|
|
202
|
+
if (request.changes.length !== 1 && !batch)
|
|
203
|
+
return { kind: 'unavailable', reason: 'This provider cannot apply a multi-file batch atomically; only a single-file change is accepted' };
|
|
204
|
+
const changes = [];
|
|
205
|
+
for (const change of request.changes) {
|
|
206
|
+
if (change.kind === 'delete')
|
|
207
|
+
return { kind: 'unavailable', reason: 'This provider does not delete files through publication' };
|
|
208
|
+
changes.push(change);
|
|
209
|
+
}
|
|
210
|
+
const expectations = [
|
|
211
|
+
...changes.map((change) => change.kind === 'create' ? { kind: 'absent', target: change.target } : { kind: 'revision', target: change.target }),
|
|
212
|
+
...(request.preconditions ?? []),
|
|
213
|
+
];
|
|
214
|
+
if (expectations.some(item => !supported(item.target)))
|
|
215
|
+
return { kind: 'unavailable', reason: 'This provider serves its published view only' };
|
|
216
|
+
if (access.signal?.aborted)
|
|
217
|
+
return { kind: 'unavailable', reason: 'The save was cancelled before it began' };
|
|
218
|
+
const operation = operationKey(request.operationId, access);
|
|
219
|
+
await sweep();
|
|
220
|
+
return queue.run(async () => {
|
|
221
|
+
const stored = journal.operation(operation);
|
|
222
|
+
if (stored) {
|
|
223
|
+
if (stored.digest !== digest)
|
|
224
|
+
return { kind: 'conflict', current: [], reason: 'Operation ID is already bound to different arguments' };
|
|
225
|
+
return ownEvidence(stored.evidence)
|
|
226
|
+
? { kind: 'committed', receipt: receipt(request.operationId, access, stored) }
|
|
227
|
+
: { kind: 'unknown', operationId: request.operationId, reason: 'The workspace was recreated since this operation; its outcome cannot be confirmed' };
|
|
228
|
+
}
|
|
229
|
+
if (journal.intent(operation))
|
|
230
|
+
return { kind: 'unknown', operationId: request.operationId, reason: 'An earlier attempt of this operation began and has no recorded outcome' };
|
|
231
|
+
journal.begin(operation, digest);
|
|
232
|
+
let applying = false;
|
|
233
|
+
const refuse = (reason) => { journal.cancel(operation); return { kind: 'unavailable', reason }; };
|
|
234
|
+
try {
|
|
235
|
+
// Every expectation is checked against the bytes as they are now, before any effect.
|
|
236
|
+
const observed = new Map();
|
|
237
|
+
const conflicts = [];
|
|
238
|
+
let stale = false;
|
|
239
|
+
for (const item of expectations) {
|
|
240
|
+
const target = item.target.resource.path;
|
|
241
|
+
let now = observed.get(target);
|
|
242
|
+
if (!now) {
|
|
243
|
+
now = await observe(target, access.signal);
|
|
244
|
+
observed.set(target, now);
|
|
245
|
+
}
|
|
246
|
+
if (now.kind === 'outside') {
|
|
247
|
+
journal.cancel(operation);
|
|
248
|
+
return { kind: 'denied', reason: now.reason };
|
|
249
|
+
}
|
|
250
|
+
if (now.kind === 'error')
|
|
251
|
+
return refuse(now.reason);
|
|
252
|
+
if (item.kind === 'absent' ? now.kind !== 'missing' : now.kind !== 'file' || now.revision !== item.target.revision) {
|
|
253
|
+
stale = true;
|
|
254
|
+
if (now.kind === 'file')
|
|
255
|
+
conflicts.push(ref(target, now.revision));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (stale) {
|
|
259
|
+
journal.cancel(operation);
|
|
260
|
+
return { kind: 'conflict', current: conflicts, reason: 'Publication precondition failed' };
|
|
261
|
+
}
|
|
262
|
+
if (expectations.some(item => isTemporary(item.target.resource.path)))
|
|
263
|
+
return refuse('That name is reserved for the provider\'s temporary files');
|
|
264
|
+
const plans = [];
|
|
265
|
+
for (const change of changes) {
|
|
266
|
+
const path = change.target.resource.path;
|
|
267
|
+
const forbidden = await escape(path, true, access.signal);
|
|
268
|
+
if (forbidden !== undefined) {
|
|
269
|
+
journal.cancel(operation);
|
|
270
|
+
return { kind: 'denied', reason: forbidden };
|
|
271
|
+
}
|
|
272
|
+
const after = await blobRevision(change.bytes);
|
|
273
|
+
const unchanged = change.kind === 'replace' && after === change.target.revision;
|
|
274
|
+
// The replaced revision was saved when its file was last modified, before this write changes that.
|
|
275
|
+
plans.push({ change, path, before: observed.get(path), after, unchanged, beforeSavedAt: change.kind === 'replace' && !unchanged ? await savedAtOf(path, access.signal) : 0 });
|
|
276
|
+
}
|
|
277
|
+
const evidence = `${key.incarnation}:${randomUUID()}`;
|
|
278
|
+
const versions = plans.flatMap(({ change, path, before, after, unchanged, beforeSavedAt }) => {
|
|
279
|
+
const kept = (revision, bytes, mediaType, savedAt) => ({ scope: historyScope, path, version: { revision, bytes, mediaType, savedAt } });
|
|
280
|
+
return unchanged ? [] : [
|
|
281
|
+
...(change.kind === 'replace' && before?.kind === 'file'
|
|
282
|
+
? [kept(before.revision, before.bytes, journal.version(historyScope, path, before.revision)?.mediaType ?? mediaTypeOf(path), beforeSavedAt)] : []),
|
|
283
|
+
kept(after, change.bytes, change.mediaType, Date.now()),
|
|
284
|
+
];
|
|
285
|
+
});
|
|
286
|
+
const record = {
|
|
287
|
+
digest, evidence,
|
|
288
|
+
changes: plans.map(({ change, path, after }) => change.kind === 'create' ? { kind: 'create', path, before: null, after } : { kind: 'replace', path, before: change.target.revision, after }),
|
|
289
|
+
};
|
|
290
|
+
if (access.signal?.aborted)
|
|
291
|
+
return refuse('The save was cancelled before any change');
|
|
292
|
+
if (batch) {
|
|
293
|
+
// SQLite: every file, and the receipt when the journal shares the database, in one transaction. The expectations are
|
|
294
|
+
// checked again inside it against the bytes they were hashed from, so a shell write in between is a conflict, not lost.
|
|
295
|
+
const together = journalConnectionOf(journal) === batch.connection;
|
|
296
|
+
const moved = batch.connection.transaction('write', () => {
|
|
297
|
+
const changed = [...observed].filter(([path, seen]) => {
|
|
298
|
+
const now = batch.bytes(absolute(path));
|
|
299
|
+
return seen.kind === 'missing' ? now !== null : seen.kind !== 'file' || !(now instanceof Uint8Array) || !sameBytes(now, seen.bytes);
|
|
300
|
+
}).map(([path]) => path);
|
|
301
|
+
if (changed.length)
|
|
302
|
+
return changed;
|
|
303
|
+
for (const plan of plans)
|
|
304
|
+
if (!plan.unchanged)
|
|
305
|
+
batch.write(absolute(plan.path), plan.change.bytes);
|
|
306
|
+
if (together)
|
|
307
|
+
journal.complete(operation, record, versions);
|
|
308
|
+
return [];
|
|
309
|
+
});
|
|
310
|
+
if (moved.length) {
|
|
311
|
+
journal.cancel(operation);
|
|
312
|
+
const current = await Promise.all(moved.map(async (path) => { const now = await observe(path, access.signal); return now.kind === 'file' ? [ref(path, now.revision)] : []; }));
|
|
313
|
+
return { kind: 'conflict', current: current.flat(), reason: 'Publication precondition failed' };
|
|
314
|
+
}
|
|
315
|
+
applying = true;
|
|
316
|
+
if (!together)
|
|
317
|
+
journal.complete(operation, record, versions);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
const { change, path, unchanged } = plans[0];
|
|
321
|
+
if (!unchanged) {
|
|
322
|
+
applying = true;
|
|
323
|
+
const target = absolute(path);
|
|
324
|
+
const temporary = `${target.slice(0, target.lastIndexOf('/'))}/.boring-${randomUUID()}.tmp`;
|
|
325
|
+
const ctx = fsContext(access.signal);
|
|
326
|
+
const failure = async (reason) => { await fs.remove(temporary, { force: true }, fsContext()); return refuse(reason); };
|
|
327
|
+
if (change.kind === 'create') {
|
|
328
|
+
const made = await fs.createDir(target.slice(0, target.lastIndexOf('/')) || '/', { recursive: true }, ctx);
|
|
329
|
+
if (!made.ok)
|
|
330
|
+
return refuse(made.error.message);
|
|
331
|
+
}
|
|
332
|
+
const written = await fs.writeFile(temporary, change.bytes, ctx);
|
|
333
|
+
if (!written.ok)
|
|
334
|
+
return await failure(written.error.message);
|
|
335
|
+
const renamed = await fs.renameFile(temporary, target, ctx);
|
|
336
|
+
if (!renamed.ok) {
|
|
337
|
+
if (renamed.error.code === 'unknown' || renamed.error.code === 'aborted')
|
|
338
|
+
throw renamed.error;
|
|
339
|
+
return await failure(`The file could not be replaced atomically: ${renamed.error.message}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
journal.complete(operation, record, versions);
|
|
343
|
+
}
|
|
344
|
+
for (const { path, after, unchanged } of plans)
|
|
345
|
+
if (!unchanged)
|
|
346
|
+
emit({ path, revision: after, source: 'provider' });
|
|
347
|
+
return { kind: 'committed', receipt: receipt(request.operationId, access, record) };
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
// Before the first effect nothing happened. After it, the outcome is unknown and the intent stays.
|
|
351
|
+
if (!applying)
|
|
352
|
+
return refuse(error instanceof Error ? error.message : 'The save failed before any change');
|
|
353
|
+
return { kind: 'unknown', operationId: request.operationId, reason: error instanceof Error ? `The save was interrupted: ${error.message}` : 'The save was interrupted' };
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
const lookup = async (operationId, context) => {
|
|
358
|
+
const id = identifier(operationId);
|
|
359
|
+
const access = accessSnapshot(context);
|
|
360
|
+
const operation = operationKey(id, access);
|
|
361
|
+
await sweep();
|
|
362
|
+
const stored = journal.operation(operation);
|
|
363
|
+
if (stored) {
|
|
364
|
+
return ownEvidence(stored.evidence)
|
|
365
|
+
? { kind: 'committed', receipt: receipt(id, access, stored) }
|
|
366
|
+
: { kind: 'unknown', operationId: id, reason: 'The workspace was recreated since this operation; its outcome cannot be confirmed' };
|
|
367
|
+
}
|
|
368
|
+
return journal.intent(operation)
|
|
369
|
+
? { kind: 'unknown', operationId: id, reason: 'The operation began and has no recorded outcome' }
|
|
370
|
+
: { kind: 'not-found' };
|
|
371
|
+
};
|
|
372
|
+
const poll = async (paths, signal) => {
|
|
373
|
+
const changed = [];
|
|
374
|
+
await sweep();
|
|
375
|
+
for (const input of paths) {
|
|
376
|
+
const path = locator({ resource: { providerId, path: input }, view: { kind: 'published' } }).resource.path;
|
|
377
|
+
const now = await observe(path, signal);
|
|
378
|
+
if (now.kind === 'error' || now.kind === 'outside' || isTemporary(path))
|
|
379
|
+
continue;
|
|
380
|
+
const revision = now.kind === 'file' ? now.revision : null;
|
|
381
|
+
const first = !known.has(path);
|
|
382
|
+
if (!first && known.get(path) === revision)
|
|
383
|
+
continue;
|
|
384
|
+
const change = { path, revision, source: 'poll' };
|
|
385
|
+
if (first)
|
|
386
|
+
known.set(path, revision);
|
|
387
|
+
else {
|
|
388
|
+
emit(change);
|
|
389
|
+
changed.push(change);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return changed;
|
|
393
|
+
};
|
|
394
|
+
return {
|
|
395
|
+
providerId, read, queue, keep, identity: Object.freeze(key),
|
|
396
|
+
catalog: createWorkspaceCatalog({ fs, ...(options.catalog ? { config: options.catalog } : {}), saves: path => journal.saves(historyScope, path) }),
|
|
397
|
+
publication: { publish }, reconciliation: { lookup }, poll,
|
|
398
|
+
history: path => journal.revisions(historyScope, locator({ resource: { providerId, path }, view: { kind: 'published' } }).resource.path),
|
|
399
|
+
saves: path => journal.saves(historyScope, locator({ resource: { providerId, path }, view: { kind: 'published' } }).resource.path),
|
|
400
|
+
onChange: listener => { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
401
|
+
capabilities: async (input) => {
|
|
402
|
+
const selected = locator(input);
|
|
403
|
+
const ok = supported(selected);
|
|
404
|
+
const actions = ['read', 'create', 'replace', 'lookup'];
|
|
405
|
+
return {
|
|
406
|
+
supported: ok ? actions : [], effective: ok ? actions : [], availability: ok ? 'available' : 'unavailable', observedAt: new Date().toISOString(),
|
|
407
|
+
guarantees: { pinnedReads: true, conditionalPublication: true, atomicMutationAndReceipt: batch !== undefined && journalConnectionOf(journal) === batch.connection, atomicBatch: batch !== undefined, operationLookup: true, revocationFencing: false },
|
|
408
|
+
};
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,121 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-files",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"description": "Versioned-resource contracts and the workspace provider over a Pi FileSystem (disk, virtual or SQLite).",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/**/*.js",
|
|
11
|
+
"dist/**/*.d.ts",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./contracts": {
|
|
22
|
+
"types": "./dist/contracts.d.ts",
|
|
23
|
+
"import": "./dist/contracts.js"
|
|
24
|
+
},
|
|
25
|
+
"./text": {
|
|
26
|
+
"types": "./dist/text.d.ts",
|
|
27
|
+
"import": "./dist/text.js"
|
|
28
|
+
},
|
|
29
|
+
"./platform": {
|
|
30
|
+
"types": "./dist/platform.d.ts",
|
|
31
|
+
"import": "./dist/platform.js"
|
|
32
|
+
},
|
|
33
|
+
"./request-guard": {
|
|
34
|
+
"types": "./dist/request-guard.d.ts",
|
|
35
|
+
"import": "./dist/request-guard.js"
|
|
36
|
+
},
|
|
37
|
+
"./node-http": {
|
|
38
|
+
"types": "./dist/node-http.d.ts",
|
|
39
|
+
"import": "./dist/node-http.js"
|
|
40
|
+
},
|
|
41
|
+
"./publication": {
|
|
42
|
+
"types": "./dist/publication-input.d.ts",
|
|
43
|
+
"import": "./dist/publication-input.js"
|
|
44
|
+
},
|
|
45
|
+
"./journal": {
|
|
46
|
+
"types": "./dist/journal.d.ts",
|
|
47
|
+
"import": "./dist/journal.js"
|
|
48
|
+
},
|
|
49
|
+
"./workspace": {
|
|
50
|
+
"types": "./dist/workspace.d.ts",
|
|
51
|
+
"import": "./dist/workspace.js"
|
|
52
|
+
},
|
|
53
|
+
"./sqlite": {
|
|
54
|
+
"types": "./dist/sqlite.d.ts",
|
|
55
|
+
"import": "./dist/sqlite.js"
|
|
56
|
+
},
|
|
57
|
+
"./sqlite-settings": {
|
|
58
|
+
"types": "./dist/sqlite-settings.d.ts",
|
|
59
|
+
"import": "./dist/sqlite-settings.js"
|
|
60
|
+
},
|
|
61
|
+
"./sqlite-filesystem": {
|
|
62
|
+
"types": "./dist/sqlite-filesystem.d.ts",
|
|
63
|
+
"import": "./dist/sqlite-filesystem.js"
|
|
64
|
+
},
|
|
65
|
+
"./sqlite-durable-object": {
|
|
66
|
+
"types": "./dist/sqlite-durable-object.d.ts",
|
|
67
|
+
"import": "./dist/sqlite-durable-object.js"
|
|
68
|
+
},
|
|
69
|
+
"./git": {
|
|
70
|
+
"types": "./dist/git.d.ts",
|
|
71
|
+
"import": "./dist/git.js"
|
|
72
|
+
},
|
|
73
|
+
"./remote": {
|
|
74
|
+
"types": "./dist/remote.d.ts",
|
|
75
|
+
"import": "./dist/remote.js"
|
|
76
|
+
},
|
|
77
|
+
"./revision": {
|
|
78
|
+
"types": "./dist/revision.d.ts",
|
|
79
|
+
"import": "./dist/revision.js"
|
|
80
|
+
},
|
|
81
|
+
"./revision-handler": {
|
|
82
|
+
"types": "./dist/revision-handler.d.ts",
|
|
83
|
+
"import": "./dist/revision-handler.js"
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"peerDependencies": {
|
|
87
|
+
"isomorphic-git": "1.42.6",
|
|
88
|
+
"@earendil-works/pi-durable": "1.1.0",
|
|
89
|
+
"@earendil-works/chord": "1.1.0"
|
|
90
|
+
},
|
|
91
|
+
"peerDependenciesMeta": {
|
|
92
|
+
"isomorphic-git": {
|
|
93
|
+
"optional": true
|
|
94
|
+
},
|
|
95
|
+
"@earendil-works/pi-durable": {
|
|
96
|
+
"optional": true
|
|
97
|
+
},
|
|
98
|
+
"@earendil-works/chord": {
|
|
99
|
+
"optional": true
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"engines": {
|
|
103
|
+
"node": ">=22.19.0"
|
|
104
|
+
},
|
|
105
|
+
"repository": {
|
|
106
|
+
"type": "git",
|
|
107
|
+
"url": "git+https://github.com/hachej/boring-ui.git",
|
|
108
|
+
"directory": "packages/files"
|
|
109
|
+
},
|
|
110
|
+
"homepage": "https://github.com/hachej/boring-ui#readme",
|
|
111
|
+
"bugs": {
|
|
112
|
+
"url": "https://github.com/hachej/boring-ui/issues"
|
|
113
|
+
},
|
|
114
|
+
"publishConfig": {
|
|
115
|
+
"access": "public",
|
|
116
|
+
"registry": "https://registry.npmjs.org/"
|
|
117
|
+
},
|
|
118
|
+
"dependencies": {
|
|
119
|
+
"ignore": "5.3.2"
|
|
120
|
+
}
|
|
121
|
+
}
|