feat: add durable asset synchronization worker

This commit is contained in:
jb
2026-09-19 22:15:23 -04:00
parent 2675ea8114
commit fe7d218195
36 changed files with 2218 additions and 189 deletions
+608 -4
View File
@@ -20,6 +20,8 @@ const MAX_CHUNKS = 4_096;
const MAX_ERROR_LENGTH = 160;
const OAUTH_MAX_LIFETIME_MS = 10 * 60 * 1000;
const SNAPSHOT_MAX_LIFETIME_MS = 60 * 60 * 1000;
const MAX_USER_ACTIVE_SYNC_JOBS = 8;
const SYNC_PROVIDER_GROUP = 'esi:assets';
const DATASET_SCOPE_REQUIREMENTS: Record<string, readonly string[]> = {
assets: ['esi-assets.read_assets.v1'],
wallet_balance: ['esi-wallet.read_character_wallet.v1'],
@@ -33,6 +35,7 @@ const CONSENT_INTENTS = ['initial_login', 'link_character', 'upgrade_dataset', '
const CONSENT_CAPABILITIES = Object.keys(CAPABILITY_DEFINITIONS) as Capability[];
const bridgeArgs = { bridgeSecret: v.string() };
const workerBridgeArgs = { workerSecret: v.string() };
const characterArgs = { userId: v.string(), characterId: v.number() };
const assetPageResultValidator = v.object({
@@ -57,6 +60,12 @@ function assertBridge(secret: string): void {
}
}
function assertSyncWorker(secret: string): void {
const expected = process.env.CONVEX_SYNC_WORKER_SECRET;
if (!expected || expected === process.env.CONVEX_BRIDGE_SECRET || !constantTimeEqual(secret, expected)) throw new Error("Sync worker authentication failed");
assertDeploymentCapability('asset_sync');
}
function assertDeploymentCapability(capability: 'asset_sync'): void {
let enabled = false;
try {
@@ -81,6 +90,19 @@ function safeSyncError(_error: string): string {
return "Asset synchronization failed";
}
function workerJob(job: any): boolean {
return job?.kind === 'asset_sync' && job?.dataset === 'assets' && job?.authority === 'user' && job?.ownerKind === 'user';
}
function authorityMatches(character: any, job: any): boolean {
return Boolean(character && character.authorizationStatus === 'active'
&& character.scopes.includes('esi-assets.read_assets.v1')
&& character.grantRevision === job.grantRevision
&& character.authorizationEpoch === job.authorizationEpoch
&& character.credentialVersion === job.credentialVersion
&& scopesEqual(character.scopes, job.scopes ?? []));
}
function newIdentifier(prefix: string): string {
return `${prefix}_${crypto.randomUUID()}`;
}
@@ -109,6 +131,11 @@ function validActualScopes(scopes: readonly string[]): boolean {
return new Set(scopes).size === scopes.length && scopes.every((scope) => SERVER_SCOPE_ALLOWLIST.includes(scope));
}
function scopesSubset(subset: readonly string[], superset: readonly string[]): boolean {
const allowed = new Set(superset);
return subset.every((scope) => allowed.has(scope));
}
function datasetStatusesFor(character: any): any[] {
return character.datasetStatuses;
}
@@ -138,7 +165,7 @@ function updateDatasetStatuses(
invalidatedAt: timestamp,
...(dataset === 'assets' ? {} : {}),
});
if (dataset === 'assets' && (old?.status === 'ready' || old?.generation !== undefined || character.activeGeneration !== undefined)) assetsInvalidated = true;
if (dataset === 'assets') assetsInvalidated = true;
continue;
}
// A newly authorized dataset has no coverage. Existing coverage remains
@@ -458,7 +485,7 @@ export const bridgeCompleteLogin = mutationGeneric({
credentialVersion,
authorizationEpoch,
datasetStatuses: updated.statuses,
...(updated.assetsInvalidated ? { state: "error" as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined } : {}),
...(updated.assetsInvalidated ? { state: "error" as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined, syncGeneration: undefined, syncLeaseExpiresAt: undefined } : {}),
};
if (character) {
await ctx.db.patch(character._id, basePatch);
@@ -492,6 +519,7 @@ export const bridgeCompleteLogin = mutationGeneric({
version: credentialVersion,
});
}
if (character && (grantChanged || updated.assetsInvalidated || credentialVersion !== character.credentialVersion)) await invalidateCharacterJobs(ctx, resolvedUserId, args.characterId, timestamp);
const sessionId = newIdentifier("session");
await ctx.db.insert("sessions", {
@@ -546,7 +574,7 @@ export const bridgeSaveTokens = mutationGeneric({
assertBridge(args.bridgeSecret);
if (!validActualScopes(args.scopes)) return false;
const character = await findCharacter(ctx, args.userId, args.characterId);
if (!character || character.authorizationStatus !== "active") return false;
if (!character || character.authorizationStatus !== "active" || !scopesSubset(args.scopes, character.scopes ?? [])) return false;
const record = await ctx.db
.query("tokenRecords")
.withIndex("by_user_character", (q: any) =>
@@ -568,8 +596,9 @@ export const bridgeSaveTokens = mutationGeneric({
grantRevision: nextRevision,
credentialVersion: nextVersion,
datasetStatuses: updated.statuses,
...(updated.assetsInvalidated ? { state: "error" as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined } : {}),
...(updated.assetsInvalidated ? { state: "error" as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined, syncGeneration: undefined, syncLeaseExpiresAt: undefined } : {}),
});
if (updated.assetsInvalidated || nextRevision !== character.grantRevision || nextVersion !== character.credentialVersion) await invalidateCharacterJobs(ctx, args.userId, args.characterId, now());
if (args.returnAuthority) {
return {
saved: true,
@@ -973,6 +1002,581 @@ export const bridgeFailSync = mutationGeneric({
},
});
/** Return only fields safe for an authenticated status response. */
function safeSyncJob(job: any): any {
const knownErrors = new Set(['Synchronization cancelled', 'Synchronization scheduled for retry', 'Authorization is no longer active', 'Asset synchronization failed']);
const error = typeof job.lastError === 'string' && knownErrors.has(job.lastError) ? job.lastError : undefined;
return {
id: job._id,
dataset: job.dataset ?? 'assets',
characterId: job.characterId,
status: job.status,
attempts: job.attempts ?? 0,
dueAt: job.dueAt ?? job.createdAt,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
leaseExpiresAt: job.status === 'running' ? (job.leaseExpiresAt ?? null) : null,
cancelRequested: job.cancelRequested === true,
...(error === undefined ? {} : { error }),
};
}
function safeJobError(): string { return 'Asset synchronization failed'; }
function activeJobStatus(status: string): boolean { return status === 'queued' || status === 'running'; }
async function findJob(ctx: any, jobId: string): Promise<any | null> {
return await ctx.db.get(jobId as any);
}
async function markTerminalSyncFailure(ctx: any, job: any, error = safeSyncError('terminal failure')): Promise<void> {
if (job.generation !== undefined) {
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
if (generation?.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error });
}
const character = await findCharacter(ctx, job.userId, job.characterId);
if (!character) return;
const assetWasSyncing = character.syncGeneration === job.generation || character.datasetStatuses?.some((entry: any) => entry.dataset === 'assets' && (entry.status === 'syncing' || entry.status === 'queued'));
if (!assetWasSyncing) return;
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets'
? { ...entry, status: character.activeGeneration ? 'error' : 'blocked', error }
: entry);
// Never clear activeGeneration/syncedAt/expiresAt: the last complete snapshot
// remains the only readable publication until a replacement succeeds.
await ctx.db.patch(character._id, { state: 'error', datasetStatuses: statuses, syncGeneration: undefined, syncLeaseExpiresAt: undefined, error });
}
async function clearMatchingSyncPointer(ctx: any, character: any, generation: string, error = 'Authorization is no longer active'): Promise<boolean> {
if (!character || character.syncGeneration !== generation) return false;
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets'
? { ...entry, status: character.activeGeneration ? 'error' : 'blocked', error, generation: character.activeGeneration ?? undefined }
: entry);
await ctx.db.patch(character._id, { state: 'error', datasetStatuses: statuses, syncGeneration: undefined, syncLeaseExpiresAt: undefined, error });
return true;
}
async function invalidateCharacterJobs(ctx: any, userId: string, characterId: number, timestamp: number): Promise<void> {
const character = await findCharacter(ctx, userId, characterId);
const jobs = await ctx.db.query('jobs').withIndex('by_user_status', (q: any) => q.eq('userId', userId)).collect();
let staleSyncState = false;
for (const job of jobs) {
if (workerJob(job) && job.characterId === characterId && activeJobStatus(job.status)) {
if (job.generation !== undefined) {
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', userId).eq('characterId', characterId).eq('generation', job.generation)).first();
if (generation?.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: 'Authorization is no longer active' });
}
if (character && job.generation !== undefined && character.syncGeneration === job.generation) staleSyncState = true;
await ctx.db.patch(job._id, {
status: 'quarantined', updatedAt: timestamp, leaseExpiresAt: undefined,
leaseToken: undefined, workerId: undefined, cancelRequested: undefined,
lastError: 'Authorization is no longer active', finishedAt: timestamp,
});
}
}
if (character && staleSyncState && character.syncGeneration !== undefined) await clearMatchingSyncPointer(ctx, character, character.syncGeneration);
}
/** Server-derived enqueue. The caller supplies a session proof, never a job authority. */
export const bridgeEnqueueAssetSync = mutationGeneric({
args: {
...bridgeArgs,
userId: v.string(),
characterId: v.number(),
initiatingSessionHash: v.string(),
},
handler: async (ctx, args) => {
assertBridge(args.bridgeSecret);
assertDeploymentCapability('asset_sync');
const timestamp = now();
const initiating = await findSessionByHash(ctx, args.initiatingSessionHash);
if (!isUsableSession(initiating, timestamp) || initiating.userId !== args.userId) throw new Error('Session is no longer valid');
const character = await findCharacter(ctx, args.userId, args.characterId);
if (!character || character.authorizationStatus !== 'active' || !character.scopes.includes('esi-assets.read_assets.v1')) {
throw new Error('Asset authorization is not active');
}
const dedupeKey = `${args.userId}:character:${args.characterId}:assets`;
const existing = await ctx.db.query('jobs').withIndex('by_dedupe', (q: any) => q.eq('dedupeKey', dedupeKey)).collect();
const active = existing.find((job: any) => activeJobStatus(job.status));
if (active) return safeSyncJob(active);
const queuedJobs = (await ctx.db.query('jobs').withIndex('by_user_asset_status', (q: any) => q.eq('userId', args.userId).eq('kind', 'asset_sync').eq('dataset', 'assets').eq('authority', 'user').eq('ownerKind', 'user').eq('status', 'queued')).take(MAX_USER_ACTIVE_SYNC_JOBS + 1)).filter((job: any) => workerJob(job));
const runningJobs = (await ctx.db.query('jobs').withIndex('by_user_asset_status', (q: any) => q.eq('userId', args.userId).eq('kind', 'asset_sync').eq('dataset', 'assets').eq('authority', 'user').eq('ownerKind', 'user').eq('status', 'running')).take(MAX_USER_ACTIVE_SYNC_JOBS + 1)).filter((job: any) => workerJob(job));
if (queuedJobs.length + runningJobs.length >= MAX_USER_ACTIVE_SYNC_JOBS) throw new Error('Synchronization queue is full');
const maxAttempts = 5;
const job = await ctx.db.insert('jobs', {
userId: args.userId,
kind: 'asset_sync',
dataset: 'assets',
status: 'queued',
characterId: args.characterId,
dedupeKey,
createdAt: timestamp,
updatedAt: timestamp,
dueAt: timestamp,
attempts: 0,
maxAttempts,
priority: 0,
ownerKind: 'user',
authority: 'user',
grantRevision: character.grantRevision,
authorizationEpoch: character.authorizationEpoch,
credentialVersion: character.credentialVersion,
scopes: [...character.scopes],
cooldownGroup: SYNC_PROVIDER_GROUP,
cancelRequested: false,
});
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets'
? { ...entry, status: 'queued', grantRevision: character.grantRevision, authorizationEpoch: character.authorizationEpoch, credentialVersion: character.credentialVersion, error: undefined }
: entry);
await ctx.db.patch(character._id, { state: 'queued', datasetStatuses: statuses, error: undefined });
return safeSyncJob({ _id: job, dataset: 'assets', characterId: args.characterId, status: 'queued', attempts: 0, dueAt: timestamp, createdAt: timestamp, updatedAt: timestamp, cancelRequested: false });
},
});
export const bridgeSyncJob = queryGeneric({
args: { ...bridgeArgs, userId: v.string(), jobId: v.string() },
handler: async (ctx, args) => {
assertBridge(args.bridgeSecret);
const job = await findJob(ctx, args.jobId);
if (!job || job.userId !== args.userId || job.kind !== 'asset_sync') return null;
return safeSyncJob(job);
},
});
/** Cancellation is authenticated against the initiating session and fences a running lease immediately. */
export const bridgeCancelSyncJob = mutationGeneric({
args: { ...bridgeArgs, userId: v.string(), jobId: v.string(), initiatingSessionHash: v.string() },
handler: async (ctx, args) => {
assertBridge(args.bridgeSecret);
assertDeploymentCapability('asset_sync');
const initiating = await findSessionByHash(ctx, args.initiatingSessionHash);
if (!isUsableSession(initiating) || initiating.userId !== args.userId) throw new Error('Session is no longer valid');
const job = await findJob(ctx, args.jobId);
if (!job || job.userId !== args.userId || job.kind !== 'asset_sync') return null;
if (!activeJobStatus(job.status)) return safeSyncJob(job);
const timestamp = now();
const character = job.characterId === undefined ? null : await findCharacter(ctx, args.userId, job.characterId);
if (job.generation !== undefined) {
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', args.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
if (generation?.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: 'Synchronization cancelled' });
}
await ctx.db.patch(job._id, {
status: 'cancelled', updatedAt: timestamp, finishedAt: timestamp,
leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined,
cancelRequested: true, lastError: 'Synchronization cancelled',
});
if (character && character.syncGeneration === job.generation) {
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets'
? { ...entry, status: character.activeGeneration ? 'error' : 'blocked', error: 'Synchronization cancelled' }
: entry);
await ctx.db.patch(character._id, { state: character.activeGeneration ? 'error' : 'error', datasetStatuses: statuses, syncGeneration: undefined, syncLeaseExpiresAt: undefined, error: 'Synchronization cancelled' });
} else if (character && character.datasetStatuses?.some((entry: any) => entry.dataset === 'assets' && entry.status === 'queued')) {
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets' ? { ...entry, status: character.activeGeneration ? 'error' : 'blocked', error: 'Synchronization cancelled' } : entry);
await ctx.db.patch(character._id, { state: character.activeGeneration ? 'error' : 'error', datasetStatuses: statuses, error: 'Synchronization cancelled' });
}
return safeSyncJob({ ...job, status: 'cancelled', updatedAt: timestamp, finishedAt: timestamp, cancelRequested: true, lastError: 'Synchronization cancelled' });
},
});
/**
* Claim and recover in one transaction. Expired leases are requeued before a
* candidate is selected, making process termination recoverable without a
* second scheduler or an in-memory queue.
*/
export const bridgeClaimSyncJob = mutationGeneric({
args: { ...workerBridgeArgs, workerId: v.string(), leaseMs: v.optional(v.number()) },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
if (!args.workerId || args.workerId.length > 160) throw new Error('Invalid worker identity');
const timestamp = now();
const leaseMs = args.leaseMs ?? 10 * 60 * 1000;
if (!Number.isSafeInteger(leaseMs) || leaseMs < 60_000 || leaseMs > 60 * 60 * 1000) throw new Error('Invalid sync lease duration');
const expired = await ctx.db.query('jobs').withIndex('by_lease_expires_at', (q: any) => q.lte('leaseExpiresAt', timestamp)).take(100);
for (const job of expired) {
if (!workerJob(job) || job.status !== 'running' || (job.leaseExpiresAt ?? Infinity) > timestamp) continue;
if (job.cancelRequested === true) {
await ctx.db.patch(job._id, { status: 'cancelled', updatedAt: timestamp, finishedAt: timestamp, leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, lastError: 'Synchronization cancelled' });
} else if ((job.attempts ?? 0) >= (job.maxAttempts ?? 5)) {
const character = await findCharacter(ctx, job.userId, job.characterId);
if (!authorityMatches(character, job)) {
if (job.generation !== undefined) {
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
if (generation?.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: 'Authorization is no longer active' });
await clearMatchingSyncPointer(ctx, character, job.generation);
}
await ctx.db.patch(job._id, { status: 'quarantined', updatedAt: timestamp, finishedAt: timestamp, leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, lastError: 'Authorization is no longer active' });
continue;
}
await markTerminalSyncFailure(ctx, job);
await ctx.db.patch(job._id, { status: 'failed', updatedAt: timestamp, finishedAt: timestamp, leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, lastError: safeJobError() });
} else {
await ctx.db.patch(job._id, { status: 'queued', updatedAt: timestamp, dueAt: timestamp, leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined });
}
}
const candidates = await ctx.db.query('jobs').withIndex('by_asset_sync_queue', (q: any) => q.eq('kind', 'asset_sync').eq('dataset', 'assets').eq('authority', 'user').eq('ownerKind', 'user').eq('status', 'queued').lte('dueAt', timestamp)).order('asc').take(100);
if (candidates.length === 0) return null;
// Bounded fair selection: an account with an in-flight job loses to an
// account with no in-flight work; due/creation order breaks ties.
const supportedCandidates = candidates.filter((candidate: any) => workerJob(candidate));
if (supportedCandidates.length === 0) return null;
const runningByUser = new Map<string, number>();
for (const candidate of supportedCandidates) {
if (runningByUser.has(candidate.userId)) continue;
const running = (await ctx.db.query('jobs').withIndex('by_user_status', (q: any) => q.eq('userId', candidate.userId).eq('status', 'running')).take(20)).filter((job: any) => workerJob(job));
runningByUser.set(candidate.userId, running.length);
}
const ordered = [...supportedCandidates].sort((left: any, right: any) =>
(runningByUser.get(left.userId)! - runningByUser.get(right.userId)!)
|| ((left.priority ?? 0) - (right.priority ?? 0))
|| ((left.dueAt ?? left.createdAt) - (right.dueAt ?? right.createdAt))
|| ((left.lastClaimedAt ?? 0) - (right.lastClaimedAt ?? 0))
|| String(left._id).localeCompare(String(right._id)));
for (const candidate of ordered) {
if (candidate.cancelRequested === true) {
await ctx.db.patch(candidate._id, { status: 'cancelled', updatedAt: timestamp, finishedAt: timestamp, lastError: 'Synchronization cancelled' });
continue;
}
if (candidate.cooldownGroup) {
const shared = await ctx.db.query('syncCooldowns').withIndex('by_context', (q: any) => q.eq('context', `group:${candidate.cooldownGroup}`)).first();
if (shared && shared.until > timestamp) continue;
}
const character = candidate.characterId === undefined ? null : await findCharacter(ctx, candidate.userId, candidate.characterId);
const authorityCurrent = authorityMatches(character, candidate);
if (!authorityCurrent) {
if (candidate.generation !== undefined) {
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', candidate.userId).eq('characterId', candidate.characterId).eq('generation', candidate.generation)).first();
if (generation?.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: 'Authorization is no longer active' });
await clearMatchingSyncPointer(ctx, character, candidate.generation);
}
await ctx.db.patch(candidate._id, { status: 'quarantined', updatedAt: timestamp, finishedAt: timestamp, lastError: 'Authorization is no longer active' });
continue;
}
const leaseToken = newIdentifier('lease');
const attempts = (candidate.attempts ?? 0) + 1;
await ctx.db.patch(candidate._id, { status: 'running', attempts, updatedAt: timestamp, startedAt: candidate.startedAt ?? timestamp, dueAt: timestamp, leaseExpiresAt: timestamp + leaseMs, leaseToken, workerId: args.workerId, lastClaimedAt: timestamp });
return { ...safeSyncJob({ ...candidate, status: 'running', attempts, updatedAt: timestamp, leaseExpiresAt: timestamp + leaseMs, leaseToken }), jobId: candidate._id, leaseToken, userId: candidate.userId, characterId: candidate.characterId, grantRevision: candidate.grantRevision, authorizationEpoch: candidate.authorizationEpoch, credentialVersion: candidate.credentialVersion, scopes: candidate.scopes ?? [], ...(candidate.generation === undefined ? {} : { generation: candidate.generation }), cooldownGroup: candidate.cooldownGroup ?? SYNC_PROVIDER_GROUP };
}
return null;
},
});
export const bridgeRenewSyncJob = mutationGeneric({
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string(), workerId: v.string(), leaseMs: v.optional(v.number()) },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const job = await findJob(ctx, args.jobId);
if (!job || !workerJob(job) || job.status !== 'running' || job.leaseToken !== args.leaseToken || job.workerId !== args.workerId) return false;
if (job.cancelRequested === true || (job.leaseExpiresAt ?? 0) <= now()) return false;
const leaseMs = args.leaseMs ?? 10 * 60 * 1000;
if (!Number.isSafeInteger(leaseMs) || leaseMs < 60_000 || leaseMs > 60 * 60 * 1000) return false;
await ctx.db.patch(job._id, { leaseExpiresAt: now() + leaseMs, updatedAt: now() });
return true;
},
});
async function jobLease(ctx: any, jobId: string, leaseToken: string): Promise<any> {
const job = await findJob(ctx, jobId);
if (!job || !workerJob(job) || job.status !== 'running' || job.leaseToken !== leaseToken || (job.leaseExpiresAt ?? 0) <= now()) throw new Error('Synchronization lease is not valid');
return job;
}
async function jobCharacter(ctx: any, job: any): Promise<any> {
const character = await findCharacter(ctx, job.userId, job.characterId);
if (!authorityMatches(character, job)) {
throw new Error('Synchronization authorization is stale');
}
return character;
}
export const bridgeRefreshSyncJobAuthority = mutationGeneric({
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string(), expectedGrantRevision: v.number(), expectedAuthorizationEpoch: v.number(), expectedCredentialVersion: v.number(), expectedScopes: v.array(v.string()) },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const job = await jobLease(ctx, args.jobId, args.leaseToken);
const character = await findCharacter(ctx, job.userId, job.characterId);
const expected = { grantRevision: args.expectedGrantRevision, authorizationEpoch: args.expectedAuthorizationEpoch, credentialVersion: args.expectedCredentialVersion, scopes: args.expectedScopes };
if (!authorityMatches(character, expected)) throw new Error('Synchronization authorization changed');
if (!authorityMatches(character, job)) throw new Error('Synchronization job authority is stale');
return expected;
},
});
export const bridgeRestartSyncJobGeneration = mutationGeneric({
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string() },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const job = await jobLease(ctx, args.jobId, args.leaseToken);
if (job.generation === undefined) return null;
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
if (generation?.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: 'Authorization refreshed before publication' });
const character = await findCharacter(ctx, job.userId, job.characterId);
if (character?.syncGeneration === job.generation) await ctx.db.patch(character._id, { syncGeneration: undefined, syncLeaseExpiresAt: undefined, state: 'queued' });
await ctx.db.patch(job._id, { generation: undefined, updatedAt: now() });
return null;
},
});
export const bridgeBeginSyncJob = mutationGeneric({
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string() },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const job = await jobLease(ctx, args.jobId, args.leaseToken);
let character = await jobCharacter(ctx, job);
if (job.generation !== undefined) {
const existing = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
if (existing && existing.status === 'staging' && existing.grantRevision === job.grantRevision && existing.authorizationEpoch === job.authorizationEpoch && existing.credentialVersion === job.credentialVersion) return job.generation;
if (existing?.status === 'staging') await ctx.db.patch(existing._id, { status: 'failed', error: 'Synchronization authority changed' });
await ctx.db.patch(job._id, { generation: undefined, updatedAt: now() });
const oldCharacter = await findCharacter(ctx, job.userId, job.characterId);
if (oldCharacter?.syncGeneration === job.generation) await ctx.db.patch(oldCharacter._id, { syncGeneration: undefined, syncLeaseExpiresAt: undefined });
// The character read above predates the atomic cleanup. Re-read it before
// checking the lease, otherwise a reclaimed job sees its own old pointer
// and incorrectly reports an active synchronization.
character = await findCharacter(ctx, job.userId, job.characterId);
if (!character || !authorityMatches(character, job)) throw new Error('Synchronization authorization is stale');
}
if (character.syncGeneration && (character.syncLeaseExpiresAt ?? 0) > now()) throw new Error('Character synchronization is already leased');
const generation = newIdentifier('generation');
await ctx.db.insert('assetGenerations', { userId: job.userId, characterId: job.characterId, generation, status: 'staging', stagedChunkCount: 0, grantRevision: job.grantRevision, authorizationEpoch: job.authorizationEpoch, credentialVersion: job.credentialVersion });
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets' ? { ...entry, status: 'syncing', grantRevision: job.grantRevision, authorizationEpoch: job.authorizationEpoch, credentialVersion: job.credentialVersion, error: undefined } : entry);
await ctx.db.patch(character._id, { state: 'syncing', datasetStatuses: statuses, syncGeneration: generation, syncLeaseExpiresAt: now() + 10 * 60 * 1000, error: undefined });
await ctx.db.patch(job._id, { generation, updatedAt: now() });
return generation;
},
});
export const bridgeStageSyncAssets = mutationGeneric({
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string(), index: v.number(), assets: v.array(assetValidator) },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
if (!Number.isInteger(args.index) || args.index < 0 || args.assets.length > MAX_ASSET_CHUNK) throw new Error('Invalid asset chunk');
const job = await jobLease(ctx, args.jobId, args.leaseToken);
const character = await jobCharacter(ctx, job);
if (args.assets.some((asset: any) => asset.characterId !== job.characterId)) throw new Error('Asset character mismatch');
if (!job.generation || character.syncGeneration !== job.generation) throw new Error('Synchronization generation is not current');
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
if (!generation || generation.status !== 'staging') throw new Error('Snapshot is not staging');
const stagedChunkCount = generation.stagedChunkCount ?? 0;
if (stagedChunkCount >= MAX_CHUNKS || args.index > stagedChunkCount) throw new Error('Asset chunks must be staged exactly once and in order');
const existing = await ctx.db.query('assetChunks').withIndex('by_snapshot_index', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation).eq('index', args.index)).first();
if (existing) {
// Convex may commit a mutation while its network response is lost. A
// duplicate delivery is accepted only when its payload is identical.
if (JSON.stringify(existing.assets) !== JSON.stringify(args.assets)) throw new Error('Asset chunk already exists');
return null;
}
if (args.index !== stagedChunkCount) throw new Error('Asset chunks must be staged exactly once and in order');
await ctx.db.insert('assetChunks', { userId: job.userId, characterId: job.characterId, generation: job.generation, index: args.index, assets: args.assets });
await ctx.db.patch(generation._id, { stagedChunkCount: stagedChunkCount + 1 });
return null;
},
});
function validJobSnapshotTimes(syncedAt: number, expiresAt: number, timestamp: number): boolean {
return Number.isFinite(syncedAt) && syncedAt >= 0 && syncedAt <= timestamp && Number.isFinite(expiresAt) && expiresAt > timestamp && expiresAt > syncedAt && expiresAt - syncedAt <= SNAPSHOT_MAX_LIFETIME_MS;
}
export const bridgeCommitSyncJob = mutationGeneric({
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string(), chunkCount: v.number(), syncedAt: v.number(), expiresAt: v.number() },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const job = await findJob(ctx, args.jobId);
if (job?.status === 'succeeded' && job.generation !== undefined) return true;
const leased = await jobLease(ctx, args.jobId, args.leaseToken);
const character = await jobCharacter(ctx, leased);
if (!leased.generation) throw new Error('Synchronization generation is not current');
if (!Number.isInteger(args.chunkCount) || args.chunkCount < 0 || args.chunkCount > MAX_CHUNKS || !validJobSnapshotTimes(args.syncedAt, args.expiresAt, now())) throw new Error('Invalid snapshot');
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', leased.userId).eq('characterId', leased.characterId).eq('generation', leased.generation)).first();
if (!generation || generation.status !== 'staging' || (generation.stagedChunkCount ?? 0) !== args.chunkCount) throw new Error('Snapshot is incomplete');
if (generation.grantRevision !== character.grantRevision || generation.authorizationEpoch !== character.authorizationEpoch || generation.credentialVersion !== character.credentialVersion) throw new Error('Synchronization authorization is stale');
await ctx.db.patch(generation._id, { status: 'complete', chunkCount: args.chunkCount, syncedAt: args.syncedAt, expiresAt: args.expiresAt });
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets' ? { ...entry, status: 'ready', generation: leased.generation, syncedAt: args.syncedAt, error: undefined } : entry);
await ctx.db.patch(character._id, { state: 'ready', datasetStatuses: statuses, syncedAt: args.syncedAt, expiresAt: args.expiresAt, activeGeneration: leased.generation, syncGeneration: undefined, syncLeaseExpiresAt: undefined, error: undefined });
await ctx.db.patch(leased._id, { status: 'succeeded', updatedAt: now(), finishedAt: now(), leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, cancelRequested: undefined, lastError: undefined });
return true;
},
});
export const bridgeFailSyncJob = mutationGeneric({
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string() },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const leased = await jobLease(ctx, args.jobId, args.leaseToken);
const character = await jobCharacter(ctx, leased);
const timestamp = now();
if (leased.generation !== undefined) {
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', leased.userId).eq('characterId', leased.characterId).eq('generation', leased.generation)).first();
if (generation?.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: safeJobError() });
}
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets' ? { ...entry, status: character.activeGeneration ? 'error' : 'blocked', error: safeJobError() } : entry);
await ctx.db.patch(character._id, { state: 'error', datasetStatuses: statuses, error: safeJobError(), syncGeneration: undefined, syncLeaseExpiresAt: undefined });
await ctx.db.patch(leased._id, { status: 'failed', updatedAt: timestamp, finishedAt: timestamp, leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, lastError: safeJobError() });
return true;
},
});
/** Reschedule transient provider/cooldown failures without exposing their raw text. */
export const bridgeDeferSyncJob = mutationGeneric({
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string(), dueAt: v.number(), cooldownContext: v.optional(v.string()), cooldownUntil: v.optional(v.number()), cooldownGroup: v.optional(v.string()) },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const leased = await jobLease(ctx, args.jobId, args.leaseToken);
const timestamp = now();
if (!Number.isFinite(args.dueAt) || args.dueAt < timestamp || args.dueAt > timestamp + 24 * 60 * 60 * 1000) throw new Error('Invalid retry due time');
if ((leased.attempts ?? 0) >= (leased.maxAttempts ?? 5)) {
await jobCharacter(ctx, leased);
await markTerminalSyncFailure(ctx, leased);
await ctx.db.patch(leased._id, { status: 'failed', updatedAt: timestamp, finishedAt: timestamp, leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, lastError: safeJobError() });
return false;
}
await ctx.db.patch(leased._id, { status: 'queued', updatedAt: timestamp, dueAt: args.dueAt, leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, lastError: 'Synchronization scheduled for retry', ...(args.cooldownGroup ? { cooldownGroup: args.cooldownGroup } : {}) });
if (args.cooldownContext && args.cooldownUntil !== undefined && Number.isFinite(args.cooldownUntil)) {
const contexts = [args.cooldownContext, ...(args.cooldownGroup ? [`group:${args.cooldownGroup}`] : [])];
for (const context of contexts) {
const existing = await ctx.db.query('syncCooldowns').withIndex('by_context', (q: any) => q.eq('context', context)).first();
const until = Math.max(existing?.until ?? 0, args.cooldownUntil);
if (existing) await ctx.db.patch(existing._id, { until, group: args.cooldownGroup, kind: 'server', updatedAt: timestamp });
else await ctx.db.insert('syncCooldowns', { context, until, group: args.cooldownGroup, kind: 'server', updatedAt: timestamp });
}
}
return true;
},
});
async function currentCooldown(ctx: any, context: string, group?: string): Promise<any | null> {
const rows = [await ctx.db.query('syncCooldowns').withIndex('by_context', (q: any) => q.eq('context', context)).first()];
if (group) rows.push(await ctx.db.query('syncCooldowns').withIndex('by_context', (q: any) => q.eq('context', `group:${group}`)).first());
const row = rows.filter(Boolean).sort((left: any, right: any) => right.until - left.until)[0];
return row && row.until > now() ? { until: row.until, group: row.group, kind: row.kind } : null;
}
export const bridgeCooldown = queryGeneric({
args: { ...bridgeArgs, context: v.string() },
handler: async (ctx, args) => {
assertBridge(args.bridgeSecret);
return await currentCooldown(ctx, args.context);
},
});
export const bridgeSyncWorkerCooldown = queryGeneric({
args: { ...workerBridgeArgs, context: v.string(), group: v.optional(v.string()) },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
return await currentCooldown(ctx, args.context, args.group);
},
});
async function markRefreshUncertainRecord(ctx: any, userId: string, characterId: number, lease: any, character: any, record: any): Promise<boolean> {
if (!lease || !character || !record || record.version !== lease.expectedVersion) {
if (lease) await ctx.db.delete(lease._id);
return false;
}
const epoch = character.authorizationEpoch + 1;
const statuses = DATASET_NAMES.map((dataset) => ({ dataset, status: 'blocked' as const, grantRevision: character.grantRevision, authorizationEpoch: epoch, credentialVersion: character.credentialVersion, invalidatedAt: now(), error: 'Reconnect required' }));
// Retain the last complete generation metadata; authorization status blocks
// reads until reconnect, while a later reconnect can explicitly replace it.
await ctx.db.patch(character._id, { authorizationStatus: 'reconnect_required', authorizationEpoch: epoch, datasetStatuses: statuses, syncGeneration: undefined, syncLeaseExpiresAt: undefined, error: 'Reconnect required', state: 'error' });
await ctx.db.delete(record._id); await ctx.db.delete(lease._id);
await invalidateCharacterJobs(ctx, userId, characterId, now());
return true;
}
export const bridgeMarkRefreshDispatched = mutationGeneric({
args: { ...workerBridgeArgs, userId: v.string(), characterId: v.number(), leaseToken: v.string(), ownerId: v.string() },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const lease = await ctx.db.query('refreshLeases').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
if (!lease || lease.leaseToken !== args.leaseToken || lease.ownerId !== args.ownerId || lease.expiresAt <= now() || lease.dispatchStartedAt !== undefined) return false;
await ctx.db.patch(lease._id, { dispatchStartedAt: now() });
return true;
},
});
/** Durable per-character refresh serialization. The encrypted grant never reaches browsers. */
export const bridgeAcquireRefreshLease = mutationGeneric({
args: { ...workerBridgeArgs, userId: v.string(), characterId: v.number(), ownerId: v.string(), leaseMs: v.optional(v.number()) },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const character = await findCharacter(ctx, args.userId, args.characterId);
if (!character || character.authorizationStatus !== 'active') throw new Error('Character authorization is not active');
const record = await ctx.db.query('tokenRecords').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
if (!record) throw new Error('Character authorization is missing');
const timestamp = now(); const leaseMs = args.leaseMs ?? 2 * 60 * 1000;
if (!Number.isSafeInteger(leaseMs) || leaseMs < 10_000 || leaseMs > 15 * 60 * 1000) throw new Error('Invalid refresh lease duration');
const existing = await ctx.db.query('refreshLeases').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
if (existing && existing.expiresAt > timestamp) return null;
if (existing?.dispatchStartedAt !== undefined) {
await markRefreshUncertainRecord(ctx, args.userId, args.characterId, existing, character, record);
return null;
}
if (existing) await ctx.db.delete(existing._id);
const leaseToken = newIdentifier('refresh');
await ctx.db.insert('refreshLeases', { userId: args.userId, characterId: args.characterId, leaseToken, ownerId: args.ownerId, expiresAt: timestamp + leaseMs, expectedVersion: record.version });
return { leaseToken, expiresAt: timestamp + leaseMs, expectedVersion: record.version, encryptedTokens: record.encryptedTokens, tokenExpiresAt: record.tokenExpiresAt, scopes: record.scopes, grantRevision: character.grantRevision, authorizationEpoch: character.authorizationEpoch, credentialVersion: character.credentialVersion };
},
});
export const bridgeRenewRefreshLease = mutationGeneric({
args: { ...workerBridgeArgs, userId: v.string(), characterId: v.number(), leaseToken: v.string(), ownerId: v.string(), leaseMs: v.optional(v.number()) },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const lease = await ctx.db.query('refreshLeases').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
if (!lease || lease.leaseToken !== args.leaseToken || lease.ownerId !== args.ownerId || lease.expiresAt <= now()) return false;
const leaseMs = args.leaseMs ?? 2 * 60 * 1000;
if (!Number.isSafeInteger(leaseMs) || leaseMs < 10_000 || leaseMs > 15 * 60 * 1000) return false;
await ctx.db.patch(lease._id, { expiresAt: now() + leaseMs }); return true;
},
});
export const bridgeReleaseRefreshLease = mutationGeneric({
args: { ...workerBridgeArgs, userId: v.string(), characterId: v.number(), leaseToken: v.string(), ownerId: v.string() },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const lease = await ctx.db.query('refreshLeases').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
if (!lease || lease.leaseToken !== args.leaseToken || lease.ownerId !== args.ownerId || lease.expiresAt <= now() || lease.dispatchStartedAt !== undefined) return false;
await ctx.db.delete(lease._id);
return true;
},
});
export const bridgeCommitRefresh = mutationGeneric({
args: { ...workerBridgeArgs, userId: v.string(), characterId: v.number(), leaseToken: v.string(), ownerId: v.string(), expectedVersion: v.number(), encryptedTokens: v.string(), tokenExpiresAt: v.number(), scopes: v.array(v.string()), jobId: v.optional(v.string()), jobLeaseToken: v.optional(v.string()) },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const lease = await ctx.db.query('refreshLeases').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
const character = await findCharacter(ctx, args.userId, args.characterId);
const record = await ctx.db.query('tokenRecords').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
if (!lease || lease.leaseToken !== args.leaseToken || lease.ownerId !== args.ownerId || lease.expiresAt <= now() || lease.dispatchStartedAt === undefined || !character || character.authorizationStatus !== 'active' || !record || record.version !== args.expectedVersion) return null;
if (!validActualScopes(args.scopes) || !scopesSubset(args.scopes, character.scopes ?? [])) throw new Error('Invalid refreshed scopes');
const nextVersion = record.version + 1;
const nextRevision = character.grantRevision + (scopesEqual(character.scopes, args.scopes) ? 0 : 1);
const updated = updateDatasetStatuses(character, args.scopes, nextRevision, character.authorizationEpoch, nextVersion, now());
await ctx.db.patch(record._id, { encryptedTokens: args.encryptedTokens, tokenExpiresAt: args.tokenExpiresAt, scopes: args.scopes, version: nextVersion });
await ctx.db.patch(character._id, { scopes: args.scopes, grantRevision: nextRevision, credentialVersion: nextVersion, datasetStatuses: updated.statuses, ...(updated.assetsInvalidated ? { state: 'error' as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined, syncGeneration: undefined, syncLeaseExpiresAt: undefined } : {}) });
if (updated.assetsInvalidated) await invalidateCharacterJobs(ctx, args.userId, args.characterId, now());
else if (args.jobId && args.jobLeaseToken) {
const job = await findJob(ctx, args.jobId);
if (job && job.userId === args.userId && job.characterId === args.characterId && job.status === 'running' && job.leaseToken === args.jobLeaseToken) {
await ctx.db.patch(job._id, { grantRevision: nextRevision, authorizationEpoch: character.authorizationEpoch, credentialVersion: nextVersion, scopes: args.scopes, updatedAt: now() });
}
}
await ctx.db.delete(lease._id);
return { saved: true, credentialVersion: nextVersion, grantRevision: nextRevision, authorizationEpoch: character.authorizationEpoch, scopes: args.scopes };
},
});
/** Unknown rotating-token outcomes fail closed and require an explicit reconnect. */
export const bridgeMarkRefreshUncertain = mutationGeneric({
args: { ...workerBridgeArgs, userId: v.string(), characterId: v.number(), leaseToken: v.string(), ownerId: v.string(), expectedVersion: v.number() },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const lease = await ctx.db.query('refreshLeases').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
if (!lease || lease.leaseToken !== args.leaseToken || lease.ownerId !== args.ownerId) return false;
const character = await findCharacter(ctx, args.userId, args.characterId);
const record = await ctx.db.query('tokenRecords').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
if (lease.expectedVersion !== args.expectedVersion) return false;
return await markRefreshUncertainRecord(ctx, args.userId, args.characterId, lease, character, record);
},
});
export const bridgeSaveInteraction = mutationGeneric({
args: {
...bridgeArgs,