282 lines
7.8 KiB
TypeScript
282 lines
7.8 KiB
TypeScript
import type { AuthContext } from "./auth.js";
|
|
import type { Env } from "./bindings.js";
|
|
import type { ElyD1PreparedStatement } from "./bindings.js";
|
|
import { StorageObjectError, putVerifiedObject } from "./storage.js";
|
|
import {
|
|
type SyncObjectRow,
|
|
SyncPushConflictError,
|
|
type SyncPushDocument,
|
|
SyncPushPersistenceError,
|
|
type SyncPushRequest,
|
|
SyncPushRequestError,
|
|
type SyncPushedObjectDocument,
|
|
currentDeviceId,
|
|
syncObjectDocument,
|
|
syncPushRequest,
|
|
} from "./sync_push_schema.js";
|
|
|
|
export {
|
|
SyncPushConflictError,
|
|
SyncPushPersistenceError,
|
|
SyncPushRequestError,
|
|
} from "./sync_push_schema.js";
|
|
|
|
const SYNC_OBJECT_BY_ID_QUERY = `
|
|
SELECT
|
|
object_id,
|
|
object_type,
|
|
payload_r2_key,
|
|
payload_hash,
|
|
schema_rev,
|
|
logical_clock,
|
|
device_id,
|
|
created_at,
|
|
updated_at,
|
|
deleted_at
|
|
FROM sync_objects
|
|
WHERE user_id = ? AND object_id = ?
|
|
`;
|
|
const SYNC_OBJECT_UPSERT_QUERY = `
|
|
INSERT INTO sync_objects (
|
|
user_id,
|
|
object_id,
|
|
object_type,
|
|
payload_inline,
|
|
payload_r2_key,
|
|
payload_hash,
|
|
schema_rev,
|
|
logical_clock,
|
|
device_id,
|
|
created_at,
|
|
updated_at,
|
|
deleted_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, object_id) DO UPDATE SET
|
|
object_type = excluded.object_type,
|
|
payload_inline = excluded.payload_inline,
|
|
payload_r2_key = excluded.payload_r2_key,
|
|
payload_hash = excluded.payload_hash,
|
|
schema_rev = excluded.schema_rev,
|
|
logical_clock = excluded.logical_clock,
|
|
device_id = excluded.device_id,
|
|
updated_at = excluded.updated_at,
|
|
deleted_at = excluded.deleted_at
|
|
WHERE excluded.logical_clock > sync_objects.logical_clock
|
|
OR (
|
|
excluded.logical_clock = sync_objects.logical_clock
|
|
AND sync_objects.object_type = excluded.object_type
|
|
AND (
|
|
(sync_objects.payload_r2_key IS NULL AND excluded.payload_r2_key IS NULL)
|
|
OR sync_objects.payload_r2_key = excluded.payload_r2_key
|
|
)
|
|
AND sync_objects.payload_hash = excluded.payload_hash
|
|
AND sync_objects.schema_rev = excluded.schema_rev
|
|
AND sync_objects.device_id = excluded.device_id
|
|
AND (
|
|
(sync_objects.deleted_at IS NULL AND excluded.deleted_at IS NULL)
|
|
OR (sync_objects.deleted_at IS NOT NULL AND excluded.deleted_at IS NOT NULL)
|
|
)
|
|
)
|
|
`;
|
|
const SYNC_CHANGE_INSERT_QUERY = `
|
|
INSERT INTO sync_change_log (
|
|
user_id,
|
|
object_id,
|
|
object_type,
|
|
operation,
|
|
payload_hash,
|
|
logical_clock,
|
|
device_id,
|
|
created_at
|
|
)
|
|
SELECT ?, ?, ?, ?, ?, ?, ?, ?
|
|
WHERE EXISTS (
|
|
SELECT 1
|
|
FROM sync_objects
|
|
WHERE user_id = ?
|
|
AND object_id = ?
|
|
AND object_type = ?
|
|
AND payload_hash = ?
|
|
AND logical_clock = ?
|
|
AND device_id = ?
|
|
AND ((? = 1 AND deleted_at IS NOT NULL) OR (? = 0 AND deleted_at IS NULL))
|
|
)
|
|
ON CONFLICT(user_id, object_id, logical_clock, device_id, operation) DO NOTHING
|
|
`;
|
|
const SYNC_TOMBSTONE_UPSERT_QUERY = `
|
|
INSERT INTO sync_tombstones (
|
|
user_id,
|
|
object_id,
|
|
object_type,
|
|
logical_clock,
|
|
device_id,
|
|
deleted_at
|
|
)
|
|
SELECT user_id, object_id, object_type, logical_clock, device_id, deleted_at
|
|
FROM sync_objects
|
|
WHERE user_id = ? AND object_id = ? AND logical_clock = ? AND deleted_at IS NOT NULL
|
|
ON CONFLICT(user_id, object_id) DO UPDATE SET
|
|
object_type = excluded.object_type,
|
|
logical_clock = excluded.logical_clock,
|
|
device_id = excluded.device_id,
|
|
deleted_at = excluded.deleted_at
|
|
WHERE excluded.logical_clock >= sync_tombstones.logical_clock
|
|
`;
|
|
|
|
export async function syncPushDocument(
|
|
request: Request,
|
|
env: Env,
|
|
context: AuthContext,
|
|
nowSeconds = Math.floor(Date.now() / 1000),
|
|
): Promise<SyncPushDocument> {
|
|
const deviceId = currentDeviceId(context);
|
|
const push = await syncPushRequest(request, context.userId);
|
|
const existingRow = await env.ELY_DB.prepare(SYNC_OBJECT_BY_ID_QUERY)
|
|
.bind(context.userId, push.objectId)
|
|
.first<SyncObjectRow>();
|
|
if (existingRow !== null) {
|
|
assertPushCanReplaceExisting(push, deviceId, syncObjectDocument(existingRow));
|
|
}
|
|
|
|
await persistR2PayloadIfNeeded(env, push);
|
|
await env.ELY_DB.batch(syncPushStatements(env, context.userId, deviceId, push, nowSeconds));
|
|
|
|
const savedRow = await env.ELY_DB.prepare(SYNC_OBJECT_BY_ID_QUERY)
|
|
.bind(context.userId, push.objectId)
|
|
.first<SyncObjectRow>();
|
|
if (savedRow === null) {
|
|
throw new SyncPushPersistenceError("sync_object_missing");
|
|
}
|
|
const object = syncObjectDocument(savedRow);
|
|
assertSavedObjectMatchesPush(push, deviceId, object);
|
|
|
|
return { version: 1, user_id: context.userId, device_id: deviceId, object };
|
|
}
|
|
|
|
async function persistR2PayloadIfNeeded(env: Env, push: SyncPushRequest): Promise<void> {
|
|
if (push.payload.kind !== "r2") {
|
|
return;
|
|
}
|
|
try {
|
|
await putVerifiedObject(
|
|
env.ELY_STORAGE,
|
|
push.payload.r2Key,
|
|
push.payload.bytes,
|
|
push.payloadHash,
|
|
"application/octet-stream",
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof StorageObjectError) {
|
|
throw new SyncPushRequestError(error.message);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function syncPushStatements(
|
|
env: Env,
|
|
userId: string,
|
|
deviceId: string,
|
|
push: SyncPushRequest,
|
|
nowSeconds: number,
|
|
): ElyD1PreparedStatement[] {
|
|
const deletedAt = push.operation === "delete" ? nowSeconds : null;
|
|
const isDelete = push.operation === "delete" ? 1 : 0;
|
|
const statements = [
|
|
env.ELY_DB.prepare(SYNC_OBJECT_UPSERT_QUERY).bind(
|
|
userId,
|
|
push.objectId,
|
|
push.objectType,
|
|
push.payload.kind === "inline" ? push.payload.bytes : null,
|
|
push.payload.r2Key,
|
|
push.payloadHash,
|
|
push.schemaRev,
|
|
push.logicalClock,
|
|
deviceId,
|
|
nowSeconds,
|
|
nowSeconds,
|
|
deletedAt,
|
|
),
|
|
env.ELY_DB.prepare(SYNC_CHANGE_INSERT_QUERY).bind(
|
|
userId,
|
|
push.objectId,
|
|
push.objectType,
|
|
push.operation,
|
|
push.payloadHash,
|
|
push.logicalClock,
|
|
deviceId,
|
|
nowSeconds,
|
|
userId,
|
|
push.objectId,
|
|
push.objectType,
|
|
push.payloadHash,
|
|
push.logicalClock,
|
|
deviceId,
|
|
isDelete,
|
|
isDelete,
|
|
),
|
|
];
|
|
if (push.operation === "delete") {
|
|
statements.push(
|
|
env.ELY_DB.prepare(SYNC_TOMBSTONE_UPSERT_QUERY).bind(
|
|
userId,
|
|
push.objectId,
|
|
push.logicalClock,
|
|
),
|
|
);
|
|
}
|
|
return statements;
|
|
}
|
|
|
|
function assertPushCanReplaceExisting(
|
|
push: SyncPushRequest,
|
|
deviceId: string,
|
|
existing: SyncPushedObjectDocument,
|
|
): void {
|
|
if (existing.logical_clock > push.logicalClock) {
|
|
throw new SyncPushConflictError("logical_clock_stale");
|
|
}
|
|
if (existing.logical_clock < push.logicalClock) {
|
|
return;
|
|
}
|
|
if (
|
|
existing.operation !== push.operation ||
|
|
existing.payload_hash !== push.payloadHash ||
|
|
existing.device_id !== deviceId
|
|
) {
|
|
throw new SyncPushConflictError("logical_clock_conflict");
|
|
}
|
|
}
|
|
|
|
function assertSavedObjectMatchesPush(
|
|
push: SyncPushRequest,
|
|
deviceId: string,
|
|
object: SyncPushedObjectDocument,
|
|
): void {
|
|
if (object.logical_clock > push.logicalClock) {
|
|
throw new SyncPushConflictError("logical_clock_stale");
|
|
}
|
|
if (
|
|
object.logical_clock === push.logicalClock &&
|
|
(object.object_type !== push.objectType ||
|
|
object.operation !== push.operation ||
|
|
object.payload_hash !== push.payloadHash ||
|
|
object.schema_rev !== push.schemaRev ||
|
|
object.device_id !== deviceId ||
|
|
object.payload_r2_key !== push.payload.r2Key)
|
|
) {
|
|
throw new SyncPushConflictError("logical_clock_conflict");
|
|
}
|
|
if (
|
|
object.object_id !== push.objectId ||
|
|
object.object_type !== push.objectType ||
|
|
object.operation !== push.operation ||
|
|
object.payload_hash !== push.payloadHash ||
|
|
object.schema_rev !== push.schemaRev ||
|
|
object.logical_clock !== push.logicalClock ||
|
|
object.device_id !== deviceId
|
|
) {
|
|
throw new SyncPushPersistenceError("sync_object_mismatch");
|
|
}
|
|
}
|