Skip to content

Commit c70db69

Browse files
feat(api): Add concurrent subscription update conflict resolution wit… (Smartdevs17#685)
* feat(api): Add concurrent subscription update conflict resolution with OCC Implements an optimistic concurrency control (OCC) mechanism using version numbers to prevent silent data loss during concurrent updates. - Adds a 'version' field to key entities. - Introduces a backend OptimisticLockService for version checking. - Returns 409 Conflict on version mismatch. - Implements a client-side service with automatic retries and exponential backoff. - Prepares for manual conflict resolution via a Zustand store if retries fail. Closes Smartdevs17#613 * fix(lint): resolve prettier, unused var, and path errors Clears out multiple workspace errors: - Fixes Prettier layout/syntax issues in crdt.ts and .storybook/main.js. - Resolves import/export name collisions in the design-system barrel file. - Corrects broken relative import paths in test files. - Removes numerous unused variables and imports flagged by the linter. --------- Co-authored-by: whitezaddy <austinihueze@gmail.com>
1 parent 5497772 commit c70db69

18 files changed

Lines changed: 363 additions & 103 deletions

File tree

.storybook/main.js

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
/**
22
* Storybook Configuration for SubTrackr Design System
3-
*
3+
*
44
* Location: .storybook/main.js
55
* Run: npm run storybook
66
*/
77

88
module.exports = {
9-
stories: [
10-
'../src/design-system/stories/**/*.stories.{ts,tsx}',
11-
'../src/**/*.stories.{ts,tsx}',
12-
],
9+
stories: ['../src/design-system/stories/**/*.stories.{ts,tsx}', '../src/**/*.stories.{ts,tsx}'],
1310
addons: [
1411
'@storybook/addon-essentials',
1512
'@storybook/addon-ondevice-actions',
@@ -30,7 +27,7 @@ module.exports = {
3027
reactDocgenTypescriptOptions: {
3128
shouldExtractLiteralValuesAsTypes: true,
3229
shouldRemoveUndefinedFromOptional: true,
33-
propFilter: (prop: any) => {
30+
propFilter: (prop) => {
3431
if (prop.parent) {
3532
return !prop.parent.fileName.includes('node_modules');
3633
}

backend/services/shared/apiResponse.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export interface ApiError {
5555
message: string;
5656
/** Optional field-level validation details. */
5757
details?: Record<string, string>;
58+
/** For OCC conflicts, the current version of the resource on the server. */
59+
version?: number;
5860
}
5961

6062
/** Successful response envelope. */
@@ -94,6 +96,8 @@ export type ErrorCode =
9496
| 'UNAUTHORIZED'
9597
| 'FORBIDDEN'
9698
| 'CONFLICT'
99+
/** Optimistic Concurrency Control failure. */
100+
| 'CONFLICT_VERSION_MISMATCH'
97101
| 'BAD_REQUEST'
98102
| 'SERVICE_UNAVAILABLE'
99103
// ── Rate limiting ─────────────────────────────────────────────────────────
@@ -168,6 +172,7 @@ export const ERROR_HTTP_STATUS_MAP: Record<ErrorCode, number> = {
168172
UNAUTHORIZED: 401,
169173
FORBIDDEN: 403,
170174
CONFLICT: 409,
175+
CONFLICT_VERSION_MISMATCH: 409,
171176
BAD_REQUEST: 400,
172177
SERVICE_UNAVAILABLE: 503,
173178
// Rate limiting
@@ -279,11 +284,12 @@ export function fail(
279284
code: ErrorCode,
280285
message: string,
281286
requestId?: string,
282-
details?: Record<string, string>,
287+
details?: Record<string, string> | { version?: number },
283288
): ApiErrorResponse {
289+
const errorPayload: ApiError = { code, message, ...details };
284290
return {
285291
success: false,
286-
error: { code, message, ...(details ? { details } : {}) },
292+
error: errorPayload,
287293
meta: buildMeta(requestId),
288294
};
289295
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* @file OptimisticLockService.ts
3+
* @description Issue #613 - Service for Optimistic Concurrency Control (OCC).
4+
*
5+
* This service provides helpers to handle version-based optimistic locking.
6+
* It ensures that concurrent updates do not silently overwrite each other.
7+
*/
8+
9+
import { fail, fromError, ok, ApiResponse } from '../apiResponse';
10+
import { getLogger } from '../../../utils/logger';
11+
12+
const logger = getLogger('OptimisticLockService');
13+
14+
export interface VersionedEntity {
15+
id: string | number;
16+
version: number;
17+
}
18+
19+
export interface UpdateOptions<T extends VersionedEntity> {
20+
/** The entity state from the client, including the version they think they are updating. */
21+
clientEntity: T;
22+
/** The current entity state from the database. */
23+
dbEntity: T;
24+
/** The user or process making the request. */
25+
actor: { id: string; type: 'user' | 'system' };
26+
/** The unique request ID for logging. */
27+
requestId?: string;
28+
/** If true, bypasses the version check (for admin overrides). */
29+
force?: boolean;
30+
}
31+
32+
/**
33+
* Checks if an update operation can proceed by comparing client and database entity versions.
34+
*
35+
* @returns A successful ApiResponse if the update is allowed, or a 409 Conflict error response if not.
36+
*/
37+
export function checkVersion<T extends VersionedEntity>(
38+
options: UpdateOptions<T>,
39+
): ApiResponse<void> {
40+
const { clientEntity, dbEntity, actor, requestId, force = false } = options;
41+
42+
if (force) {
43+
logger.warn(
44+
{
45+
actor,
46+
entityId: dbEntity.id,
47+
clientVersion: clientEntity.version,
48+
dbVersion: dbEntity.version,
49+
requestId,
50+
},
51+
'OCC check bypassed with force=true',
52+
);
53+
return ok(undefined, requestId);
54+
}
55+
56+
if (clientEntity.version !== dbEntity.version) {
57+
logger.warn(
58+
{
59+
actor,
60+
entityId: dbEntity.id,
61+
clientVersion: clientEntity.version,
62+
dbVersion: dbEntity.version,
63+
requestId,
64+
},
65+
'OCC conflict detected: version mismatch',
66+
);
67+
return fail(
68+
'CONFLICT_VERSION_MISMATCH',
69+
`The resource was updated by another process. Please refresh and try again.`,
70+
requestId,
71+
{ version: dbEntity.version },
72+
);
73+
}
74+
75+
return ok(undefined, requestId);
76+
}
77+
78+
/**
79+
* Executes a version-checked update.
80+
*
81+
* @param updateFn A function that performs the database update. It receives the new version number.
82+
* It should return the updated entity or null/undefined if the update fails.
83+
* @returns The result of the update function, or a conflict error.
84+
*/
85+
export async function withOptimisticLock<T extends VersionedEntity, R>(
86+
options: UpdateOptions<T>,
87+
updateFn: (newVersion: number) => Promise<R | null>,
88+
): Promise<ApiResponse<R>> {
89+
const versionCheckResult = checkVersion(options);
90+
if (!versionCheckResult.success) {
91+
return versionCheckResult;
92+
}
93+
94+
const newVersion = options.dbEntity.version + 1;
95+
96+
try {
97+
const result = await updateFn(newVersion);
98+
// Assuming the update function returns null if the DB update fails (e.g., row count 0)
99+
return result ? ok(result, options.requestId) : fromError(new Error('Update failed'), options.requestId);
100+
} catch (err) {
101+
return fromError(err, options.requestId);
102+
}
103+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { checkVersion, VersionedEntity } from '../OptimisticLockService';
2+
3+
describe('OptimisticLockService', () => {
4+
const actor = { id: 'user-123', type: 'user' as const };
5+
6+
describe('checkVersion', () => {
7+
it('should succeed if versions match', () => {
8+
const clientEntity: VersionedEntity = { id: 'sub-1', version: 2 };
9+
const dbEntity: VersionedEntity = { id: 'sub-1', version: 2 };
10+
11+
const result = checkVersion({ clientEntity, dbEntity, actor });
12+
13+
expect(result.success).toBe(true);
14+
});
15+
16+
it('should fail with 409 conflict if versions mismatch', () => {
17+
const clientEntity: VersionedEntity = { id: 'sub-1', version: 1 };
18+
const dbEntity: VersionedEntity = { id: 'sub-1', version: 2 };
19+
20+
const result = checkVersion({ clientEntity, dbEntity, actor });
21+
22+
expect(result.success).toBe(false);
23+
if (!result.success) {
24+
expect(result.error.code).toBe('CONFLICT_VERSION_MISMATCH');
25+
expect(result.error.message).toContain('The resource was updated by another process.');
26+
expect(result.error.version).toBe(2);
27+
expect(result.meta.apiVersion).toBe(1);
28+
}
29+
});
30+
31+
it('should succeed if force=true is used, even with version mismatch', () => {
32+
const clientEntity: VersionedEntity = { id: 'sub-1', version: 1 };
33+
const dbEntity: VersionedEntity = { id: 'sub-1', version: 2 };
34+
35+
const result = checkVersion({ clientEntity, dbEntity, actor, force: true });
36+
37+
expect(result.success).toBe(true);
38+
});
39+
40+
it('should include requestId in meta for both success and failure', () => {
41+
const requestId = 'test-request-id';
42+
43+
// Success case
44+
const successResult = checkVersion({
45+
clientEntity: { id: 'sub-1', version: 1 },
46+
dbEntity: { id: 'sub-1', version: 1 },
47+
actor,
48+
requestId,
49+
});
50+
expect(successResult.meta.requestId).toBe(requestId);
51+
52+
// Failure case
53+
const failureResult = checkVersion({
54+
clientEntity: { id: 'sub-1', version: 1 },
55+
dbEntity: { id: 'sub-1', version: 2 },
56+
actor,
57+
requestId,
58+
});
59+
expect(failureResult.meta.requestId).toBe(requestId);
60+
});
61+
62+
it('should handle a complex entity type', () => {
63+
interface Subscription extends VersionedEntity {
64+
name: string;
65+
status: 'active' | 'paused';
66+
}
67+
68+
const clientEntity: Subscription = {
69+
id: 'sub-1',
70+
version: 3,
71+
name: 'New Name',
72+
status: 'paused',
73+
};
74+
const dbEntity: Subscription = {
75+
id: 'sub-1',
76+
version: 4,
77+
name: 'Old Name',
78+
status: 'active',
79+
};
80+
81+
const result = checkVersion({ clientEntity, dbEntity, actor });
82+
expect(result.success).toBe(false);
83+
if (!result.success) {
84+
expect(result.error.version).toBe(4);
85+
}
86+
});
87+
});
88+
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* @file conflictResolutionService.ts
3+
* @description Issue #613 - Client-side service for handling OCC conflicts.
4+
*
5+
* This service provides a wrapper for API mutation functions to automatically
6+
* handle 409 version conflicts with a retry mechanism.
7+
*/
8+
9+
import { create } from 'zustand';
10+
import { ApiErrorResponse } from '../../../backend/services/shared/apiResponse';
11+
12+
13+
export interface ConflictState<T> {
14+
entityId: string | number;
15+
/** The user's attempted changes that were rejected. */
16+
localState: T;
17+
/** The state of the entity on the server that caused the conflict. */
18+
remoteState: T;
19+
/** The error response from the server. */
20+
error: ApiErrorResponse;
21+
}
22+
23+
interface ConflictStore<T> {
24+
conflict: ConflictState<T> | null;
25+
resolve: (conflict: ConflictState<T> | null) => void;
26+
}
27+
28+
// A generic Zustand store for managing a single, active conflict.
29+
// In a real app, you might want a map of conflicts by entityId.
30+
export const useConflictStore = create<ConflictStore<object>>((set) => ({
31+
conflict: null,
32+
resolve: (conflict) => set({ conflict }),
33+
}));
34+
35+
export interface RetryOptions<T extends { id: string | number; version: number }> {
36+
/** The mutation function to wrap. It must accept the entity to save. */
37+
mutationFn: (entity: T) => Promise<ApiErrorResponse | { success: true; data: T }>;
38+
/** A function to fetch the latest version of the entity from the server. */
39+
fetchLatestFn: (id: string | number) => Promise<T>;
40+
/** The initial entity state being submitted by the user. */
41+
entity: T & { id: string | number; version: number };
42+
/** Maximum number of retry attempts. Defaults to 3. */
43+
maxRetries?: number;
44+
/** Initial backoff delay in ms. Defaults to 100. */
45+
initialBackoffMs?: number;
46+
/** Optional callback for when retries are exhausted and manual resolution is required. */
47+
onConflictResolved?: (conflict: ConflictState<T>) => void;
48+
}
49+
50+
/**
51+
* Wraps a mutation function with automatic retry logic for OCC conflicts.
52+
* If all retries fail, it populates the conflict store for manual resolution.
53+
*/
54+
export async function withConflictResolution<T extends { id: string | number; version: number }>(
55+
options: RetryOptions<T>,
56+
): Promise<ApiErrorResponse | { success: true; data: T }> {
57+
const {
58+
mutationFn,
59+
fetchLatestFn,
60+
entity,
61+
maxRetries = 3,
62+
initialBackoffMs = 100,
63+
onConflictResolved,
64+
} = options;
65+
66+
let lastError: ApiErrorResponse | null = null;
67+
let currentEntity = entity;
68+
69+
for (let attempt = 0; attempt < maxRetries; attempt++) {
70+
const response = await mutationFn(currentEntity);
71+
72+
if (response.success) {
73+
return response;
74+
}
75+
76+
lastError = response;
77+
78+
// Check if it's a version conflict error
79+
if (response.error.code === 'CONFLICT_VERSION_MISMATCH' && response.error.version !== undefined) {
80+
// It's a conflict, try to fetch the latest version and retry
81+
console.log(`Attempt ${attempt + 1}: Conflict detected. Retrying...`);
82+
83+
// Exponential backoff
84+
if (attempt > 0) {
85+
const backoff = initialBackoffMs * Math.pow(2, attempt);
86+
await new Promise((resolve) => setTimeout(resolve, backoff));
87+
}
88+
89+
try {
90+
const latestEntity = await fetchLatestFn(entity.id);
91+
// Merge user's changes onto the new base version
92+
currentEntity = { ...latestEntity, ...entity, version: latestEntity.version };
93+
continue; // Retry the loop
94+
} catch (fetchError) {
95+
console.error('Failed to fetch latest entity for conflict resolution:', fetchError);
96+
// If fetching the latest fails, we can't proceed automatically.
97+
break;
98+
}
99+
} else {
100+
// Not a conflict error, so fail immediately
101+
return response;
102+
}
103+
}
104+
105+
// If all retries are exhausted, set the conflict state for the UI to handle
106+
if (lastError && lastError.error.code === 'CONFLICT_VERSION_MISMATCH') {
107+
try {
108+
const remoteState = await fetchLatestFn(entity.id);
109+
const conflict: ConflictState<T> = {
110+
entityId: entity.id,
111+
localState: entity,
112+
remoteState: remoteState,
113+
error: lastError,
114+
};
115+
// Use the callback if provided, otherwise fall back to the global store
116+
onConflictResolved ? onConflictResolved(conflict) : useConflictStore.getState().resolve(conflict);
117+
} catch (fetchError) {
118+
console.error('Failed to fetch latest entity for manual conflict resolution:', fetchError);
119+
// Return the original error as we cannot construct the full conflict state
120+
}
121+
}
122+
123+
return lastError!;
124+
}

sandbox/services/usageTrackingService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { UsageMetrics, HourlyUsage, DailyUsage } from '../types/sandbox';
1+
import { UsageMetrics } from '../types/sandbox';
22

33
export class UsageTrackingService {
44
private usageData: Map<string, UsageMetrics> = new Map();

0 commit comments

Comments
 (0)