feat: give plugins their own tables, migrations and sync entities

This commit is contained in:
LukeGus committed 2026-09-22 15:49:54 -05:00
1 parent 5796186a1c
commit c496a093ca
47 files changed
+32595 -290

No files matched your search

+5
View File
@@ -1,3 +1,8 @@
// The fallback list, for a server older than GET /sync/entity-types.
//
// Current servers return the entity types and their order, which is the only
// way this process learns about one a plugin added. This array is what those
// servers would have synced anyway, so an old pairing keeps working.
const SYNCED_ENTITY_TYPES = Object.freeze([
// Ordered by reference dependency: hosts and snippets resolve credential,
// vault and folder syncIds, so those have to exist on the other side first.
+42 -2
View File
@@ -311,7 +311,10 @@ class RemoteSyncEngine {
const state = readJson(getRemoteSyncStatePath(), { entities: {} });
let sawAuthFailure = false;
for (const entityType of SYNCED_ENTITY_TYPES) {
const remoteBase = config.serverUrl.replace(/\/$/, "");
const entityTypes = await this.resolveEntityTypes(remoteBase, remoteJwt);
for (const entityType of entityTypes) {
const entityState = state.entities[entityType] || {
lastPulledAt: null,
lastPushedAt: null,
@@ -319,7 +322,7 @@ class RemoteSyncEngine {
const result = await this.syncEntity({
entityType,
remoteBaseUrl: config.serverUrl.replace(/\/$/, ""),
remoteBaseUrl: remoteBase,
remoteJwt,
since: entityState.lastPulledAt,
});
@@ -374,6 +377,43 @@ class RemoteSyncEngine {
return this.status;
}
/**
* Which entities to sync, and in what order.
*
* Asked of the remote server, because a plugin installed there can add one
* and this process has no way to know. Intersected with what the embedded
* backend supports: the two sides may have different plugins installed, and
* pushing an entity the local backend has never heard of just 400s.
*
* A server older than this endpoint 404s, and the frozen list is what it
* would have synced anyway.
*/
async resolveEntityTypes(remoteBaseUrl, remoteJwt) {
const fetchTypes = async (base, token) => {
try {
const body = await this.fetchJson(`${base}/sync/entity-types`, token);
const types = (body?.entityTypes || [])
.slice()
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((entity) => entity.type)
.filter((type) => typeof type === "string" && type);
return types.length > 0 ? types : null;
} catch {
return null;
}
};
const remote = await fetchTypes(remoteBaseUrl, remoteJwt);
if (!remote) return SYNCED_ENTITY_TYPES;
const local = await fetchTypes(EMBEDDED_BASE_URL, this.localJwt);
if (!local) return remote;
const supported = new Set(local);
const shared = remote.filter((type) => supported.has(type));
return shared.length > 0 ? shared : SYNCED_ENTITY_TYPES;
}
async fetchJson(url, token, options = {}) {
const res = await fetchRemoteSync(url, {
...options,
+22 -4
View File
@@ -1,6 +1,26 @@
// Sort writes per destination: response order is lost when both sides are merged.
//
// Keyed on the self-reference field the rows actually carry rather than on the
// entity name, so an entity a plugin adds gets the same treatment without this
// process needing the server's entity registry.
const SELF_REFERENCE_FIELDS = { hosts: "parentHostSyncId" };
function selfReferenceField(entityType, writes) {
const known = SELF_REFERENCE_FIELDS[entityType];
if (known) return known;
// A plugin entity declares its own; detect it from the payload.
const sample = writes.find((write) => write && write.row);
if (!sample) return null;
const candidate = Object.keys(sample.row).find(
(key) => key === `parent${entityType}SyncId`,
);
return candidate || null;
}
function orderSyncWrites(entityType, writes) {
if (entityType !== "hosts") return writes;
const field = selfReferenceField(entityType, writes);
if (!field) return writes;
const destinations = new Map();
for (const write of writes) {
if (!destinations.has(write.baseUrl)) {
@@ -16,9 +36,7 @@ function orderSyncWrites(entityType, writes) {
if (visiting.has(write))
throw new Error("Cyclic parent host sync dependency");
visiting.add(write);
const parent = destinations
.get(write.baseUrl)
.get(write.row.parentHostSyncId);
const parent = destinations.get(write.baseUrl).get(write.row[field]);
if (parent) visit(parent);
visiting.delete(write);
visited.add(write);