fix: preserve remote sync references (#1092)

This commit is contained in:
ZacharyZcR
2026-07-28 01:47:25 +08:00
committed by GitHub
parent 4a7117b67f
commit 113eb5619c
4 changed files with 244 additions and 7 deletions
+4 -4
View File
@@ -16,12 +16,12 @@ const fs = require("fs");
const path = require("path");
const SYNCED_ENTITY_TYPES = [
"hosts",
"sshCredentials",
"sshFolders",
"snippets",
"snippetFolders",
"vaultProfiles",
"sshFolders",
"snippetFolders",
"hosts",
"snippets",
"dashboardServiceLinks",
"homepageItems",
];
@@ -0,0 +1,95 @@
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
export type SyncReferenceEntity = "sshCredentials" | "vaultProfiles";
interface SyncReference {
field: string;
syncField: string;
entityType: SyncReferenceEntity;
}
const HOST_REFERENCES: SyncReference[] = [
{
field: "credentialId",
syncField: "credentialSyncId",
entityType: "sshCredentials",
},
{
field: "rdpCredentialId",
syncField: "rdpCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vncCredentialId",
syncField: "vncCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "telnetCredentialId",
syncField: "telnetCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vaultProfileId",
syncField: "vaultProfileSyncId",
entityType: "vaultProfiles",
},
];
const REFERENCES: Partial<Record<SyncEntityType, SyncReference[]>> = {
hosts: HOST_REFERENCES,
sshFolders: [HOST_REFERENCES[0]],
};
export async function serializeSyncReferences(
entityType: SyncEntityType,
row: Record<string, unknown>,
resolveSyncId: (
entityType: SyncReferenceEntity,
id: number,
) => Promise<string | null>,
): Promise<Record<string, unknown>> {
const serialized = { ...row };
for (const reference of REFERENCES[entityType] ?? []) {
const id = serialized[reference.field];
serialized[reference.syncField] =
typeof id === "number"
? await resolveSyncId(reference.entityType, id)
: null;
delete serialized[reference.field];
}
return serialized;
}
export async function deserializeSyncReferences(
entityType: SyncEntityType,
row: Record<string, unknown>,
resolveId: (
entityType: SyncReferenceEntity,
syncId: string,
) => Promise<number | null>,
): Promise<Record<string, unknown>> {
const deserialized = { ...row };
for (const reference of REFERENCES[entityType] ?? []) {
const syncId = deserialized[reference.syncField];
delete deserialized[reference.syncField];
delete deserialized[reference.field];
if (syncId == null) {
deserialized[reference.field] = null;
continue;
}
if (typeof syncId !== "string") {
throw new Error(`Invalid ${reference.syncField}`);
}
const id = await resolveId(reference.entityType, syncId);
if (id === null) {
throw new Error(
`Missing ${reference.entityType} dependency ${reference.syncField}=${syncId}`,
);
}
deserialized[reference.field] = id;
}
return deserialized;
}
+75 -3
View File
@@ -21,6 +21,11 @@ import {
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
import {
deserializeSyncReferences,
serializeSyncReferences,
type SyncReferenceEntity,
} from "./sync-references.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -65,11 +70,65 @@ const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
};
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
type RepositoryContext = ReturnType<typeof createCurrentRepositoryContext>;
export function isValidEntityType(value: unknown): value is SyncEntityType {
return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
}
async function findReferenceSyncId(
context: RepositoryContext,
entityType: SyncReferenceEntity,
id: number,
userId: string,
): Promise<string | null> {
if (entityType === "sshCredentials") {
const [row] = await context.drizzle
.select({ syncId: sshCredentials.syncId })
.from(sshCredentials)
.where(and(eq(sshCredentials.id, id), eq(sshCredentials.userId, userId)))
.limit(1);
return row?.syncId ?? null;
}
const [row] = await context.drizzle
.select({ syncId: vaultProfiles.syncId })
.from(vaultProfiles)
.where(and(eq(vaultProfiles.id, id), eq(vaultProfiles.userId, userId)))
.limit(1);
return row?.syncId ?? null;
}
async function findReferenceId(
context: RepositoryContext,
entityType: SyncReferenceEntity,
syncId: string,
userId: string,
): Promise<number | null> {
if (entityType === "sshCredentials") {
const [row] = await context.drizzle
.select({ id: sshCredentials.id })
.from(sshCredentials)
.where(
and(
eq(sshCredentials.syncId, syncId),
eq(sshCredentials.userId, userId),
),
)
.limit(1);
return row?.id ?? null;
}
const [row] = await context.drizzle
.select({ id: vaultProfiles.id })
.from(vaultProfiles)
.where(
and(eq(vaultProfiles.syncId, syncId), eq(vaultProfiles.userId, userId)),
)
.limit(1);
return row?.id ?? null;
}
function requireUserDataKey(userId: string): Buffer {
return DataCrypto.validateUserAccess(userId);
}
@@ -173,8 +232,15 @@ router.get(
.from(table as typeof hosts)
.where(and(...conditions));
const decrypted = rows.map((row) =>
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
const decrypted = await Promise.all(
rows.map((row) =>
serializeSyncReferences(
entityType,
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
(referenceType, id) =>
findReferenceSyncId(context, referenceType, id, userId),
),
),
);
res.json({ rows: decrypted });
@@ -242,7 +308,13 @@ router.post(
.limit(1);
const existing = existingRows[0] as Record<string, unknown> | undefined;
const writePayload = stripWritePayload(entityType, payload);
const resolvedPayload = await deserializeSyncReferences(
entityType,
payload,
(referenceType, referenceSyncId) =>
findReferenceId(context, referenceType, referenceSyncId, userId),
);
const writePayload = stripWritePayload(entityType, resolvedPayload);
const encryptedPayload = encryptIfNeeded(
entityType,
writePayload,
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
deserializeSyncReferences,
serializeSyncReferences,
} from "../../database/routes/sync-references.js";
describe("sync references", () => {
it("serializes database-local host IDs as stable sync IDs", async () => {
const row = await serializeSyncReferences(
"hosts",
{
id: 7,
credentialId: 12,
rdpCredentialId: 13,
vncCredentialId: null,
telnetCredentialId: null,
vaultProfileId: 4,
},
async (entityType, id) => `${entityType}-${id}`,
);
expect(row).toMatchObject({
credentialSyncId: "sshCredentials-12",
rdpCredentialSyncId: "sshCredentials-13",
vncCredentialSyncId: null,
telnetCredentialSyncId: null,
vaultProfileSyncId: "vaultProfiles-4",
});
expect(row).not.toHaveProperty("credentialId");
expect(row).not.toHaveProperty("vaultProfileId");
});
it("resolves stable sync IDs to IDs from the receiving database", async () => {
const ids = new Map([
["sshCredentials:credential-sync", 91],
["vaultProfiles:vault-sync", 37],
]);
const row = await deserializeSyncReferences(
"hosts",
{
credentialId: 12,
credentialSyncId: "credential-sync",
rdpCredentialSyncId: null,
vncCredentialSyncId: null,
telnetCredentialSyncId: null,
vaultProfileSyncId: "vault-sync",
},
async (entityType, syncId) => ids.get(`${entityType}:${syncId}`) ?? null,
);
expect(row).toMatchObject({
credentialId: 91,
rdpCredentialId: null,
vncCredentialId: null,
telnetCredentialId: null,
vaultProfileId: 37,
});
expect(row).not.toHaveProperty("credentialSyncId");
});
it("rejects a row whose referenced dependency has not synced", async () => {
await expect(
deserializeSyncReferences(
"sshFolders",
{ credentialSyncId: "missing" },
async () => null,
),
).rejects.toThrow("Missing sshCredentials dependency");
});
});