@hachej/boring-files 0.0.0-stage → 0.2.1
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 +215 -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 +83 -0
- package/dist/journal.js +124 -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 +89 -0
- package/dist/revision-contracts.js +8 -0
- package/dist/revision-handler.d.ts +14 -0
- package/dist/revision-handler.js +213 -0
- package/dist/revision-protocol.d.ts +22 -0
- package/dist/revision-protocol.js +106 -0
- package/dist/revision.d.ts +8 -0
- package/dist/revision.js +145 -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 +423 -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 +108 -0
- package/dist/workspace.js +649 -0
- package/package.json +119 -4
package/dist/git.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import git from 'isomorphic-git';
|
|
2
|
+
import { posix } from 'node:path';
|
|
3
|
+
export class GitOperationError extends Error {
|
|
4
|
+
effects;
|
|
5
|
+
constructor(message, effects, options) {
|
|
6
|
+
super(message, options);
|
|
7
|
+
this.effects = effects;
|
|
8
|
+
this.name = 'GitOperationError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function name(value, label) {
|
|
12
|
+
if (typeof value !== 'string' || !value || /[\x00-\x1f\x7f]/.test(value))
|
|
13
|
+
throw new TypeError(`Invalid Git ${label}`);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
/** check-ref-format rules, so a name can never resolve outside refs/ or the repository. */
|
|
17
|
+
function refname(value) {
|
|
18
|
+
name(value, 'reference');
|
|
19
|
+
if (value.startsWith('-') || value.startsWith('/') || value.endsWith('/') || value.endsWith('.') || value === '@' || value.includes('//')
|
|
20
|
+
|| value.includes('..') || value.includes('@{') || /[ ~^:?*[\\]/.test(value)
|
|
21
|
+
|| value.split('/').some(part => part.startsWith('.') || part.endsWith('.lock')))
|
|
22
|
+
throw new TypeError('Invalid Git reference');
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function filepath(value) {
|
|
26
|
+
name(value, 'path');
|
|
27
|
+
if (value.startsWith('/') || value.includes('\\') || value.split('/').some(part => part === '..' || part.toLowerCase() === '.git'))
|
|
28
|
+
throw new TypeError('Git paths must stay in the selected worktree');
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
/** Borrow one filesystem and share this instance across callers. Raw filesystem writers do not take this queue. */
|
|
32
|
+
export function createGitRepository(options) {
|
|
33
|
+
const { fs, authorize } = options;
|
|
34
|
+
const directory = name(options.directory, 'directory');
|
|
35
|
+
if (!directory.startsWith('/') || posix.normalize(directory) !== directory)
|
|
36
|
+
throw new TypeError('Git directory must be an absolute normalized path');
|
|
37
|
+
const author = { ...options.author };
|
|
38
|
+
const repo = { fs, dir: directory };
|
|
39
|
+
const limits = { files: 100, bytes: 1024 * 1024, ...options.diffLimits };
|
|
40
|
+
if (![limits.files, limits.bytes].every(value => Number.isSafeInteger(value) && value > 0))
|
|
41
|
+
throw new TypeError('Git diff limits must be positive safe integers');
|
|
42
|
+
let pending = Promise.resolve();
|
|
43
|
+
function run(operation, mutation, signal, execute) {
|
|
44
|
+
const result = pending.then(async () => {
|
|
45
|
+
let started = false;
|
|
46
|
+
try {
|
|
47
|
+
if (signal?.aborted)
|
|
48
|
+
throw new Error('Git operation aborted before execution');
|
|
49
|
+
if (await authorize(operation) !== true)
|
|
50
|
+
throw new Error('Git repository authorization denied');
|
|
51
|
+
if (signal?.aborted)
|
|
52
|
+
throw new Error('Git operation aborted before execution');
|
|
53
|
+
started = true;
|
|
54
|
+
const value = await execute();
|
|
55
|
+
if (signal?.aborted)
|
|
56
|
+
throw new Error('Git operation aborted after execution');
|
|
57
|
+
if (await authorize(operation) !== true)
|
|
58
|
+
throw new Error('Git repository authorization changed during execution');
|
|
59
|
+
if (signal?.aborted)
|
|
60
|
+
throw new Error('Git operation aborted after execution');
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
catch (cause) {
|
|
64
|
+
const effects = started && mutation ? 'possible' : 'none';
|
|
65
|
+
throw new GitOperationError(`${cause instanceof Error ? cause.message : String(cause)}${effects === 'possible' ? '; prior Git effects may remain' : ''}`, effects, { cause });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
pending = result.then(() => undefined, () => undefined);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
function tree(view) {
|
|
72
|
+
switch (view.kind) {
|
|
73
|
+
case 'tree': return git.TREE({ ref: refname(view.ref) });
|
|
74
|
+
case 'index': return git.STAGE();
|
|
75
|
+
case 'worktree': return git.WORKDIR({ refresh: false });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return Object.freeze({
|
|
79
|
+
fs, directory,
|
|
80
|
+
init: (signal) => run('init', true, signal, () => git.init({ ...repo, defaultBranch: 'main' })),
|
|
81
|
+
status: (signal) => run('status', false, signal, () => git.statusMatrix({ ...repo, refresh: false })),
|
|
82
|
+
add: (path, signal) => {
|
|
83
|
+
const selected = filepath(path);
|
|
84
|
+
return run('add', true, signal, () => git.add({ ...repo, filepath: selected }));
|
|
85
|
+
},
|
|
86
|
+
remove: (path, signal) => {
|
|
87
|
+
const selected = filepath(path);
|
|
88
|
+
return run('remove', true, signal, () => git.remove({ ...repo, filepath: selected }));
|
|
89
|
+
},
|
|
90
|
+
commit: (message, signal) => {
|
|
91
|
+
if (typeof message !== 'string' || !message.trim() || message.includes('\0'))
|
|
92
|
+
throw new TypeError('Invalid Git message');
|
|
93
|
+
const selected = message;
|
|
94
|
+
return run('commit', true, signal, () => git.commit({ ...repo, author, message: selected }));
|
|
95
|
+
},
|
|
96
|
+
log: (signal) => run('log', false, signal, () => git.log({ ...repo, depth: 20 })),
|
|
97
|
+
branches: (signal) => run('branches', false, signal, () => git.listBranches(repo)),
|
|
98
|
+
branch: (ref, signal) => {
|
|
99
|
+
const selected = refname(ref);
|
|
100
|
+
return run('branch', true, signal, () => git.branch({ ...repo, ref: selected }));
|
|
101
|
+
},
|
|
102
|
+
checkout: (ref, signal) => {
|
|
103
|
+
const selected = refname(ref);
|
|
104
|
+
return run('checkout', true, signal, () => git.checkout({ ...repo, ref: selected }));
|
|
105
|
+
},
|
|
106
|
+
diff: (before, after, signal) => {
|
|
107
|
+
const trees = [tree(before), tree(after)];
|
|
108
|
+
return run('diff', false, signal, async () => {
|
|
109
|
+
const changes = [];
|
|
110
|
+
let bytes = 0;
|
|
111
|
+
async function version(entry) {
|
|
112
|
+
if (!entry)
|
|
113
|
+
return null;
|
|
114
|
+
if (await entry.type() !== 'blob')
|
|
115
|
+
throw new Error('Git diff supports blob entries only');
|
|
116
|
+
const oid = await entry.oid();
|
|
117
|
+
const content = await entry.content() ?? (await git.readBlob({ ...repo, oid })).blob;
|
|
118
|
+
const copied = Uint8Array.from(content);
|
|
119
|
+
bytes += copied.length;
|
|
120
|
+
if (bytes > limits.bytes)
|
|
121
|
+
throw new Error('Git diff byte limit exceeded');
|
|
122
|
+
return { oid, mode: await entry.mode(), bytes: copied };
|
|
123
|
+
}
|
|
124
|
+
await git.walk({ ...repo, trees, iterate: async (walk, children) => {
|
|
125
|
+
for (const child of children)
|
|
126
|
+
await walk(child);
|
|
127
|
+
return [];
|
|
128
|
+
}, map: async (path, entries) => {
|
|
129
|
+
if (path === '.git')
|
|
130
|
+
return null;
|
|
131
|
+
if (signal?.aborted)
|
|
132
|
+
throw new Error('Git diff aborted');
|
|
133
|
+
let left = entries[0] ?? null, right = entries[1] ?? null;
|
|
134
|
+
if (left && await left.type() === 'tree')
|
|
135
|
+
left = null;
|
|
136
|
+
if (right && await right.type() === 'tree')
|
|
137
|
+
right = null;
|
|
138
|
+
if (!left && !right)
|
|
139
|
+
return;
|
|
140
|
+
if (left && right && await left.oid() === await right.oid() && await left.mode() === await right.mode())
|
|
141
|
+
return;
|
|
142
|
+
if (changes.length >= limits.files)
|
|
143
|
+
throw new Error('Git diff file limit exceeded');
|
|
144
|
+
const change = { path, before: await version(left), after: await version(right) };
|
|
145
|
+
changes.push(change);
|
|
146
|
+
} });
|
|
147
|
+
return changes.sort((left, right) => left.path.localeCompare(right.path));
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const ERRNO = { not_found: 'ENOENT', not_directory: 'ENOTDIR', is_directory: 'EISDIR', permission_denied: 'EACCES', not_supported: 'ENOSYS', aborted: 'EINTR' };
|
|
153
|
+
const failure = (message, code, cause) => Object.assign(new Error(message, cause === undefined ? undefined : { cause }), { code });
|
|
154
|
+
/**
|
|
155
|
+
* isomorphic-git I/O over a Pi `FileSystem` (an `ExecutionEnv`'s files): every read and write goes through that environment, so its
|
|
156
|
+
* confinement (for example the AWS adapter's folder and symbolic link checks) also confines Git. No second checkout, no native Git.
|
|
157
|
+
* Modes are not part of Pi's file contract: files read as 0o100644. Symbolic links are refused (`ENOSYS`): Git fails on a worktree
|
|
158
|
+
* that contains one rather than following it.
|
|
159
|
+
*/
|
|
160
|
+
export function createEnvGitFs(filesystem, context) {
|
|
161
|
+
const value = (result) => {
|
|
162
|
+
if (result.ok)
|
|
163
|
+
return result.value;
|
|
164
|
+
throw failure(result.error.message, ERRNO[result.error.code] ?? 'EIO', result.error);
|
|
165
|
+
};
|
|
166
|
+
const unsupported = (what) => { throw failure(`${what}: symbolic links are not supported on this workspace`, 'ENOSYS'); };
|
|
167
|
+
// Racy-clean guard, as in the virtual Git fs: isomorphic-git compares whole seconds, so a same-size edit in the second of its `add`
|
|
168
|
+
// looks clean. The reported inode folds in the millisecond mtime, and is unique per call while the file is fresh.
|
|
169
|
+
let fresh = 0;
|
|
170
|
+
const stat = (info) => {
|
|
171
|
+
const racy = Date.now() - info.mtimeMs < 1000;
|
|
172
|
+
return {
|
|
173
|
+
type: info.kind === 'directory' ? 'dir' : info.kind, mode: info.kind === 'directory' ? 0o40000 : info.kind === 'symlink' ? 0o120000 : 0o100644,
|
|
174
|
+
size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.mtimeMs, uid: 0, gid: 0, dev: 0,
|
|
175
|
+
ino: racy ? 0x80000000 + (++fresh % 0x7fffffff) : Math.floor(info.mtimeMs) % 0x80000000,
|
|
176
|
+
isFile: () => info.kind === 'file', isDirectory: () => info.kind === 'directory', isSymbolicLink: () => info.kind === 'symlink',
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
return { promises: {
|
|
180
|
+
readFile: async (path, options) => {
|
|
181
|
+
const bytes = value(await filesystem.readBinaryFile(path, context));
|
|
182
|
+
return (typeof options === 'string' ? options : options?.encoding) ? new TextDecoder().decode(bytes) : bytes;
|
|
183
|
+
},
|
|
184
|
+
writeFile: async (path, bytes) => { value(await filesystem.writeFile(path, typeof bytes === 'string' ? bytes : Uint8Array.from(bytes), context)); },
|
|
185
|
+
unlink: async (path) => {
|
|
186
|
+
if (value(await filesystem.fileInfo(path, context)).kind === 'directory')
|
|
187
|
+
throw failure(`EISDIR: ${path}`, 'EISDIR');
|
|
188
|
+
value(await filesystem.remove(path, { recursive: false }, context));
|
|
189
|
+
},
|
|
190
|
+
readdir: async (path) => value(await filesystem.listDir(path, context)).map(entry => entry.name),
|
|
191
|
+
mkdir: async (path) => { value(await filesystem.createDir(path, { recursive: true }, context)); },
|
|
192
|
+
rmdir: async (path) => {
|
|
193
|
+
if (value(await filesystem.fileInfo(path, context)).kind !== 'directory')
|
|
194
|
+
throw failure(`ENOTDIR: ${path}`, 'ENOTDIR');
|
|
195
|
+
if (value(await filesystem.listDir(path, context)).length)
|
|
196
|
+
throw failure(`ENOTEMPTY: ${path}`, 'ENOTEMPTY');
|
|
197
|
+
value(await filesystem.remove(path, { recursive: true }, context));
|
|
198
|
+
},
|
|
199
|
+
stat: async (path) => stat(value(await filesystem.fileInfo(value(await filesystem.canonicalPath(path, context)), context))),
|
|
200
|
+
lstat: async (path) => stat(value(await filesystem.fileInfo(path, context))),
|
|
201
|
+
readlink: async (path) => unsupported(`readlink ${path}`),
|
|
202
|
+
symlink: async (_target, path) => unsupported(`symlink ${path}`),
|
|
203
|
+
chmod: async () => { },
|
|
204
|
+
} };
|
|
205
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { CommittedChange, NonEmpty, ResourceRef } from './contracts.js';
|
|
2
|
+
import type { SqliteConnection } from './sqlite.js';
|
|
3
|
+
/**
|
|
4
|
+
* Operation tables of the workspace journal: who asked for which operation, the digest of
|
|
5
|
+
* its arguments, its evidence reference and the changes it committed. Rows are plain strings; callers turn them into references.
|
|
6
|
+
* Every method is synchronous and runs in the caller's transaction (open one with `connection.transaction`), except the methods
|
|
7
|
+
* of `WorkspaceJournal` that say they commit on their own.
|
|
8
|
+
*/
|
|
9
|
+
export interface OperationKey {
|
|
10
|
+
readonly scope: string;
|
|
11
|
+
readonly principal: string;
|
|
12
|
+
readonly initiator: string;
|
|
13
|
+
readonly operation: string;
|
|
14
|
+
}
|
|
15
|
+
export interface StoredChange {
|
|
16
|
+
readonly kind: 'create' | 'replace' | 'delete';
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly before: string | null;
|
|
19
|
+
readonly after: string | null;
|
|
20
|
+
}
|
|
21
|
+
export interface StoredOperation {
|
|
22
|
+
readonly digest: string;
|
|
23
|
+
readonly evidence: string;
|
|
24
|
+
readonly changes: readonly StoredChange[];
|
|
25
|
+
}
|
|
26
|
+
export interface OperationJournal {
|
|
27
|
+
/** Create the operation tables when missing. */
|
|
28
|
+
readonly install: () => void;
|
|
29
|
+
readonly operation: (key: OperationKey) => StoredOperation | undefined;
|
|
30
|
+
readonly record: (key: OperationKey, operation: StoredOperation) => void;
|
|
31
|
+
}
|
|
32
|
+
export declare function createOperationJournal(db: SqliteConnection): OperationJournal;
|
|
33
|
+
/** Stored rows as committed changes, with the same shape checks the receipt parser applies. */
|
|
34
|
+
export declare function committedChanges(changes: readonly StoredChange[], ref: (path: string, revision: string) => ResourceRef): NonEmpty<CommittedChange>;
|
|
35
|
+
export interface StoredVersion {
|
|
36
|
+
readonly revision: string;
|
|
37
|
+
readonly bytes: Uint8Array;
|
|
38
|
+
readonly mediaType: string;
|
|
39
|
+
/** When this revision was saved, in milliseconds since the epoch. Kept the first time the revision is retained. */
|
|
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;
|
|
46
|
+
}
|
|
47
|
+
export type HistorySource = 'publish' | 'agent' | 'observed';
|
|
48
|
+
/**
|
|
49
|
+
* The operation journal plus what a workspace needs beyond receipts: an intent row written before an operation's first effect
|
|
50
|
+
* (an intent without a completion is an operation of unknown outcome), and the last versions of each file when there is no Git.
|
|
51
|
+
* Receipts and intents are scoped per operation key; history is scoped per `scope` argument and path.
|
|
52
|
+
*/
|
|
53
|
+
export interface WorkspaceJournal extends OperationJournal {
|
|
54
|
+
/** The intent of an operation that has begun and neither completed nor been cancelled. */
|
|
55
|
+
readonly intent: (key: OperationKey) => {
|
|
56
|
+
readonly digest: string;
|
|
57
|
+
} | undefined;
|
|
58
|
+
/** Commits on its own. */
|
|
59
|
+
readonly begin: (key: OperationKey, digest: string) => void;
|
|
60
|
+
/** Commits on its own: the operation is known not to have taken effect. */
|
|
61
|
+
readonly cancel: (key: OperationKey) => void;
|
|
62
|
+
/** Commits on its own: the receipt, the end of the intent and the new versions together. */
|
|
63
|
+
readonly complete: (key: OperationKey, operation: StoredOperation, versions: {
|
|
64
|
+
readonly scope: string;
|
|
65
|
+
readonly path: string;
|
|
66
|
+
readonly version: StoredVersion;
|
|
67
|
+
}[]) => void;
|
|
68
|
+
/** Commits on its own. Keeps the newest `historyLimit` revisions of the file. */
|
|
69
|
+
readonly remember: (scope: string, path: string, version: StoredVersion) => void;
|
|
70
|
+
readonly version: (scope: string, path: string, revision: string) => StoredVersion | undefined;
|
|
71
|
+
/** Retained revisions of a file, newest first. */
|
|
72
|
+
readonly revisions: (scope: string, path: string) => string[];
|
|
73
|
+
/** Retained revisions of a file with their save times, newest first. A time of 0 is unknown (kept before times were recorded). */
|
|
74
|
+
readonly saves: (scope: string, path: string) => {
|
|
75
|
+
readonly revision: string;
|
|
76
|
+
readonly savedAt: number;
|
|
77
|
+
readonly source: HistorySource;
|
|
78
|
+
}[];
|
|
79
|
+
}
|
|
80
|
+
export declare function createWorkspaceJournal(db: SqliteConnection, options?: {
|
|
81
|
+
readonly historyLimit?: number;
|
|
82
|
+
}): WorkspaceJournal;
|
|
83
|
+
//# sourceMappingURL=journal.d.ts.map
|
package/dist/journal.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { registerJournalConnection } from './sqlite-batch.js';
|
|
2
|
+
const keyValues = (key) => [key.scope, key.principal, key.initiator, key.operation];
|
|
3
|
+
function text(value) {
|
|
4
|
+
if (typeof value !== 'string')
|
|
5
|
+
throw new Error('Invalid stored journal value');
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
function nullableText(value) {
|
|
9
|
+
return value === null ? null : text(value);
|
|
10
|
+
}
|
|
11
|
+
export function createOperationJournal(db) {
|
|
12
|
+
return {
|
|
13
|
+
install: () => db.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS boring_operations (
|
|
15
|
+
scope TEXT NOT NULL, principal TEXT NOT NULL, initiator TEXT NOT NULL, operation TEXT NOT NULL,
|
|
16
|
+
digest TEXT NOT NULL, evidence TEXT NOT NULL,
|
|
17
|
+
PRIMARY KEY (scope, principal, initiator, operation)
|
|
18
|
+
) STRICT;
|
|
19
|
+
CREATE TABLE IF NOT EXISTS boring_changes (
|
|
20
|
+
scope TEXT NOT NULL, principal TEXT NOT NULL, initiator TEXT NOT NULL, operation TEXT NOT NULL,
|
|
21
|
+
ordinal INTEGER NOT NULL, path TEXT NOT NULL, kind TEXT NOT NULL,
|
|
22
|
+
before_revision TEXT, after_revision TEXT,
|
|
23
|
+
PRIMARY KEY (scope, principal, initiator, operation, ordinal)
|
|
24
|
+
) STRICT;
|
|
25
|
+
`),
|
|
26
|
+
operation: key => {
|
|
27
|
+
const values = keyValues(key);
|
|
28
|
+
const row = db.get('SELECT digest, evidence FROM boring_operations WHERE scope = ? AND principal = ? AND initiator = ? AND operation = ?', ...values);
|
|
29
|
+
if (!row)
|
|
30
|
+
return undefined;
|
|
31
|
+
const changes = db.all('SELECT * FROM boring_changes WHERE scope = ? AND principal = ? AND initiator = ? AND operation = ? ORDER BY ordinal', ...values).map((item) => {
|
|
32
|
+
const kind = text(item.kind);
|
|
33
|
+
if (kind !== 'create' && kind !== 'replace' && kind !== 'delete')
|
|
34
|
+
throw new Error('Invalid stored publication change');
|
|
35
|
+
return { kind, path: text(item.path), before: nullableText(item.before_revision), after: nullableText(item.after_revision) };
|
|
36
|
+
});
|
|
37
|
+
return { digest: text(row.digest), evidence: text(row.evidence), changes };
|
|
38
|
+
},
|
|
39
|
+
record: (key, operation) => {
|
|
40
|
+
const values = keyValues(key);
|
|
41
|
+
db.run('INSERT INTO boring_operations VALUES (?, ?, ?, ?, ?, ?)', ...values, operation.digest, operation.evidence);
|
|
42
|
+
operation.changes.forEach((item, index) => db.run('INSERT INTO boring_changes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', ...values, index, item.path, item.kind, item.before, item.after));
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Stored rows as committed changes, with the same shape checks the receipt parser applies. */
|
|
47
|
+
export function committedChanges(changes, ref) {
|
|
48
|
+
const mapped = changes.map((item) => {
|
|
49
|
+
if (item.kind === 'create' && item.before === null && item.after !== null)
|
|
50
|
+
return { kind: 'create', before: null, after: ref(item.path, item.after) };
|
|
51
|
+
if (item.kind === 'delete' && item.after === null && item.before !== null)
|
|
52
|
+
return { kind: 'delete', before: ref(item.path, item.before), after: null };
|
|
53
|
+
if (item.kind === 'replace' && item.before !== null && item.after !== null)
|
|
54
|
+
return { kind: 'replace', before: ref(item.path, item.before), after: ref(item.path, item.after) };
|
|
55
|
+
throw new Error('Invalid stored publication change');
|
|
56
|
+
});
|
|
57
|
+
const [first, ...rest] = mapped;
|
|
58
|
+
if (first === undefined)
|
|
59
|
+
throw new Error('Stored operation has no changes');
|
|
60
|
+
return [first, ...rest];
|
|
61
|
+
}
|
|
62
|
+
const historySource = (value) => value === 'agent' || value === 'observed' ? value : 'publish';
|
|
63
|
+
export function createWorkspaceJournal(db, options = {}) {
|
|
64
|
+
const limit = options.historyLimit ?? 20;
|
|
65
|
+
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
66
|
+
throw new RangeError('The history limit must be a positive integer');
|
|
67
|
+
const operations = createOperationJournal(db);
|
|
68
|
+
db.transaction('write', () => {
|
|
69
|
+
operations.install();
|
|
70
|
+
db.exec(`
|
|
71
|
+
CREATE TABLE IF NOT EXISTS boring_intents (
|
|
72
|
+
scope TEXT NOT NULL, principal TEXT NOT NULL, initiator TEXT NOT NULL, operation TEXT NOT NULL,
|
|
73
|
+
digest TEXT NOT NULL,
|
|
74
|
+
PRIMARY KEY (scope, principal, initiator, operation)
|
|
75
|
+
) STRICT;
|
|
76
|
+
CREATE TABLE IF NOT EXISTS boring_history (
|
|
77
|
+
scope TEXT NOT NULL, path TEXT NOT NULL, revision TEXT NOT NULL, sequence INTEGER NOT NULL,
|
|
78
|
+
bytes BLOB NOT NULL, media_type TEXT NOT NULL, saved_at INTEGER NOT NULL DEFAULT 0, source TEXT NOT NULL DEFAULT 'publish',
|
|
79
|
+
PRIMARY KEY (scope, path, revision)
|
|
80
|
+
) STRICT;
|
|
81
|
+
`);
|
|
82
|
+
// A journal created before save times were recorded gets the column; its old rows read as unknown (0).
|
|
83
|
+
if (!db.all("SELECT name FROM pragma_table_info('boring_history')").some(column => column.name === 'saved_at'))
|
|
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'");
|
|
88
|
+
});
|
|
89
|
+
const remember = (scope, path, version) => {
|
|
90
|
+
const last = db.get('SELECT COALESCE(MAX(sequence), 0) AS last FROM boring_history WHERE scope = ? AND path = ?', scope, path);
|
|
91
|
+
const sequence = Number(last?.last ?? 0) + 1;
|
|
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));
|
|
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);
|
|
95
|
+
};
|
|
96
|
+
const journal = {
|
|
97
|
+
...operations,
|
|
98
|
+
intent: key => {
|
|
99
|
+
const row = db.get('SELECT digest FROM boring_intents WHERE scope = ? AND principal = ? AND initiator = ? AND operation = ?', ...keyValues(key));
|
|
100
|
+
return row ? { digest: text(row.digest) } : undefined;
|
|
101
|
+
},
|
|
102
|
+
begin: (key, digest) => db.transaction('write', () => { db.run('INSERT INTO boring_intents VALUES (?, ?, ?, ?, ?)', ...keyValues(key), digest); }),
|
|
103
|
+
cancel: key => db.transaction('write', () => { db.run('DELETE FROM boring_intents WHERE scope = ? AND principal = ? AND initiator = ? AND operation = ?', ...keyValues(key)); }),
|
|
104
|
+
complete: (key, operation, versions) => db.transaction('write', () => {
|
|
105
|
+
operations.record(key, operation);
|
|
106
|
+
db.run('DELETE FROM boring_intents WHERE scope = ? AND principal = ? AND initiator = ? AND operation = ?', ...keyValues(key));
|
|
107
|
+
for (const item of versions)
|
|
108
|
+
remember(item.scope, item.path, item.version);
|
|
109
|
+
}),
|
|
110
|
+
remember: (scope, path, version) => db.transaction('write', () => { remember(scope, path, version); }),
|
|
111
|
+
version: (scope, path, revision) => {
|
|
112
|
+
const row = db.get('SELECT bytes, media_type FROM boring_history WHERE scope = ? AND path = ? AND revision = ?', scope, path, revision);
|
|
113
|
+
if (!row)
|
|
114
|
+
return undefined;
|
|
115
|
+
if (!(row.bytes instanceof Uint8Array))
|
|
116
|
+
throw new Error('Invalid stored bytes');
|
|
117
|
+
return { revision, bytes: Uint8Array.from(row.bytes), mediaType: text(row.media_type) };
|
|
118
|
+
},
|
|
119
|
+
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)),
|
|
120
|
+
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) })),
|
|
121
|
+
};
|
|
122
|
+
registerJournalConnection(journal, db);
|
|
123
|
+
return journal;
|
|
124
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
/** Reads at most `maxBytes` of the body. Returns `undefined` when the body is larger (answer 413) or the client went away. */
|
|
3
|
+
export declare function readBody(incoming: IncomingMessage, maxBytes?: number): Promise<Uint8Array | undefined>;
|
|
4
|
+
export interface WebRequestOptions {
|
|
5
|
+
/** Aborts the web request, for example when the Node response closes. */
|
|
6
|
+
readonly signal?: AbortSignal;
|
|
7
|
+
/** Largest accepted body (default 16 MiB). */
|
|
8
|
+
readonly maxBytes?: number;
|
|
9
|
+
}
|
|
10
|
+
/** The web `Request` for an incoming Node request, or `undefined` when its body is over `maxBytes` or unreadable. */
|
|
11
|
+
export declare function webRequest(incoming: IncomingMessage, url: string | URL, { signal, maxBytes }?: WebRequestOptions): Promise<Request | undefined>;
|
|
12
|
+
export interface SendWebResponseOptions {
|
|
13
|
+
/** Stops sending (the web body is cancelled), in addition to the Node response closing. */
|
|
14
|
+
readonly signal?: AbortSignal;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Write a web `Response` to a Node response: status and headers first (flushed immediately; multiple `set-cookie` values kept),
|
|
18
|
+
* then the body chunk by chunk, waiting for `drain` whenever `write()` returns false. When the client disconnects or `signal`
|
|
19
|
+
* aborts, the web body is cancelled and this resolves. A body that fails mid-stream destroys the connection, so the client
|
|
20
|
+
* sees a truncated response rather than a clean end, and the error is rethrown.
|
|
21
|
+
*/
|
|
22
|
+
export declare function sendWebResponse(response: Response, outgoing: ServerResponse, { signal }?: SendWebResponseOptions): Promise<void>;
|
|
23
|
+
//# sourceMappingURL=node-http.d.ts.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
|
|
2
|
+
/** Reads at most `maxBytes` of the body. Returns `undefined` when the body is larger (answer 413) or the client went away. */
|
|
3
|
+
export async function readBody(incoming, maxBytes = DEFAULT_MAX_BYTES) {
|
|
4
|
+
const chunks = [];
|
|
5
|
+
let size = 0;
|
|
6
|
+
try {
|
|
7
|
+
for await (const chunk of incoming) {
|
|
8
|
+
size += chunk.length;
|
|
9
|
+
if (size > maxBytes) {
|
|
10
|
+
incoming.resume();
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
chunks.push(chunk);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const body = new Uint8Array(size);
|
|
20
|
+
let offset = 0;
|
|
21
|
+
for (const chunk of chunks) {
|
|
22
|
+
body.set(chunk, offset);
|
|
23
|
+
offset += chunk.length;
|
|
24
|
+
}
|
|
25
|
+
return body;
|
|
26
|
+
}
|
|
27
|
+
/** The web `Request` for an incoming Node request, or `undefined` when its body is over `maxBytes` or unreadable. */
|
|
28
|
+
export async function webRequest(incoming, url, { signal, maxBytes = DEFAULT_MAX_BYTES } = {}) {
|
|
29
|
+
const headers = new Headers();
|
|
30
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
31
|
+
if (value === undefined)
|
|
32
|
+
continue;
|
|
33
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
34
|
+
headers.append(name, item);
|
|
35
|
+
}
|
|
36
|
+
const init = { method: incoming.method ?? 'GET', headers, ...(signal ? { signal } : {}) };
|
|
37
|
+
if (init.method === 'GET' || init.method === 'HEAD')
|
|
38
|
+
return new Request(url, init);
|
|
39
|
+
const body = await readBody(incoming, maxBytes);
|
|
40
|
+
return body === undefined ? undefined : new Request(url, { ...init, body: body });
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Write a web `Response` to a Node response: status and headers first (flushed immediately; multiple `set-cookie` values kept),
|
|
44
|
+
* then the body chunk by chunk, waiting for `drain` whenever `write()` returns false. When the client disconnects or `signal`
|
|
45
|
+
* aborts, the web body is cancelled and this resolves. A body that fails mid-stream destroys the connection, so the client
|
|
46
|
+
* sees a truncated response rather than a clean end, and the error is rethrown.
|
|
47
|
+
*/
|
|
48
|
+
export async function sendWebResponse(response, outgoing, { signal } = {}) {
|
|
49
|
+
const headers = {};
|
|
50
|
+
response.headers.forEach((value, name) => { if (name !== 'set-cookie')
|
|
51
|
+
headers[name] = value; });
|
|
52
|
+
const cookies = response.headers.getSetCookie();
|
|
53
|
+
if (cookies.length)
|
|
54
|
+
headers['set-cookie'] = cookies;
|
|
55
|
+
outgoing.writeHead(response.status, headers);
|
|
56
|
+
outgoing.flushHeaders();
|
|
57
|
+
if (!response.body) {
|
|
58
|
+
outgoing.end();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const reader = response.body.getReader();
|
|
62
|
+
let gone = false;
|
|
63
|
+
const stop = () => { gone = true; void reader.cancel().catch(() => { }); };
|
|
64
|
+
const closed = () => { if (!outgoing.writableFinished)
|
|
65
|
+
stop(); };
|
|
66
|
+
if (signal?.aborted)
|
|
67
|
+
stop();
|
|
68
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
69
|
+
outgoing.on('close', closed);
|
|
70
|
+
try {
|
|
71
|
+
for (;;) {
|
|
72
|
+
const { done, value } = await reader.read();
|
|
73
|
+
if (done || gone || outgoing.destroyed)
|
|
74
|
+
break;
|
|
75
|
+
if (!outgoing.write(value)) {
|
|
76
|
+
await new Promise(resolve => {
|
|
77
|
+
const go = () => { outgoing.off('drain', go); outgoing.off('close', go); signal?.removeEventListener('abort', go); resolve(); };
|
|
78
|
+
outgoing.on('drain', go);
|
|
79
|
+
outgoing.on('close', go);
|
|
80
|
+
signal?.addEventListener('abort', go, { once: true });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (gone || outgoing.destroyed)
|
|
87
|
+
return;
|
|
88
|
+
outgoing.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
signal?.removeEventListener('abort', stop);
|
|
93
|
+
outgoing.off('close', closed);
|
|
94
|
+
}
|
|
95
|
+
if (gone) {
|
|
96
|
+
if (!outgoing.destroyed)
|
|
97
|
+
outgoing.destroy();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (!outgoing.destroyed)
|
|
101
|
+
outgoing.end();
|
|
102
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment-sensitive browser capabilities, in one place. Browsers expose `crypto.randomUUID`, `crypto.subtle`,
|
|
3
|
+
* `navigator.clipboard` and `navigator.share` only in secure contexts (HTTPS, localhost); a page served over plain HTTP on a
|
|
4
|
+
* private network gets none of them. Every browser-reachable package and the studio import these instead of the raw APIs,
|
|
5
|
+
* and `npm run check` (scripts/check-platform-apis.mjs) fails when browser-reachable source uses a raw one.
|
|
6
|
+
*/
|
|
7
|
+
/** A version 4 UUID; falls back to `crypto.getRandomValues`, which every context has. */
|
|
8
|
+
export declare function randomUUID(): string;
|
|
9
|
+
/**
|
|
10
|
+
* Puts `text` on the clipboard: the async Clipboard API where present, otherwise a hidden-textarea `execCommand('copy')`
|
|
11
|
+
* (works over plain HTTP). Resolves `false` when neither worked, so the caller can show the text for a manual copy.
|
|
12
|
+
*/
|
|
13
|
+
export declare function copyToClipboard(text: string): Promise<boolean>;
|
|
14
|
+
/** SHA-256 of `bytes`; without `crypto.subtle` it uses the portable standard (FIPS 180-4) implementation below, with identical output. */
|
|
15
|
+
export declare function sha256(bytes: Uint8Array<ArrayBuffer>): Promise<Uint8Array>;
|
|
16
|
+
/** SHA-1 of `bytes` (a content identifier, not a security boundary): `crypto.subtle` where present, otherwise the portable implementation below. */
|
|
17
|
+
export declare function sha1(bytes: Uint8Array<ArrayBuffer>): Promise<Uint8Array>;
|
|
18
|
+
/** Exported for the equivalence check only; use `sha1`. */
|
|
19
|
+
export declare function sha1Portable(bytes: Uint8Array): Uint8Array;
|
|
20
|
+
/** Exported for the equivalence check only; use `sha256`. */
|
|
21
|
+
export declare function sha256Portable(bytes: Uint8Array): Uint8Array;
|
|
22
|
+
//# sourceMappingURL=platform.d.ts.map
|