Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions .storybook/main.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
/**
* Storybook Configuration for SubTrackr Design System
*
*
* Location: .storybook/main.js
* Run: npm run storybook
*/

module.exports = {
stories: [
'../src/design-system/stories/**/*.stories.{ts,tsx}',
'../src/**/*.stories.{ts,tsx}',
],
stories: ['../src/design-system/stories/**/*.stories.{ts,tsx}', '../src/**/*.stories.{ts,tsx}'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-ondevice-actions',
Expand All @@ -30,7 +27,7 @@ module.exports = {
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesAsTypes: true,
shouldRemoveUndefinedFromOptional: true,
propFilter: (prop: any) => {
propFilter: (prop) => {
if (prop.parent) {
return !prop.parent.fileName.includes('node_modules');
}
Expand Down
10 changes: 8 additions & 2 deletions backend/services/shared/apiResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export interface ApiError {
message: string;
/** Optional field-level validation details. */
details?: Record<string, string>;
/** For OCC conflicts, the current version of the resource on the server. */
version?: number;
}

/** Successful response envelope. */
Expand Down Expand Up @@ -94,6 +96,8 @@ export type ErrorCode =
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'CONFLICT'
/** Optimistic Concurrency Control failure. */
| 'CONFLICT_VERSION_MISMATCH'
| 'BAD_REQUEST'
| 'SERVICE_UNAVAILABLE'
// ── Rate limiting ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -168,6 +172,7 @@ export const ERROR_HTTP_STATUS_MAP: Record<ErrorCode, number> = {
UNAUTHORIZED: 401,
FORBIDDEN: 403,
CONFLICT: 409,
CONFLICT_VERSION_MISMATCH: 409,
BAD_REQUEST: 400,
SERVICE_UNAVAILABLE: 503,
// Rate limiting
Expand Down Expand Up @@ -279,11 +284,12 @@ export function fail(
code: ErrorCode,
message: string,
requestId?: string,
details?: Record<string, string>,
details?: Record<string, string> | { version?: number },
): ApiErrorResponse {
const errorPayload: ApiError = { code, message, ...details };
return {
success: false,
error: { code, message, ...(details ? { details } : {}) },
error: errorPayload,
meta: buildMeta(requestId),
};
}
Expand Down
103 changes: 103 additions & 0 deletions backend/services/shared/occ/OptimisticLockService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* @file OptimisticLockService.ts
* @description Issue #613 - Service for Optimistic Concurrency Control (OCC).
*
* This service provides helpers to handle version-based optimistic locking.
* It ensures that concurrent updates do not silently overwrite each other.
*/

import { fail, fromError, ok, ApiResponse } from '../apiResponse';
import { getLogger } from '../../../utils/logger';

const logger = getLogger('OptimisticLockService');

export interface VersionedEntity {
id: string | number;
version: number;
}

export interface UpdateOptions<T extends VersionedEntity> {
/** The entity state from the client, including the version they think they are updating. */
clientEntity: T;
/** The current entity state from the database. */
dbEntity: T;
/** The user or process making the request. */
actor: { id: string; type: 'user' | 'system' };
/** The unique request ID for logging. */
requestId?: string;
/** If true, bypasses the version check (for admin overrides). */
force?: boolean;
}

/**
* Checks if an update operation can proceed by comparing client and database entity versions.
*
* @returns A successful ApiResponse if the update is allowed, or a 409 Conflict error response if not.
*/
export function checkVersion<T extends VersionedEntity>(
options: UpdateOptions<T>,
): ApiResponse<void> {
const { clientEntity, dbEntity, actor, requestId, force = false } = options;

if (force) {
logger.warn(
{
actor,
entityId: dbEntity.id,
clientVersion: clientEntity.version,
dbVersion: dbEntity.version,
requestId,
},
'OCC check bypassed with force=true',
);
return ok(undefined, requestId);
}

if (clientEntity.version !== dbEntity.version) {
logger.warn(
{
actor,
entityId: dbEntity.id,
clientVersion: clientEntity.version,
dbVersion: dbEntity.version,
requestId,
},
'OCC conflict detected: version mismatch',
);
return fail(
'CONFLICT_VERSION_MISMATCH',
`The resource was updated by another process. Please refresh and try again.`,
requestId,
{ version: dbEntity.version },
);
}

return ok(undefined, requestId);
}

/**
* Executes a version-checked update.
*
* @param updateFn A function that performs the database update. It receives the new version number.
* It should return the updated entity or null/undefined if the update fails.
* @returns The result of the update function, or a conflict error.
*/
export async function withOptimisticLock<T extends VersionedEntity, R>(
options: UpdateOptions<T>,
updateFn: (newVersion: number) => Promise<R | null>,
): Promise<ApiResponse<R>> {
const versionCheckResult = checkVersion(options);
if (!versionCheckResult.success) {
return versionCheckResult;
}

const newVersion = options.dbEntity.version + 1;

try {
const result = await updateFn(newVersion);
// Assuming the update function returns null if the DB update fails (e.g., row count 0)
return result ? ok(result, options.requestId) : fromError(new Error('Update failed'), options.requestId);
} catch (err) {
return fromError(err, options.requestId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { checkVersion, VersionedEntity } from '../OptimisticLockService';

describe('OptimisticLockService', () => {
const actor = { id: 'user-123', type: 'user' as const };

describe('checkVersion', () => {
it('should succeed if versions match', () => {
const clientEntity: VersionedEntity = { id: 'sub-1', version: 2 };
const dbEntity: VersionedEntity = { id: 'sub-1', version: 2 };

const result = checkVersion({ clientEntity, dbEntity, actor });

expect(result.success).toBe(true);
});

it('should fail with 409 conflict if versions mismatch', () => {
const clientEntity: VersionedEntity = { id: 'sub-1', version: 1 };
const dbEntity: VersionedEntity = { id: 'sub-1', version: 2 };

const result = checkVersion({ clientEntity, dbEntity, actor });

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('CONFLICT_VERSION_MISMATCH');
expect(result.error.message).toContain('The resource was updated by another process.');
expect(result.error.version).toBe(2);
expect(result.meta.apiVersion).toBe(1);
}
});

it('should succeed if force=true is used, even with version mismatch', () => {
const clientEntity: VersionedEntity = { id: 'sub-1', version: 1 };
const dbEntity: VersionedEntity = { id: 'sub-1', version: 2 };

const result = checkVersion({ clientEntity, dbEntity, actor, force: true });

expect(result.success).toBe(true);
});

it('should include requestId in meta for both success and failure', () => {
const requestId = 'test-request-id';

// Success case
const successResult = checkVersion({
clientEntity: { id: 'sub-1', version: 1 },
dbEntity: { id: 'sub-1', version: 1 },
actor,
requestId,
});
expect(successResult.meta.requestId).toBe(requestId);

// Failure case
const failureResult = checkVersion({
clientEntity: { id: 'sub-1', version: 1 },
dbEntity: { id: 'sub-1', version: 2 },
actor,
requestId,
});
expect(failureResult.meta.requestId).toBe(requestId);
});

it('should handle a complex entity type', () => {
interface Subscription extends VersionedEntity {
name: string;
status: 'active' | 'paused';
}

const clientEntity: Subscription = {
id: 'sub-1',
version: 3,
name: 'New Name',
status: 'paused',
};
const dbEntity: Subscription = {
id: 'sub-1',
version: 4,
name: 'Old Name',
status: 'active',
};

const result = checkVersion({ clientEntity, dbEntity, actor });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.version).toBe(4);
}
});
});
});
124 changes: 124 additions & 0 deletions mobile/app/services/conflictResolutionService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* @file conflictResolutionService.ts
* @description Issue #613 - Client-side service for handling OCC conflicts.
*
* This service provides a wrapper for API mutation functions to automatically
* handle 409 version conflicts with a retry mechanism.
*/

import { create } from 'zustand';
import { ApiErrorResponse } from '../../../backend/services/shared/apiResponse';


export interface ConflictState<T> {
entityId: string | number;
/** The user's attempted changes that were rejected. */
localState: T;
/** The state of the entity on the server that caused the conflict. */
remoteState: T;
/** The error response from the server. */
error: ApiErrorResponse;
}

interface ConflictStore<T> {
conflict: ConflictState<T> | null;
resolve: (conflict: ConflictState<T> | null) => void;
}

// A generic Zustand store for managing a single, active conflict.
// In a real app, you might want a map of conflicts by entityId.
export const useConflictStore = create<ConflictStore<object>>((set) => ({
conflict: null,
resolve: (conflict) => set({ conflict }),
}));

export interface RetryOptions<T extends { id: string | number; version: number }> {
/** The mutation function to wrap. It must accept the entity to save. */
mutationFn: (entity: T) => Promise<ApiErrorResponse | { success: true; data: T }>;
/** A function to fetch the latest version of the entity from the server. */
fetchLatestFn: (id: string | number) => Promise<T>;
/** The initial entity state being submitted by the user. */
entity: T & { id: string | number; version: number };
/** Maximum number of retry attempts. Defaults to 3. */
maxRetries?: number;
/** Initial backoff delay in ms. Defaults to 100. */
initialBackoffMs?: number;
/** Optional callback for when retries are exhausted and manual resolution is required. */
onConflictResolved?: (conflict: ConflictState<T>) => void;
}

/**
* Wraps a mutation function with automatic retry logic for OCC conflicts.
* If all retries fail, it populates the conflict store for manual resolution.
*/
export async function withConflictResolution<T extends { id: string | number; version: number }>(
options: RetryOptions<T>,
): Promise<ApiErrorResponse | { success: true; data: T }> {
const {
mutationFn,
fetchLatestFn,
entity,
maxRetries = 3,
initialBackoffMs = 100,
onConflictResolved,
} = options;

let lastError: ApiErrorResponse | null = null;
let currentEntity = entity;

for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await mutationFn(currentEntity);

if (response.success) {
return response;
}

lastError = response;

// Check if it's a version conflict error
if (response.error.code === 'CONFLICT_VERSION_MISMATCH' && response.error.version !== undefined) {
// It's a conflict, try to fetch the latest version and retry
console.log(`Attempt ${attempt + 1}: Conflict detected. Retrying...`);

// Exponential backoff
if (attempt > 0) {
const backoff = initialBackoffMs * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, backoff));
}

try {
const latestEntity = await fetchLatestFn(entity.id);
// Merge user's changes onto the new base version
currentEntity = { ...latestEntity, ...entity, version: latestEntity.version };
continue; // Retry the loop
} catch (fetchError) {
console.error('Failed to fetch latest entity for conflict resolution:', fetchError);
// If fetching the latest fails, we can't proceed automatically.
break;
}
} else {
// Not a conflict error, so fail immediately
return response;
}
}

// If all retries are exhausted, set the conflict state for the UI to handle
if (lastError && lastError.error.code === 'CONFLICT_VERSION_MISMATCH') {
try {
const remoteState = await fetchLatestFn(entity.id);
const conflict: ConflictState<T> = {
entityId: entity.id,
localState: entity,
remoteState: remoteState,
error: lastError,
};
// Use the callback if provided, otherwise fall back to the global store
onConflictResolved ? onConflictResolved(conflict) : useConflictStore.getState().resolve(conflict);
} catch (fetchError) {
console.error('Failed to fetch latest entity for manual conflict resolution:', fetchError);
// Return the original error as we cannot construct the full conflict state
}
}

return lastError!;
}
2 changes: 1 addition & 1 deletion sandbox/services/usageTrackingService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { UsageMetrics, HourlyUsage, DailyUsage } from '../types/sandbox';
import { UsageMetrics } from '../types/sandbox';

export class UsageTrackingService {
private usageData: Map<string, UsageMetrics> = new Map();
Expand Down
Loading