@lapis-notes/api 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +5 -0
- package/dist/app-database-host.d.ts +1 -0
- package/dist/app-database-host.js +1 -0
- package/dist/browser-sync-security.d.ts +45 -0
- package/dist/browser-sync-security.js +189 -0
- package/dist/build-tools/vite.d.ts +15 -0
- package/dist/build-tools/vite.js +36 -0
- package/dist/cache.svelte.d.ts +1 -1
- package/dist/cache.svelte.js +4 -1
- package/dist/components/editor/editor.css +54 -2
- package/dist/components/editor/extensions/lint/lapis-lint-tooltip.svelte +18 -5
- package/dist/components/editor/extensions/lint/lint-tooltip-actions.d.ts +0 -2
- package/dist/components/editor/extensions/lint/lint-tooltip-actions.js +0 -14
- package/dist/components/editor/language-service/index.js +23 -2
- package/dist/components/icon/icon-list.svelte.d.ts +1 -1
- package/dist/components/select/select.svelte.d.ts +1 -1
- package/dist/context.svelte.d.ts +11 -1
- package/dist/context.svelte.js +38 -1
- package/dist/docs-api.d.ts +2 -0
- package/dist/docs-api.js +1 -0
- package/dist/generated/plugin-host-modules.schema.generated.d.ts +443 -13
- package/dist/generated/plugin-host-modules.schema.generated.js +970 -306
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/language-service/manager.d.ts +5 -0
- package/dist/language-service/manager.js +58 -14
- package/dist/lapis-extension.d.ts +1 -1
- package/dist/lapis-extension.js +12 -0
- package/dist/nostr-signer.d.ts +68 -0
- package/dist/nostr-signer.js +49 -0
- package/dist/plugin-asset-server.d.ts +1 -0
- package/dist/plugin-asset-server.js +3 -0
- package/dist/plugin-capability-facade.d.ts +1 -1
- package/dist/plugin-capability-facade.js +6 -0
- package/dist/plugin-distribution/catalog-media.d.ts +4 -0
- package/dist/plugin-distribution/catalog-media.js +40 -0
- package/dist/plugin-distribution/download-stats.d.ts +4 -0
- package/dist/plugin-distribution/download-stats.js +19 -0
- package/dist/plugin-distribution/index.d.ts +4 -0
- package/dist/plugin-distribution/index.js +4 -0
- package/dist/plugin-distribution/installer.d.ts +3 -0
- package/dist/plugin-distribution/installer.js +9 -2
- package/dist/plugin-distribution/manager.d.ts +71 -5
- package/dist/plugin-distribution/manager.js +376 -37
- package/dist/plugin-distribution/nostr-registry-adapter.d.ts +29 -0
- package/dist/plugin-distribution/nostr-registry-adapter.js +152 -0
- package/dist/plugin-distribution/plugin-bundle-verifier.d.ts +43 -0
- package/dist/plugin-distribution/plugin-bundle-verifier.js +64 -1
- package/dist/plugin-distribution/plugin-bundle.d.ts +14 -0
- package/dist/plugin-distribution/plugin-bundle.js +46 -7
- package/dist/plugin-distribution/plugin-markdown.d.ts +6 -0
- package/dist/plugin-distribution/plugin-markdown.js +94 -0
- package/dist/plugin-distribution/registry-client.d.ts +6 -1
- package/dist/plugin-distribution/registry-client.js +32 -10
- package/dist/plugin-distribution/registry-source-state.d.ts +15 -0
- package/dist/plugin-distribution/registry-source-state.js +42 -0
- package/dist/plugin-distribution/schemas.d.ts +2511 -2824
- package/dist/plugin-distribution/schemas.js +111 -42
- package/dist/plugin-distribution/types.d.ts +116 -5
- package/dist/plugin-distribution-host.d.ts +4 -0
- package/dist/plugin-distribution-host.js +2 -0
- package/dist/plugin-install-progress.js +1 -1
- package/dist/plugin-manager.d.ts +3 -0
- package/dist/plugin-manager.js +21 -13
- package/dist/plugin-settings.d.ts +21 -0
- package/dist/plugin-settings.js +41 -0
- package/dist/plugin.d.ts +13 -0
- package/dist/plugin.js +56 -0
- package/dist/registry-schema/catalog-v1.d.ts +4143 -0
- package/dist/registry-schema/catalog-v1.js +172 -0
- package/dist/registry-schema/catalog-v2.d.ts +5275 -0
- package/dist/registry-schema/catalog-v2.js +137 -0
- package/dist/registry-schema/index.d.ts +1 -0
- package/dist/registry-schema/index.js +1 -0
- package/dist/registry-schema/release-notes.d.ts +5 -0
- package/dist/registry-schema/release-notes.js +21 -0
- package/dist/server-host.d.ts +28 -0
- package/dist/server-host.js +17 -0
- package/dist/storage/app-database.d.ts +37 -2
- package/dist/storage/app-database.js +41 -1
- package/dist/storage/browser-coordinated-app-database.d.ts +3 -1
- package/dist/storage/browser-coordinated-app-database.js +8 -0
- package/dist/storage/browser.d.ts +4 -1
- package/dist/storage/browser.js +84 -0
- package/dist/storage/desktop-native.d.ts +4 -2
- package/dist/storage/desktop-native.js +90 -1
- package/dist/storage/fs.d.ts +17 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/memory-vault-adapter.d.ts +3 -1
- package/dist/storage/memory-vault-adapter.js +60 -0
- package/dist/storage/remote-app-database.d.ts +19 -0
- package/dist/storage/remote-app-database.js +130 -0
- package/dist/storage/task-projection.d.ts +2 -1
- package/dist/storage/task-projection.js +7 -2
- package/dist/storage/turso-app-database.d.ts +3 -1
- package/dist/storage/turso-app-database.js +45 -0
- package/dist/storage/vault-session.js +11 -0
- package/dist/storage/vault-state.d.ts +1 -1
- package/dist/storage/vault.svelte.d.ts +3 -1
- package/dist/storage/vault.svelte.js +76 -3
- package/dist/sync-host-security.d.ts +285 -0
- package/dist/sync-host-security.js +2237 -0
- package/dist/sync-host.d.ts +179 -0
- package/dist/sync-host.js +769 -0
- package/dist/workspace.svelte.d.ts +1 -1
- package/dist/workspace.svelte.js +1 -1
- package/package.json +48 -10
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
import { TFile } from "./storage/fs";
|
|
2
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
3
|
+
import { bytesToHex } from "@noble/hashes/utils.js";
|
|
4
|
+
import { SyncHostSecurityService, } from "./sync-host-security";
|
|
5
|
+
const ROOT_PREFIX = "sync-host:v1:";
|
|
6
|
+
const DATABASE_GENERATION_KEY = `${ROOT_PREFIX}database-generation`;
|
|
7
|
+
const CHANGE_SEQUENCE_KEY = `${ROOT_PREFIX}change-sequence`;
|
|
8
|
+
const FILE_PREFIX = `${ROOT_PREFIX}file:`;
|
|
9
|
+
const PATH_PREFIX = `${ROOT_PREFIX}path:`;
|
|
10
|
+
const CHANGE_PREFIX = `${ROOT_PREFIX}change:`;
|
|
11
|
+
const LATEST_CHANGE_PREFIX = `${ROOT_PREFIX}latest-change:`;
|
|
12
|
+
const ALIAS_PREFIX = `${ROOT_PREFIX}alias:`;
|
|
13
|
+
const BINDING_PREFIX = `${ROOT_PREFIX}binding:`;
|
|
14
|
+
const GENERATED_PREFIX = `${ROOT_PREFIX}generated:`;
|
|
15
|
+
const MAX_PAGE_SIZE = 500;
|
|
16
|
+
const MAX_TRANSACTION_ATTEMPTS = 8;
|
|
17
|
+
function randomId() {
|
|
18
|
+
if (typeof crypto !== "undefined" &&
|
|
19
|
+
typeof crypto.randomUUID === "function") {
|
|
20
|
+
return crypto.randomUUID();
|
|
21
|
+
}
|
|
22
|
+
if (typeof crypto === "undefined") {
|
|
23
|
+
throw new Error("Secure randomness is unavailable in this host");
|
|
24
|
+
}
|
|
25
|
+
const bytes = new Uint8Array(16);
|
|
26
|
+
crypto.getRandomValues(bytes);
|
|
27
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
28
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
29
|
+
const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0"));
|
|
30
|
+
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
|
|
31
|
+
.slice(6, 8)
|
|
32
|
+
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
|
33
|
+
}
|
|
34
|
+
function encodeKeyPart(value) {
|
|
35
|
+
const bytes = new TextEncoder().encode(value);
|
|
36
|
+
let binary = "";
|
|
37
|
+
for (const byte of bytes)
|
|
38
|
+
binary += String.fromCharCode(byte);
|
|
39
|
+
return btoa(binary)
|
|
40
|
+
.replaceAll("+", "-")
|
|
41
|
+
.replaceAll("/", "_")
|
|
42
|
+
.replace(/=+$/, "");
|
|
43
|
+
}
|
|
44
|
+
async function hashKeyPart(value) {
|
|
45
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
46
|
+
return [...new Uint8Array(digest)]
|
|
47
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
48
|
+
.join("");
|
|
49
|
+
}
|
|
50
|
+
function sequenceKey(sequence, fileId) {
|
|
51
|
+
return `${CHANGE_PREFIX}${String(sequence).padStart(20, "0")}:${fileId}`;
|
|
52
|
+
}
|
|
53
|
+
function requireBoundedString(value, label, maximum = 256) {
|
|
54
|
+
const normalized = value.trim();
|
|
55
|
+
if (!normalized || normalized.length > maximum) {
|
|
56
|
+
throw new Error(`${label} must contain between 1 and ${maximum} characters`);
|
|
57
|
+
}
|
|
58
|
+
return normalized;
|
|
59
|
+
}
|
|
60
|
+
function requireGeneratedKey(key) {
|
|
61
|
+
const normalized = requireBoundedString(key, "Generated-state key", 160);
|
|
62
|
+
if (!/^[A-Za-z0-9._:/-]+$/.test(normalized)) {
|
|
63
|
+
throw new Error("Generated-state keys may contain letters, digits, dot, underscore, colon, slash, and dash");
|
|
64
|
+
}
|
|
65
|
+
return normalized;
|
|
66
|
+
}
|
|
67
|
+
function boundedLimit(limit = 100) {
|
|
68
|
+
return Math.max(1, Math.min(MAX_PAGE_SIZE, Math.floor(limit)));
|
|
69
|
+
}
|
|
70
|
+
export class SyncHostService {
|
|
71
|
+
database;
|
|
72
|
+
vault;
|
|
73
|
+
clock;
|
|
74
|
+
options;
|
|
75
|
+
#ready;
|
|
76
|
+
#eventQueue = Promise.resolve();
|
|
77
|
+
#runtimeIds = new Map();
|
|
78
|
+
#origins = new Map();
|
|
79
|
+
#security;
|
|
80
|
+
constructor(database, vault, clock = Date.now, options = {}) {
|
|
81
|
+
this.database = database;
|
|
82
|
+
this.vault = vault;
|
|
83
|
+
this.clock = clock;
|
|
84
|
+
this.options = options;
|
|
85
|
+
this.#security = new SyncHostSecurityService(database, (pluginId, runtimeId) => this.#assertRuntime(pluginId, runtimeId), options.security);
|
|
86
|
+
this.vault.on("all", (event, file, context) => {
|
|
87
|
+
if (!(file instanceof TFile) || !this.#isChangeKind(event))
|
|
88
|
+
return;
|
|
89
|
+
const previousPath = typeof context.oldPath === "string" ? context.oldPath : undefined;
|
|
90
|
+
const originId = typeof context.operationOrigin === "string"
|
|
91
|
+
? context.operationOrigin
|
|
92
|
+
: undefined;
|
|
93
|
+
this.#eventQueue = this.#eventQueue
|
|
94
|
+
.then(async () => {
|
|
95
|
+
await this.#recordFileChange(event, file, previousPath, originId);
|
|
96
|
+
})
|
|
97
|
+
.catch(() => undefined);
|
|
98
|
+
});
|
|
99
|
+
this.#ready = this.#initialize();
|
|
100
|
+
}
|
|
101
|
+
async ready() {
|
|
102
|
+
await this.#ready;
|
|
103
|
+
await this.#eventQueue;
|
|
104
|
+
}
|
|
105
|
+
forPlugin(pluginId, grantedCapabilities) {
|
|
106
|
+
const normalizedPluginId = requireBoundedString(pluginId, "Plugin ID", 128);
|
|
107
|
+
const runtimeId = randomId();
|
|
108
|
+
const runtimeIds = this.#runtimeIds.get(normalizedPluginId) ?? new Set();
|
|
109
|
+
runtimeIds.add(runtimeId);
|
|
110
|
+
this.#runtimeIds.set(normalizedPluginId, runtimeIds);
|
|
111
|
+
const assertActive = () => this.#assertRuntime(normalizedPluginId, runtimeId);
|
|
112
|
+
const grants = grantedCapabilities ? new Set(grantedCapabilities) : null;
|
|
113
|
+
const assertGranted = (capability) => {
|
|
114
|
+
assertActive();
|
|
115
|
+
if (grants && !grants.has(capability)) {
|
|
116
|
+
throw new Error(`Plugin ${normalizedPluginId} is not granted ${capability}`);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
files: {
|
|
121
|
+
getIdentity: async (path) => {
|
|
122
|
+
assertGranted("metadata:identity");
|
|
123
|
+
await this.ready();
|
|
124
|
+
return this.#getIdentityByPath(path);
|
|
125
|
+
},
|
|
126
|
+
resolveIdentity: async (fileId) => {
|
|
127
|
+
assertGranted("metadata:identity");
|
|
128
|
+
await this.ready();
|
|
129
|
+
return this.#getIdentityById(fileId);
|
|
130
|
+
},
|
|
131
|
+
listChangedSince: async (cursor, options) => {
|
|
132
|
+
assertGranted("metadata:changes");
|
|
133
|
+
await this.ready();
|
|
134
|
+
return this.#listChangedSince(normalizedPluginId, runtimeId, cursor, options?.limit);
|
|
135
|
+
},
|
|
136
|
+
getExternalBinding: async (externalFileId) => {
|
|
137
|
+
assertGranted("metadata:identity");
|
|
138
|
+
await this.ready();
|
|
139
|
+
return this.#getExternalBinding(normalizedPluginId, externalFileId);
|
|
140
|
+
},
|
|
141
|
+
bindExternalIdentity: async (localFileId, externalFileId) => {
|
|
142
|
+
assertGranted("metadata:identity");
|
|
143
|
+
await this.ready();
|
|
144
|
+
return this.#bindExternalIdentity(normalizedPluginId, localFileId, externalFileId);
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
origins: {
|
|
148
|
+
create: async () => {
|
|
149
|
+
assertGranted("vault:stream");
|
|
150
|
+
const token = randomId();
|
|
151
|
+
this.#origins.set(token, { pluginId: normalizedPluginId, runtimeId });
|
|
152
|
+
return Object.freeze({ token });
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
vault: {
|
|
156
|
+
flushOpenEditor: async (path) => {
|
|
157
|
+
assertGranted("vault:stream");
|
|
158
|
+
return ((await this.options.flushOpenEditor?.(path)) ?? {
|
|
159
|
+
flushed: false,
|
|
160
|
+
path,
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
openRead: async (path) => {
|
|
164
|
+
assertGranted("vault:stream");
|
|
165
|
+
await this.ready();
|
|
166
|
+
const file = this.vault.getFileByPath(path);
|
|
167
|
+
const identity = await this.#getIdentityByPath(path);
|
|
168
|
+
if (!file || !identity || identity.state !== "present") {
|
|
169
|
+
throw new Error(`Cannot stream missing file ${path}`);
|
|
170
|
+
}
|
|
171
|
+
return Object.assign(await this.vault.openRead(file), {
|
|
172
|
+
fileId: identity.fileId,
|
|
173
|
+
contentGeneration: identity.contentGeneration,
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
atomicWrite: async (path, source, options = {}) => {
|
|
177
|
+
assertGranted("vault:stream");
|
|
178
|
+
await this.ready();
|
|
179
|
+
const current = await this.#getIdentityByPath(path);
|
|
180
|
+
if (options.expectedFileId !== undefined &&
|
|
181
|
+
current?.fileId !== options.expectedFileId) {
|
|
182
|
+
throw new Error(`File identity changed before atomic write: ${path}`);
|
|
183
|
+
}
|
|
184
|
+
if (options.expectedContentGeneration !== undefined &&
|
|
185
|
+
current?.contentGeneration !== options.expectedContentGeneration) {
|
|
186
|
+
throw new Error(`File content generation changed before atomic write: ${path}`);
|
|
187
|
+
}
|
|
188
|
+
if (options.origin) {
|
|
189
|
+
const owner = this.#origins.get(options.origin.token);
|
|
190
|
+
if (!owner ||
|
|
191
|
+
owner.pluginId !== normalizedPluginId ||
|
|
192
|
+
owner.runtimeId !== runtimeId) {
|
|
193
|
+
throw new Error("Operation origin is invalid or belongs to another plugin runtime");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
await this.vault.atomicWrite(path, source, {
|
|
197
|
+
signal: options.signal,
|
|
198
|
+
operationOrigin: options.origin?.token,
|
|
199
|
+
});
|
|
200
|
+
await this.ready();
|
|
201
|
+
const next = await this.#getIdentityByPath(path);
|
|
202
|
+
if (!next)
|
|
203
|
+
throw new Error(`Atomic write did not produce file identity: ${path}`);
|
|
204
|
+
return next;
|
|
205
|
+
},
|
|
206
|
+
remove: async (path, options = {}) => {
|
|
207
|
+
assertGranted("vault:stream");
|
|
208
|
+
await this.ready();
|
|
209
|
+
const current = await this.#getIdentityByPath(path);
|
|
210
|
+
const file = this.vault.getFileByPath(path);
|
|
211
|
+
if (!current || !file)
|
|
212
|
+
throw new Error(`Cannot remove missing file ${path}`);
|
|
213
|
+
if (options.expectedFileId !== undefined &&
|
|
214
|
+
current.fileId !== options.expectedFileId) {
|
|
215
|
+
throw new Error(`File identity changed before remove: ${path}`);
|
|
216
|
+
}
|
|
217
|
+
if (options.expectedContentGeneration !== undefined &&
|
|
218
|
+
current.contentGeneration !== options.expectedContentGeneration) {
|
|
219
|
+
throw new Error(`File content generation changed before remove: ${path}`);
|
|
220
|
+
}
|
|
221
|
+
if (options.origin) {
|
|
222
|
+
const owner = this.#origins.get(options.origin.token);
|
|
223
|
+
if (!owner ||
|
|
224
|
+
owner.pluginId !== normalizedPluginId ||
|
|
225
|
+
owner.runtimeId !== runtimeId) {
|
|
226
|
+
throw new Error("Operation origin is invalid or belongs to another plugin runtime");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
await this.vault.delete(file, false, {
|
|
230
|
+
operationOrigin: options.origin?.token,
|
|
231
|
+
});
|
|
232
|
+
await this.ready();
|
|
233
|
+
const tombstone = await this.#getIdentityById(current.fileId);
|
|
234
|
+
if (!tombstone)
|
|
235
|
+
throw new Error(`Remove did not produce tombstone: ${path}`);
|
|
236
|
+
return tombstone;
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
network: this.#security.network(normalizedPluginId, runtimeId, () => assertGranted("network:connect"), async (origin, options) => {
|
|
240
|
+
if (await this.#security.hasNetworkOriginGrant(normalizedPluginId, origin)) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const approved = await this.options.requestNetworkOriginGrant?.({
|
|
244
|
+
pluginId: normalizedPluginId,
|
|
245
|
+
origin: new URL(origin).origin,
|
|
246
|
+
allowPrivateNetwork: options?.allowPrivateNetwork ?? false,
|
|
247
|
+
});
|
|
248
|
+
if (approved !== true) {
|
|
249
|
+
throw new Error(`Network origin grant was not approved for ${normalizedPluginId}`);
|
|
250
|
+
}
|
|
251
|
+
await this.#security.grantNetworkOrigin(normalizedPluginId, origin, options);
|
|
252
|
+
}),
|
|
253
|
+
crypto: this.#security.crypto(normalizedPluginId, runtimeId, () => assertGranted("crypto:nostr-sync"), async () => {
|
|
254
|
+
const status = await this.#security.status(normalizedPluginId);
|
|
255
|
+
if (status.state === "unlocked")
|
|
256
|
+
return status;
|
|
257
|
+
if (!this.options.requestCryptoUnlock) {
|
|
258
|
+
throw new Error(`Sync crypto is ${status.state} for ${normalizedPluginId}; the host has no unlock flow`);
|
|
259
|
+
}
|
|
260
|
+
await this.options.requestCryptoUnlock(normalizedPluginId, status);
|
|
261
|
+
const next = await this.#security.status(normalizedPluginId);
|
|
262
|
+
if (next.state !== "unlocked") {
|
|
263
|
+
throw new Error(`The host did not unlock sync crypto for ${normalizedPluginId}`);
|
|
264
|
+
}
|
|
265
|
+
return next;
|
|
266
|
+
}, async (enrollment) => {
|
|
267
|
+
const status = await this.#security.status(normalizedPluginId);
|
|
268
|
+
if (status.state !== "unenrolled") {
|
|
269
|
+
throw new Error(`Sync crypto is already enrolled for ${normalizedPluginId}`);
|
|
270
|
+
}
|
|
271
|
+
if (!this.options.requestCryptoUnlock) {
|
|
272
|
+
throw new Error(`The host has no sync enrollment flow for ${normalizedPluginId}`);
|
|
273
|
+
}
|
|
274
|
+
await this.options.requestCryptoUnlock(normalizedPluginId, status, enrollment);
|
|
275
|
+
const next = await this.#security.status(normalizedPluginId);
|
|
276
|
+
if (next.state !== "unlocked") {
|
|
277
|
+
throw new Error(`The host did not enroll sync crypto for ${normalizedPluginId}`);
|
|
278
|
+
}
|
|
279
|
+
return next;
|
|
280
|
+
}),
|
|
281
|
+
syncIdentity: this.#security.syncIdentity(normalizedPluginId, runtimeId, () => assertGranted("crypto:nostr-sync")),
|
|
282
|
+
pairing: this.#security.pairing(normalizedPluginId, runtimeId, () => assertGranted("crypto:nostr-sync"), async (enrollment) => {
|
|
283
|
+
const status = await this.#security.status(normalizedPluginId);
|
|
284
|
+
if (status.state !== "unenrolled")
|
|
285
|
+
return status;
|
|
286
|
+
if (!this.options.requestCryptoUnlock) {
|
|
287
|
+
throw new Error(`The host has no sync enrollment flow for ${normalizedPluginId}`);
|
|
288
|
+
}
|
|
289
|
+
await this.options.requestCryptoUnlock(normalizedPluginId, status, enrollment);
|
|
290
|
+
return this.#security.status(normalizedPluginId);
|
|
291
|
+
}),
|
|
292
|
+
membership: this.#security.membership(normalizedPluginId, runtimeId, () => assertGranted("crypto:nostr-sync")),
|
|
293
|
+
recovery: this.#security.recovery(normalizedPluginId, runtimeId, () => assertGranted("crypto:nostr-sync"), this.options.requestRecoveryPassphrase
|
|
294
|
+
? (mode) => this.options.requestRecoveryPassphrase(normalizedPluginId, mode)
|
|
295
|
+
: undefined, async (enrollment) => {
|
|
296
|
+
const status = await this.#security.status(normalizedPluginId);
|
|
297
|
+
if (status.state !== "unenrolled")
|
|
298
|
+
return status;
|
|
299
|
+
if (!this.options.requestCryptoUnlock) {
|
|
300
|
+
throw new Error(`The host has no sync enrollment flow for ${normalizedPluginId}`);
|
|
301
|
+
}
|
|
302
|
+
await this.options.requestCryptoUnlock(normalizedPluginId, status, enrollment);
|
|
303
|
+
return this.#security.status(normalizedPluginId);
|
|
304
|
+
}),
|
|
305
|
+
generatedState: this.#generatedState(normalizedPluginId, runtimeId, () => assertGranted("plugin:generated-state")),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
revokePlugin(pluginId) {
|
|
309
|
+
this.#runtimeIds.delete(pluginId);
|
|
310
|
+
for (const [token, owner] of this.#origins) {
|
|
311
|
+
if (owner.pluginId === pluginId)
|
|
312
|
+
this.#origins.delete(token);
|
|
313
|
+
}
|
|
314
|
+
this.#security.revokePlugin(pluginId);
|
|
315
|
+
}
|
|
316
|
+
/** Host-controlled enrollment; this operation is intentionally absent from plugin facades. */
|
|
317
|
+
enrollPluginCrypto(pluginId, passphrase, request) {
|
|
318
|
+
return this.#security.enroll(requireBoundedString(pluginId, "Plugin ID", 128), passphrase, request);
|
|
319
|
+
}
|
|
320
|
+
/** Host-controlled unlock; plugins can only observe locked state. */
|
|
321
|
+
unlockPluginCrypto(pluginId, passphrase) {
|
|
322
|
+
return this.#security.unlock(requireBoundedString(pluginId, "Plugin ID", 128), passphrase);
|
|
323
|
+
}
|
|
324
|
+
/** Host-controlled passkey or OS-credential enrollment. */
|
|
325
|
+
enrollPluginCryptoWithWrappingKey(pluginId, wrappingKey, descriptor, request) {
|
|
326
|
+
return this.#security.enrollWithWrappingKey(requireBoundedString(pluginId, "Plugin ID", 128), wrappingKey, descriptor, request);
|
|
327
|
+
}
|
|
328
|
+
/** Host-controlled passkey or OS-credential unlock. */
|
|
329
|
+
unlockPluginCryptoWithWrappingKey(pluginId, wrappingKey) {
|
|
330
|
+
return this.#security.unlockWithWrappingKey(requireBoundedString(pluginId, "Plugin ID", 128), wrappingKey);
|
|
331
|
+
}
|
|
332
|
+
lockPluginCrypto(pluginId) {
|
|
333
|
+
this.#security.lock(requireBoundedString(pluginId, "Plugin ID", 128));
|
|
334
|
+
}
|
|
335
|
+
grantPluginNetworkOrigin(pluginId, origin, options) {
|
|
336
|
+
return this.#security.grantNetworkOrigin(requireBoundedString(pluginId, "Plugin ID", 128), origin, options);
|
|
337
|
+
}
|
|
338
|
+
revokePluginNetworkOrigin(pluginId, origin) {
|
|
339
|
+
return this.#security.revokeNetworkOrigin(requireBoundedString(pluginId, "Plugin ID", 128), origin);
|
|
340
|
+
}
|
|
341
|
+
async recordOriginatedChange(pluginId, origin, kind, file, previousPath) {
|
|
342
|
+
await this.ready();
|
|
343
|
+
const owner = this.#origins.get(origin.token);
|
|
344
|
+
if (!owner || owner.pluginId !== pluginId) {
|
|
345
|
+
throw new Error("Operation origin is invalid or belongs to another plugin runtime");
|
|
346
|
+
}
|
|
347
|
+
this.#assertRuntime(owner.pluginId, owner.runtimeId);
|
|
348
|
+
await this.#recordFileChange(kind, file, previousPath, origin.token);
|
|
349
|
+
}
|
|
350
|
+
async #initialize() {
|
|
351
|
+
await this.vault.load();
|
|
352
|
+
const generation = await this.database.getMeta(DATABASE_GENERATION_KEY);
|
|
353
|
+
if (!generation) {
|
|
354
|
+
await this.database.transactMeta({
|
|
355
|
+
checks: [{ key: DATABASE_GENERATION_KEY, exists: false }],
|
|
356
|
+
mutations: [
|
|
357
|
+
{ key: DATABASE_GENERATION_KEY, value: randomId() },
|
|
358
|
+
{ key: CHANGE_SEQUENCE_KEY, value: 0 },
|
|
359
|
+
],
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
// Tombstone missing paths first so a later unmatched file can recover one
|
|
363
|
+
// unique strong-fingerprint identity after an out-of-process rename.
|
|
364
|
+
let after;
|
|
365
|
+
do {
|
|
366
|
+
const page = await this.database.scanMeta({
|
|
367
|
+
prefix: FILE_PREFIX,
|
|
368
|
+
after,
|
|
369
|
+
limit: MAX_PAGE_SIZE,
|
|
370
|
+
});
|
|
371
|
+
for (const { value } of page.entries) {
|
|
372
|
+
if (value.state === "present" &&
|
|
373
|
+
!this.vault.getFileByPath(value.currentPath)) {
|
|
374
|
+
const synthetic = new TFile(value.currentPath, {
|
|
375
|
+
ctime: value.createdAt,
|
|
376
|
+
mtime: value.lastObservedMtime ?? value.updatedAt,
|
|
377
|
+
size: value.lastObservedSize ?? 0,
|
|
378
|
+
}, null, this.vault);
|
|
379
|
+
await this.#recordFileChange("delete", synthetic);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
after = page.nextCursor;
|
|
383
|
+
} while (after);
|
|
384
|
+
for (const file of this.vault.iterateFiles()) {
|
|
385
|
+
const identity = await this.#getIdentityByPath(file.path);
|
|
386
|
+
if (!identity) {
|
|
387
|
+
await this.#recordFileChange("create", file, undefined, undefined, true);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (identity.lastObservedMtime !== file.stat.mtime ||
|
|
391
|
+
identity.lastObservedSize !== file.stat.size) {
|
|
392
|
+
await this.#recordFileChange("modify", file);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
#isChangeKind(value) {
|
|
397
|
+
return (value === "create" ||
|
|
398
|
+
value === "modify" ||
|
|
399
|
+
value === "rename" ||
|
|
400
|
+
value === "delete");
|
|
401
|
+
}
|
|
402
|
+
#assertRuntime(pluginId, runtimeId) {
|
|
403
|
+
if (!this.#runtimeIds.get(pluginId)?.has(runtimeId)) {
|
|
404
|
+
throw new Error(`Sync-host capabilities for ${pluginId} have been revoked`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async #getIdentityByPath(path) {
|
|
408
|
+
const pointer = await this.database.getMeta(`${PATH_PREFIX}${await hashKeyPart(path)}`);
|
|
409
|
+
if (!pointer || pointer.path !== path)
|
|
410
|
+
return null;
|
|
411
|
+
return this.#getIdentityById(pointer.fileId);
|
|
412
|
+
}
|
|
413
|
+
async #getIdentityById(fileId) {
|
|
414
|
+
return ((await this.database.getMeta(`${FILE_PREFIX}${requireBoundedString(fileId, "File ID", 128)}`)) ?? null);
|
|
415
|
+
}
|
|
416
|
+
async #strongFingerprint(path, readIfMissing = false) {
|
|
417
|
+
const indexed = await this.database.getIndexedFile(path);
|
|
418
|
+
if (indexed?.file.hash)
|
|
419
|
+
return indexed.file.hash;
|
|
420
|
+
if (!readIfMissing)
|
|
421
|
+
return undefined;
|
|
422
|
+
const file = this.vault.getFileByPath(path);
|
|
423
|
+
if (!file)
|
|
424
|
+
return undefined;
|
|
425
|
+
const handle = await this.vault.openRead(file);
|
|
426
|
+
const reader = handle.stream().getReader();
|
|
427
|
+
const hash = sha256.create();
|
|
428
|
+
try {
|
|
429
|
+
for (;;) {
|
|
430
|
+
const chunk = await reader.read();
|
|
431
|
+
if (chunk.done)
|
|
432
|
+
break;
|
|
433
|
+
hash.update(chunk.value);
|
|
434
|
+
}
|
|
435
|
+
return bytesToHex(hash.digest());
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
reader.releaseLock();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
async #findUniqueTombstone(fingerprint) {
|
|
442
|
+
let match = null;
|
|
443
|
+
let after;
|
|
444
|
+
do {
|
|
445
|
+
const page = await this.database.scanMeta({
|
|
446
|
+
prefix: FILE_PREFIX,
|
|
447
|
+
after,
|
|
448
|
+
limit: MAX_PAGE_SIZE,
|
|
449
|
+
});
|
|
450
|
+
for (const { value } of page.entries) {
|
|
451
|
+
if (value.state !== "tombstoned" ||
|
|
452
|
+
value.contentFingerprint !== fingerprint) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (match)
|
|
456
|
+
return null;
|
|
457
|
+
match = value;
|
|
458
|
+
}
|
|
459
|
+
after = page.nextCursor;
|
|
460
|
+
} while (after);
|
|
461
|
+
return match;
|
|
462
|
+
}
|
|
463
|
+
async #recordFileChange(kind, file, previousPath, originId, allowFingerprintRecovery = false) {
|
|
464
|
+
for (let attempt = 0; attempt < MAX_TRANSACTION_ATTEMPTS; attempt += 1) {
|
|
465
|
+
const now = this.clock();
|
|
466
|
+
const generation = await this.database.getMeta(DATABASE_GENERATION_KEY);
|
|
467
|
+
if (!generation)
|
|
468
|
+
throw new Error("Sync-host database generation is unavailable");
|
|
469
|
+
const currentSequence = (await this.database.getMeta(CHANGE_SEQUENCE_KEY)) ?? 0;
|
|
470
|
+
const path = file.path;
|
|
471
|
+
const lookupPath = kind === "rename" ? (previousPath ?? path) : path;
|
|
472
|
+
let current = await this.#getIdentityByPath(lookupPath);
|
|
473
|
+
let fingerprint;
|
|
474
|
+
if (kind === "delete") {
|
|
475
|
+
fingerprint = current?.contentFingerprint;
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
try {
|
|
479
|
+
fingerprint = await this.#strongFingerprint(path, allowFingerprintRecovery);
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
const missing = error;
|
|
483
|
+
if (missing?.code !== "ENOENT" && missing?.name !== "NotFoundError") {
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
// Vault discovery and native streaming are separate operations. A
|
|
487
|
+
// concurrent uninstall or external delete can remove the bytes between
|
|
488
|
+
// them; reconcile that removal instead of rejecting host readiness.
|
|
489
|
+
kind = "delete";
|
|
490
|
+
fingerprint = current?.contentFingerprint;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (!current &&
|
|
494
|
+
kind === "create" &&
|
|
495
|
+
allowFingerprintRecovery &&
|
|
496
|
+
fingerprint) {
|
|
497
|
+
current = await this.#findUniqueTombstone(fingerprint);
|
|
498
|
+
if (current)
|
|
499
|
+
kind = "rename";
|
|
500
|
+
}
|
|
501
|
+
if (!current && kind === "delete") {
|
|
502
|
+
return {
|
|
503
|
+
fileId: randomId(),
|
|
504
|
+
currentPath: path,
|
|
505
|
+
state: "tombstoned",
|
|
506
|
+
createdAt: now,
|
|
507
|
+
updatedAt: now,
|
|
508
|
+
tombstonedAt: now,
|
|
509
|
+
contentGeneration: 0,
|
|
510
|
+
lastChangeSequence: currentSequence,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const nextSequence = currentSequence + 1;
|
|
514
|
+
const fileId = current?.fileId ?? randomId();
|
|
515
|
+
const next = {
|
|
516
|
+
fileId,
|
|
517
|
+
currentPath: path,
|
|
518
|
+
state: kind === "delete" ? "tombstoned" : "present",
|
|
519
|
+
createdAt: current?.createdAt ?? now,
|
|
520
|
+
updatedAt: now,
|
|
521
|
+
tombstonedAt: kind === "delete" ? now : undefined,
|
|
522
|
+
lastObservedMtime: file.stat.mtime,
|
|
523
|
+
lastObservedSize: file.stat.size,
|
|
524
|
+
contentGeneration: (current?.contentGeneration ?? 0) +
|
|
525
|
+
(kind === "modify" || kind === "create" ? 1 : 0),
|
|
526
|
+
lastChangeSequence: nextSequence,
|
|
527
|
+
contentFingerprint: fingerprint ?? current?.contentFingerprint,
|
|
528
|
+
};
|
|
529
|
+
const change = {
|
|
530
|
+
file: next,
|
|
531
|
+
kind,
|
|
532
|
+
previousPath: kind === "rename"
|
|
533
|
+
? (previousPath ?? current?.currentPath)
|
|
534
|
+
: undefined,
|
|
535
|
+
originId,
|
|
536
|
+
originPluginId: originId
|
|
537
|
+
? this.#origins.get(originId)?.pluginId
|
|
538
|
+
: undefined,
|
|
539
|
+
};
|
|
540
|
+
const mutations = [
|
|
541
|
+
{ key: CHANGE_SEQUENCE_KEY, value: nextSequence },
|
|
542
|
+
{ key: `${FILE_PREFIX}${fileId}`, value: next },
|
|
543
|
+
];
|
|
544
|
+
const oldLatestKey = await this.database.getMeta(`${LATEST_CHANGE_PREFIX}${fileId}`);
|
|
545
|
+
if (oldLatestKey)
|
|
546
|
+
mutations.push({ key: oldLatestKey, delete: true });
|
|
547
|
+
const nextChangeKey = sequenceKey(nextSequence, fileId);
|
|
548
|
+
mutations.push({ key: nextChangeKey, value: change }, { key: `${LATEST_CHANGE_PREFIX}${fileId}`, value: nextChangeKey });
|
|
549
|
+
const currentPathKey = `${PATH_PREFIX}${await hashKeyPart(path)}`;
|
|
550
|
+
if (kind === "delete") {
|
|
551
|
+
mutations.push({ key: currentPathKey, delete: true });
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
mutations.push({ key: currentPathKey, value: { fileId, path } });
|
|
555
|
+
}
|
|
556
|
+
if (previousPath && previousPath !== path) {
|
|
557
|
+
mutations.push({
|
|
558
|
+
key: `${PATH_PREFIX}${await hashKeyPart(previousPath)}`,
|
|
559
|
+
delete: true,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
const alias = {
|
|
563
|
+
fileId,
|
|
564
|
+
path,
|
|
565
|
+
firstSeenAt: now,
|
|
566
|
+
lastSeenAt: kind === "delete" ? now : undefined,
|
|
567
|
+
reason: kind === "rename"
|
|
568
|
+
? "rename"
|
|
569
|
+
: allowFingerprintRecovery
|
|
570
|
+
? "scan-reconciliation"
|
|
571
|
+
: "create",
|
|
572
|
+
};
|
|
573
|
+
mutations.push({
|
|
574
|
+
key: `${ALIAS_PREFIX}${fileId}:${String(now).padStart(16, "0")}:${await hashKeyPart(path)}`,
|
|
575
|
+
value: alias,
|
|
576
|
+
});
|
|
577
|
+
const result = await this.database.transactMeta({
|
|
578
|
+
checks: [{ key: CHANGE_SEQUENCE_KEY, value: currentSequence }],
|
|
579
|
+
mutations,
|
|
580
|
+
});
|
|
581
|
+
if (result.applied)
|
|
582
|
+
return next;
|
|
583
|
+
}
|
|
584
|
+
throw new Error("Sync-host file change could not be committed after concurrent updates");
|
|
585
|
+
}
|
|
586
|
+
async #listChangedSince(pluginId, runtimeId, cursor, limit = 100) {
|
|
587
|
+
const databaseGenerationId = (await this.database.getMeta(DATABASE_GENERATION_KEY)) ?? "";
|
|
588
|
+
const currentSequence = (await this.database.getMeta(CHANGE_SEQUENCE_KEY)) ?? 0;
|
|
589
|
+
if (cursor && cursor.databaseGenerationId !== databaseGenerationId) {
|
|
590
|
+
return {
|
|
591
|
+
status: "reset-required",
|
|
592
|
+
databaseGenerationId,
|
|
593
|
+
changes: [],
|
|
594
|
+
nextCursor: { databaseGenerationId, sequence: 0 },
|
|
595
|
+
hasMore: false,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
const fromSequence = Math.max(0, cursor?.sequence ?? 0);
|
|
599
|
+
const page = await this.database.scanMeta({
|
|
600
|
+
prefix: CHANGE_PREFIX,
|
|
601
|
+
after: sequenceKey(fromSequence, "~"),
|
|
602
|
+
limit: boundedLimit(limit),
|
|
603
|
+
});
|
|
604
|
+
const changes = page.entries.map(({ value }) => ({
|
|
605
|
+
file: value.file,
|
|
606
|
+
kind: value.kind,
|
|
607
|
+
previousPath: value.previousPath,
|
|
608
|
+
selfOriginated: value.originPluginId === pluginId ||
|
|
609
|
+
(Boolean(value.originId) &&
|
|
610
|
+
this.#origins.get(value.originId)?.pluginId === pluginId &&
|
|
611
|
+
this.#origins.get(value.originId)?.runtimeId === runtimeId),
|
|
612
|
+
}));
|
|
613
|
+
const sequence = changes.at(-1)?.file.lastChangeSequence ?? currentSequence;
|
|
614
|
+
return {
|
|
615
|
+
status: "ok",
|
|
616
|
+
databaseGenerationId,
|
|
617
|
+
changes,
|
|
618
|
+
nextCursor: { databaseGenerationId, sequence },
|
|
619
|
+
hasMore: Boolean(page.nextCursor),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
async #bindingKey(pluginId, externalFileId) {
|
|
623
|
+
return `${BINDING_PREFIX}${encodeKeyPart(pluginId)}:${await hashKeyPart(requireBoundedString(externalFileId, "External file ID", 512))}`;
|
|
624
|
+
}
|
|
625
|
+
async #getExternalBinding(pluginId, externalFileId) {
|
|
626
|
+
const binding = await this.database.getMeta(await this.#bindingKey(pluginId, externalFileId));
|
|
627
|
+
return binding?.namespace === pluginId &&
|
|
628
|
+
binding.externalFileId === externalFileId
|
|
629
|
+
? binding
|
|
630
|
+
: null;
|
|
631
|
+
}
|
|
632
|
+
async #bindExternalIdentity(pluginId, localFileId, externalFileId) {
|
|
633
|
+
const identity = await this.#getIdentityById(localFileId);
|
|
634
|
+
if (!identity || identity.state !== "present") {
|
|
635
|
+
throw new Error(`Cannot bind missing or tombstoned local file ${localFileId}`);
|
|
636
|
+
}
|
|
637
|
+
const key = await this.#bindingKey(pluginId, externalFileId);
|
|
638
|
+
const existing = await this.database.getMeta(key);
|
|
639
|
+
if (existing && existing.localFileId !== localFileId) {
|
|
640
|
+
throw new Error(`External file identity ${externalFileId} is already bound`);
|
|
641
|
+
}
|
|
642
|
+
const now = this.clock();
|
|
643
|
+
const binding = {
|
|
644
|
+
namespace: pluginId,
|
|
645
|
+
externalFileId,
|
|
646
|
+
localFileId,
|
|
647
|
+
boundAt: existing?.boundAt ?? now,
|
|
648
|
+
updatedAt: now,
|
|
649
|
+
};
|
|
650
|
+
const result = await this.database.transactMeta({
|
|
651
|
+
checks: existing ? [{ key, value: existing }] : [{ key, exists: false }],
|
|
652
|
+
mutations: [{ key, value: binding }],
|
|
653
|
+
});
|
|
654
|
+
if (!result.applied) {
|
|
655
|
+
throw new Error(`External file identity ${externalFileId} changed concurrently`);
|
|
656
|
+
}
|
|
657
|
+
return binding;
|
|
658
|
+
}
|
|
659
|
+
#generatedState(pluginId, runtimeId, assertActive) {
|
|
660
|
+
const prefix = `${GENERATED_PREFIX}${encodeKeyPart(pluginId)}:`;
|
|
661
|
+
const revisionKey = `${prefix}__revision__`;
|
|
662
|
+
const schemaKey = `${prefix}__schema__`;
|
|
663
|
+
const recordPrefix = `${prefix}record:`;
|
|
664
|
+
const recordKey = (key) => `${recordPrefix}${requireGeneratedKey(key)}`;
|
|
665
|
+
return {
|
|
666
|
+
get: async (key) => {
|
|
667
|
+
assertActive();
|
|
668
|
+
return ((await this.database.getMeta(recordKey(key))) ?? null);
|
|
669
|
+
},
|
|
670
|
+
scan: async ({ prefix: keyPrefix = "", after, limit, } = {}) => {
|
|
671
|
+
assertActive();
|
|
672
|
+
const normalizedPrefix = keyPrefix
|
|
673
|
+
? requireGeneratedKey(keyPrefix)
|
|
674
|
+
: "";
|
|
675
|
+
const page = await this.database.scanMeta({
|
|
676
|
+
prefix: `${recordPrefix}${normalizedPrefix}`,
|
|
677
|
+
after: after ? recordKey(after) : undefined,
|
|
678
|
+
limit: boundedLimit(limit),
|
|
679
|
+
});
|
|
680
|
+
return {
|
|
681
|
+
records: page.entries.map(({ value }) => value),
|
|
682
|
+
nextCursor: page.nextCursor?.slice(recordPrefix.length),
|
|
683
|
+
};
|
|
684
|
+
},
|
|
685
|
+
getRevision: async () => {
|
|
686
|
+
assertActive();
|
|
687
|
+
return (await this.database.getMeta(revisionKey)) ?? 0;
|
|
688
|
+
},
|
|
689
|
+
getSchemaVersion: async () => {
|
|
690
|
+
assertActive();
|
|
691
|
+
return (await this.database.getMeta(schemaKey)) ?? 0;
|
|
692
|
+
},
|
|
693
|
+
transact: async ({ expectedRevision, expectedSchemaVersion, schemaVersion, mutations, }) => {
|
|
694
|
+
assertActive();
|
|
695
|
+
this.#assertRuntime(pluginId, runtimeId);
|
|
696
|
+
const currentRevision = (await this.database.getMeta(revisionKey)) ?? 0;
|
|
697
|
+
const currentSchemaVersion = (await this.database.getMeta(schemaKey)) ?? 0;
|
|
698
|
+
if ((expectedRevision !== undefined &&
|
|
699
|
+
expectedRevision !== currentRevision) ||
|
|
700
|
+
(expectedSchemaVersion !== undefined &&
|
|
701
|
+
expectedSchemaVersion !== currentSchemaVersion)) {
|
|
702
|
+
return {
|
|
703
|
+
applied: false,
|
|
704
|
+
revision: currentRevision,
|
|
705
|
+
schemaVersion: currentSchemaVersion,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
const now = this.clock();
|
|
709
|
+
const databaseMutations = [];
|
|
710
|
+
for (const mutation of mutations) {
|
|
711
|
+
const key = requireGeneratedKey(mutation.key);
|
|
712
|
+
const storageKey = recordKey(key);
|
|
713
|
+
if (mutation.type === "delete") {
|
|
714
|
+
databaseMutations.push({ key: storageKey, delete: true });
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
const existing = await this.database.getMeta(storageKey);
|
|
718
|
+
databaseMutations.push({
|
|
719
|
+
key: storageKey,
|
|
720
|
+
value: {
|
|
721
|
+
key,
|
|
722
|
+
value: mutation.value,
|
|
723
|
+
version: (existing?.version ?? 0) + 1,
|
|
724
|
+
updatedAt: now,
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
const nextRevision = currentRevision + 1;
|
|
729
|
+
const nextSchemaVersion = schemaVersion ?? currentSchemaVersion;
|
|
730
|
+
databaseMutations.push({ key: revisionKey, value: nextRevision }, { key: schemaKey, value: nextSchemaVersion });
|
|
731
|
+
const applied = await this.database.transactMeta({
|
|
732
|
+
checks: [
|
|
733
|
+
currentRevision === 0
|
|
734
|
+
? { key: revisionKey, exists: false }
|
|
735
|
+
: { key: revisionKey, value: currentRevision },
|
|
736
|
+
currentSchemaVersion === 0
|
|
737
|
+
? { key: schemaKey, exists: false }
|
|
738
|
+
: { key: schemaKey, value: currentSchemaVersion },
|
|
739
|
+
],
|
|
740
|
+
mutations: databaseMutations,
|
|
741
|
+
});
|
|
742
|
+
return {
|
|
743
|
+
applied: applied.applied,
|
|
744
|
+
revision: applied.applied ? nextRevision : currentRevision,
|
|
745
|
+
schemaVersion: applied.applied
|
|
746
|
+
? nextSchemaVersion
|
|
747
|
+
: currentSchemaVersion,
|
|
748
|
+
};
|
|
749
|
+
},
|
|
750
|
+
clear: async () => {
|
|
751
|
+
assertActive();
|
|
752
|
+
let after;
|
|
753
|
+
do {
|
|
754
|
+
const page = await this.database.scanMeta({
|
|
755
|
+
prefix,
|
|
756
|
+
after,
|
|
757
|
+
limit: MAX_PAGE_SIZE,
|
|
758
|
+
});
|
|
759
|
+
if (page.entries.length > 0) {
|
|
760
|
+
await this.database.transactMeta({
|
|
761
|
+
mutations: page.entries.map(({ key }) => ({ key, delete: true })),
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
after = page.nextCursor;
|
|
765
|
+
} while (after);
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
}
|