mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
* fix(sync): don't delete a local row when the server can't send its copy A push conflict on a live record whose server row failed to serialize came back as row: null, deleted: false, and the desktop treated that as a delete. The server now rejects that op as unavailable, the desktop never deletes on a conflict without a row, and serialization failures are logged. * fix(sync): retry temporarily unavailable records
385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
/**
|
|
* Reading and writing a registered entity's rows, whatever table they are in.
|
|
*
|
|
* Every registration names a drizzle table with an id, a syncId (unless it is
|
|
* a singleton) and an owner column, and that is all this relies on. Writes
|
|
* read the row back by its key rather than using RETURNING, which MySQL lacks.
|
|
*/
|
|
|
|
import crypto from "crypto";
|
|
import { and, eq, getTableColumns, type SQL } from "drizzle-orm";
|
|
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
|
import { createCurrentRepositoryContext } from "../database/repositories/factory.js";
|
|
import { DataCrypto } from "../utils/data-crypto.js";
|
|
import { SystemCrypto } from "../utils/system-crypto.js";
|
|
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
|
|
import { syncLogger } from "../utils/logger.js";
|
|
import {
|
|
getEntity,
|
|
type RegisteredSyncEntity,
|
|
} from "../plugins/sync-registry.js";
|
|
import {
|
|
decryptFields,
|
|
deserializeReferences,
|
|
encryptFields,
|
|
hashWire,
|
|
serializeReferences,
|
|
singletonSyncId,
|
|
toWire,
|
|
toWritable,
|
|
type ResolveId,
|
|
type ResolveSyncId,
|
|
} from "./wire.js";
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
type AnyTable = any;
|
|
|
|
function tableOf(entity: RegisteredSyncEntity): AnyTable {
|
|
if (!entity.table) {
|
|
throw new Error(`Sync entity "${entity.type}" has no table`);
|
|
}
|
|
return entity.table as AnyTable;
|
|
}
|
|
|
|
function db() {
|
|
return createCurrentRepositoryContext().drizzle as any;
|
|
}
|
|
|
|
function ownerColumn(entity: RegisteredSyncEntity) {
|
|
const column = tableOf(entity)[entity.userColumn];
|
|
if (!column) {
|
|
throw new Error(
|
|
`Sync entity "${entity.type}" has no ${entity.userColumn} column`,
|
|
);
|
|
}
|
|
return column;
|
|
}
|
|
|
|
export function locate(
|
|
entity: RegisteredSyncEntity,
|
|
userId: string,
|
|
syncId: string,
|
|
): SQL {
|
|
const table = tableOf(entity);
|
|
if (entity.singleton) return eq(ownerColumn(entity), userId);
|
|
return and(eq(table.syncId, syncId), eq(ownerColumn(entity), userId))!;
|
|
}
|
|
|
|
let hashKey: Buffer | null = null;
|
|
|
|
export async function getHashKey(): Promise<Buffer> {
|
|
if (!hashKey) {
|
|
const secret = await SystemCrypto.getInstance().getEncryptionKey();
|
|
hashKey = Buffer.from(
|
|
crypto.hkdfSync(
|
|
"sha256",
|
|
secret,
|
|
Buffer.alloc(0),
|
|
"termix-sync-hash",
|
|
32,
|
|
),
|
|
);
|
|
}
|
|
return hashKey;
|
|
}
|
|
|
|
/** Test seam. */
|
|
export function resetHashKey(): void {
|
|
hashKey = null;
|
|
}
|
|
|
|
export async function listStoredRows(
|
|
entity: RegisteredSyncEntity,
|
|
userId: string,
|
|
): Promise<SyncRow[]> {
|
|
const rows = (await db()
|
|
.select()
|
|
.from(tableOf(entity))
|
|
.where(eq(ownerColumn(entity), userId))) as SyncRow[];
|
|
return rows;
|
|
}
|
|
|
|
export async function findStoredRow(
|
|
entity: RegisteredSyncEntity,
|
|
userId: string,
|
|
syncId: string,
|
|
): Promise<SyncRow | null> {
|
|
const [row] = (await db()
|
|
.select()
|
|
.from(tableOf(entity))
|
|
.where(locate(entity, userId, syncId))
|
|
.limit(1)) as SyncRow[];
|
|
return row ?? null;
|
|
}
|
|
|
|
/** Gives an old row with no syncId one, so it can take part. */
|
|
async function ensureSyncId(
|
|
entity: RegisteredSyncEntity,
|
|
row: SyncRow,
|
|
): Promise<string> {
|
|
if (entity.singleton) return singletonSyncId(entity.type);
|
|
if (typeof row.syncId === "string" && row.syncId) return row.syncId;
|
|
const syncId = crypto.randomUUID();
|
|
const table = tableOf(entity);
|
|
await db().update(table).set({ syncId }).where(eq(table.id, row.id));
|
|
row.syncId = syncId;
|
|
return syncId;
|
|
}
|
|
|
|
export interface Resolvers {
|
|
resolveSyncId: ResolveSyncId;
|
|
resolveId: ResolveId;
|
|
}
|
|
|
|
/**
|
|
* Id and syncId lookups for references, scoped to rows the user owns. A core
|
|
* entity can widen that (a credential shared with the user is a valid
|
|
* reference target on the server).
|
|
*/
|
|
export function createResolvers(
|
|
userId: string,
|
|
onMissing?: (entityType: string, syncId: string) => void,
|
|
): Resolvers {
|
|
const allowed = async (
|
|
entityType: string,
|
|
ownerId: unknown,
|
|
id: number,
|
|
): Promise<boolean> => {
|
|
if (ownerId === userId) return true;
|
|
const canReference = getEntity(entityType)?.canReference;
|
|
return canReference ? canReference(userId, String(ownerId), id) : false;
|
|
};
|
|
|
|
return {
|
|
resolveSyncId: async (entityType, id) => {
|
|
const target = getEntity(entityType);
|
|
if (!target?.table || target.singleton) return null;
|
|
const table = tableOf(target);
|
|
const [row] = await db()
|
|
.select({ syncId: table.syncId, owner: ownerColumn(target) })
|
|
.from(table)
|
|
.where(eq(table.id, id))
|
|
.limit(1);
|
|
if (!row || !(await allowed(entityType, row.owner, id))) return null;
|
|
return row.syncId ?? null;
|
|
},
|
|
resolveId: async (entityType, syncId) => {
|
|
const target = getEntity(entityType);
|
|
if (!target?.table || target.singleton) {
|
|
onMissing?.(entityType, syncId);
|
|
return null;
|
|
}
|
|
const table = tableOf(target);
|
|
const rows = await db()
|
|
.select({ id: table.id, owner: ownerColumn(target) })
|
|
.from(table)
|
|
.where(eq(table.syncId, syncId));
|
|
const own = rows.find((row: any) => row.owner === userId);
|
|
if (own) return own.id;
|
|
for (const row of rows) {
|
|
if (await allowed(entityType, row.owner, row.id)) return row.id;
|
|
}
|
|
onMissing?.(entityType, syncId);
|
|
return null;
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface WireRow {
|
|
syncId: string;
|
|
wire: SyncRow;
|
|
hash: string;
|
|
}
|
|
|
|
export interface LoadResult {
|
|
rows: Map<string, WireRow>;
|
|
/** Rows that exist but could not be serialized, so must not read as deleted. */
|
|
failed: Set<string>;
|
|
}
|
|
|
|
/** The user's rows of one entity, on the wire and hashed. */
|
|
export async function loadWireRows(
|
|
entity: RegisteredSyncEntity,
|
|
userId: string,
|
|
resolvers: Resolvers,
|
|
options: { filter?: (row: SyncRow) => boolean } = {},
|
|
): Promise<LoadResult> {
|
|
const key = await getHashKey();
|
|
const rows = new Map<string, WireRow>();
|
|
const failed = new Set<string>();
|
|
|
|
if (entity.load) {
|
|
for (const wire of await entity.load(userId)) {
|
|
const syncId = String(wire.syncId);
|
|
rows.set(syncId, { syncId, wire, hash: hashWire(entity, wire, key) });
|
|
}
|
|
return { rows, failed };
|
|
}
|
|
|
|
const dataKey = DataCrypto.getUserDataKey(userId);
|
|
for (const stored of await listStoredRows(entity, userId)) {
|
|
if (entity.shouldSync && entity.shouldSync(stored) === false) continue;
|
|
if (options.filter && !options.filter(stored)) continue;
|
|
const syncId = await ensureSyncId(entity, stored);
|
|
try {
|
|
const plain = decryptFields(entity, stored, dataKey);
|
|
const serialized = await serializeReferences(
|
|
entity,
|
|
plain,
|
|
resolvers.resolveSyncId,
|
|
);
|
|
const wire = toWire(entity, serialized, syncId);
|
|
rows.set(syncId, { syncId, wire, hash: hashWire(entity, wire, key) });
|
|
} catch (error) {
|
|
syncLogger.warn("Could not serialize a sync row", {
|
|
operation: "sync_serialize",
|
|
entityType: entity.type,
|
|
syncId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
failed.add(syncId);
|
|
}
|
|
}
|
|
return { rows, failed };
|
|
}
|
|
|
|
/** One row on the wire, or null when it is gone or excluded. */
|
|
export async function loadWireRow(
|
|
entity: RegisteredSyncEntity,
|
|
userId: string,
|
|
syncId: string,
|
|
resolvers: Resolvers,
|
|
): Promise<WireRow | null> {
|
|
if (entity.load) {
|
|
const all = await loadWireRows(entity, userId, resolvers);
|
|
return all.rows.get(syncId) ?? null;
|
|
}
|
|
const stored = await findStoredRow(entity, userId, syncId);
|
|
if (!stored) return null;
|
|
if (entity.shouldSync && entity.shouldSync(stored) === false) return null;
|
|
const plain = decryptFields(
|
|
entity,
|
|
stored,
|
|
DataCrypto.getUserDataKey(userId),
|
|
);
|
|
const serialized = await serializeReferences(
|
|
entity,
|
|
plain,
|
|
resolvers.resolveSyncId,
|
|
);
|
|
const wire = toWire(entity, serialized, syncId);
|
|
return { syncId, wire, hash: hashWire(entity, wire, await getHashKey()) };
|
|
}
|
|
|
|
function columnsOf(entity: RegisteredSyncEntity): Set<string> {
|
|
return new Set(Object.keys(getTableColumns(tableOf(entity))));
|
|
}
|
|
|
|
export interface WriteResult {
|
|
id: number | null;
|
|
created: boolean;
|
|
}
|
|
|
|
/**
|
|
* Writes an inbound wire row for the user. `extra` is set on top of the
|
|
* inbound fields, for columns this side owns (a shared copy's source).
|
|
*/
|
|
export async function writeWireRow(
|
|
entity: RegisteredSyncEntity,
|
|
userId: string,
|
|
wire: SyncRow,
|
|
resolvers: Resolvers,
|
|
extra: SyncRow = {},
|
|
): Promise<WriteResult> {
|
|
const syncId = String(wire.syncId);
|
|
if (entity.write) {
|
|
await entity.write(userId, wire);
|
|
DatabaseSaveTrigger.triggerSave("sync_write");
|
|
return { id: null, created: false };
|
|
}
|
|
const table = tableOf(entity);
|
|
const current = await findStoredRow(entity, userId, syncId);
|
|
|
|
const resolved = await deserializeReferences(
|
|
entity,
|
|
wire,
|
|
resolvers.resolveId,
|
|
current,
|
|
);
|
|
const columns = columnsOf(entity);
|
|
const writable = Object.fromEntries(
|
|
Object.entries({ ...toWritable(entity, resolved), ...extra }).filter(
|
|
([field]) => columns.has(field),
|
|
),
|
|
);
|
|
const encrypted = encryptFields(
|
|
entity,
|
|
writable,
|
|
DataCrypto.getUserDataKey(userId),
|
|
current ? String(current.id) : `sync-${syncId}`,
|
|
);
|
|
if (columns.has("updatedAt")) encrypted.updatedAt = new Date().toISOString();
|
|
|
|
const where = locate(entity, userId, syncId);
|
|
if (current) {
|
|
await db().update(table).set(encrypted).where(where);
|
|
} else {
|
|
const insert: SyncRow = { ...encrypted, [entity.userColumn]: userId };
|
|
if (!entity.singleton) insert.syncId = syncId;
|
|
await db().insert(table).values(insert);
|
|
}
|
|
|
|
const written = await findStoredRow(entity, userId, syncId);
|
|
const id = typeof written?.id === "number" ? written.id : null;
|
|
if (entity.afterWrite) {
|
|
await entity.afterWrite({
|
|
id,
|
|
userId,
|
|
wire,
|
|
created: !current,
|
|
resolveId: resolvers.resolveId,
|
|
});
|
|
}
|
|
DatabaseSaveTrigger.triggerSave("sync_write");
|
|
return { id, created: !current };
|
|
}
|
|
|
|
/** Deletes the user's row, through the entity's own remove when it has one. */
|
|
export async function deleteStoredRow(
|
|
entity: RegisteredSyncEntity,
|
|
userId: string,
|
|
syncId: string,
|
|
): Promise<boolean> {
|
|
if (entity.erase) {
|
|
await entity.erase(userId, syncId);
|
|
DatabaseSaveTrigger.triggerSave("sync_delete");
|
|
return true;
|
|
}
|
|
const current = await findStoredRow(entity, userId, syncId);
|
|
if (!current) return false;
|
|
if (entity.remove) {
|
|
await entity.remove(current, userId);
|
|
} else {
|
|
await db()
|
|
.delete(tableOf(entity))
|
|
.where(locate(entity, userId, syncId));
|
|
}
|
|
DatabaseSaveTrigger.triggerSave("sync_delete");
|
|
return true;
|
|
}
|
|
|
|
/** Sets fresh syncIds on every row the user owns, so they read as new. */
|
|
export async function regenerateSyncIds(
|
|
entity: RegisteredSyncEntity,
|
|
userId: string,
|
|
): Promise<void> {
|
|
if (entity.singleton || entity.load || !entity.table) return;
|
|
const table = tableOf(entity);
|
|
for (const row of await listStoredRows(entity, userId)) {
|
|
await db()
|
|
.update(table)
|
|
.set({ syncId: crypto.randomUUID() })
|
|
.where(eq(table.id, row.id));
|
|
}
|
|
}
|