@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/README.md CHANGED
@@ -10,6 +10,12 @@ Opaque resource view IDs are provider-scoped and bound to the actual backing/inc
10
10
 
11
11
  The provider accepts all-or-nothing create and replace. It refuses working views, per-change requests and deletes (files are removed with the workspace's own tools) 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
+ ### Agent writes and the change feed
14
+
15
+ Agent writes are observed, never receipted ([owner ruling 2](../../docs/architecture/FILES-GIT-EXEC.md#one-place-for-files)). `provider.record({ path, before?, after, source: 'agent', conversationId? }, access)` is what the file guard (`@hachej/boring-agent/file-guard`) calls after a Pi `write` or `edit` succeeded: the file's history gains the written revision with `source: 'agent'` (and the replaced revision, `source: 'observed'`, when `before` carries its bytes), and the change feed an `agent` event. It never writes the publication journal and never mints a receipt. It does not enter the provider's queue (the guard already runs inside it), so calling it from queued work cannot deadlock. History entries (`saves`, `catalog.history`) carry `source`: `publish`, `agent` or `observed` (`keep` and replaced revisions).
16
+
17
+ `provider.changes({ since?, signal }, access)` is one change feed for every file view: an `AsyncIterable` of `{ kind: 'change', path, revision | null, source: 'publish' | 'agent' | 'poll' | 'watch', seq, at }`, ordered by `seq` and replayable from `since` (the last `seq` seen; `changeHead()` is "now"). The provider retains the last `changeBuffer` events (default 1024); a cursor older than that, from another provider lifetime or ahead of this one, and a listener that falls behind get `{ kind: 'resnapshot', seq, at }`: reload everything shown, then continue. Events are invalidation hints; bytes and the journal stay authoritative. Sources: conditional writes (`publish`), `record` (`agent`), `poll` and, while someone listens (and `watchLingerMs` after, default 30 s), the file system's own `watch` (`watch`; a reported directory is `{ revision: null, directory: true }`). The SQLite file system reports writes through its own connection at once and polls every second for other connections. When the file system cannot watch (the virtual and remote environments report `not_supported`), the host calls `provider.poll()` at the end of each agent turn: without paths it hashes every file (not `.git` or `node_modules`); the first call takes the baseline, later ones report changed, new and removed files. A `bash` write gets change events only, never history. After the last listener leaves the watcher lingers and then closes; changes made while no watcher was open are unknown, so the first listener afterwards makes the feed append a `resnapshot` (a client that stays connected is never affected). `changeIncarnation` names the provider lifetime: a cursor sent with another `incarnation` is a `resnapshot` whatever its number. `provider.close()` ends every change stream, closes the watcher and drops the listeners.
18
+
13
19
  Receipts are scoped by workspace identity, principal, scope and initiator. Identical committed retries return the original receipt; changed arguments conflict. Refused operations do not reserve an ID. A receipt from an earlier incarnation of the workspace reads `unknown`. A not-found lookup is not permission to replay an uncertain operation.
14
20
 
15
21
  `@hachej/boring-files/publication` is browser-safe. It captures request bytes, computes a versioned canonical SHA-256 digest with WebCrypto, and parses result shapes. Digests carry `boring-publication-v1:sha256:` and bind that format identifier in the hashed payload. Consumers must also check operation, target, scope and index association against their own request. This module confers no authorization. The SQLite connection (`openNodeConnection` from `@hachej/boring-files/sqlite`) is a separate server entry point.
@@ -138,7 +144,21 @@ const revisionProvider = await connectRevisionProvider({
138
144
 
139
145
  The binding discovers identity and workspace incarnation from the authenticated
140
146
  server. Reconnect when the selected workspace changes. It exposes `list`,
141
- filename `search`, retained `history`, `locate`, `read`, `publish` and `lookup`.
147
+ filename `search`, retained `history`, `locate`, `read`, `publish`, `lookup` and
148
+ `changes({ since?, signal })`, the workspace's change feed.
149
+
150
+ The feed is `GET <endpoint>?op=changes[&since=<seq>&incarnation=<id>]` with the binding header:
151
+ server-sent events (`event: ready` with the current seq and the provider incarnation, then `event: change` or
152
+ `event: resnapshot` with `id:` the seq and the JSON event as `data:`), and a
153
+ comment line every `heartbeatMs` (`createRevisionHandler({ resolve, heartbeatMs })`,
154
+ default 15 s). It is a plain streamed response with no queue (pull-based), so a slow client
155
+ costs the provider's bounded replay buffer and a `resnapshot`, never memory in
156
+ the handler; revocation discards anything unread. It runs on Node and Workers
157
+ alike. The handler holds the workspace lease for the life of the stream and
158
+ releases it when the client disconnects or its access signal aborts; a request
159
+ the host refuses, or whose binding names another workspace or identity, gets no
160
+ event (403 or 409). The client reconnects after the last seq it delivered, with
161
+ backoff, until its `signal` aborts; a refusal ends it with a `RevisionError`.
142
162
  All writes use conditional publication; uploading a new file requires absence,
143
163
  and replacing one requires its exact resource revision. An unknown result must
144
164
  be reconciled by operation ID before another upload to that path.
@@ -132,6 +132,14 @@ export type PublicationRefusal = {
132
132
  } | {
133
133
  readonly kind: 'unavailable';
134
134
  readonly reason: string;
135
+ }
136
+ /** A change above the provider's byte limit: permanent for these bytes, nothing was written. */
137
+ | {
138
+ readonly kind: 'file_too_large';
139
+ readonly path: string;
140
+ readonly size: number;
141
+ readonly limit: number;
142
+ readonly reason: string;
135
143
  };
136
144
  export type PublicationOutcome = {
137
145
  readonly kind: 'committed';
@@ -0,0 +1,10 @@
1
+ import { FileError } from '@earendil-works/pi-durable/env';
2
+ export declare const SQLITE_MAX_FILE_BYTES = 1900000;
3
+ /** A limit is a non-negative safe integer of bytes. */
4
+ export declare function assertLimit(value: unknown): asserts value is number;
5
+ /**
6
+ * A write above the limit. Pi's `FileErrorCode` has no size code and Pi is not patched, so this is an `invalid` error whose message
7
+ * starts with the POSIX `EFBIG` name, like the other errors of the file system.
8
+ */
9
+ export declare function fileTooLarge(path: string, size: number, limit: number): FileError;
10
+ //# sourceMappingURL=file-limits.d.ts.map
@@ -0,0 +1,16 @@
1
+ // The largest file a SQLite-backed workspace stores by default: a Durable Object's SQL storage caps one row (and so one file's bytes)
2
+ // at 2 MB, so the default leaves headroom for the row's other columns. Disk and remote providers have no default limit.
3
+ import { FileError } from '@earendil-works/pi-durable/env';
4
+ export const SQLITE_MAX_FILE_BYTES = 1_900_000;
5
+ /** A limit is a non-negative safe integer of bytes. */
6
+ export function assertLimit(value) {
7
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
8
+ throw new TypeError('maxFileBytes must be a non-negative safe integer');
9
+ }
10
+ /**
11
+ * A write above the limit. Pi's `FileErrorCode` has no size code and Pi is not patched, so this is an `invalid` error whose message
12
+ * starts with the POSIX `EFBIG` name, like the other errors of the file system.
13
+ */
14
+ export function fileTooLarge(path, size, limit) {
15
+ return new FileError('invalid', `EFBIG: file_too_large: ${size} bytes exceeds the ${limit} byte limit, '${path}'`, path);
16
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The one place boring-files says what a file is: its extension, its media type (by name and by leading bytes), the viewer that
3
+ * opens it, the provider's temporary and hidden names, the workspace path a user or agent typed, and the name of a new upload.
4
+ * Callers import these; none keeps a copy. Pure: no Node, no DOM.
5
+ */
6
+ /** The lower-case extension of the last path segment, or ''. */
7
+ export declare const extensionOf: (path: string) => string;
8
+ /** The IANA media type by extension. Anything not listed is `application/octet-stream` (see `mediaTypeOf`). Null-prototype: `constructor` or `__proto__` is not an entry. */
9
+ export declare const MEDIA_TYPES: Readonly<Record<string, string>>;
10
+ /** The IANA media type of a path, `application/octet-stream` when unknown. */
11
+ export declare const mediaTypeOf: (path: string) => string;
12
+ /** The media type a file's leading bytes name (a PDF, PNG, JPEG, GIF or WebP signature), or undefined. */
13
+ export declare function sniffMediaType(head: Uint8Array): string | undefined;
14
+ /** Which viewer opens a file. Anything not listed is shown as text when it decodes. */
15
+ export type ViewerKind = 'markdown' | 'html' | 'canvas' | 'image' | 'pdf' | 'text';
16
+ /** The viewer kind of a path: `markdown`, `html`, `canvas`, `image`, `pdf` or `text`. */
17
+ export declare const viewerKindOf: (path: string) => ViewerKind;
18
+ /** Binary viewers never decode a file as text, whatever its bytes look like (an SVG is text). */
19
+ export declare const isBinaryKind: (path: string) => boolean;
20
+ /** The basename of a new temporary file, for a provider's `uuid`. */
21
+ export declare const temporaryFileName: (uuid: string) => string;
22
+ /** Whether a path's last segment is one of the provider's temporary files (a lister hides them). */
23
+ export declare const isTemporary: (path: string) => boolean;
24
+ /** Names a presentation listing hides by default (dependency, build and internal folders, and the provider's own). Not an access policy. */
25
+ export declare const HIDDEN_NAMES: ReadonlySet<string>;
26
+ /** A path as a person or agent typed it, as a workspace-relative resource path: leading `./` segments removed. A leading `/` stays: it is not a workspace path, and the provider refuses it rather than reading it as a relative one. */
27
+ export declare const workspacePath: (path: string) => string;
28
+ /** A new upload's workspace path: `uploads/<stem>-<8 hex>.<ext>`, so a second `image.png` (every pasted screenshot has that name) is a new file beside the first. Whitespace becomes `_`, because a chat mention ends at a space. */
29
+ export declare function uploadPath(name: string, randomSuffix: string): string;
30
+ //# sourceMappingURL=file-types.d.ts.map
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The one place boring-files says what a file is: its extension, its media type (by name and by leading bytes), the viewer that
3
+ * opens it, the provider's temporary and hidden names, the workspace path a user or agent typed, and the name of a new upload.
4
+ * Callers import these; none keeps a copy. Pure: no Node, no DOM.
5
+ */
6
+ /** The lower-case extension of the last path segment, or ''. */
7
+ export const extensionOf = (path) => { const dot = path.lastIndexOf('.'); return dot > path.lastIndexOf('/') ? path.slice(dot + 1).toLowerCase() : ''; };
8
+ /** The IANA media type by extension. Anything not listed is `application/octet-stream` (see `mediaTypeOf`). Null-prototype: `constructor` or `__proto__` is not an entry. */
9
+ export const MEDIA_TYPES = Object.assign(Object.create(null), {
10
+ md: 'text/markdown', markdown: 'text/markdown', html: 'text/html', htm: 'text/html', json: 'application/json', svg: 'image/svg+xml',
11
+ txt: 'text/plain', csv: 'text/csv', js: 'text/javascript', mjs: 'text/javascript', css: 'text/css', tldraw: 'application/vnd.tldraw+json',
12
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
13
+ pdf: 'application/pdf', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
14
+ xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
15
+ ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
16
+ odt: 'application/vnd.oasis.opendocument.text', ods: 'application/vnd.oasis.opendocument.spreadsheet', odp: 'application/vnd.oasis.opendocument.presentation',
17
+ rtf: 'application/rtf', epub: 'application/epub+zip',
18
+ });
19
+ /** The IANA media type of a path, `application/octet-stream` when unknown. */
20
+ export const mediaTypeOf = (path) => MEDIA_TYPES[extensionOf(path)] ?? 'application/octet-stream';
21
+ /** The media type a file's leading bytes name (a PDF, PNG, JPEG, GIF or WebP signature), or undefined. */
22
+ export function sniffMediaType(head) {
23
+ const at = (signature, offset = 0) => signature.every((byte, index) => head[offset + index] === byte);
24
+ if (at([0x25, 0x50, 0x44, 0x46, 0x2d]))
25
+ return 'application/pdf';
26
+ if (at([0x89, 0x50, 0x4e, 0x47]))
27
+ return 'image/png';
28
+ if (at([0xff, 0xd8, 0xff]))
29
+ return 'image/jpeg';
30
+ if (at([0x47, 0x49, 0x46, 0x38]))
31
+ return 'image/gif';
32
+ if (at([0x52, 0x49, 0x46, 0x46]) && at([0x57, 0x45, 0x42, 0x50], 8))
33
+ return 'image/webp';
34
+ return undefined;
35
+ }
36
+ const VIEWER_KINDS = Object.assign(Object.create(null), {
37
+ md: 'markdown', markdown: 'markdown', html: 'html', htm: 'html', tldraw: 'canvas',
38
+ png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', svg: 'image', pdf: 'pdf',
39
+ txt: 'text', json: 'text', csv: 'text', js: 'text', mjs: 'text', css: 'text',
40
+ });
41
+ /** The viewer kind of a path: `markdown`, `html`, `canvas`, `image`, `pdf` or `text`. */
42
+ export const viewerKindOf = (path) => VIEWER_KINDS[extensionOf(path)] ?? 'text';
43
+ /** Binary viewers never decode a file as text, whatever its bytes look like (an SVG is text). */
44
+ export const isBinaryKind = (path) => ['image', 'pdf'].includes(viewerKindOf(path)) && extensionOf(path) !== 'svg';
45
+ /** The provider's temporary files are named `.boring-<uuid>.tmp` beside their target. They are removed when the provider opens and are never a resource. */
46
+ const temporaryPattern = /^\.boring-[0-9a-f-]{36}\.tmp$/;
47
+ /** The basename of a new temporary file, for a provider's `uuid`. */
48
+ export const temporaryFileName = (uuid) => `.boring-${uuid}.tmp`;
49
+ /** Whether a path's last segment is one of the provider's temporary files (a lister hides them). */
50
+ export const isTemporary = (path) => temporaryPattern.test(path.slice(path.lastIndexOf('/') + 1));
51
+ /** Names a presentation listing hides by default (dependency, build and internal folders, and the provider's own). Not an access policy. */
52
+ export const HIDDEN_NAMES = new Set(['.git', '.boring', 'node_modules', 'dist', 'build', '.cache', '.next', 'coverage', '__pycache__']);
53
+ /** A path as a person or agent typed it, as a workspace-relative resource path: leading `./` segments removed. A leading `/` stays: it is not a workspace path, and the provider refuses it rather than reading it as a relative one. */
54
+ export const workspacePath = (path) => path.replace(/^(\.\/)+/, '');
55
+ /** A new upload's workspace path: `uploads/<stem>-<8 hex>.<ext>`, so a second `image.png` (every pasted screenshot has that name) is a new file beside the first. Whitespace becomes `_`, because a chat mention ends at a space. */
56
+ export function uploadPath(name, randomSuffix) {
57
+ const dot = name.lastIndexOf('.');
58
+ const [stem, extension] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ''];
59
+ const safe = (part) => part.replace(/\s+/g, '_');
60
+ return `uploads/${safe(stem)}-${randomSuffix}${safe(extension)}`;
61
+ }
package/dist/journal.d.ts CHANGED
@@ -38,7 +38,13 @@ export interface StoredVersion {
38
38
  readonly mediaType: string;
39
39
  /** When this revision was saved, in milliseconds since the epoch. Kept the first time the revision is retained. */
40
40
  readonly savedAt?: number;
41
+ /**
42
+ * Who produced the revision: `publish` (a conditional write), `agent` (a Pi `write` or `edit`, recorded by `provider.record`: it
43
+ * has no receipt) or `observed` (retained as found: `keep`, or the replaced revision of a write). Default `publish`.
44
+ */
45
+ readonly source?: HistorySource;
41
46
  }
47
+ export type HistorySource = 'publish' | 'agent' | 'observed';
42
48
  /**
43
49
  * The operation journal plus what a workspace needs beyond receipts: an intent row written before an operation's first effect
44
50
  * (an intent without a completion is an operation of unknown outcome), and the last versions of each file when there is no Git.
@@ -68,6 +74,7 @@ export interface WorkspaceJournal extends OperationJournal {
68
74
  readonly saves: (scope: string, path: string) => {
69
75
  readonly revision: string;
70
76
  readonly savedAt: number;
77
+ readonly source: HistorySource;
71
78
  }[];
72
79
  }
73
80
  export declare function createWorkspaceJournal(db: SqliteConnection, options?: {
package/dist/journal.js CHANGED
@@ -59,6 +59,7 @@ export function committedChanges(changes, ref) {
59
59
  throw new Error('Stored operation has no changes');
60
60
  return [first, ...rest];
61
61
  }
62
+ const historySource = (value) => value === 'agent' || value === 'observed' ? value : 'publish';
62
63
  export function createWorkspaceJournal(db, options = {}) {
63
64
  const limit = options.historyLimit ?? 20;
64
65
  if (!Number.isSafeInteger(limit) || limit < 1)
@@ -74,18 +75,22 @@ export function createWorkspaceJournal(db, options = {}) {
74
75
  ) STRICT;
75
76
  CREATE TABLE IF NOT EXISTS boring_history (
76
77
  scope TEXT NOT NULL, path TEXT NOT NULL, revision TEXT NOT NULL, sequence INTEGER NOT NULL,
77
- bytes BLOB NOT NULL, media_type TEXT NOT NULL, saved_at INTEGER NOT NULL DEFAULT 0,
78
+ bytes BLOB NOT NULL, media_type TEXT NOT NULL, saved_at INTEGER NOT NULL DEFAULT 0, source TEXT NOT NULL DEFAULT 'publish',
78
79
  PRIMARY KEY (scope, path, revision)
79
80
  ) STRICT;
80
81
  `);
81
82
  // A journal created before save times were recorded gets the column; its old rows read as unknown (0).
82
83
  if (!db.all("SELECT name FROM pragma_table_info('boring_history')").some(column => column.name === 'saved_at'))
83
84
  db.exec('ALTER TABLE boring_history ADD COLUMN saved_at INTEGER NOT NULL DEFAULT 0');
85
+ // A journal created before history recorded who wrote each revision: its old rows were all conditional writes.
86
+ if (!db.all("SELECT name FROM pragma_table_info('boring_history')").some(column => column.name === 'source'))
87
+ db.exec("ALTER TABLE boring_history ADD COLUMN source TEXT NOT NULL DEFAULT 'publish'");
84
88
  });
85
89
  const remember = (scope, path, version) => {
86
90
  const last = db.get('SELECT COALESCE(MAX(sequence), 0) AS last FROM boring_history WHERE scope = ? AND path = ?', scope, path);
87
91
  const sequence = Number(last?.last ?? 0) + 1;
88
- db.run('INSERT INTO boring_history (scope, path, revision, sequence, bytes, media_type, saved_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(scope, path, revision) DO UPDATE SET sequence = excluded.sequence, bytes = excluded.bytes, media_type = excluded.media_type, saved_at = CASE WHEN boring_history.saved_at = 0 THEN excluded.saved_at ELSE boring_history.saved_at END', scope, path, version.revision, sequence, version.bytes, version.mediaType, version.savedAt ?? 0);
92
+ // A revision found again (`observed`) keeps the writer it was first recorded with; a new write of the same bytes names its writer.
93
+ db.run("INSERT INTO boring_history (scope, path, revision, sequence, bytes, media_type, saved_at, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(scope, path, revision) DO UPDATE SET sequence = excluded.sequence, bytes = excluded.bytes, media_type = excluded.media_type, saved_at = CASE WHEN boring_history.saved_at = 0 THEN excluded.saved_at ELSE boring_history.saved_at END, source = CASE WHEN excluded.source = 'observed' THEN boring_history.source ELSE excluded.source END", scope, path, version.revision, sequence, version.bytes, version.mediaType, version.savedAt ?? 0, historySource(version.source));
89
94
  db.run('DELETE FROM boring_history WHERE scope = ? AND path = ? AND revision NOT IN (SELECT revision FROM boring_history WHERE scope = ? AND path = ? ORDER BY sequence DESC LIMIT ?)', scope, path, scope, path, limit);
90
95
  };
91
96
  const journal = {
@@ -107,12 +112,15 @@ export function createWorkspaceJournal(db, options = {}) {
107
112
  const row = db.get('SELECT bytes, media_type FROM boring_history WHERE scope = ? AND path = ? AND revision = ?', scope, path, revision);
108
113
  if (!row)
109
114
  return undefined;
115
+ // A zero-length BLOB can come back as no bytes (see sqlite-filesystem.ts): a saved empty file is an empty version.
116
+ if (row.bytes === null)
117
+ return { revision, bytes: new Uint8Array(), mediaType: text(row.media_type) };
110
118
  if (!(row.bytes instanceof Uint8Array))
111
119
  throw new Error('Invalid stored bytes');
112
120
  return { revision, bytes: Uint8Array.from(row.bytes), mediaType: text(row.media_type) };
113
121
  },
114
122
  revisions: (scope, path) => db.all('SELECT revision FROM boring_history WHERE scope = ? AND path = ? ORDER BY sequence DESC', scope, path).map(row => text(row.revision)),
115
- saves: (scope, path) => db.all('SELECT revision, saved_at FROM boring_history WHERE scope = ? AND path = ? ORDER BY sequence DESC', scope, path).map(row => ({ revision: text(row.revision), savedAt: Number(row.saved_at) })),
123
+ saves: (scope, path) => db.all('SELECT revision, saved_at, source FROM boring_history WHERE scope = ? AND path = ? ORDER BY sequence DESC', scope, path).map(row => ({ revision: text(row.revision), savedAt: Number(row.saved_at), source: historySource(row.source) })),
116
124
  };
117
125
  registerJournalConnection(journal, db);
118
126
  return journal;
@@ -135,6 +135,12 @@ function publicationOutcome(value) {
135
135
  case 'committed': return { kind: 'committed', receipt: publicationReceipt(input.receipt) };
136
136
  case 'denied':
137
137
  case 'unavailable': return { kind: input.kind, reason: reason(input.reason) };
138
+ case 'file_too_large': {
139
+ const { path, size, limit } = input;
140
+ if (typeof path !== 'string' || typeof size !== 'number' || !Number.isSafeInteger(size) || typeof limit !== 'number' || !Number.isSafeInteger(limit))
141
+ throw new TypeError('Invalid size refusal');
142
+ return { kind: 'file_too_large', path, size, limit, reason: reason(input.reason) };
143
+ }
138
144
  case 'unknown': return { kind: 'unknown', operationId: identifier(input.operationId), reason: reason(input.reason) };
139
145
  case 'conflict': {
140
146
  if (!Array.isArray(input.current))
@@ -24,6 +24,41 @@ export interface FileSearchRequest {
24
24
  export interface SavedRevision {
25
25
  readonly revision: string;
26
26
  readonly savedAt: number;
27
+ /** Who wrote it: `publish` (a conditional write), `agent` (a Pi `write` or `edit`: observed, never receipted) or `observed` (retained as found). */
28
+ readonly source?: 'publish' | 'agent' | 'observed';
29
+ }
30
+ /**
31
+ * One entry of a workspace's change feed (ruling 2 of 2026-10-10): an invalidation hint, never content or a receipt. Bytes and
32
+ * the publication journal stay authoritative; a viewer that receives one reads again.
33
+ *
34
+ * - `change`: something at `path` changed. `revision` is the Git blob id now there, or `null` when the path is absent or is a
35
+ * directory (`directory: true`: something below it may have changed). `source`: `publish` (a conditional write), `agent` (a Pi
36
+ * `write` or `edit`), `poll` (found by hashing, for example after a `bash` command) or `watch` (reported by the file system).
37
+ * - `resnapshot`: events were lost (the cursor is older than the retained window, from another provider lifetime, or the watcher
38
+ * overflowed or restarted). Reload everything shown, then continue after this `seq`.
39
+ *
40
+ * `seq` increases strictly within one provider lifetime; `at` is milliseconds since the epoch.
41
+ */
42
+ export type WorkspaceChangeEvent = {
43
+ readonly kind: 'change';
44
+ readonly path: string;
45
+ readonly revision: string | null;
46
+ readonly source: 'publish' | 'agent' | 'poll' | 'watch';
47
+ readonly seq: number;
48
+ readonly at: number;
49
+ readonly directory?: true;
50
+ readonly conversationId?: string;
51
+ } | {
52
+ readonly kind: 'resnapshot';
53
+ readonly seq: number;
54
+ readonly at: number;
55
+ };
56
+ export interface ChangeFeedRequest {
57
+ /** The last `seq` already seen: only later events are delivered (a gap is a `resnapshot`). Absent: live events only. */
58
+ readonly since?: number | undefined;
59
+ /** The provider lifetime `since` came from (`ready` event). A cursor from another lifetime is a `resnapshot`, whatever its number. */
60
+ readonly incarnation?: string | undefined;
61
+ readonly signal?: AbortSignal | undefined;
27
62
  }
28
63
  export interface WorkspaceCatalog {
29
64
  readonly list: (request: FileListRequest, access: ResourceAccess) => Promise<FilePage>;
@@ -44,5 +79,11 @@ export interface RevisionProvider extends ResourceClient {
44
79
  readonly list: (request: FileListRequest, signal?: AbortSignal) => Promise<FilePage>;
45
80
  readonly search: (request: FileSearchRequest, signal?: AbortSignal) => Promise<FilePage>;
46
81
  readonly history: (path: string, signal?: AbortSignal) => Promise<readonly SavedRevision[]>;
82
+ /**
83
+ * The workspace's change feed (`GET ?op=changes`, server-sent events), reconnecting after the last `seq` seen until `signal`
84
+ * aborts; a refused binding (another workspace, revoked access) ends it with a `RevisionError`. Optional: a hand-built provider
85
+ * may have none, and a viewer then refreshes on its own schedule.
86
+ */
87
+ readonly changes?: (request?: ChangeFeedRequest) => AsyncIterable<WorkspaceChangeEvent>;
47
88
  }
48
89
  //# sourceMappingURL=revision-contracts.d.ts.map
@@ -1,11 +1,14 @@
1
1
  import type { ResourceAccess } from './contracts.js';
2
2
  import type { WorkspaceResourceProvider } from './workspace.js';
3
3
  export interface RevisionHandlerOptions {
4
+ /** The host's authentication and workspace selection, once per request. For the change feed the lease is held until the stream ends. */
4
5
  readonly resolve: (request: Request) => Promise<null | {
5
6
  readonly provider: WorkspaceResourceProvider;
6
7
  readonly access: ResourceAccess;
7
8
  readonly release?: () => void | Promise<void>;
8
9
  }>;
10
+ /** How often the change feed sends a comment line so proxies keep it open, in milliseconds (default 15 000). */
11
+ readonly heartbeatMs?: number;
9
12
  }
10
13
  export declare function createRevisionHandler(options: RevisionHandlerOptions): (request: Request) => Promise<Response>;
11
14
  //# sourceMappingURL=revision-handler.d.ts.map
@@ -3,6 +3,90 @@ import { binding, bindingHeader, bindingValue, catalogHeader, filePage, record,
3
3
  import { guardStatus, readJsonBody, RequestGuardError } from './request-guard.js';
4
4
  import { accessSnapshot, identifier } from './publication-input.js';
5
5
  import { RevisionError } from './revision-contracts.js';
6
+ const encoder = new TextEncoder();
7
+ /**
8
+ * `GET ?op=changes[&since=<seq>&incarnation=<id>]`: the workspace's change feed as server-sent events (`event: change | resnapshot`,
9
+ * `id:` the seq, `data:` the JSON event), after an `event: ready` naming the current seq and the provider's incarnation, with a
10
+ * comment every `heartbeatMs` while idle. Plain streamed responses, so it runs on Node and on Workers alike.
11
+ *
12
+ * The stream is pull-based with no queue (`highWaterMark: 0`): the provider is read only while the client is reading, so a slow
13
+ * client costs the provider's bounded replay buffer (and a `resnapshot` when it falls behind), never unbounded memory here. The
14
+ * stream ends when the person disconnects or their access is revoked, discarding anything not yet read, and the workspace lease is
15
+ * released then.
16
+ */
17
+ function changeStream(provider, access, request, heartbeatMs, release) {
18
+ const stop = new AbortController();
19
+ const signal = AbortSignal.any([access.signal, stop.signal, provider.lifetime]);
20
+ let iterator;
21
+ let pending;
22
+ let ready = false, ended = false;
23
+ const finish = () => {
24
+ if (ended)
25
+ return;
26
+ ended = true;
27
+ stop.abort();
28
+ void iterator?.return?.().catch(() => { });
29
+ void release();
30
+ };
31
+ const body = new ReadableStream({
32
+ start: controller => {
33
+ const close = () => { finish(); try {
34
+ controller.close();
35
+ }
36
+ catch { /* already closed or cancelled */ } };
37
+ signal.addEventListener('abort', close, { once: true });
38
+ if (signal.aborted)
39
+ close();
40
+ },
41
+ pull: async (controller) => {
42
+ if (ended)
43
+ return;
44
+ const send = (text) => { try {
45
+ controller.enqueue(encoder.encode(text));
46
+ }
47
+ catch {
48
+ finish();
49
+ } };
50
+ const end = () => { finish(); try {
51
+ controller.close();
52
+ }
53
+ catch { /* already closed */ } };
54
+ try {
55
+ if (!ready) {
56
+ ready = true;
57
+ // Without a cursor the feed starts at the current seq, which the client learns first and resumes from after a disconnect.
58
+ const start = request.since ?? provider.changeHead();
59
+ iterator = provider.changes({ since: start, ...(request.incarnation === undefined ? {} : { incarnation: request.incarnation }), signal }, { ...access, signal })[Symbol.asyncIterator]();
60
+ // An async iterator runs its body only on the first pull, so prime it here: the watcher then starts before `ready`, and a
61
+ // write the client makes after reading `ready` is not missed.
62
+ pending = iterator.next();
63
+ pending.catch(() => { });
64
+ send(`event: ready\ndata: ${JSON.stringify({ seq: start, incarnation: provider.changeIncarnation })}\n\n`);
65
+ return;
66
+ }
67
+ pending ??= iterator.next();
68
+ pending.catch(() => { });
69
+ let timer;
70
+ const beat = new Promise(resolve => { timer = setTimeout(() => resolve('beat'), heartbeatMs); });
71
+ const outcome = await Promise.race([pending, beat]).finally(() => clearTimeout(timer));
72
+ if (signal.aborted)
73
+ return end();
74
+ if (outcome === 'beat')
75
+ return send(': heartbeat\n\n');
76
+ pending = undefined;
77
+ if (outcome.done)
78
+ return end();
79
+ const event = outcome.value;
80
+ send(`id: ${event.seq}\nevent: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`);
81
+ }
82
+ catch {
83
+ end(); /* the client reconnects from its last seq */
84
+ }
85
+ },
86
+ cancel: () => { finish(); },
87
+ }, { highWaterMark: 0 });
88
+ return new Response(body, { headers: { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store', 'x-accel-buffering': 'no' } });
89
+ }
6
90
  function listRequest(value) {
7
91
  const data = record(value);
8
92
  if ((data.directory !== undefined && typeof data.directory !== 'string') || (data.cursor !== undefined && typeof data.cursor !== 'string') || (data.limit !== undefined && typeof data.limit !== 'number'))
@@ -10,6 +94,9 @@ function listRequest(value) {
10
94
  return { ...(typeof data.directory === 'string' ? { directory: data.directory } : {}), ...(typeof data.cursor === 'string' ? { cursor: data.cursor } : {}), ...(typeof data.limit === 'number' ? { limit: data.limit } : {}) };
11
95
  }
12
96
  export function createRevisionHandler(options) {
97
+ const heartbeatMs = options.heartbeatMs ?? 15_000;
98
+ if (!Number.isSafeInteger(heartbeatMs) || heartbeatMs < 1)
99
+ throw new RangeError('The heartbeat interval must be a positive integer');
13
100
  return async (request) => {
14
101
  let release;
15
102
  const operations = new Set();
@@ -34,7 +121,8 @@ export function createRevisionHandler(options) {
34
121
  return refuse(403);
35
122
  const association = binding({ identity: captured, workspace: selected.provider.identity });
36
123
  const respond = (value) => Response.json(value, { headers: { 'cache-control': 'no-store' } });
37
- if (request.method === 'GET')
124
+ const op = new URL(request.url).searchParams.get('op');
125
+ if (request.method === 'GET' && op === null)
38
126
  return respond(association);
39
127
  const header = request.headers.get(bindingHeader);
40
128
  if (!header || header.length > 16384)
@@ -49,6 +137,26 @@ export function createRevisionHandler(options) {
49
137
  if (requested !== bindingValue(association))
50
138
  return refuse(409);
51
139
  const provider = selected.provider;
140
+ if (request.method === 'GET') {
141
+ if (op !== 'changes')
142
+ return refuse(400);
143
+ const cursor = new URL(request.url).searchParams.get('since');
144
+ if (cursor !== null && !/^\d{1,16}$/.test(cursor))
145
+ return refuse(400);
146
+ const since = cursor === null ? undefined : Number(cursor);
147
+ const incarnation = new URL(request.url).searchParams.get('incarnation') ?? undefined;
148
+ if (incarnation !== undefined && !/^[\w-]{1,64}$/.test(incarnation))
149
+ return refuse(400);
150
+ if (since !== undefined && !Number.isSafeInteger(since))
151
+ return refuse(400);
152
+ // The stream owns the lease from here: it is released when the stream ends, not when this function returns.
153
+ const lease = release;
154
+ release = undefined;
155
+ return changeStream(provider, access, { since, incarnation }, heartbeatMs, async () => { try {
156
+ await lease?.();
157
+ }
158
+ catch { /* the stream already ended */ } });
159
+ }
52
160
  if (!request.headers.has(catalogHeader))
53
161
  return await createResourceHandler({
54
162
  authenticate: async () => access,
@@ -1,6 +1,6 @@
1
1
  import type { ResourceIdentity } from './remote-protocol.js';
2
2
  import type { WorkspaceKey } from './workspace.js';
3
- import type { FilePage, SavedRevision } from './revision-contracts.js';
3
+ import type { FilePage, SavedRevision, WorkspaceChangeEvent } from './revision-contracts.js';
4
4
  export declare const bindingHeader = "x-boring-workspace";
5
5
  export declare const catalogHeader = "x-boring-catalog";
6
6
  export interface RevisionBinding {
@@ -12,4 +12,11 @@ export declare function binding(value: unknown): RevisionBinding;
12
12
  export declare const bindingValue: (value: RevisionBinding) => string;
13
13
  export declare function filePage(value: unknown): FilePage;
14
14
  export declare function savedRevisions(value: unknown): readonly SavedRevision[];
15
+ /** One change-feed event as the server sent it, checked field by field (`data:` of `event: change | resnapshot`). */
16
+ export declare function changeEvent(value: unknown): WorkspaceChangeEvent;
17
+ /** Server-sent events from a response body: `{ event, data }` per message, comments skipped, a line longer than `limit` refused. */
18
+ export declare function serverEvents(body: ReadableStream<Uint8Array>, limit?: number): AsyncGenerator<{
19
+ readonly event: string;
20
+ readonly data: string;
21
+ }>;
15
22
  //# sourceMappingURL=revision-protocol.d.ts.map
@@ -37,6 +37,70 @@ export function savedRevisions(value) {
37
37
  const entry = record(item);
38
38
  if (typeof entry.savedAt !== 'number' || !Number.isSafeInteger(entry.savedAt) || entry.savedAt < 0)
39
39
  throw new RevisionError('invalid', 'Invalid save timestamp');
40
- return { revision: identifier(entry.revision), savedAt: entry.savedAt };
40
+ const source = entry.source === 'publish' || entry.source === 'agent' || entry.source === 'observed' ? entry.source : undefined;
41
+ return { revision: identifier(entry.revision), savedAt: entry.savedAt, ...(source ? { source } : {}) };
41
42
  });
42
43
  }
44
+ const sources = new Set(['publish', 'agent', 'poll', 'watch']);
45
+ const sequence = (value, what) => {
46
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
47
+ throw new RevisionError('invalid', `Invalid ${what}`);
48
+ return value;
49
+ };
50
+ /** One change-feed event as the server sent it, checked field by field (`data:` of `event: change | resnapshot`). */
51
+ export function changeEvent(value) {
52
+ const data = record(value);
53
+ const seq = sequence(data.seq, 'change sequence'), at = sequence(data.at, 'change time');
54
+ if (data.kind === 'resnapshot')
55
+ return { kind: 'resnapshot', seq, at };
56
+ if (data.kind !== 'change' || typeof data.path !== 'string' || typeof data.source !== 'string' || !sources.has(data.source))
57
+ throw new RevisionError('invalid', 'Invalid change event');
58
+ if (data.directory !== undefined && data.directory !== true)
59
+ throw new RevisionError('invalid', 'Invalid change event');
60
+ const path = data.path === '' && data.directory === true ? '' : catalogPath(data.path);
61
+ const revision = data.revision === null ? null : identifier(data.revision);
62
+ return {
63
+ kind: 'change', path, revision, source: data.source, seq, at,
64
+ ...(data.directory === true ? { directory: true } : {}),
65
+ ...(data.conversationId !== undefined ? { conversationId: identifier(data.conversationId) } : {}),
66
+ };
67
+ }
68
+ /** Server-sent events from a response body: `{ event, data }` per message, comments skipped, a line longer than `limit` refused. */
69
+ export async function* serverEvents(body, limit = 1_048_576) {
70
+ const reader = body.getReader(), decoder = new TextDecoder();
71
+ let pending = '', event = 'message', data = [];
72
+ try {
73
+ for (;;) {
74
+ const { done, value } = await reader.read();
75
+ if (done)
76
+ return;
77
+ pending += decoder.decode(value, { stream: true });
78
+ let newline;
79
+ while ((newline = pending.search(/\r?\n/)) !== -1) {
80
+ const line = pending.slice(0, newline);
81
+ pending = pending.slice(pending[newline] === '\r' ? newline + 2 : newline + 1);
82
+ if (line === '') {
83
+ if (data.length)
84
+ yield { event, data: data.join('\n') };
85
+ event = 'message';
86
+ data = [];
87
+ continue;
88
+ }
89
+ if (line.startsWith(':'))
90
+ continue;
91
+ const colon = line.indexOf(':');
92
+ const field = colon === -1 ? line : line.slice(0, colon);
93
+ const content = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '');
94
+ if (field === 'event')
95
+ event = content;
96
+ else if (field === 'data')
97
+ data.push(content);
98
+ }
99
+ if (pending.length > limit)
100
+ throw new RevisionError('invalid', 'Change feed line exceeds the limit');
101
+ }
102
+ }
103
+ finally {
104
+ void reader.cancel().catch(() => { });
105
+ }
106
+ }