@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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -2
  3. package/dist/client.d.ts +12 -0
  4. package/dist/client.js +1 -0
  5. package/dist/contracts.d.ts +194 -0
  6. package/dist/contracts.js +1 -0
  7. package/dist/git.d.ts +105 -0
  8. package/dist/git.js +205 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.js +1 -0
  11. package/dist/journal.d.ts +76 -0
  12. package/dist/journal.js +119 -0
  13. package/dist/node-http.d.ts +23 -0
  14. package/dist/node-http.js +102 -0
  15. package/dist/platform.d.ts +22 -0
  16. package/dist/platform.js +157 -0
  17. package/dist/publication-input.d.ts +15 -0
  18. package/dist/publication-input.js +164 -0
  19. package/dist/remote-handler.d.ts +11 -0
  20. package/dist/remote-handler.js +63 -0
  21. package/dist/remote-protocol.d.ts +34 -0
  22. package/dist/remote-protocol.js +154 -0
  23. package/dist/remote.d.ts +26 -0
  24. package/dist/remote.js +92 -0
  25. package/dist/request-guard.d.ts +20 -0
  26. package/dist/request-guard.js +75 -0
  27. package/dist/revision-contracts.d.ts +48 -0
  28. package/dist/revision-contracts.js +8 -0
  29. package/dist/revision-handler.d.ts +11 -0
  30. package/dist/revision-handler.js +105 -0
  31. package/dist/revision-protocol.d.ts +15 -0
  32. package/dist/revision-protocol.js +42 -0
  33. package/dist/revision.d.ts +8 -0
  34. package/dist/revision.js +57 -0
  35. package/dist/sqlite-batch.d.ts +19 -0
  36. package/dist/sqlite-batch.js +7 -0
  37. package/dist/sqlite-durable-object.d.ts +24 -0
  38. package/dist/sqlite-durable-object.js +49 -0
  39. package/dist/sqlite-filesystem.d.ts +16 -0
  40. package/dist/sqlite-filesystem.js +381 -0
  41. package/dist/sqlite-settings.d.ts +61 -0
  42. package/dist/sqlite-settings.js +104 -0
  43. package/dist/sqlite.d.ts +37 -0
  44. package/dist/sqlite.js +50 -0
  45. package/dist/text.d.ts +16 -0
  46. package/dist/text.js +29 -0
  47. package/dist/workspace-catalog.d.ts +17 -0
  48. package/dist/workspace-catalog.js +147 -0
  49. package/dist/workspace-identity.d.ts +8 -0
  50. package/dist/workspace-identity.js +1 -0
  51. package/dist/workspace.d.ts +55 -0
  52. package/dist/workspace.js +411 -0
  53. package/package.json +119 -4
package/dist/text.js ADDED
@@ -0,0 +1,29 @@
1
+ export function parseTextEdits(value) {
2
+ if (!Array.isArray(value) || !value.length)
3
+ throw new TypeError('At least one exact text edit is required');
4
+ const edits = value;
5
+ return edits.map(item => {
6
+ if (!item || typeof item !== 'object' || !('find' in item) || !('replace' in item) || typeof item.find !== 'string' || !item.find.length || typeof item.replace !== 'string'
7
+ || Object.keys(item).some(key => key !== 'find' && key !== 'replace'))
8
+ throw new TypeError('Each text edit requires nonempty find and string replace');
9
+ return { find: item.find, replace: item.replace };
10
+ });
11
+ }
12
+ /** Each edit sees the preceding edit's result; ambiguous or missing matches refuse the entire transformation. */
13
+ export function applyTextEdits(text, input) {
14
+ if (typeof text !== 'string')
15
+ throw new TypeError('Expected text');
16
+ const edits = parseTextEdits(input);
17
+ let result = text;
18
+ for (const [editIndex, edit] of edits.entries()) {
19
+ const at = result.indexOf(edit.find);
20
+ if (at < 0)
21
+ return { kind: 'rejected', editIndex, reason: 'Exact text was not found' };
22
+ if (result.indexOf(edit.find, at + 1) >= 0)
23
+ return { kind: 'rejected', editIndex, reason: 'Exact text occurs more than once' };
24
+ result = result.slice(0, at) + edit.replace + result.slice(at + edit.find.length);
25
+ }
26
+ if (new TextDecoder('utf-8', { ignoreBOM: true }).decode(new TextEncoder().encode(result)) !== result)
27
+ return { kind: 'rejected', editIndex: edits.length - 1, reason: 'Result contains invalid Unicode' };
28
+ return { kind: 'applied', text: result };
29
+ }
@@ -0,0 +1,17 @@
1
+ import type { FileSystem } from '@earendil-works/pi-durable/env';
2
+ import type { FileEntry, WorkspaceCatalog, SavedRevision } from './revision-contracts.js';
3
+ export interface WorkspaceCatalogOptions {
4
+ /** Presentation defaults, never an access policy. False shows dependency/build/internal names and gitignored files. */
5
+ readonly defaults?: boolean;
6
+ /** Additional presentation filtering. Returning false prunes a directory and its descendants. */
7
+ readonly filter?: (entry: FileEntry) => boolean;
8
+ /** A traversal exceeding this bound fails explicitly; it never returns a silently incomplete page. */
9
+ readonly maxEntries?: number;
10
+ }
11
+ export declare function catalogPath(path: string, allowRoot?: boolean): string;
12
+ export declare function createWorkspaceCatalog(options: {
13
+ readonly fs: FileSystem;
14
+ readonly config?: WorkspaceCatalogOptions;
15
+ readonly saves: (path: string) => readonly SavedRevision[];
16
+ }): WorkspaceCatalog;
17
+ //# sourceMappingURL=workspace-catalog.d.ts.map
@@ -0,0 +1,147 @@
1
+ import ignore from 'ignore';
2
+ import { identifier, locator } from './publication-input.js';
3
+ import { RevisionError } from './revision-contracts.js';
4
+ const hidden = new Set(['.git', '.boring', 'node_modules', 'dist', 'build', '.cache', '.next', 'coverage', '__pycache__']);
5
+ const temporary = /^\.boring-[0-9a-f-]{36}\.tmp$/;
6
+ const inside = (root, path) => path === root || path.startsWith(root === '/' ? '/' : `${root}/`);
7
+ export function catalogPath(path, allowRoot = false) {
8
+ if (allowRoot && path === '')
9
+ return path;
10
+ return locator({ resource: { providerId: 'catalog', path }, view: { kind: 'published' } }).resource.path;
11
+ }
12
+ export function createWorkspaceCatalog(options) {
13
+ const { fs } = options, config = options.config ?? {};
14
+ const maximum = config.maxEntries ?? 100_000;
15
+ if (!Number.isSafeInteger(maximum) || maximum < 1)
16
+ throw new TypeError('Invalid catalog traversal bound');
17
+ const root = fs.cwd.replace(/\/$/, '') || '/';
18
+ const absolute = (path) => `${root === '/' ? '' : root}/${path}`;
19
+ const check = (access) => { if (access.signal?.aborted)
20
+ throw new RevisionError('aborted', 'File browsing was cancelled'); };
21
+ const context = (access) => ({ abortSignal: access.signal, value: () => undefined, toString: () => 'boring-catalog' });
22
+ async function contained(path, access) {
23
+ check(access);
24
+ const base = await fs.canonicalPath(root, context(access));
25
+ const resolved = await fs.canonicalPath(absolute(path), context(access));
26
+ check(access);
27
+ return base.ok && resolved.ok && inside(base.value, resolved.value);
28
+ }
29
+ async function rulesAt(path, directory, parents, access) {
30
+ if (config.defaults === false)
31
+ return parents;
32
+ const info = await fs.fileInfo(absolute(path), context(access));
33
+ if (!info.ok) {
34
+ if (info.error.code === 'not_found' || info.error.code === 'not_directory')
35
+ return parents;
36
+ throw new RevisionError('unavailable', 'Ignore rules could not be inspected');
37
+ }
38
+ if (info.value.kind !== 'file' || !await contained(path, access))
39
+ throw new RevisionError('denied', 'Ignore rules must be a file inside the workspace');
40
+ if (info.value.size > 1_048_576)
41
+ throw new RevisionError('unavailable', 'Ignore rules exceed the catalog limit');
42
+ const read = await fs.readBinaryFile(absolute(path), context(access));
43
+ check(access);
44
+ if (!read.ok)
45
+ throw new RevisionError('unavailable', 'Ignore rules could not be read');
46
+ return [...parents, { directory, matcher: ignore.default().add(new TextDecoder('utf-8', { fatal: true }).decode(read.value)) }];
47
+ }
48
+ function visible(entry, rules) {
49
+ const name = entry.path.slice(entry.path.lastIndexOf('/') + 1);
50
+ if (temporary.test(name))
51
+ return false;
52
+ if (config.defaults !== false) {
53
+ if (hidden.has(name))
54
+ return false;
55
+ let ignored = false;
56
+ for (const rule of rules) {
57
+ const relative = entry.path.slice(rule.directory ? rule.directory.length + 1 : 0) + (entry.kind === 'directory' ? '/' : '');
58
+ const result = rule.matcher.test(relative);
59
+ if (result.ignored)
60
+ ignored = true;
61
+ else if (result.unignored)
62
+ ignored = false;
63
+ }
64
+ if (ignored)
65
+ return false;
66
+ }
67
+ return config.filter?.(entry) !== false;
68
+ }
69
+ async function collect(directory, recursive, access) {
70
+ check(access);
71
+ if (!await contained(directory, access))
72
+ throw new RevisionError('denied', 'The directory is outside the workspace or unavailable');
73
+ const entries = [];
74
+ let scanned = 0;
75
+ async function walk(parent, rules) {
76
+ check(access);
77
+ const own = await rulesAt(parent ? `${parent}/.gitignore` : '.gitignore', parent, rules, access);
78
+ const listing = await fs.listDir(absolute(parent), context(access));
79
+ check(access);
80
+ if (!listing.ok)
81
+ throw new RevisionError('unavailable', 'The directory could not be listed');
82
+ for (const item of listing.value) {
83
+ check(access);
84
+ if (++scanned > maximum)
85
+ throw new RevisionError('unavailable', 'The workspace exceeds the catalog traversal limit');
86
+ if (item.kind !== 'file' && item.kind !== 'directory')
87
+ continue;
88
+ const path = catalogPath(parent ? `${parent}/${item.name}` : item.name);
89
+ const entry = { path, kind: item.kind, size: item.size };
90
+ if (!visible(entry, own))
91
+ continue;
92
+ if (!await contained(path, access))
93
+ throw new RevisionError('denied', 'A listed entry resolves outside the workspace');
94
+ entries.push(entry);
95
+ if (item.kind === 'directory' && (recursive || directory === path || directory.startsWith(`${path}/`)))
96
+ await walk(path, own);
97
+ }
98
+ }
99
+ // Walk from the root so parent ignore rules and directory exclusions also apply to a direct nested-directory request.
100
+ let initial = [];
101
+ if (config.defaults !== false) {
102
+ for (const path of ['.git', '.git/info']) {
103
+ const info = await fs.fileInfo(absolute(path), context(access));
104
+ if (info.ok && info.value.kind === 'symlink')
105
+ throw new RevisionError('denied', 'Repository ignore rules cannot use symbolic links');
106
+ }
107
+ initial = await rulesAt('.git/info/exclude', '', [], access);
108
+ }
109
+ await walk('', initial);
110
+ if (recursive)
111
+ return entries;
112
+ return entries.filter(entry => entry.path.slice(0, entry.path.lastIndexOf('/') + 1) === (directory ? `${directory}/` : ''));
113
+ }
114
+ function page(entries, key, cursor, limit) {
115
+ const count = limit ?? 100;
116
+ if (!Number.isSafeInteger(count) || count < 1 || count > 500)
117
+ throw new RevisionError('invalid', 'Page limit must be between 1 and 500');
118
+ let after = '';
119
+ if (cursor !== undefined) {
120
+ try {
121
+ const value = JSON.parse(decodeURIComponent(cursor));
122
+ if (!Array.isArray(value) || value.length !== 2 || value[0] !== key || typeof value[1] !== 'string')
123
+ throw new Error();
124
+ after = catalogPath(value[1]);
125
+ }
126
+ catch {
127
+ throw new RevisionError('invalid', 'Invalid catalog cursor');
128
+ }
129
+ }
130
+ const sorted = entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).filter(entry => entry.path > after);
131
+ const selected = sorted.slice(0, count), last = selected.at(-1);
132
+ return { entries: selected, ...(last && sorted.length > count ? { cursor: encodeURIComponent(JSON.stringify([key, last.path])) } : {}) };
133
+ }
134
+ return {
135
+ list: async (request, access) => {
136
+ const directory = catalogPath(request.directory ?? '', true);
137
+ return page(await collect(directory, false, access), `list:${directory}`, request.cursor, request.limit);
138
+ },
139
+ search: async (request, access) => {
140
+ if (typeof request.query !== 'string' || request.query.length > 1024)
141
+ throw new RevisionError('invalid', 'Invalid filename query');
142
+ const query = request.query.toLowerCase();
143
+ return page((await collect('', true, access)).filter(entry => entry.kind === 'file' && entry.path.toLowerCase().includes(query)), `search:${query}`, request.cursor, request.limit);
144
+ },
145
+ history: async (path, access) => { check(access); identifier(access.scopeId); return options.saves(catalogPath(path)); },
146
+ };
147
+ }
@@ -0,0 +1,8 @@
1
+ /** The host's identity of the workspace (the same shape as `WorkspaceIdentity` in `@hachej/boring-execution`). Pi's environment `id` is only a namespace. */
2
+ export interface WorkspaceKey {
3
+ readonly providerId: string;
4
+ readonly instanceId: string;
5
+ readonly incarnation: string;
6
+ readonly viewId: string;
7
+ }
8
+ //# sourceMappingURL=workspace-identity.d.ts.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ import type { FileSystem } from '@earendil-works/pi-durable/env';
2
+ import type { PublicationLookupResult, PublicationResult, ReadResult, ResourceAccess, ResourceProvider } from './contracts.js';
3
+ import type { WorkspaceCatalogOptions } from './workspace-catalog.js';
4
+ import type { WorkspaceCatalog } from './revision-contracts.js';
5
+ export type { WorkspaceCatalogOptions } from './workspace-catalog.js';
6
+ import type { WorkspaceJournal } from './journal.js';
7
+ import type { WorkspaceKey } from './workspace-identity.js';
8
+ export type { WorkspaceKey } from './workspace-identity.js';
9
+ export interface WorkspaceChange {
10
+ readonly path: string;
11
+ /** `null` when the file is absent. */
12
+ readonly revision: string | null;
13
+ readonly source: 'provider' | 'poll';
14
+ }
15
+ /** Runs one piece of work at a time, in order. A failed piece does not stop the ones behind it. */
16
+ export interface WorkspaceQueue {
17
+ readonly run: <Value>(work: () => Promise<Value>) => Promise<Value>;
18
+ }
19
+ export interface WorkspaceResourceProvider extends ResourceProvider {
20
+ readonly identity: WorkspaceKey;
21
+ readonly catalog: WorkspaceCatalog;
22
+ readonly publication: {
23
+ readonly publish: (request: Parameters<NonNullable<ResourceProvider['publication']>['publish']>[0], access: ResourceAccess) => Promise<PublicationResult>;
24
+ };
25
+ readonly reconciliation: {
26
+ readonly lookup: (operationId: string, access: ResourceAccess) => Promise<PublicationLookupResult>;
27
+ };
28
+ /** The workspace's mutation queue. Native tool calls that must not interleave with a conditional write run through it. */
29
+ readonly queue: WorkspaceQueue;
30
+ /** Revisions still retained for a file, newest first. */
31
+ readonly history: (path: string) => string[];
32
+ /** The same revisions with the time each was saved (milliseconds since the epoch; 0 when unknown), newest first. */
33
+ readonly saves: (path: string) => {
34
+ readonly revision: string;
35
+ readonly savedAt: number;
36
+ }[];
37
+ /** Read the file as it is now and retain that revision in its history (what `present` shows). Serialised with writes. */
38
+ readonly keep: (path: string, access: ResourceAccess) => Promise<ReadResult>;
39
+ /** Provider writes and polled detections. The listener must not throw. */
40
+ readonly onChange: (listener: (change: WorkspaceChange) => void) => () => void;
41
+ /** Hash each path now and report those that differ from the last revision this provider wrote or polled. Finds shell writes. */
42
+ readonly poll: (paths: readonly string[], signal?: AbortSignal) => Promise<WorkspaceChange[]>;
43
+ }
44
+ export interface WorkspaceProviderOptions {
45
+ readonly identity: WorkspaceKey;
46
+ readonly fs: FileSystem;
47
+ readonly journal: WorkspaceJournal;
48
+ readonly catalog?: WorkspaceCatalogOptions;
49
+ }
50
+ /** The Git blob id of `bytes` (`sha1("blob <length>\0" + bytes)`), computed without Git. */
51
+ export declare function blobRevision(bytes: Uint8Array): Promise<string>;
52
+ /** Whether a path is one of the provider's temporary files (a lister hides them). */
53
+ export declare const isTemporary: (path: string) => boolean;
54
+ export declare function createWorkspaceProvider(options: WorkspaceProviderOptions): WorkspaceResourceProvider;
55
+ //# sourceMappingURL=workspace.d.ts.map