Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
411 lines
16 KiB
JavaScript
411 lines
16 KiB
JavaScript
"use strict";
|
|
|
|
/**
|
|
* Host-side collection and apply path for plugin encrypted sync sidecars.
|
|
* Uses non-cascade tables so uninstall/missing plugins do not destroy data.
|
|
*/
|
|
|
|
const {
|
|
collectPluginSyncSidecars,
|
|
excludeSecretPluginSettingsFromSidecars,
|
|
mergePluginSyncSidecars,
|
|
parseSettingsSidecarKey,
|
|
resolveSettingsSidecarTarget,
|
|
settingsSidecarKey,
|
|
isCloudSyncablePluginSetting,
|
|
} = require("./pluginSyncSidecarHelpers.cjs");
|
|
|
|
class PluginSyncSidecarService {
|
|
constructor(options) {
|
|
if (!options?.database) {
|
|
throw new TypeError("Plugin sync sidecar service requires a database");
|
|
}
|
|
this.database = options.database;
|
|
this.contributionService = options.contributionService ?? null;
|
|
}
|
|
|
|
#declaredSettingsByPlugin() {
|
|
const map = new Map();
|
|
const snapshot = this.contributionService?.snapshot?.();
|
|
const plugins = snapshot?.plugins ?? [];
|
|
for (const plugin of plugins) {
|
|
const fields = (plugin.settings ?? []).map((setting) => ({
|
|
id: setting.id,
|
|
secret: setting.secret === true,
|
|
sync: setting.sync === true,
|
|
scope: setting.scope,
|
|
}));
|
|
map.set(plugin.id, fields);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Build the sidecar bundle for inclusion in the encrypted cloud payload.
|
|
*/
|
|
collectForSync() {
|
|
const declared = this.#declaredSettingsByPlugin();
|
|
const storedSettings = typeof this.database.listAllSettings === "function"
|
|
? this.database.listAllSettings()
|
|
: [];
|
|
const existingSidecars = typeof this.database.listAllSyncSidecars === "function"
|
|
? this.database.listAllSyncSidecars()
|
|
: [];
|
|
// Keep retained settings sidecars for installed plugins even when
|
|
// plugin_settings has no row yet (downloaded while plugin was missing).
|
|
// Do not write them into plugin_settings here — that would bypass the
|
|
// contribution service's schema validation. applyFromSync / enable paths
|
|
// rehydrate through updateSetting. User resets clear the sidecar row via
|
|
// contributionService.resetSetting so deliberate deletions stay deleted.
|
|
const filteredExisting = existingSidecars.filter((entry) => {
|
|
if (entry.kind !== "settings") return true;
|
|
if (!declared.has(entry.pluginId)) return true; // missing plugin — preserve
|
|
const parsed = parseSettingsSidecarKey(entry.key);
|
|
if (!parsed) return false;
|
|
const fields = declared.get(entry.pluginId) ?? [];
|
|
const field = fields.find((item) => item.id === parsed.settingId);
|
|
// Drop secret / non-sync declarations; unknown fields stay for schema lag.
|
|
if (field && !isCloudSyncablePluginSetting(field)) return false;
|
|
return true;
|
|
});
|
|
const bundle = collectPluginSyncSidecars({
|
|
declaredSettingsByPlugin: declared,
|
|
storedSettings,
|
|
existingSidecars: filteredExisting,
|
|
});
|
|
bundle.entries = excludeSecretPluginSettingsFromSidecars(bundle.entries, declared);
|
|
// Persist collected settings into the non-cascade table so a later uninstall
|
|
// still has rows to re-emit (plugin_settings alone is not enough once the
|
|
// declaration disappears and collection skips undeclared stored values).
|
|
if (typeof this.database.replaceAllSyncSidecars === "function") {
|
|
this.database.replaceAllSyncSidecars(bundle.entries);
|
|
}
|
|
return bundle;
|
|
}
|
|
|
|
/**
|
|
* Merge a remote sidecar bundle into local non-cascade storage.
|
|
* Does not delete local rows for plugins absent from the remote bundle.
|
|
* Installed-plugin settings go through the contribution service when available
|
|
* so values are validated and runtime change events fire.
|
|
*/
|
|
async applyFromSync(remoteBundle) {
|
|
const declared = this.#declaredSettingsByPlugin();
|
|
const local = typeof this.database.listAllSyncSidecars === "function"
|
|
? this.database.listAllSyncSidecars()
|
|
: [];
|
|
const remoteEntries = Array.isArray(remoteBundle?.entries) ? remoteBundle.entries : [];
|
|
// Explicit empty remote bundle is an authoritative wipe (including rows for
|
|
// missing plugins). Legacy payloads without pluginSidecars never reach here
|
|
// (apply path only runs when the field is present).
|
|
const remoteIsAuthoritativeEmpty = Array.isArray(remoteBundle?.entries)
|
|
&& remoteBundle.entries.length === 0;
|
|
// Remote is authoritative for installed plugins. For missing plugins, if the
|
|
// remote bundle mentions the plugin at all, remote is authoritative for that
|
|
// plugin's entire sidecar set (do not resurrect local-only keys another
|
|
// device deleted). Only plugins completely absent from remote keep local
|
|
// retention so first-time missing-plugin baselines are not wiped.
|
|
const remotePluginIds = new Set(
|
|
remoteEntries.map((entry) => entry.pluginId).filter((id) => typeof id === "string"),
|
|
);
|
|
const remoteKeys = new Set(
|
|
remoteEntries.map((entry) => `${entry.pluginId}\0${entry.kind}\0${entry.key}`),
|
|
);
|
|
// Settings: remote is authoritative for installed plugins and for missing
|
|
// plugins mentioned in the remote bundle. Baselines (account/crdt) always
|
|
// enter merge so domain LWW can compare local vs remote; orphans survive.
|
|
const preservedLocal = local.filter((entry) => {
|
|
if (entry.kind !== "settings") {
|
|
return true;
|
|
}
|
|
if (remoteIsAuthoritativeEmpty) return false;
|
|
if (declared.has(entry.pluginId)) return false; // installed: remote wins
|
|
if (remotePluginIds.has(entry.pluginId)) return false;
|
|
return true;
|
|
});
|
|
const merged = mergePluginSyncSidecars({
|
|
local: preservedLocal,
|
|
remote: { version: 1, entries: remoteEntries },
|
|
declaredSettingsByPlugin: declared,
|
|
});
|
|
const safe = excludeSecretPluginSettingsFromSidecars(merged, declared);
|
|
if (typeof this.database.replaceAllSyncSidecars === "function") {
|
|
this.database.replaceAllSyncSidecars(safe);
|
|
}
|
|
// Drop installed-plugin settings that remote no longer carries.
|
|
// Prefer contributionService.resetSetting so plugins and the renderer
|
|
// receive change events; fall back to direct DB delete when unavailable.
|
|
// Include live plugin_settings rows that were never mirrored into
|
|
// plugin_sync_sidecars (e.g. pre-sidecar schema migrations) so remote
|
|
// deletions still clear them instead of being republished on next collect.
|
|
const resetCandidates = [];
|
|
const seenResetKeys = new Set();
|
|
const pushResetCandidate = (entry) => {
|
|
const mapKey = `${entry.pluginId}\0${entry.kind}\0${entry.key}`;
|
|
if (seenResetKeys.has(mapKey)) return;
|
|
seenResetKeys.add(mapKey);
|
|
resetCandidates.push(entry);
|
|
};
|
|
for (const entry of local) {
|
|
if (entry.kind !== "settings") continue;
|
|
if (!declared.has(entry.pluginId)) continue;
|
|
if (remoteKeys.has(`${entry.pluginId}\0${entry.kind}\0${entry.key}`)) continue;
|
|
pushResetCandidate(entry);
|
|
}
|
|
if (typeof this.database.listAllSettings === "function") {
|
|
for (const row of this.database.listAllSettings()) {
|
|
if (!declared.has(row.pluginId)) continue;
|
|
const fields = declared.get(row.pluginId) ?? [];
|
|
const field = fields.find((item) => item.id === row.settingId);
|
|
if (!field || !isCloudSyncablePluginSetting(field)) continue;
|
|
const key = settingsSidecarKey(row.settingId, row.scope, row.scopeId);
|
|
if (remoteKeys.has(`${row.pluginId}\0settings\0${key}`)) continue;
|
|
pushResetCandidate({
|
|
pluginId: row.pluginId,
|
|
kind: "settings",
|
|
key,
|
|
value: row.value,
|
|
updatedAt: row.updatedAt,
|
|
});
|
|
}
|
|
}
|
|
for (const entry of resetCandidates) {
|
|
const parsed = parseSettingsSidecarKey(entry.key);
|
|
if (!parsed) continue;
|
|
if (typeof this.contributionService?.resetSetting === "function") {
|
|
try {
|
|
await this.contributionService.resetSetting(
|
|
entry.pluginId,
|
|
parsed.settingId,
|
|
parsed.scopeId,
|
|
);
|
|
} catch {
|
|
// Validation rejected the reset (e.g. required setting) — still try
|
|
// the stored-coordinate delete below for stale-scope rows.
|
|
}
|
|
// resetSetting normalizes against the *current* declared scope. After a
|
|
// scope migration (application → device), that may miss the stale row
|
|
// still stored under the old coordinates; always delete those too.
|
|
try {
|
|
this.database.deleteSetting(
|
|
entry.pluginId,
|
|
parsed.settingId,
|
|
parsed.scope,
|
|
parsed.scopeId,
|
|
);
|
|
} catch {
|
|
// Best-effort cleanup of the stored-coordinate row.
|
|
}
|
|
continue;
|
|
}
|
|
this.database.deleteSetting(entry.pluginId, parsed.settingId, parsed.scope, parsed.scopeId);
|
|
}
|
|
for (const entry of safe) {
|
|
if (entry.kind !== "settings") continue;
|
|
const fields = declared.get(entry.pluginId);
|
|
if (!fields) continue;
|
|
const parsed = parseSettingsSidecarKey(entry.key);
|
|
if (!parsed) continue;
|
|
const field = fields.find((item) => item.id === parsed.settingId);
|
|
if (!field || !isCloudSyncablePluginSetting(field)) continue;
|
|
// Always write under the currently declared scope/key coordinates.
|
|
// Older sidecars may encode an obsolete scope after a plugin update;
|
|
// recreating that scope would leave duplicate rows that collection
|
|
// republishes. Application scope always uses the fixed "application" id.
|
|
const target = resolveSettingsSidecarTarget(field, parsed);
|
|
if (!target) continue;
|
|
const { targetScope, targetScopeId, nextKey } = target;
|
|
if (typeof this.contributionService?.updateSetting === "function") {
|
|
try {
|
|
await this.contributionService.updateSetting(
|
|
entry.pluginId,
|
|
parsed.settingId,
|
|
entry.value,
|
|
targetScopeId,
|
|
{ source: "host" },
|
|
);
|
|
// Preserve remote LWW timestamp after validated write (updateSetting uses clock).
|
|
this.database.setSetting(
|
|
entry.pluginId,
|
|
parsed.settingId,
|
|
targetScope,
|
|
targetScopeId,
|
|
entry.value,
|
|
entry.updatedAt,
|
|
);
|
|
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
|
|
try {
|
|
this.database.deleteSetting(
|
|
entry.pluginId,
|
|
parsed.settingId,
|
|
parsed.scope,
|
|
parsed.scopeId,
|
|
);
|
|
} catch {
|
|
// Best-effort cleanup of the obsolete scope row.
|
|
}
|
|
if (typeof this.database.setSyncSidecar === "function") {
|
|
try {
|
|
this.database.setSyncSidecar(
|
|
entry.pluginId,
|
|
"settings",
|
|
nextKey,
|
|
entry.value,
|
|
entry.updatedAt,
|
|
);
|
|
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
|
|
this.database.deleteSyncSidecar(entry.pluginId, "settings", entry.key);
|
|
}
|
|
} catch {
|
|
// Sidecar re-key is best-effort after settings write.
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Invalid against current schema — keep sidecar row only.
|
|
}
|
|
continue;
|
|
}
|
|
this.database.setSetting(
|
|
entry.pluginId,
|
|
parsed.settingId,
|
|
targetScope,
|
|
targetScopeId,
|
|
entry.value,
|
|
entry.updatedAt,
|
|
);
|
|
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
|
|
try {
|
|
this.database.deleteSetting(
|
|
entry.pluginId,
|
|
parsed.settingId,
|
|
parsed.scope,
|
|
parsed.scopeId,
|
|
);
|
|
} catch {
|
|
// Best-effort cleanup of the obsolete scope row.
|
|
}
|
|
if (typeof this.database.setSyncSidecar === "function") {
|
|
try {
|
|
this.database.setSyncSidecar(
|
|
entry.pluginId,
|
|
"settings",
|
|
nextKey,
|
|
entry.value,
|
|
entry.updatedAt,
|
|
);
|
|
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
|
|
this.database.deleteSyncSidecar(entry.pluginId, "settings", entry.key);
|
|
}
|
|
} catch {
|
|
// Sidecar re-key is best-effort after settings write.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return safe;
|
|
}
|
|
|
|
/**
|
|
* After a plugin is installed/enabled, validate and apply retained settings
|
|
* sidecars into plugin_settings so the running plugin sees cloud values.
|
|
*/
|
|
async hydrateInstalledPluginSettings(pluginId) {
|
|
if (typeof pluginId !== "string" || pluginId.length < 1) return;
|
|
const declared = this.#declaredSettingsByPlugin().get(pluginId);
|
|
if (!declared) return;
|
|
const rows = typeof this.database.listSyncSidecars === "function"
|
|
? this.database.listSyncSidecars(pluginId)
|
|
: [];
|
|
for (const entry of rows) {
|
|
if (entry.kind !== "settings") continue;
|
|
const parsed = parseSettingsSidecarKey(entry.key);
|
|
if (!parsed) continue;
|
|
const field = declared.find((item) => item.id === parsed.settingId);
|
|
if (!field || !isCloudSyncablePluginSetting(field)) continue;
|
|
const target = resolveSettingsSidecarTarget(field, parsed);
|
|
if (!target) continue;
|
|
const { targetScope, targetScopeId, nextKey } = target;
|
|
// Do not overwrite a newer local edit that has not been collected yet.
|
|
if (typeof this.database.listSettings === "function") {
|
|
const localRows = this.database.listSettings(pluginId);
|
|
const local = localRows.find((row) => (
|
|
row.settingId === parsed.settingId
|
|
&& row.scope === targetScope
|
|
&& row.scopeId === targetScopeId
|
|
));
|
|
if (local && Number(local.updatedAt) > Number(entry.updatedAt)) {
|
|
continue;
|
|
}
|
|
}
|
|
if (typeof this.contributionService?.updateSetting === "function") {
|
|
try {
|
|
await this.contributionService.updateSetting(
|
|
pluginId,
|
|
parsed.settingId,
|
|
entry.value,
|
|
targetScopeId,
|
|
{ source: "host" },
|
|
);
|
|
this.database.setSetting(
|
|
pluginId,
|
|
parsed.settingId,
|
|
targetScope,
|
|
targetScopeId,
|
|
entry.value,
|
|
entry.updatedAt,
|
|
);
|
|
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
|
|
try {
|
|
this.database.deleteSetting(
|
|
pluginId,
|
|
parsed.settingId,
|
|
parsed.scope,
|
|
parsed.scopeId,
|
|
);
|
|
} catch {
|
|
// Best-effort cleanup of the obsolete scope row.
|
|
}
|
|
if (typeof this.database.setSyncSidecar === "function") {
|
|
try {
|
|
this.database.setSyncSidecar(
|
|
pluginId,
|
|
"settings",
|
|
nextKey,
|
|
entry.value,
|
|
entry.updatedAt,
|
|
);
|
|
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
|
|
this.database.deleteSyncSidecar(pluginId, "settings", entry.key);
|
|
}
|
|
} catch {
|
|
// Sidecar re-key is best-effort after settings write.
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Invalid against current schema — keep sidecar only.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persist an account or CRDT baseline without cascading on uninstall.
|
|
*/
|
|
setBaseline(pluginId, kind, key, value) {
|
|
if (kind !== "account_baseline" && kind !== "crdt_baseline") {
|
|
throw new TypeError("Baseline kind must be account_baseline or crdt_baseline");
|
|
}
|
|
this.database.setSyncSidecar(pluginId, kind, key, value);
|
|
}
|
|
|
|
getBaseline(pluginId, kind, key) {
|
|
return this.database.getSyncSidecar(pluginId, kind, key);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
PluginSyncSidecarService,
|
|
};
|