@ixo/oracle-runtime-workers 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.
- package/README.md +110 -0
- package/package.json +77 -0
- package/src/attachments/classify.ts +114 -0
- package/src/attachments/content-blocks.ts +51 -0
- package/src/attachments/download.ts +235 -0
- package/src/attachments/extract.ts +241 -0
- package/src/attachments/index.ts +84 -0
- package/src/attachments/magic.ts +133 -0
- package/src/attachments/pipeline.ts +409 -0
- package/src/attachments/retention.ts +205 -0
- package/src/attachments/route.ts +41 -0
- package/src/attachments/sandbox-archive.ts +90 -0
- package/src/attachments/sanitize.ts +35 -0
- package/src/attachments/types.ts +86 -0
- package/src/attachments/view.ts +107 -0
- package/src/core/env.ts +200 -0
- package/src/core/index.ts +513 -0
- package/src/core/llm.ts +842 -0
- package/src/core/main-agent-types.ts +123 -0
- package/src/core/main-agent.ts +399 -0
- package/src/core/manifest.ts +365 -0
- package/src/core/meta-tools.ts +232 -0
- package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
- package/src/core/middlewares/capability-gate.ts +76 -0
- package/src/core/middlewares/index.ts +18 -0
- package/src/core/middlewares/page-context.ts +98 -0
- package/src/core/middlewares/safety-guardrail.ts +117 -0
- package/src/core/middlewares/summarization.ts +99 -0
- package/src/core/middlewares/tool-repetition-guard.ts +125 -0
- package/src/core/middlewares/tool-validation.ts +83 -0
- package/src/core/middlewares/work-status.ts +57 -0
- package/src/core/openrouter-pricing.ts +124 -0
- package/src/core/plugin-loader.ts +192 -0
- package/src/core/plugins/index.ts +6 -0
- package/src/core/plugins/skills/index.ts +8 -0
- package/src/core/plugins/skills/skills-tools.ts +356 -0
- package/src/core/plugins/skills/skills.plugin.ts +113 -0
- package/src/core/plugins/weather/index.ts +8 -0
- package/src/core/plugins/weather/weather-client.ts +153 -0
- package/src/core/plugins/weather/weather-middleware.ts +27 -0
- package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
- package/src/core/plugins/weather/weather-tools.ts +103 -0
- package/src/core/plugins/weather/weather.plugin.ts +227 -0
- package/src/core/prompt-composer.ts +469 -0
- package/src/core/registries.ts +642 -0
- package/src/core/runtime-context.ts +619 -0
- package/src/core/state.ts +108 -0
- package/src/core/sub-agent-fallback.ts +187 -0
- package/src/core/subagent-as-tool.ts +322 -0
- package/src/core/template.ts +245 -0
- package/src/core/utils.ts +246 -0
- package/src/core/wrap-plugin-tool.ts +100 -0
- package/src/do/ambient.ts +266 -0
- package/src/do/contracts.ts +562 -0
- package/src/do/debug-timers.ts +120 -0
- package/src/do/fresh-stub.ts +32 -0
- package/src/do/gateway-retry.ts +73 -0
- package/src/do/secrets-adapter.ts +32 -0
- package/src/do/size-alerts.ts +76 -0
- package/src/do/sse-stream.ts +472 -0
- package/src/do/transcript.ts +192 -0
- package/src/do/turn-body.ts +84 -0
- package/src/do/turn-metadata.ts +146 -0
- package/src/do/ucan-service.ts +339 -0
- package/src/do/user-oracle-do.ts +3035 -0
- package/src/gateway-worker.ts +82 -0
- package/src/index.ts +237 -0
- package/src/llm/byo-adapter.ts +59 -0
- package/src/llm/byo-catalog.ts +520 -0
- package/src/llm/byo-client.ts +280 -0
- package/src/llm/byo-routes.ts +255 -0
- package/src/llm/byo-service.ts +836 -0
- package/src/llm/chatgpt-oauth.ts +430 -0
- package/src/llm/provider-error.ts +283 -0
- package/src/matrix/gateway-do.ts +918 -0
- package/src/matrix/ingest.ts +209 -0
- package/src/matrix/replay-format.ts +34 -0
- package/src/matrix/reply-chain.ts +109 -0
- package/src/matrix/room-state-codec.ts +115 -0
- package/src/matrix/user-homeserver.ts +106 -0
- package/src/matrix/work-status.ts +294 -0
- package/src/memory/session-history-indexer.ts +328 -0
- package/src/owner-store/ixo-vfs-store.ts +570 -0
- package/src/owner-store/matrix-media-store.ts +136 -0
- package/src/owner-store/migrating-store.ts +185 -0
- package/src/owner-store/owner-copy-errors.ts +175 -0
- package/src/owner-store/retry.ts +45 -0
- package/src/owner-store/tus-upload.ts +333 -0
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/define-plugin.ts +45 -0
- package/src/plugin-api/index.ts +56 -0
- package/src/plugin-api/oracle-plugin.ts +145 -0
- package/src/plugin-api/tool-helper.ts +82 -0
- package/src/plugin-api/types.ts +816 -0
- package/src/plugin-api/ucan-errors.ts +16 -0
- package/src/plugins/agui/agui-agent.ts +115 -0
- package/src/plugins/agui/agui.plugin.ts +148 -0
- package/src/plugins/agui/index.ts +2 -0
- package/src/plugins/attachments/attachments.plugin.ts +54 -0
- package/src/plugins/attachments/index.ts +6 -0
- package/src/plugins/attachments/view-attachment-tool.ts +91 -0
- package/src/plugins/base64.ts +26 -0
- package/src/plugins/composio/composio-tools.ts +365 -0
- package/src/plugins/composio/composio-ucan.ts +43 -0
- package/src/plugins/composio/composio.plugin.ts +214 -0
- package/src/plugins/composio/index.ts +15 -0
- package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
- package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
- package/src/plugins/domain-indexer/index.ts +3 -0
- package/src/plugins/editor/blocknote-bridge.ts +120 -0
- package/src/plugins/editor/content-session.ts +331 -0
- package/src/plugins/editor/content-tools.ts +686 -0
- package/src/plugins/editor/document-model.ts +0 -0
- package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
- package/src/plugins/editor/editor-agent.ts +34 -0
- package/src/plugins/editor/editor-config.ts +64 -0
- package/src/plugins/editor/editor-mx.ts +128 -0
- package/src/plugins/editor/editor.plugin.ts +143 -0
- package/src/plugins/editor/failures.ts +154 -0
- package/src/plugins/editor/index.ts +31 -0
- package/src/plugins/editor/jsdom-shim.ts +55 -0
- package/src/plugins/editor/prompts.ts +59 -0
- package/src/plugins/editor/prop-policy.ts +147 -0
- package/src/plugins/editor/provider.ts +386 -0
- package/src/plugins/editor/room-membership.ts +95 -0
- package/src/plugins/editor/standalone-editor-tool.ts +200 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
- package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
- package/src/plugins/firecrawl/index.ts +15 -0
- package/src/plugins/flows/action-metadata.ts +3505 -0
- package/src/plugins/flows/actions.ts +190 -0
- package/src/plugins/flows/edit.ts +314 -0
- package/src/plugins/flows/errors.ts +38 -0
- package/src/plugins/flows/explain.ts +43 -0
- package/src/plugins/flows/flow-doc.ts +111 -0
- package/src/plugins/flows/flows.plugin.ts +129 -0
- package/src/plugins/flows/forms.ts +330 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/linkage.ts +147 -0
- package/src/plugins/flows/port-types.ts +53 -0
- package/src/plugins/flows/prompts.ts +65 -0
- package/src/plugins/flows/read.ts +392 -0
- package/src/plugins/flows/references.ts +66 -0
- package/src/plugins/flows/test-support.ts +165 -0
- package/src/plugins/flows/tools/authoring.ts +516 -0
- package/src/plugins/flows/tools/discovery.ts +119 -0
- package/src/plugins/flows/tools/forms.ts +142 -0
- package/src/plugins/flows/tools/inspect.ts +165 -0
- package/src/plugins/flows/tools/linkage.ts +117 -0
- package/src/plugins/flows/tools/settings.ts +184 -0
- package/src/plugins/flows/translator.ts +328 -0
- package/src/plugins/flows/types.ts +210 -0
- package/src/plugins/flows/ydoc-helpers.ts +543 -0
- package/src/plugins/index.ts +70 -0
- package/src/plugins/mcp-call-timeout.ts +38 -0
- package/src/plugins/mcp-tool-adapter.ts +87 -0
- package/src/plugins/memory/index.ts +17 -0
- package/src/plugins/memory/memory-tools.ts +463 -0
- package/src/plugins/memory/memory-ucan.ts +51 -0
- package/src/plugins/memory/memory.plugin.ts +122 -0
- package/src/plugins/memory/types.ts +147 -0
- package/src/plugins/portal/action-log.ts +34 -0
- package/src/plugins/portal/index.ts +2 -0
- package/src/plugins/portal/portal.plugin.ts +203 -0
- package/src/plugins/sandbox/index.ts +34 -0
- package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
- package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
- package/src/plugins/tasks/index.ts +17 -0
- package/src/plugins/tasks/manifest.ts +81 -0
- package/src/plugins/tasks/middleware.ts +219 -0
- package/src/plugins/tasks/tasks-tools.ts +414 -0
- package/src/plugins/tasks/tasks.plugin.ts +74 -0
- package/src/plugins/ucan-failure.ts +133 -0
- package/src/plugins/user-preferences/index.ts +22 -0
- package/src/plugins/user-preferences/schema.ts +31 -0
- package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
- package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
- package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
- package/src/plugins/vfs/index.ts +40 -0
- package/src/plugins/vfs/vfs-auth.ts +56 -0
- package/src/plugins/vfs/vfs-client.ts +655 -0
- package/src/plugins/vfs/vfs-content.ts +181 -0
- package/src/plugins/vfs/vfs-errors.ts +163 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
- package/src/plugins/vfs/vfs-tools.ts +635 -0
- package/src/plugins/vfs/vfs.plugin.ts +201 -0
- package/src/realtime/frontend-call-registry.ts +163 -0
- package/src/realtime/realtime-endpoint.ts +619 -0
- package/src/realtime/session-socket-hub.ts +249 -0
- package/src/realtime/socket-io-codec.ts +187 -0
- package/src/secrets/jwe.ts +74 -0
- package/src/secrets/pin-cipher.ts +43 -0
- package/src/secrets/secrets-service.ts +319 -0
- package/src/shell/app.ts +564 -0
- package/src/shell/auth.ts +290 -0
- package/src/sqlite/blob-codec.ts +100 -0
- package/src/sqlite/blob-compactor.ts +146 -0
- package/src/sqlite/cache-config.ts +45 -0
- package/src/sqlite/database.ts +643 -0
- package/src/sqlite/do-vfs.ts +1485 -0
- package/src/sqlite/index.ts +61 -0
- package/src/sqlite/serialization.ts +370 -0
- package/src/sqlite/sessions-store.ts +311 -0
- package/src/sqlite/sqlite-saver.ts +919 -0
- package/src/sqlite/vacuum-policy.ts +77 -0
- package/src/sqlite/wa-sqlite-loader.ts +123 -0
- package/src/sqlite/wasm.d.ts +10 -0
- package/src/tasks/schedule.ts +174 -0
- package/src/tasks/scheduler.ts +753 -0
- package/src/tasks/spec.ts +125 -0
- package/src/tasks/store.ts +351 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/* eslint-disable no-console -- console is the logger on workerd (no @ixo/logger); retries and cleanups must be visible */
|
|
2
|
+
/**
|
|
3
|
+
* `OwnerStore` backed by the user's IXO Virtual File System.
|
|
4
|
+
*
|
|
5
|
+
* The file lives at `/.oracles/<oracleDid>/state.db.gz` in the user's own
|
|
6
|
+
* namespace — they can list, download, share or delete it like any file.
|
|
7
|
+
* Every request carries a fresh single-use UCAN invocation minted from the
|
|
8
|
+
* delegation the user deposited for this oracle in the UCAN store worker
|
|
9
|
+
* (two-hop flow, same as the Node runtime's VFS plugin):
|
|
10
|
+
*
|
|
11
|
+
* store worker `GET /api/delegations?rootIssuer=<user>` (oracle self-signed)
|
|
12
|
+
* → delegation over `ixo:filesystem` granting `can: '*'` (the VFS grant
|
|
13
|
+
* lattice has no `fs/*` — `'*'` is the owner grant covering
|
|
14
|
+
* read/write/list/delete)
|
|
15
|
+
* → invocation attenuated to the one ability, proved by that delegation.
|
|
16
|
+
*
|
|
17
|
+
* Endpoints are the VFS worker's `/api/fs/*` (see the Node runtime's
|
|
18
|
+
* `plugins/vfs/vfs-client.ts`): `GET /files?path=` (stat via a SHORT prefix —
|
|
19
|
+
* see `listOracleFiles()`), `POST /files?path=` (create, ≤ one part),
|
|
20
|
+
* `POST /upload` + `PATCH /upload/:id` (tus, larger files), `GET
|
|
21
|
+
* /files/:id/content` (bytes, streamed), `POST /batch/delete`, `POST
|
|
22
|
+
* /batch/move`. The file is never `PUT` (a new VERSION, capped at 50 by the
|
|
23
|
+
* VFS): every flush uploads to a temp path and swaps it in — see `save()`.
|
|
24
|
+
*
|
|
25
|
+
* Two VFS semantics this store must respect:
|
|
26
|
+
* - `/.oracles` is a dot-folder, so the file is HIDDEN by the VFS dotfile
|
|
27
|
+
* convention. Every invocation therefore carries `nb.hidden: ['*']` — it
|
|
28
|
+
* imposes no widening (the worker intersects reveal sets across the proof
|
|
29
|
+
* chain), but without it even the owner's own chain reveals nothing. The
|
|
30
|
+
* USER's delegation must itself grant a reveal covering `/.oracles`
|
|
31
|
+
* (`nb.hidden: ['/.oracles']`, or `['*']`).
|
|
32
|
+
* - D1 caps LIKE patterns at 50 bytes, and the full file path embeds the
|
|
33
|
+
* oracle DID (70+ bytes) — so it can never be used as a path/glob filter.
|
|
34
|
+
* `stat()` lists under the short literal `/.oracles` prefix and matches
|
|
35
|
+
* the exact path client-side.
|
|
36
|
+
*/
|
|
37
|
+
import type { WorkersUcanService } from '../do/ucan-service';
|
|
38
|
+
import { isNetworkError, withRetry } from './retry';
|
|
39
|
+
import { tusUpload } from './tus-upload';
|
|
40
|
+
import {
|
|
41
|
+
bytesOfStream,
|
|
42
|
+
countStream,
|
|
43
|
+
gunzipStreamIfNeeded,
|
|
44
|
+
gzipStream,
|
|
45
|
+
type FileSnapshot,
|
|
46
|
+
type OwnerCopy,
|
|
47
|
+
type OwnerStore,
|
|
48
|
+
type SaveResult,
|
|
49
|
+
} from './types';
|
|
50
|
+
|
|
51
|
+
const VFS_RESOURCE = 'ixo:filesystem';
|
|
52
|
+
const INVOCATION_TTL_SECONDS = 60;
|
|
53
|
+
/** Root folder for per-oracle state files — also the short stat prefix. */
|
|
54
|
+
const ORACLES_ROOT = '/.oracles';
|
|
55
|
+
|
|
56
|
+
export const VFS_DEFAULT_BASE_URLS: Record<string, string> = {
|
|
57
|
+
mainnet: 'https://vfs.ixo.earth',
|
|
58
|
+
testnet: 'https://testnet.vfs.ixo.earth',
|
|
59
|
+
devnet: 'https://devnet.vfs.ixo.earth',
|
|
60
|
+
};
|
|
61
|
+
export const UCAN_STORE_DEFAULT_URLS: Record<string, string> = {
|
|
62
|
+
mainnet: 'https://store.ucan.ixo.earth',
|
|
63
|
+
testnet: 'https://testnet.store.ucan.ixo.earth',
|
|
64
|
+
devnet: 'https://devnet.store.ucan.ixo.earth',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The user has not deposited an `ixo:filesystem` delegation for this oracle
|
|
69
|
+
* in the UCAN store (yet). Distinguished from transient store/VFS failures so
|
|
70
|
+
* the migrating store can treat "not on VFS yet" as *no VFS copy* (and read
|
|
71
|
+
* the legacy Matrix media) instead of failing the whole boot — while genuine
|
|
72
|
+
* outages still fail loudly rather than silently serving a stale legacy copy.
|
|
73
|
+
*/
|
|
74
|
+
export class VfsNoDelegationError extends Error {
|
|
75
|
+
constructor(userDid: string) {
|
|
76
|
+
super(
|
|
77
|
+
`No active ixo:filesystem delegation from ${userDid} in the UCAN store`,
|
|
78
|
+
);
|
|
79
|
+
this.name = 'VfsNoDelegationError';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The UCAN store (which holds the user's delegation) was unreachable or
|
|
85
|
+
* failing — transient, unlike a missing delegation. Retried like a 5xx.
|
|
86
|
+
*/
|
|
87
|
+
export class VfsStoreUnavailableError extends Error {
|
|
88
|
+
readonly retryable = true as const;
|
|
89
|
+
|
|
90
|
+
constructor(detail: string) {
|
|
91
|
+
super(`UCAN store unavailable while resolving VFS access: ${detail}`);
|
|
92
|
+
this.name = 'VfsStoreUnavailableError';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A non-2xx VFS response, with the status and body for callers that recover. */
|
|
97
|
+
export class VfsRequestError extends Error {
|
|
98
|
+
constructor(
|
|
99
|
+
readonly status: number,
|
|
100
|
+
readonly body: string,
|
|
101
|
+
message: string,
|
|
102
|
+
) {
|
|
103
|
+
super(message);
|
|
104
|
+
this.name = 'VfsRequestError';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Uploads land at `<path>.uploading-<ts>` first and are moved into place
|
|
110
|
+
* once complete; anything still carrying the infix is a crashed flush.
|
|
111
|
+
*/
|
|
112
|
+
export const TEMP_PATH_INFIX = '.uploading-';
|
|
113
|
+
/** tus part size: a multiple of 64 KiB, ≥ 5 MiB (the VFS minimum) — and the peak upload buffer. */
|
|
114
|
+
export const TUS_PART_BYTES = 5 * 1024 * 1024;
|
|
115
|
+
/** Gzipped files up to one part go through the single-request `POST /files`. */
|
|
116
|
+
export const SINGLE_SHOT_MAX_BYTES = TUS_PART_BYTES;
|
|
117
|
+
/** Requests carrying a part-sized body get a longer timeout than metadata calls. */
|
|
118
|
+
const UPLOAD_TIMEOUT_MS = 120_000;
|
|
119
|
+
|
|
120
|
+
interface BatchResult {
|
|
121
|
+
results?: Array<{
|
|
122
|
+
id: string;
|
|
123
|
+
ok: boolean;
|
|
124
|
+
status: number;
|
|
125
|
+
error?: string;
|
|
126
|
+
}>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function describe(error: unknown): string {
|
|
130
|
+
return error instanceof Error ? error.message : String(error);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Transient: network failures, 5xx, 429. Everything else is a real answer. */
|
|
134
|
+
export function isRetryableRequest(error: unknown): boolean {
|
|
135
|
+
if (error instanceof VfsRequestError)
|
|
136
|
+
return error.status >= 500 || error.status === 429;
|
|
137
|
+
if (error instanceof VfsStoreUnavailableError) return true;
|
|
138
|
+
return isNetworkError(error);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface IxoVfsOwnerStoreOptions {
|
|
142
|
+
ucan: WorkersUcanService;
|
|
143
|
+
userDid: string;
|
|
144
|
+
oracleDid: string;
|
|
145
|
+
/** VFS worker origin, e.g. `https://devnet.vfs.ixo.earth`. */
|
|
146
|
+
vfsBaseUrl: string;
|
|
147
|
+
ucanStoreUrl: string;
|
|
148
|
+
fetchImpl?: typeof fetch;
|
|
149
|
+
timeoutMs?: number;
|
|
150
|
+
/** Backoff between retries of a failed request (tests: zeros). */
|
|
151
|
+
retryDelaysMs?: readonly number[];
|
|
152
|
+
sleep?: (ms: number) => Promise<void>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface VfsFileStat {
|
|
156
|
+
id: string;
|
|
157
|
+
path: string;
|
|
158
|
+
size?: number;
|
|
159
|
+
updatedAt?: string;
|
|
160
|
+
checksum?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export class IxoVfsOwnerStore implements OwnerStore {
|
|
164
|
+
readonly kind = 'vfs' as const;
|
|
165
|
+
private readonly fetchImpl: typeof fetch;
|
|
166
|
+
private readonly apiBase: string;
|
|
167
|
+
private readonly path: string;
|
|
168
|
+
|
|
169
|
+
constructor(private readonly opts: IxoVfsOwnerStoreOptions) {
|
|
170
|
+
// Bind the global: calling `this.fetchImpl(...)` hands the store instance
|
|
171
|
+
// as `this`, and workerd's fetch throws "Illegal invocation" for any
|
|
172
|
+
// `this` other than the global scope.
|
|
173
|
+
this.fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis);
|
|
174
|
+
this.apiBase = `${opts.vfsBaseUrl.replace(/\/+$/, '')}/api/fs`;
|
|
175
|
+
this.path = `${ORACLES_ROOT}/${opts.oracleDid}/state.db.gz`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async head(): Promise<{ etag: string } | null> {
|
|
179
|
+
const stat = await this.statPath(this.path);
|
|
180
|
+
return stat ? { etag: this.etagOf(stat) } : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Stream the durable copy: the response body is gunzipped on the fly (a
|
|
185
|
+
* raw legacy upload passes through) and handed to the object, which
|
|
186
|
+
* writes it into its VFS chunk by chunk and validates the SQLite header
|
|
187
|
+
* on the first one.
|
|
188
|
+
*/
|
|
189
|
+
async load(): Promise<OwnerCopy | null> {
|
|
190
|
+
const stat = await this.statPath(this.path);
|
|
191
|
+
if (!stat) return null;
|
|
192
|
+
const res = await this.request(
|
|
193
|
+
'fs/read',
|
|
194
|
+
'GET',
|
|
195
|
+
`/files/${encodeURIComponent(stat.id)}/content`,
|
|
196
|
+
);
|
|
197
|
+
if (!res.body) throw new Error('VFS content response has no body');
|
|
198
|
+
return {
|
|
199
|
+
stream: await gunzipStreamIfNeeded(res.body),
|
|
200
|
+
etag: this.etagOf(stat),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Atomic replace, always delete + re-create, never a new version:
|
|
206
|
+
*
|
|
207
|
+
* 1. drop stale `.uploading-*` temp files a crashed flush left behind;
|
|
208
|
+
* 2. measure the gzipped length in a counting pass (the VFS needs
|
|
209
|
+
* `Upload-Length` at session creation — `Upload-Defer-Length` is not
|
|
210
|
+
* supported — and gzip output is deterministic for identical input);
|
|
211
|
+
* 3. upload gzip → a TEMP path next to the real one: one `POST /files`
|
|
212
|
+
* when it fits in a single part, else a tus session in 5 MiB parts
|
|
213
|
+
* (`tusUpload`, resumable per part);
|
|
214
|
+
* 4. delete the old file at the real path, then `batch/move` the temp
|
|
215
|
+
* file into place.
|
|
216
|
+
*
|
|
217
|
+
* Every request is retried with backoff; a step that still fails throws,
|
|
218
|
+
* the working copy stays dirty and the next tick starts over (the temp
|
|
219
|
+
* file is cleaned up then). Peak memory is one part (5 MiB) plus fixed
|
|
220
|
+
* overhead, whatever the file size.
|
|
221
|
+
*/
|
|
222
|
+
async save(snapshot: FileSnapshot): Promise<SaveResult> {
|
|
223
|
+
const listing = await this.listOracleFiles();
|
|
224
|
+
const existing = listing.filter((f) => f.path === this.path);
|
|
225
|
+
const stale = listing.filter((f) =>
|
|
226
|
+
f.path.startsWith(`${this.path}${TEMP_PATH_INFIX}`),
|
|
227
|
+
);
|
|
228
|
+
if (stale.length > 0) {
|
|
229
|
+
await this.batchDelete(stale.map((f) => f.id)).catch((err: unknown) => {
|
|
230
|
+
console.warn(
|
|
231
|
+
`[owner-store] could not delete ${stale.length} stale temp upload(s): ${describe(err)}`,
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const gzLength = await countStream(gzipStream(snapshot.open()));
|
|
237
|
+
const tempPath = `${this.path}${TEMP_PATH_INFIX}${Date.now().toString(36)}`;
|
|
238
|
+
const uploaded =
|
|
239
|
+
gzLength <= SINGLE_SHOT_MAX_BYTES
|
|
240
|
+
? await this.uploadSingleShot(tempPath, snapshot)
|
|
241
|
+
: await this.uploadResumable(tempPath, snapshot, gzLength);
|
|
242
|
+
const tempId = uploaded.id ?? (await this.statPath(tempPath))?.id;
|
|
243
|
+
if (!tempId) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`VFS upload of ${tempPath} completed but the file cannot be found`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (existing.length > 0) {
|
|
250
|
+
await withRetry(() => this.batchDelete(existing.map((f) => f.id)), {
|
|
251
|
+
isRetryable: isRetryableRequest,
|
|
252
|
+
...this.retryOptions(),
|
|
253
|
+
onRetry: (err, attempt, delay) =>
|
|
254
|
+
console.warn(
|
|
255
|
+
`[owner-store] delete of the previous state file failed (${describe(err)}) — retry ${attempt} in ${delay} ms`,
|
|
256
|
+
),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
await withRetry(
|
|
260
|
+
async () => {
|
|
261
|
+
try {
|
|
262
|
+
await this.move(tempId, this.path);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
// Someone (a crashed earlier flush) still occupies the path: clear it
|
|
265
|
+
// and let the retry move again.
|
|
266
|
+
if (err instanceof VfsRequestError && err.status === 409) {
|
|
267
|
+
const occupant = await this.statPath(this.path);
|
|
268
|
+
if (occupant && occupant.id !== tempId)
|
|
269
|
+
await this.batchDelete([occupant.id]);
|
|
270
|
+
}
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
isRetryable: (err) =>
|
|
276
|
+
isRetryableRequest(err) ||
|
|
277
|
+
(err instanceof VfsRequestError && err.status === 409),
|
|
278
|
+
...this.retryOptions(),
|
|
279
|
+
onRetry: (err, attempt, delay) =>
|
|
280
|
+
console.warn(
|
|
281
|
+
`[owner-store] move into ${this.path} failed (${describe(err)}) — retry ${attempt} in ${delay} ms`,
|
|
282
|
+
),
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
const etag =
|
|
286
|
+
uploaded.contentHash ??
|
|
287
|
+
(await this.statPath(this.path))?.checksum ??
|
|
288
|
+
tempId;
|
|
289
|
+
return { etag, bytes: gzLength };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** ≤ one part: buffer the gzip once and `POST /files` it (retried as a whole). */
|
|
293
|
+
private async uploadSingleShot(
|
|
294
|
+
tempPath: string,
|
|
295
|
+
snapshot: FileSnapshot,
|
|
296
|
+
): Promise<{ id: string | null; contentHash: string | null }> {
|
|
297
|
+
const gz = await bytesOfStream(gzipStream(snapshot.open()));
|
|
298
|
+
const created = await withRetry(
|
|
299
|
+
async () =>
|
|
300
|
+
this.json<Record<string, unknown>>(
|
|
301
|
+
await this.request(
|
|
302
|
+
'fs/write',
|
|
303
|
+
'POST',
|
|
304
|
+
`/files?path=${encodeURIComponent(tempPath)}`,
|
|
305
|
+
gz,
|
|
306
|
+
'application/gzip',
|
|
307
|
+
UPLOAD_TIMEOUT_MS,
|
|
308
|
+
),
|
|
309
|
+
),
|
|
310
|
+
{
|
|
311
|
+
isRetryable: isRetryableRequest,
|
|
312
|
+
...this.retryOptions(),
|
|
313
|
+
onRetry: (err, attempt, delay) =>
|
|
314
|
+
console.warn(
|
|
315
|
+
`[owner-store] upload of ${tempPath} failed (${describe(err)}) — retry ${attempt} in ${delay} ms`,
|
|
316
|
+
),
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
return {
|
|
320
|
+
id: typeof created?.id === 'string' ? created.id : null,
|
|
321
|
+
contentHash:
|
|
322
|
+
typeof created?.contentHash === 'string' ? created.contentHash : null,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** > one part: tus session in `TUS_PART_BYTES` parts, resumable per part. */
|
|
327
|
+
private async uploadResumable(
|
|
328
|
+
tempPath: string,
|
|
329
|
+
snapshot: FileSnapshot,
|
|
330
|
+
gzLength: number,
|
|
331
|
+
): Promise<{ id: string | null; contentHash: string | null }> {
|
|
332
|
+
const result = await tusUpload({
|
|
333
|
+
endpoint: `${this.apiBase}/upload`,
|
|
334
|
+
path: tempPath,
|
|
335
|
+
contentType: 'application/gzip',
|
|
336
|
+
totalLength: gzLength,
|
|
337
|
+
partSize: TUS_PART_BYTES,
|
|
338
|
+
source: () => gzipStream(snapshot.open()),
|
|
339
|
+
authHeaders: () => this.authHeaders('fs/write'),
|
|
340
|
+
fetchImpl: this.fetchImpl,
|
|
341
|
+
timeoutMs: UPLOAD_TIMEOUT_MS,
|
|
342
|
+
...(this.opts.retryDelaysMs
|
|
343
|
+
? { retryDelaysMs: this.opts.retryDelaysMs }
|
|
344
|
+
: {}),
|
|
345
|
+
...(this.opts.sleep ? { sleep: this.opts.sleep } : {}),
|
|
346
|
+
log: (message) => console.warn(`[owner-store] ${message}`),
|
|
347
|
+
});
|
|
348
|
+
return { id: result.fileId, contentHash: result.contentHash };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async remove(): Promise<void> {
|
|
352
|
+
const listing = await this.listOracleFiles();
|
|
353
|
+
const mine = listing.filter(
|
|
354
|
+
(f) =>
|
|
355
|
+
f.path === this.path ||
|
|
356
|
+
f.path.startsWith(`${this.path}${TEMP_PATH_INFIX}`),
|
|
357
|
+
);
|
|
358
|
+
if (mine.length === 0) return;
|
|
359
|
+
await this.batchDelete(mine.map((f) => f.id));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private async batchDelete(ids: string[]): Promise<void> {
|
|
363
|
+
const body = await this.json<BatchResult>(
|
|
364
|
+
await this.request(
|
|
365
|
+
'fs/delete',
|
|
366
|
+
'POST',
|
|
367
|
+
'/batch/delete',
|
|
368
|
+
JSON.stringify({ ids }),
|
|
369
|
+
'application/json',
|
|
370
|
+
),
|
|
371
|
+
);
|
|
372
|
+
// Already gone counts as deleted.
|
|
373
|
+
const failed = (body?.results ?? []).filter(
|
|
374
|
+
(r) => !r.ok && r.status !== 404,
|
|
375
|
+
);
|
|
376
|
+
const first = failed[0];
|
|
377
|
+
if (first) {
|
|
378
|
+
throw new VfsRequestError(
|
|
379
|
+
first.status,
|
|
380
|
+
first.error ?? '',
|
|
381
|
+
`VFS batch/delete ${first.id} → ${first.status} ${first.error ?? ''}`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private async move(id: string, destinationPath: string): Promise<void> {
|
|
387
|
+
const body = await this.json<BatchResult>(
|
|
388
|
+
await this.request(
|
|
389
|
+
'fs/write',
|
|
390
|
+
'POST',
|
|
391
|
+
'/batch/move',
|
|
392
|
+
JSON.stringify({ items: [{ id, destinationPath }] }),
|
|
393
|
+
'application/json',
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
const item = body?.results?.[0];
|
|
397
|
+
if (!item?.ok) {
|
|
398
|
+
throw new VfsRequestError(
|
|
399
|
+
item?.status ?? 500,
|
|
400
|
+
item?.error ?? '',
|
|
401
|
+
`VFS batch/move ${id} → ${destinationPath}: ${item?.status ?? '?'} ${item?.error ?? 'no result'}`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private etagOf(stat: VfsFileStat): string {
|
|
407
|
+
return (
|
|
408
|
+
stat.checksum ?? `${stat.id}:${stat.updatedAt ?? ''}:${stat.size ?? ''}`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The file at exactly `path`, if any (see `listOracleFiles`). */
|
|
413
|
+
private async statPath(path: string): Promise<VfsFileStat | null> {
|
|
414
|
+
const listing = await this.listOracleFiles();
|
|
415
|
+
return listing.find((f) => f.path === path) ?? null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Every file under `/.oracles`. Lists the short literal prefix (the full
|
|
420
|
+
* path would blow D1's 50-byte LIKE pattern budget) and pages in case the
|
|
421
|
+
* user runs many oracles; callers match exact paths client-side.
|
|
422
|
+
*/
|
|
423
|
+
private async listOracleFiles(): Promise<VfsFileStat[]> {
|
|
424
|
+
const limit = 200;
|
|
425
|
+
const out: VfsFileStat[] = [];
|
|
426
|
+
for (let offset = 0; ; offset += limit) {
|
|
427
|
+
// The listing backs every existence check (head/load/save): a transient
|
|
428
|
+
// store or VFS failure here must not read as "no file" — retry it like
|
|
429
|
+
// any other request.
|
|
430
|
+
const res = await withRetry(
|
|
431
|
+
() =>
|
|
432
|
+
this.request(
|
|
433
|
+
'fs/list',
|
|
434
|
+
'GET',
|
|
435
|
+
`/files?path=${encodeURIComponent(ORACLES_ROOT)}&limit=${limit}&offset=${offset}`,
|
|
436
|
+
),
|
|
437
|
+
{
|
|
438
|
+
isRetryable: isRetryableRequest,
|
|
439
|
+
...this.retryOptions(),
|
|
440
|
+
onRetry: (err, attempt, delay) =>
|
|
441
|
+
console.warn(
|
|
442
|
+
`[owner-store] listing /.oracles failed (${describe(err)}) — retry ${attempt} in ${delay} ms`,
|
|
443
|
+
),
|
|
444
|
+
},
|
|
445
|
+
);
|
|
446
|
+
const body = await this.json<{ files?: Array<Record<string, unknown>> }>(
|
|
447
|
+
res,
|
|
448
|
+
);
|
|
449
|
+
const files = body?.files ?? [];
|
|
450
|
+
for (const f of files) {
|
|
451
|
+
if (typeof f.id !== 'string' || typeof f.path !== 'string') continue;
|
|
452
|
+
out.push({
|
|
453
|
+
id: f.id,
|
|
454
|
+
path: f.path,
|
|
455
|
+
size: typeof f.size === 'number' ? f.size : undefined,
|
|
456
|
+
updatedAt:
|
|
457
|
+
typeof f.updatedAt === 'string' || typeof f.updatedAt === 'number'
|
|
458
|
+
? String(f.updatedAt)
|
|
459
|
+
: undefined,
|
|
460
|
+
checksum:
|
|
461
|
+
typeof f.contentHash === 'string' ? f.contentHash : undefined,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
if (files.length < limit) return out;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private async bearer(
|
|
469
|
+
ability: 'fs/list' | 'fs/read' | 'fs/write' | 'fs/delete',
|
|
470
|
+
): Promise<string> {
|
|
471
|
+
const delegation = await this.opts.ucan.getServiceDelegation(
|
|
472
|
+
this.opts.userDid,
|
|
473
|
+
{
|
|
474
|
+
storeUrl: this.opts.ucanStoreUrl,
|
|
475
|
+
resource: VFS_RESOURCE,
|
|
476
|
+
requiredAbility: ability,
|
|
477
|
+
},
|
|
478
|
+
);
|
|
479
|
+
if ('error' in delegation) {
|
|
480
|
+
if (delegation.error === 'no-delegation')
|
|
481
|
+
throw new VfsNoDelegationError(this.opts.userDid);
|
|
482
|
+
// 'store-error': the UCAN store itself failed (5xx, network) — transient.
|
|
483
|
+
throw new VfsStoreUnavailableError(
|
|
484
|
+
`${delegation.error}${delegation.detail ? ` (${delegation.detail})` : ''}`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
const minted = await this.opts.ucan.createInvocationFromDelegation(
|
|
488
|
+
delegation.token,
|
|
489
|
+
this.opts.vfsBaseUrl,
|
|
490
|
+
// `nb.hidden: ['*']` imposes NO widening — the VFS intersects reveal
|
|
491
|
+
// sets across the chain, so the effective reveal is exactly what the
|
|
492
|
+
// user's delegation granted. Omitting it would zero the reveal and make
|
|
493
|
+
// the dot-folder state file invisible to its own writer.
|
|
494
|
+
{ can: ability, with: delegation.with, nb: { hidden: ['*'] } },
|
|
495
|
+
{ maxTtlSeconds: INVOCATION_TTL_SECONDS },
|
|
496
|
+
);
|
|
497
|
+
if ('error' in minted)
|
|
498
|
+
throw new Error(`VFS auth: mint failed — ${minted.error}`);
|
|
499
|
+
return minted.invocation;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private retryOptions(): {
|
|
503
|
+
delaysMs?: readonly number[];
|
|
504
|
+
sleep?: (ms: number) => Promise<void>;
|
|
505
|
+
} {
|
|
506
|
+
return {
|
|
507
|
+
...(this.opts.retryDelaysMs ? { delaysMs: this.opts.retryDelaysMs } : {}),
|
|
508
|
+
...(this.opts.sleep ? { sleep: this.opts.sleep } : {}),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Fresh single-use auth headers for one request (the tus client mints per part). */
|
|
513
|
+
private async authHeaders(
|
|
514
|
+
ability: 'fs/list' | 'fs/read' | 'fs/write' | 'fs/delete',
|
|
515
|
+
): Promise<Record<string, string>> {
|
|
516
|
+
return {
|
|
517
|
+
authorization: `Bearer ${await this.bearer(ability)}`,
|
|
518
|
+
'x-auth-type': 'ucan',
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private async request(
|
|
523
|
+
ability: 'fs/list' | 'fs/read' | 'fs/write' | 'fs/delete',
|
|
524
|
+
method: string,
|
|
525
|
+
pathAndQuery: string,
|
|
526
|
+
body?: Uint8Array | string,
|
|
527
|
+
contentType?: string,
|
|
528
|
+
timeoutMs?: number,
|
|
529
|
+
): Promise<Response> {
|
|
530
|
+
const headers: Record<string, string> = {
|
|
531
|
+
...(await this.authHeaders(ability)),
|
|
532
|
+
accept: 'application/json',
|
|
533
|
+
};
|
|
534
|
+
if (body !== undefined)
|
|
535
|
+
headers['content-type'] = contentType ?? 'application/json';
|
|
536
|
+
const controller = new AbortController();
|
|
537
|
+
const timer = setTimeout(
|
|
538
|
+
() => controller.abort(),
|
|
539
|
+
timeoutMs ?? this.opts.timeoutMs ?? 30_000,
|
|
540
|
+
);
|
|
541
|
+
try {
|
|
542
|
+
const res = await this.fetchImpl(`${this.apiBase}${pathAndQuery}`, {
|
|
543
|
+
method,
|
|
544
|
+
headers,
|
|
545
|
+
// A Uint8Array view is a valid body: no extra copy of a multi-MB file.
|
|
546
|
+
body,
|
|
547
|
+
signal: controller.signal,
|
|
548
|
+
});
|
|
549
|
+
if (!res.ok) {
|
|
550
|
+
const text = await res.text().catch(() => '');
|
|
551
|
+
throw new VfsRequestError(
|
|
552
|
+
res.status,
|
|
553
|
+
text,
|
|
554
|
+
`VFS ${method} ${pathAndQuery} → ${res.status} ${text.slice(0, 200)}`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
return res;
|
|
558
|
+
} finally {
|
|
559
|
+
clearTimeout(timer);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private async json<T>(res: Response): Promise<T | null> {
|
|
564
|
+
try {
|
|
565
|
+
return await res.json();
|
|
566
|
+
} catch {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OwnerStore` backed by the user's Matrix room, through the gateway object.
|
|
3
|
+
*
|
|
4
|
+
* Wire format is the Node runtime's (`m.ixo.media_upload` timeline event +
|
|
5
|
+
* `m.ixo.media_state[storageKey]` pointer), so a user migrating from the
|
|
6
|
+
* Node runtime finds their checkpoint file where they left it. The gateway
|
|
7
|
+
* handles encryption and media transfer; this class owns gzip and the
|
|
8
|
+
* SQLite sanity check.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { MatrixGatewayObject } from '../do/contracts';
|
|
12
|
+
import {
|
|
13
|
+
bytesOfStream,
|
|
14
|
+
gunzip,
|
|
15
|
+
gzip,
|
|
16
|
+
isSqliteFile,
|
|
17
|
+
streamOfBytes,
|
|
18
|
+
type FileSnapshot,
|
|
19
|
+
type OwnerCopy,
|
|
20
|
+
type OwnerStore,
|
|
21
|
+
type SaveResult,
|
|
22
|
+
} from './types';
|
|
23
|
+
|
|
24
|
+
const GZIP_MAGIC_0 = 0x1f;
|
|
25
|
+
const GZIP_MAGIC_1 = 0x8b;
|
|
26
|
+
|
|
27
|
+
function looksGzipped(bytes: Uint8Array): boolean {
|
|
28
|
+
return (
|
|
29
|
+
bytes.length >= 2 && bytes[0] === GZIP_MAGIC_0 && bytes[1] === GZIP_MAGIC_1
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MatrixMediaOwnerStoreOptions {
|
|
34
|
+
gateway: DurableObjectStub<MatrixGatewayObject>;
|
|
35
|
+
userDid: string;
|
|
36
|
+
storageKey: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class MatrixMediaOwnerStore implements OwnerStore {
|
|
40
|
+
readonly kind = 'matrix' as const;
|
|
41
|
+
private readonly gateway: DurableObjectStub<MatrixGatewayObject>;
|
|
42
|
+
private readonly userDid: string;
|
|
43
|
+
private readonly storageKey: string;
|
|
44
|
+
|
|
45
|
+
constructor(opts: MatrixMediaOwnerStoreOptions) {
|
|
46
|
+
this.gateway = opts.gateway;
|
|
47
|
+
this.userDid = opts.userDid;
|
|
48
|
+
this.storageKey = opts.storageKey;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private get filename(): string {
|
|
52
|
+
return `${this.storageKey}.db.gz`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The gateway RPC hands the whole (encrypted, then decrypted) media blob
|
|
57
|
+
* over; this path is the legacy one and inherently materializes the file.
|
|
58
|
+
*/
|
|
59
|
+
async load(): Promise<OwnerCopy | null> {
|
|
60
|
+
const found = await this.gateway.downloadUserSnapshot(
|
|
61
|
+
this.userDid,
|
|
62
|
+
this.storageKey,
|
|
63
|
+
);
|
|
64
|
+
if (!found) return null;
|
|
65
|
+
// Old Node-runtime uploads were always gzipped, but tolerate a raw file
|
|
66
|
+
// so a hand-uploaded database still loads.
|
|
67
|
+
const bytes = looksGzipped(found.bytes)
|
|
68
|
+
? await gunzip(found.bytes)
|
|
69
|
+
: found.bytes;
|
|
70
|
+
if (!isSqliteFile(bytes)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`MatrixMediaOwnerStore: snapshot ${found.eventId} for ${this.storageKey} is not a SQLite file (${bytes.length} bytes)`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return { stream: streamOfBytes(bytes), etag: found.eventId };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async save(snapshot: FileSnapshot): Promise<SaveResult> {
|
|
79
|
+
const bytes = await bytesOfStream(snapshot.open());
|
|
80
|
+
if (!isSqliteFile(bytes)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`MatrixMediaOwnerStore: refusing to save ${bytes.length} bytes that are not a SQLite file`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const compressed = await gzip(bytes);
|
|
86
|
+
const { eventId } = await this.gateway.uploadUserSnapshot(
|
|
87
|
+
this.userDid,
|
|
88
|
+
this.storageKey,
|
|
89
|
+
compressed,
|
|
90
|
+
this.filename,
|
|
91
|
+
);
|
|
92
|
+
return { etag: eventId, bytes: compressed.byteLength };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async head(): Promise<{ etag: string } | null> {
|
|
96
|
+
const room = await this.gateway.resolveUserRoom(this.userDid);
|
|
97
|
+
if (!room) return null;
|
|
98
|
+
const eventId = await this.currentEventId(room.roomId);
|
|
99
|
+
return eventId ? { etag: eventId } : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async remove(reason = 'User requested deletion'): Promise<void> {
|
|
103
|
+
const room = await this.gateway.resolveUserRoom(this.userDid);
|
|
104
|
+
if (!room) return;
|
|
105
|
+
const eventId = await this.currentEventId(room.roomId);
|
|
106
|
+
if (eventId) {
|
|
107
|
+
// The gateway routes `m.room.redaction` through the redaction endpoint.
|
|
108
|
+
await this.gateway.sendEvent(
|
|
109
|
+
room.roomId,
|
|
110
|
+
'm.room.redaction',
|
|
111
|
+
JSON.stringify({ redacts: eventId, reason }),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
// Clear the pointer so `head()`/`load()` report "nothing" from now on.
|
|
115
|
+
await this.gateway.sendStateEvent(
|
|
116
|
+
room.roomId,
|
|
117
|
+
'm.ixo.media_state',
|
|
118
|
+
JSON.stringify({}),
|
|
119
|
+
this.storageKey,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `m.ixo.media_state[storageKey].eventId`, or null when unset/cleared. */
|
|
124
|
+
private async currentEventId(roomId: string): Promise<string | null> {
|
|
125
|
+
const json = await this.gateway.getRoomStateEvent(
|
|
126
|
+
roomId,
|
|
127
|
+
'm.ixo.media_state',
|
|
128
|
+
this.storageKey,
|
|
129
|
+
);
|
|
130
|
+
if (!json) return null;
|
|
131
|
+
const parsed: unknown = JSON.parse(json);
|
|
132
|
+
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
133
|
+
const eventId = (parsed as Record<string, unknown>)['eventId'];
|
|
134
|
+
return typeof eventId === 'string' && eventId.length > 0 ? eventId : null;
|
|
135
|
+
}
|
|
136
|
+
}
|