@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/revision.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createResourceClient } from './remote.js';
|
|
2
|
+
import { binding, bindingHeader, bindingValue, catalogHeader, changeEvent, filePage, record, savedRevisions, serverEvents } from './revision-protocol.js';
|
|
3
|
+
import { readJsonBody } from './request-guard.js';
|
|
4
|
+
import { RevisionError } from './revision-contracts.js';
|
|
5
|
+
import { observe } from './remote-protocol.js';
|
|
6
|
+
import { randomUUID } from './platform.js';
|
|
7
|
+
import { locator } from './publication-input.js';
|
|
8
|
+
export * from './revision-contracts.js';
|
|
9
|
+
export async function connectRevisionProvider(options) {
|
|
10
|
+
const endpoint = new URL(options.endpoint), fetch = options.fetch;
|
|
11
|
+
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.hash)
|
|
12
|
+
throw new TypeError('Expected an HTTP endpoint without credentials or fragment');
|
|
13
|
+
async function send(request) {
|
|
14
|
+
try {
|
|
15
|
+
const pending = fetch(request);
|
|
16
|
+
void pending.then(response => { if (request.signal.aborted)
|
|
17
|
+
void response.body?.cancel().catch(() => { }); }, () => { });
|
|
18
|
+
const response = await observe(pending, request.signal);
|
|
19
|
+
if (request.signal.aborted) {
|
|
20
|
+
void response.body?.cancel().catch(() => { });
|
|
21
|
+
throw new RevisionError('aborted', 'File request was cancelled');
|
|
22
|
+
}
|
|
23
|
+
if (response.redirected || !response.ok) {
|
|
24
|
+
void response.body?.cancel().catch(() => { });
|
|
25
|
+
throw new RevisionError(response.status === 403 || response.status === 409 ? 'denied' : response.status === 400 ? 'invalid' : 'unavailable', 'The selected workspace is unavailable or no longer authorized');
|
|
26
|
+
}
|
|
27
|
+
return await readJsonBody(response, 8_388_608, request.signal);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (request.signal.aborted)
|
|
31
|
+
throw new RevisionError('aborted', 'File request was cancelled');
|
|
32
|
+
if (error instanceof RevisionError)
|
|
33
|
+
throw error;
|
|
34
|
+
throw new RevisionError('unavailable', 'The file request could not be completed');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const selected = binding(await send(new Request(endpoint, { method: 'GET', redirect: 'error', ...(options.signal ? { signal: options.signal } : {}) })));
|
|
38
|
+
const bound = bindingValue(selected);
|
|
39
|
+
const client = createResourceClient({ endpoint, identity: selected.identity, publication: true, reconciliation: true,
|
|
40
|
+
fetch: request => { const headers = new Headers(request.headers); headers.set(bindingHeader, bound); return fetch(new Request(request, { headers })); } });
|
|
41
|
+
async function invoke(kind, value, signal) {
|
|
42
|
+
const requestId = randomUUID();
|
|
43
|
+
const result = record(await send(new Request(endpoint, { method: 'POST', redirect: 'error', headers: { 'content-type': 'application/json', [bindingHeader]: bound, [catalogHeader]: '1' }, body: JSON.stringify({ requestId, kind, value }), ...(signal ? { signal } : {}) })));
|
|
44
|
+
if (bindingValue(binding(result)) !== bound || result.kind !== kind || result.requestId !== requestId)
|
|
45
|
+
throw new RevisionError('denied', 'The response belongs to another workspace');
|
|
46
|
+
return result.value;
|
|
47
|
+
}
|
|
48
|
+
/** One feed across reconnections: each resumes after the last seq delivered; a refused binding ends it. */
|
|
49
|
+
async function* changes(request = {}) {
|
|
50
|
+
const signal = request.signal;
|
|
51
|
+
let cursor = request.since, incarnation = request.incarnation, staged, delay = 500;
|
|
52
|
+
const pause = () => new Promise(resolve => {
|
|
53
|
+
const timer = setTimeout(done, delay);
|
|
54
|
+
function done() { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); }
|
|
55
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
56
|
+
delay = Math.min(delay * 2, 30_000);
|
|
57
|
+
});
|
|
58
|
+
while (!signal?.aborted) {
|
|
59
|
+
const url = new URL(endpoint);
|
|
60
|
+
url.searchParams.set('op', 'changes');
|
|
61
|
+
if (cursor !== undefined) {
|
|
62
|
+
url.searchParams.set('since', String(cursor));
|
|
63
|
+
if (incarnation !== undefined)
|
|
64
|
+
url.searchParams.set('incarnation', incarnation);
|
|
65
|
+
}
|
|
66
|
+
const connection = new AbortController();
|
|
67
|
+
const stop = () => connection.abort();
|
|
68
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
69
|
+
try {
|
|
70
|
+
let response;
|
|
71
|
+
try {
|
|
72
|
+
response = await fetch(new Request(url, { method: 'GET', redirect: 'error', headers: { accept: 'text/event-stream', [bindingHeader]: bound }, signal: connection.signal }));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
if (signal?.aborted)
|
|
76
|
+
return;
|
|
77
|
+
await pause();
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (response.redirected || !response.ok || !response.body) {
|
|
81
|
+
void response.body?.cancel().catch(() => { });
|
|
82
|
+
if (response.status === 403 || response.status === 409)
|
|
83
|
+
throw new RevisionError('denied', 'The selected workspace is unavailable or no longer authorized');
|
|
84
|
+
if (response.status === 400)
|
|
85
|
+
throw new RevisionError('invalid', 'The change feed request was refused');
|
|
86
|
+
await pause();
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
for await (const message of serverEvents(response.body)) {
|
|
91
|
+
if (message.event === 'ready') {
|
|
92
|
+
const ready = record(JSON.parse(message.data)), seq = ready.seq;
|
|
93
|
+
// A new provider lifetime is adopted with its resnapshot, not with `ready`: a disconnect in between must reconnect with the
|
|
94
|
+
// old cursor and incarnation, which the server answers with a resnapshot again.
|
|
95
|
+
if (typeof ready.incarnation === 'string') {
|
|
96
|
+
if (cursor === undefined || incarnation === undefined)
|
|
97
|
+
incarnation = ready.incarnation;
|
|
98
|
+
else
|
|
99
|
+
staged = ready.incarnation;
|
|
100
|
+
}
|
|
101
|
+
if (cursor === undefined && typeof seq === 'number' && Number.isSafeInteger(seq))
|
|
102
|
+
cursor = seq;
|
|
103
|
+
delay = 500;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (message.event !== 'change' && message.event !== 'resnapshot')
|
|
107
|
+
continue;
|
|
108
|
+
const event = changeEvent(JSON.parse(message.data));
|
|
109
|
+
if (event.kind === 'change' && cursor !== undefined && event.seq <= cursor)
|
|
110
|
+
continue;
|
|
111
|
+
if (event.kind === 'resnapshot' && staged !== undefined) {
|
|
112
|
+
incarnation = staged;
|
|
113
|
+
staged = undefined;
|
|
114
|
+
}
|
|
115
|
+
cursor = event.seq;
|
|
116
|
+
yield event;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (signal?.aborted)
|
|
121
|
+
return;
|
|
122
|
+
if (error instanceof RevisionError && error.kind === 'invalid')
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
signal?.removeEventListener('abort', stop);
|
|
128
|
+
connection.abort();
|
|
129
|
+
}
|
|
130
|
+
if (signal?.aborted)
|
|
131
|
+
return;
|
|
132
|
+
await pause();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!client.publish || !client.lookup)
|
|
136
|
+
throw new Error('Publishing client lacks reconciliation');
|
|
137
|
+
return {
|
|
138
|
+
...client, publish: client.publish, lookup: client.lookup, identity: Object.freeze(selected.identity), workspace: Object.freeze(selected.workspace),
|
|
139
|
+
locate: path => locator({ resource: { providerId: selected.workspace.providerId, path }, view: { kind: 'published' } }),
|
|
140
|
+
list: async (request, signal) => filePage(await invoke('list', request, signal)),
|
|
141
|
+
search: async (request, signal) => filePage(await invoke('search', request, signal)),
|
|
142
|
+
history: async (path, signal) => savedRevisions(await invoke('history', path, signal)),
|
|
143
|
+
changes: request => changes(request),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SqliteConnection } from './sqlite.js';
|
|
2
|
+
/**
|
|
3
|
+
* Internal (not a package export): what the workspace provider needs from the SQLite file system to commit a multi-file batch in
|
|
4
|
+
* one transaction. Registered by `openSqliteFileSystem` against the file system object it returns, so the provider keeps taking a
|
|
5
|
+
* plain Pi `FileSystem`. Both functions are synchronous and run inside `connection.transaction`.
|
|
6
|
+
*/
|
|
7
|
+
export interface SqliteBatch {
|
|
8
|
+
readonly connection: SqliteConnection;
|
|
9
|
+
/** The bytes of the regular file at an absolute path, `null` when nothing is there, `undefined` when it is not a file. */
|
|
10
|
+
readonly bytes: (path: string) => Uint8Array | null | undefined;
|
|
11
|
+
/** Write a regular file at an absolute path, creating its missing parent directories. Throws a Pi `FileError` on failure. */
|
|
12
|
+
readonly write: (path: string, bytes: Uint8Array) => void;
|
|
13
|
+
}
|
|
14
|
+
export declare const registerSqliteBatch: (fs: object, batch: SqliteBatch) => void;
|
|
15
|
+
export declare const sqliteBatchOf: (fs: object) => SqliteBatch | undefined;
|
|
16
|
+
/** The journal's connection, so the provider can commit the bytes and the receipt in one transaction when they share a database. */
|
|
17
|
+
export declare const registerJournalConnection: (journal: object, connection: SqliteConnection) => void;
|
|
18
|
+
export declare const journalConnectionOf: (journal: object) => SqliteConnection | undefined;
|
|
19
|
+
//# sourceMappingURL=sqlite-batch.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const batches = new WeakMap();
|
|
2
|
+
const journals = new WeakMap();
|
|
3
|
+
export const registerSqliteBatch = (fs, batch) => { batches.set(fs, batch); };
|
|
4
|
+
export const sqliteBatchOf = (fs) => batches.get(fs);
|
|
5
|
+
/** The journal's connection, so the provider can commit the bytes and the receipt in one transaction when they share a database. */
|
|
6
|
+
export const registerJournalConnection = (journal, connection) => { journals.set(journal, connection); };
|
|
7
|
+
export const journalConnectionOf = (journal) => journals.get(journal);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SqliteConnection } from './sqlite.js';
|
|
2
|
+
type DurableValue = ArrayBuffer | string | number | null;
|
|
3
|
+
/**
|
|
4
|
+
* The part of a Cloudflare Durable Object's `ctx.storage` this adapter uses, declared structurally so the package needs no
|
|
5
|
+
* Cloudflare types: `sql.exec(query, ...bindings)` returns a cursor and `transactionSync(work)` runs `work` as one SQLite transaction.
|
|
6
|
+
*/
|
|
7
|
+
export interface DurableObjectSqlStorage {
|
|
8
|
+
readonly sql: {
|
|
9
|
+
readonly exec: (query: string, ...bindings: DurableValue[]) => {
|
|
10
|
+
readonly toArray: () => Record<string, unknown>[];
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
readonly transactionSync: <Value>(work: () => Value) => Value;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A `SqliteConnection` over a SQLite-backed Durable Object's storage, for `openSqliteFileSystem({ connection })` and `createWorkspaceJournal(connection)`. Statements run on
|
|
17
|
+
* the object's own synchronous SQL API. A `write` transaction is `transactionSync`: SQLite commits every statement of the callback
|
|
18
|
+
* together, or rolls all of them back when it throws, and nothing else in the object runs in between because the callback is
|
|
19
|
+
* synchronous. A `read` runs the callback directly, which is consistent for the same reason. The object owns the database:
|
|
20
|
+
* the connection has no `close`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function durableObjectSqliteConnection(storage: DurableObjectSqlStorage): SqliteConnection;
|
|
23
|
+
export {};
|
|
24
|
+
//# sourceMappingURL=sqlite-durable-object.d.ts.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
function binding(value) {
|
|
2
|
+
if (typeof value === 'bigint') {
|
|
3
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < BigInt(Number.MIN_SAFE_INTEGER))
|
|
4
|
+
throw new RangeError('SQLite integer is outside the safe range');
|
|
5
|
+
return Number(value);
|
|
6
|
+
}
|
|
7
|
+
return value instanceof Uint8Array ? value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) : value;
|
|
8
|
+
}
|
|
9
|
+
/** Blobs come back as ArrayBuffers; the provider reads Uint8Arrays. */
|
|
10
|
+
function row(raw) {
|
|
11
|
+
for (const key of Object.keys(raw)) {
|
|
12
|
+
const value = raw[key];
|
|
13
|
+
if (value instanceof ArrayBuffer)
|
|
14
|
+
raw[key] = new Uint8Array(value);
|
|
15
|
+
}
|
|
16
|
+
return raw;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A `SqliteConnection` over a SQLite-backed Durable Object's storage, for `openSqliteFileSystem({ connection })` and `createWorkspaceJournal(connection)`. Statements run on
|
|
20
|
+
* the object's own synchronous SQL API. A `write` transaction is `transactionSync`: SQLite commits every statement of the callback
|
|
21
|
+
* together, or rolls all of them back when it throws, and nothing else in the object runs in between because the callback is
|
|
22
|
+
* synchronous. A `read` runs the callback directly, which is consistent for the same reason. The object owns the database:
|
|
23
|
+
* the connection has no `close`.
|
|
24
|
+
*/
|
|
25
|
+
export function durableObjectSqliteConnection(storage) {
|
|
26
|
+
const exec = (sql, params) => storage.sql.exec(sql, ...params.map(binding));
|
|
27
|
+
let depth = 0;
|
|
28
|
+
return {
|
|
29
|
+
exec: sql => { exec(sql, []).toArray(); },
|
|
30
|
+
run: (sql, ...params) => { exec(sql, params).toArray(); },
|
|
31
|
+
get: (sql, ...params) => {
|
|
32
|
+
const [first] = exec(sql, params).toArray();
|
|
33
|
+
return first === undefined ? undefined : row(first);
|
|
34
|
+
},
|
|
35
|
+
all: (sql, ...params) => exec(sql, params).toArray().map(item => row(item)),
|
|
36
|
+
// A transaction opened inside another one joins it.
|
|
37
|
+
transaction: (mode, work) => {
|
|
38
|
+
if (mode === 'read' || depth > 0)
|
|
39
|
+
return work();
|
|
40
|
+
depth++;
|
|
41
|
+
try {
|
|
42
|
+
return storage.transactionSync(work);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
depth--;
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FileSystem } from '@earendil-works/pi-durable/env';
|
|
2
|
+
import type { SqliteConnection } from './sqlite.js';
|
|
3
|
+
export interface SqliteFileSystemOptions {
|
|
4
|
+
/** The database. The host owns it: `cleanup` leaves it open. */
|
|
5
|
+
readonly connection: SqliteConnection;
|
|
6
|
+
/** Which workspace's rows: one database can hold several workspaces. */
|
|
7
|
+
readonly workspace: string;
|
|
8
|
+
/** The absolute working directory, created when missing (for example `/workspace`). Relative paths resolve against `cwd`. */
|
|
9
|
+
readonly cwd: string;
|
|
10
|
+
}
|
|
11
|
+
/** A Pi `FileSystem` plus the incarnation of its rows: new when the workspace was first created in this database. */
|
|
12
|
+
export interface SqliteFileSystem extends FileSystem {
|
|
13
|
+
readonly incarnation: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function openSqliteFileSystem(options: SqliteFileSystemOptions): SqliteFileSystem;
|
|
16
|
+
//# sourceMappingURL=sqlite-filesystem.d.ts.map
|