@hachej/boring-files 0.2.0 → 0.2.2

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/dist/workspace.js CHANGED
@@ -2,7 +2,10 @@ import { createWorkspaceCatalog } from './workspace-catalog.js';
2
2
  import { committedChanges } from './journal.js';
3
3
  import { randomUUID, sha1 } from './platform.js';
4
4
  import { accessSnapshot, identifier, locator, publicationDigest, publicationSnapshot } from './publication-input.js';
5
+ import { assertLimit, fileTooLarge } from './file-limits.js';
5
6
  import { journalConnectionOf, sqliteBatchOf } from './sqlite-batch.js';
7
+ import { isTemporary, mediaTypeOf, temporaryFileName } from './file-types.js';
8
+ export { isTemporary } from './file-types.js';
6
9
  const sameBytes = (left, right) => left.length === right.length && left.every((byte, index) => byte === right[index]);
7
10
  const queues = new WeakMap();
8
11
  /** One queue per file system object: two providers over the same `fs` share it. */
@@ -30,16 +33,6 @@ export async function blobRevision(bytes) {
30
33
  whole.set(bytes, header.length);
31
34
  return hex(await sha1(whole));
32
35
  }
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
36
  export function createWorkspaceProvider(options) {
44
37
  const { fs, journal } = options;
45
38
  const identity = options.identity;
@@ -58,6 +51,11 @@ export function createWorkspaceProvider(options) {
58
51
  const queue = queueFor(fs);
59
52
  /** Present when `fs` is the SQLite file system (`@hachej/boring-files/sqlite-filesystem`): multi-file batches then commit in one transaction. */
60
53
  const batch = sqliteBatchOf(fs);
54
+ if (options.maxFileBytes !== undefined) {
55
+ assertLimit(options.maxFileBytes);
56
+ batch?.setMaxFileBytes(options.maxFileBytes);
57
+ }
58
+ const limitOf = () => batch ? batch.maxFileBytes() : options.maxFileBytes;
61
59
  const historyScope = JSON.stringify([key.providerId, key.instanceId, key.incarnation, key.viewId]);
62
60
  const listeners = new Set();
63
61
  const known = new Map();
@@ -71,14 +69,38 @@ export function createWorkspaceProvider(options) {
71
69
  principal: access.principalId, initiator: access.initiatorId, operation: operationId,
72
70
  });
73
71
  const ownEvidence = (evidence) => evidence.startsWith(`${key.incarnation}:`);
74
- function emit(change) {
75
- known.set(change.path, change.revision);
72
+ // ---- The change feed: a bounded, seq-ordered buffer of invalidation hints ----------------------------------------------------
73
+ const bufferLimit = options.changeBuffer ?? 1024;
74
+ if (!Number.isSafeInteger(bufferLimit) || bufferLimit < 1)
75
+ throw new RangeError('The change buffer must be a positive integer');
76
+ const lingerMs = options.watchLingerMs ?? 30_000;
77
+ // Sequence numbers start from the clock (microseconds), so a cursor from an earlier provider lifetime is older than this one's
78
+ // first event and reads as a gap, never as a position in this lifetime.
79
+ const firstSeq = Date.now() * 1000;
80
+ let head = firstSeq;
81
+ // Names this provider lifetime. A cursor from another lifetime is refused by this, not only by the clock: the clock may go back.
82
+ const incarnationOfFeed = randomUUID();
83
+ const lifetime = new AbortController();
84
+ const buffer = [];
85
+ const waiting = new Set();
86
+ function append(event) {
87
+ head += 1;
88
+ buffer.push(Object.freeze({ ...event, seq: head, at: Date.now() }));
89
+ if (buffer.length > bufferLimit)
90
+ buffer.shift();
91
+ for (const wake of [...waiting])
92
+ wake();
93
+ }
94
+ function emit(change, extra = {}) {
95
+ if (!extra.directory)
96
+ known.set(change.path, change.revision);
76
97
  for (const listener of [...listeners]) {
77
98
  try {
78
99
  listener(change);
79
100
  }
80
101
  catch { /* a listener failure cannot undo a write */ }
81
102
  }
103
+ append({ kind: 'change', ...change, ...extra });
82
104
  }
83
105
  const dirname = (path) => path.slice(0, path.lastIndexOf('/')) || '/';
84
106
  const inside = (base, path) => path === base || path.startsWith(base === '/' ? '/' : `${base}/`);
@@ -182,7 +204,7 @@ export function createWorkspaceProvider(options) {
182
204
  const result = await read({ target: { resource: { providerId, path }, view: { kind: 'published' } }, revision: { kind: 'latest' } }, access);
183
205
  if (result.kind === 'available') {
184
206
  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) });
207
+ journal.remember(historyScope, path, { revision: result.snapshot.ref.revision, bytes: result.snapshot.bytes, mediaType: result.snapshot.mediaType, savedAt: await savedAtOf(path), source: 'observed' });
186
208
  }
187
209
  return result;
188
210
  });
@@ -228,6 +250,15 @@ export function createWorkspaceProvider(options) {
228
250
  }
229
251
  if (journal.intent(operation))
230
252
  return { kind: 'unknown', operationId: request.operationId, reason: 'An earlier attempt of this operation began and has no recorded outcome' };
253
+ // After the receipt and intent checks, so a replay of a committed operation keeps its result under a lower limit.
254
+ const limit = limitOf();
255
+ if (limit !== undefined) {
256
+ const oversized = changes.find(change => change.bytes.length > limit);
257
+ if (oversized) {
258
+ const size = oversized.bytes.length, path = oversized.target.resource.path;
259
+ return { kind: 'file_too_large', path, size, limit, reason: fileTooLarge(path, size, limit).message };
260
+ }
261
+ }
231
262
  journal.begin(operation, digest);
232
263
  let applying = false;
233
264
  const refuse = (reason) => { journal.cancel(operation); return { kind: 'unavailable', reason }; };
@@ -276,11 +307,11 @@ export function createWorkspaceProvider(options) {
276
307
  }
277
308
  const evidence = `${key.incarnation}:${randomUUID()}`;
278
309
  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 } });
310
+ const kept = (revision, bytes, mediaType, savedAt, source) => ({ scope: historyScope, path, version: { revision, bytes, mediaType, savedAt, source } });
280
311
  return unchanged ? [] : [
281
312
  ...(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()),
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'),
284
315
  ];
285
316
  });
286
317
  const record = {
@@ -321,7 +352,7 @@ export function createWorkspaceProvider(options) {
321
352
  if (!unchanged) {
322
353
  applying = true;
323
354
  const target = absolute(path);
324
- const temporary = `${target.slice(0, target.lastIndexOf('/'))}/.boring-${randomUUID()}.tmp`;
355
+ const temporary = `${target.slice(0, target.lastIndexOf('/'))}/${temporaryFileName(randomUUID())}`;
325
356
  const ctx = fsContext(access.signal);
326
357
  const failure = async (reason) => { await fs.remove(temporary, { force: true }, fsContext()); return refuse(reason); };
327
358
  if (change.kind === 'create') {
@@ -343,7 +374,7 @@ export function createWorkspaceProvider(options) {
343
374
  }
344
375
  for (const { path, after, unchanged } of plans)
345
376
  if (!unchanged)
346
- emit({ path, revision: after, source: 'provider' });
377
+ emit({ path, revision: after, source: 'publish' });
347
378
  return { kind: 'committed', receipt: receipt(request.operationId, access, record) };
348
379
  }
349
380
  catch (error) {
@@ -369,20 +400,63 @@ export function createWorkspaceProvider(options) {
369
400
  ? { kind: 'unknown', operationId: id, reason: 'The operation began and has no recorded outcome' }
370
401
  : { kind: 'not-found' };
371
402
  };
403
+ /** Every file of the workspace, relative to the root (not `.git`, `node_modules` or the provider's temporary files). */
404
+ async function everyFile(signal) {
405
+ const found = [];
406
+ let scanned = 0;
407
+ const walk = async (directory) => {
408
+ const listing = await fs.listDir(directory ? absolute(directory) : root, fsContext(signal));
409
+ if (!listing.ok)
410
+ return true;
411
+ for (const entry of listing.value) {
412
+ if (++scanned > 100_000)
413
+ return false;
414
+ const path = directory ? `${directory}/${entry.name}` : entry.name;
415
+ if (entry.kind === 'directory') {
416
+ if (entry.name !== '.git' && entry.name !== 'node_modules' && !await walk(path))
417
+ return false;
418
+ }
419
+ else if (entry.kind === 'file' && !isTemporary(path))
420
+ found.push(path);
421
+ }
422
+ return true;
423
+ };
424
+ return await walk('') ? found : undefined;
425
+ }
426
+ let baselined = false;
372
427
  const poll = async (paths, signal) => {
373
428
  const changed = [];
374
429
  await sweep();
375
- for (const input of paths) {
430
+ let inputs;
431
+ let report = (path) => known.has(path);
432
+ if (paths === undefined) {
433
+ const files = await everyFile(signal);
434
+ // Too many files to hash on each turn: say so to every listener instead of reporting a partial picture.
435
+ if (files === undefined) {
436
+ append({ kind: 'resnapshot' });
437
+ return changed;
438
+ }
439
+ const present = new Set(files);
440
+ const removed = [...known].filter(([path, revision]) => revision !== null && !present.has(path)).map(([path]) => path);
441
+ inputs = [...files, ...removed];
442
+ if (baselined)
443
+ report = () => true;
444
+ baselined = true;
445
+ }
446
+ else
447
+ inputs = paths;
448
+ for (const input of inputs) {
376
449
  const path = locator({ resource: { providerId, path: input }, view: { kind: 'published' } }).resource.path;
377
450
  const now = await observe(path, signal);
378
451
  if (now.kind === 'error' || now.kind === 'outside' || isTemporary(path))
379
452
  continue;
380
453
  const revision = now.kind === 'file' ? now.revision : null;
381
- const first = !known.has(path);
382
- if (!first && known.get(path) === revision)
454
+ if (known.has(path) && known.get(path) === revision)
455
+ continue;
456
+ if (!known.has(path) && revision === null && paths === undefined)
383
457
  continue;
384
458
  const change = { path, revision, source: 'poll' };
385
- if (first)
459
+ if (!report(path))
386
460
  known.set(path, revision);
387
461
  else {
388
462
  emit(change);
@@ -391,10 +465,181 @@ export function createWorkspaceProvider(options) {
391
465
  }
392
466
  return changed;
393
467
  };
468
+ const record = async (write, context) => {
469
+ const access = accessSnapshot(context);
470
+ if (write.source !== 'agent')
471
+ throw new TypeError('Only agent writes are recorded');
472
+ const path = locator({ resource: { providerId, path: write.path }, view: { kind: 'published' } }).resource.path;
473
+ const after = identifier(write.after);
474
+ const conversationId = write.conversationId === undefined ? undefined : identifier(write.conversationId);
475
+ // Known before the await: a watcher event for this same write, handled meanwhile, then finds nothing new and emits nothing twice.
476
+ if (!isTemporary(path))
477
+ known.set(path, after);
478
+ // Deliberately not `queue.run`: the file guard calls this from inside the queue, and the queue runs one piece at a time.
479
+ const now = await observe(path, access.signal);
480
+ if (now.kind === 'outside' || now.kind === 'error')
481
+ return;
482
+ if (now.kind === 'file' && now.revision === after) {
483
+ const mediaType = journal.version(historyScope, path, after)?.mediaType ?? mediaTypeOf(path);
484
+ const before = write.before ?? undefined;
485
+ // The replaced bytes are kept only when they really are that revision.
486
+ if (before && before.revision !== after && before.bytes instanceof Uint8Array && await blobRevision(before.bytes) === identifier(before.revision)) {
487
+ journal.remember(historyScope, path, { revision: before.revision, bytes: Uint8Array.from(before.bytes), mediaType: journal.version(historyScope, path, before.revision)?.mediaType ?? mediaType, savedAt: 0, source: 'observed' });
488
+ }
489
+ journal.remember(historyScope, path, { revision: after, bytes: now.bytes, mediaType, savedAt: Date.now(), source: 'agent' });
490
+ }
491
+ emit({ path, revision: now.kind === 'file' ? now.revision : null, source: 'agent' }, conversationId === undefined ? {} : { conversationId });
492
+ };
493
+ // ---- The watcher: open while someone listens to the feed (and `lingerMs` after), when the file system can watch ----------------
494
+ let listening = 0;
495
+ let watcher;
496
+ let watchUnsupported = false, watchedBefore = false;
497
+ let linger;
498
+ let watchWork = Promise.resolve();
499
+ const relativeTo = (absolutePath, base) => {
500
+ base = base.replace(/\/+$/, '') || '/';
501
+ if (absolutePath === base)
502
+ return '';
503
+ const prefix = base === '/' ? '/' : `${base}/`;
504
+ return absolutePath.startsWith(prefix) ? absolutePath.slice(prefix.length) : undefined;
505
+ };
506
+ async function watched(input) {
507
+ // The watcher may name the root as given or as resolved (a symbolic link on the way to the workspace).
508
+ const real = await realRoot();
509
+ const path = relativeTo(input, root) ?? (real === undefined ? undefined : relativeTo(input, real));
510
+ if (path === undefined || isTemporary(path) || path.split('/').some(name => name === '.git' || name === 'node_modules'))
511
+ return;
512
+ if (path === '') {
513
+ emit({ path, revision: null, source: 'watch' }, { directory: true });
514
+ return;
515
+ }
516
+ const info = await fs.fileInfo(absolute(path), fsContext());
517
+ if (info.ok && info.value.kind === 'directory') {
518
+ emit({ path, revision: null, source: 'watch' }, { directory: true });
519
+ return;
520
+ }
521
+ const now = await observe(path);
522
+ if (now.kind === 'error' || now.kind === 'outside')
523
+ return;
524
+ const revision = now.kind === 'file' ? now.revision : null;
525
+ if (known.has(path) && known.get(path) === revision)
526
+ return;
527
+ emit({ path, revision, source: 'watch' });
528
+ }
529
+ function onWatch(change) {
530
+ if ('paths' in change) {
531
+ const paths = [...change.paths];
532
+ watchWork = watchWork.then(async () => { for (const path of paths)
533
+ await watched(path).catch(() => undefined); });
534
+ return;
535
+ }
536
+ // Overflow or a failed watcher: what changed is unknown, so every listener reloads. A failed watcher is not reopened (it would
537
+ // fail again, for example on a tree past the environment's limit); the host's `poll()` at turn end remains.
538
+ if ('error' in change) {
539
+ watcher = undefined;
540
+ watchUnsupported = true;
541
+ }
542
+ append({ kind: 'resnapshot' });
543
+ }
544
+ function startWatching() {
545
+ if (lifetime.signal.aborted)
546
+ return;
547
+ if (linger !== undefined) {
548
+ clearTimeout(linger);
549
+ linger = undefined;
550
+ }
551
+ if (watcher || watchUnsupported)
552
+ return;
553
+ // Changes made while nobody watched were not seen: a listener resuming from before that reloads.
554
+ if (watchedBefore)
555
+ append({ kind: 'resnapshot' });
556
+ watchedBefore = true;
557
+ watcher = fs.watch([{ path: root, recursive: true, exclude: { names: ['.git', 'node_modules'] } }], onWatch, fsContext()).then(result => {
558
+ if (result.ok)
559
+ return result.value;
560
+ watchUnsupported = true;
561
+ return undefined;
562
+ }, () => { watchUnsupported = true; return undefined; });
563
+ }
564
+ function stopWatching() {
565
+ const current = watcher;
566
+ watcher = undefined;
567
+ void current?.then(value => value?.close(fsContext()));
568
+ }
569
+ async function* changes(request, context) {
570
+ const access = accessSnapshot(context);
571
+ identifier(access.scopeId);
572
+ const since = request.since;
573
+ if (since !== undefined && (!Number.isSafeInteger(since) || since < 0))
574
+ throw new TypeError('Invalid change cursor');
575
+ if (lifetime.signal.aborted)
576
+ return;
577
+ const signals = [request.signal, access.signal, lifetime.signal].filter((value) => value !== undefined);
578
+ const signal = signals.length ? AbortSignal.any(signals) : new AbortController().signal;
579
+ listening += 1;
580
+ startWatching();
581
+ try {
582
+ let cursor = head;
583
+ if (since !== undefined) {
584
+ const oldest = buffer[0]?.seq ?? head + 1;
585
+ const foreign = request.incarnation !== undefined && request.incarnation !== incarnationOfFeed;
586
+ if (foreign || since > head || since < firstSeq || since < oldest - 1)
587
+ yield { kind: 'resnapshot', seq: head, at: Date.now() };
588
+ else
589
+ cursor = since;
590
+ }
591
+ while (!signal.aborted) {
592
+ const oldest = buffer[0];
593
+ // This listener fell behind the retained window: it reloads, then continues with what is retained.
594
+ if (oldest && oldest.seq > cursor + 1) {
595
+ cursor = oldest.seq - 1;
596
+ yield { kind: 'resnapshot', seq: cursor, at: Date.now() };
597
+ continue;
598
+ }
599
+ const next = buffer.find(event => event.seq > cursor);
600
+ if (next) {
601
+ cursor = next.seq;
602
+ // The access projection: nothing after revocation, never the provider's temporary files.
603
+ if (signal.aborted)
604
+ return;
605
+ if (next.kind === 'change' && isTemporary(next.path))
606
+ continue;
607
+ yield next;
608
+ continue;
609
+ }
610
+ await new Promise(resolve => {
611
+ const wake = () => { waiting.delete(wake); signal.removeEventListener('abort', wake); resolve(); };
612
+ waiting.add(wake);
613
+ signal.addEventListener('abort', wake, { once: true });
614
+ });
615
+ }
616
+ }
617
+ finally {
618
+ listening -= 1;
619
+ if (listening === 0 && watcher && !lifetime.signal.aborted) {
620
+ linger = setTimeout(() => { linger = undefined; if (listening === 0)
621
+ stopWatching(); }, lingerMs);
622
+ linger.unref?.();
623
+ }
624
+ }
625
+ }
394
626
  return {
395
627
  providerId, read, queue, keep, identity: Object.freeze(key),
396
628
  catalog: createWorkspaceCatalog({ fs, ...(options.catalog ? { config: options.catalog } : {}), saves: path => journal.saves(historyScope, path) }),
397
- publication: { publish }, reconciliation: { lookup }, poll,
629
+ publication: { publish }, reconciliation: { lookup }, poll, record, changes, changeHead: () => head, changeIncarnation: incarnationOfFeed,
630
+ lifetime: lifetime.signal,
631
+ close: () => {
632
+ // Every change stream ends, the watcher closes and the listeners are dropped: nothing of this provider keeps running.
633
+ lifetime.abort();
634
+ if (linger !== undefined) {
635
+ clearTimeout(linger);
636
+ linger = undefined;
637
+ }
638
+ stopWatching();
639
+ listeners.clear();
640
+ for (const wake of [...waiting])
641
+ wake();
642
+ },
398
643
  history: path => journal.revisions(historyScope, locator({ resource: { providerId, path }, view: { kind: 'published' } }).resource.path),
399
644
  saves: path => journal.saves(historyScope, locator({ resource: { providerId, path }, view: { kind: 'published' } }).resource.path),
400
645
  onChange: listener => { listeners.add(listener); return () => { listeners.delete(listener); }; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-files",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -81,12 +81,16 @@
81
81
  "./revision-handler": {
82
82
  "types": "./dist/revision-handler.d.ts",
83
83
  "import": "./dist/revision-handler.js"
84
+ },
85
+ "./file-types": {
86
+ "types": "./dist/file-types.d.ts",
87
+ "import": "./dist/file-types.js"
84
88
  }
85
89
  },
86
90
  "peerDependencies": {
87
- "isomorphic-git": "1.42.6",
88
- "@earendil-works/pi-durable": "1.1.0",
89
- "@earendil-works/chord": "1.1.0"
91
+ "isomorphic-git": "^1.42.6",
92
+ "@earendil-works/pi-durable": "^1.1.0",
93
+ "@earendil-works/chord": "^1.1.0"
90
94
  },
91
95
  "peerDependenciesMeta": {
92
96
  "isomorphic-git": {