diff --git a/projects/kit/offline/src/lib/offline-repository-concurrency.ts b/projects/kit/offline/src/lib/offline-repository-concurrency.ts index 4f477cb..8a8b4a3 100644 --- a/projects/kit/offline/src/lib/offline-repository-concurrency.ts +++ b/projects/kit/offline/src/lib/offline-repository-concurrency.ts @@ -5,3 +5,39 @@ * This symbol is intentionally not re-exported from the package entry point. */ export const OFFLINE_REPOSITORY_ATOMIC_MUTATION: unique symbol = Symbol('OFFLINE_REPOSITORY_ATOMIC_MUTATION'); + +export type OfflineReplicaTransientWriteReason = 'concurrent_revision' | 'sqlite_busy' | 'sqlite_locked'; + +/** Internal typed boundary for a local write that is safe to recompute once from a fresh snapshot. */ +export class OfflineReplicaTransientWriteError extends Error { + constructor( + readonly reason: OfflineReplicaTransientWriteReason, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'OfflineReplicaTransientWriteError'; + } +} + +export function normalizeOfflineReplicaTransientWriteError(error: unknown): unknown { + if (error instanceof OfflineReplicaTransientWriteError) return error; + const code = + typeof error === 'object' && error !== null && typeof (error as { code?: unknown }).code === 'string' + ? (error as { code: string }).code.toUpperCase() + : ''; + const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; + const normalizedMessage = message.toUpperCase(); + if (code.includes('SQLITE_BUSY') || normalizedMessage.includes('SQLITE_BUSY')) { + return new OfflineReplicaTransientWriteError('sqlite_busy', message || 'SQLite is busy.', { cause: error }); + } + if ( + code.includes('SQLITE_LOCKED') || + normalizedMessage.includes('SQLITE_LOCKED') || + message.includes('database is locked') || + message.includes('database table is locked') + ) { + return new OfflineReplicaTransientWriteError('sqlite_locked', message || 'SQLite database is locked.', { cause: error }); + } + return error; +} diff --git a/projects/kit/offline/src/lib/offline-request-policy.ts b/projects/kit/offline/src/lib/offline-request-policy.ts index e0d93fb..7ff7bab 100644 --- a/projects/kit/offline/src/lib/offline-request-policy.ts +++ b/projects/kit/offline/src/lib/offline-request-policy.ts @@ -63,6 +63,8 @@ export interface OfflineReadRequestPlan { * winning remote response cancels and suppresses the slower local read. */ readStrategy?: OfflineReadStrategy; + /** Serializes read-only response projection behind replica mutations. Do not enable when projection starts a replica mutation itself. */ + serializeResponseProjection?: boolean; /** * Persists and projects a remote response, or projects a local fallback. * diff --git a/projects/kit/offline/src/lib/offline-sync.service.spec.ts b/projects/kit/offline/src/lib/offline-sync.service.spec.ts index b50dc18..2cf03ad 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -14,7 +14,7 @@ import { OfflineNetworkService } from './offline-network.service'; import { OfflineMutationAdmissionService, OfflineMutationPersistenceDisabledError } from './offline-mutation-admission.service'; import { OfflineReplicaPullService, OfflineReplicaSchemaMismatchError } from './offline-replica-pull.service'; import { OfflineReplicaMutationCoordinator } from './offline-replica-mutation-coordinator'; -import { OFFLINE_REPOSITORY_ATOMIC_MUTATION } from './offline-repository-concurrency'; +import { OFFLINE_REPOSITORY_ATOMIC_MUTATION, OfflineReplicaTransientWriteError } from './offline-repository-concurrency'; import { defineOfflineReplicaSchema, defineReplicaEntity, @@ -540,6 +540,40 @@ describe('OfflineSyncService', () => { }); it.each([ + [ + 'enqueue', + async () => { + await service.enqueue( + { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'atomic-owner-enqueue-new', remoteId: 42 }, + operation: 'documents.create', + payload: { title: 'new' }, + }, + { flush: false }, + ); + }, + ], + [ + 'enqueuePreparedBatch', + async () => { + await service.enqueuePreparedBatch( + async () => [ + { + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated' as const, localId: 'atomic-owner-batch-new', remoteId: 43 }, + operation: 'documents.create', + payload: { title: 'batch' }, + }, + }, + ], + { flush: false }, + ); + }, + ], ['discard', async (commandId: string) => service.discard(commandId, { flush: false })], [ 'retryNow', @@ -551,7 +585,7 @@ describe('OfflineSyncService', () => { }, ], ['discardAllPending', async () => service.discardAllPending()], - ] as const)('%s reads commands through the repository owned by the atomic mutation', async (_name, action) => { + ] as const)('%s uses only reads owned by the atomic mutation', async (_name, action) => { const commandId = await service.enqueue( { scopeId: '10', @@ -566,12 +600,36 @@ describe('OfflineSyncService', () => { [OFFLINE_REPOSITORY_ATOMIC_MUTATION]?: (operation: (owner: OfflineRepository) => Promise) => Promise; }; const rootGetCommandsForUser = vi.mocked(repository.getCommandsForUser!); - const ownerGetCommandsForUser = vi.fn(async (userId: number) => + const rootGetReplicaRow = vi.mocked(repository.getReplicaRow); + const rootGetReplicaRowIncludingPendingDelete = vi.mocked(repository.getReplicaRowIncludingPendingDelete!); + const rootGetReplicaRowByRemoteIdentity = vi.mocked(repository.getReplicaRowByRemoteIdentity); + const rootGetPullAttentions = vi.mocked(repository.getPullAttentions!); + const originalGetReplicaRow = rootGetReplicaRow.getMockImplementation()!; + const originalGetReplicaRowIncludingPendingDelete = rootGetReplicaRowIncludingPendingDelete.getMockImplementation()!; + const originalGetReplicaRowByRemoteIdentity = rootGetReplicaRowByRemoteIdentity.getMockImplementation()!; + const originalGetPullAttentions = rootGetPullAttentions.getMockImplementation()!; + const ownerGetCommandsForUser = vi.fn(async (userId: OfflinePrincipalId) => commands.filter((command) => command.userId === userId).map((command) => structuredClone(command)), ); - const owner = { ...repository, getCommandsForUser: ownerGetCommandsForUser } as OfflineRepository; + const ownerGetReplicaRow = vi.fn(originalGetReplicaRow); + const ownerGetReplicaRowIncludingPendingDelete = vi.fn(originalGetReplicaRowIncludingPendingDelete); + const ownerGetReplicaRowByRemoteIdentity = vi.fn(originalGetReplicaRowByRemoteIdentity); + const ownerGetPullAttentions = vi.fn(originalGetPullAttentions); + const owner = { + ...repository, + getCommandsForUser: ownerGetCommandsForUser, + getReplicaRow: ownerGetReplicaRow, + getReplicaRowIncludingPendingDelete: ownerGetReplicaRowIncludingPendingDelete, + getReplicaRowByRemoteIdentity: ownerGetReplicaRowByRemoteIdentity, + getPullAttentions: ownerGetPullAttentions, + } as OfflineRepository; let atomicMutationActive = false; let rootReadsDuringAtomicMutation = 0; + const failRootReadDuringAtomicMutation = (): void => { + if (!atomicMutationActive) return; + rootReadsDuringAtomicMutation += 1; + throw new Error('Use the repository passed to an atomic mutation for snapshot reads.'); + }; repository[OFFLINE_REPOSITORY_ATOMIC_MUTATION] = (operation) => { atomicMutationActive = true; return operation(owner).finally(() => { @@ -580,12 +638,25 @@ describe('OfflineSyncService', () => { }; rootGetCommandsForUser.mockClear(); rootGetCommandsForUser.mockImplementation(async (userId: OfflinePrincipalId) => { - if (atomicMutationActive) { - rootReadsDuringAtomicMutation += 1; - throw new Error('Use the repository passed to an atomic mutation for snapshot reads.'); - } + failRootReadDuringAtomicMutation(); return commands.filter((command) => command.userId === userId).map((command) => structuredClone(command)); }); + rootGetReplicaRow.mockImplementation(async (...args) => { + failRootReadDuringAtomicMutation(); + return originalGetReplicaRow(...args); + }); + rootGetReplicaRowIncludingPendingDelete.mockImplementation(async (...args) => { + failRootReadDuringAtomicMutation(); + return originalGetReplicaRowIncludingPendingDelete(...args); + }); + rootGetReplicaRowByRemoteIdentity.mockImplementation(async (...args) => { + failRootReadDuringAtomicMutation(); + return originalGetReplicaRowByRemoteIdentity(...args); + }); + rootGetPullAttentions.mockImplementation(async (...args) => { + failRootReadDuringAtomicMutation(); + return originalGetPullAttentions(...args); + }); await expect(action(commandId)).resolves.toBeUndefined(); @@ -3766,9 +3837,7 @@ describe('OfflineSyncService', () => { it.each([0, 408])('background遷移をまたいだpullのstatus %sは報告せずforeground復帰後に再同期する', async (status) => { let rejectSuspendedPull!: (error: unknown) => void; - pull.mockImplementationOnce( - () => new Promise((_resolve, reject) => (rejectSuspendedPull = reject)), - ); + pull.mockImplementationOnce(() => new Promise((_resolve, reject) => (rejectSuspendedPull = reject))); connected.set(true); await service.initialize(); await vi.waitFor(() => expect(pull).toHaveBeenCalledOnce()); @@ -4028,12 +4097,45 @@ describe('OfflineSyncService', () => { expect(execute).not.toHaveBeenCalled(); }); - it('transactReplica failureはrejectしbackground flushはErrorHandlerへ渡す', async () => { + it('transport成功後の一時的なlocal ACK failureは通信を再送せずfresh snapshotで確定する', async () => { + const repository = TestBed.inject(OFFLINE_REPOSITORY) as OfflineRepository; + const originalTransact = vi.mocked(repository.transactReplica).getMockImplementation()!; + let remainingFailures = 1; + vi.mocked(repository.transactReplica).mockImplementation(async (transaction) => { + if (transaction.putCommands?.some((command) => command.state === 'awaiting_pull') && remainingFailures > 0) { + remainingFailures -= 1; + throw new OfflineReplicaTransientWriteError('sqlite_busy', 'SQLITE_BUSY'); + } + return originalTransact(transaction); + }); + await service.enqueue( + { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: '1' }, + operation: 'documents.upsert', + payload: {}, + }, + { flush: false }, + ); + connected.set(true); + await service.flush(); + + expect(execute).toHaveBeenCalledTimes(1); + expect(service.pendingCommands()[0]).toMatchObject({ + state: 'awaiting_pull', + lastErrorCode: null, + serverCommitUnknown: false, + }); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('繰り返すlocal ACK failureはlocal_completionとして記録しbackground flushはErrorHandlerへ渡す', async () => { const repository = TestBed.inject(OFFLINE_REPOSITORY) as OfflineRepository; const originalTransact = vi.mocked(repository.transactReplica).getMockImplementation()!; vi.mocked(repository.transactReplica).mockImplementation(async (transaction) => { if (transaction.putCommands?.some((command) => command.state === 'awaiting_pull')) { - throw new Error('transaction failed'); + throw new OfflineReplicaTransientWriteError('sqlite_locked', 'SQLITE_LOCKED'); } return originalTransact(transaction); }); @@ -4049,11 +4151,18 @@ describe('OfflineSyncService', () => { ); connected.set(true); await service.refreshSession(); - await vi.waitFor(() => expect(handleError).toHaveBeenCalledWith(expect.objectContaining({ message: 'transaction failed' }))); + await vi.waitFor(() => + expect(handleError).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'OfflineLocalCompletionError', + message: 'Offline command reached the server but local acknowledgement could not be persisted.', + }), + ), + ); await service.refreshSession(); await expect(service.flush()).resolves.toBeUndefined(); - expect(service.pendingCommands()[0]).toMatchObject({ state: 'retry_wait', lastErrorCode: 'network' }); + expect(service.pendingCommands()[0]).toMatchObject({ state: 'retry_wait', lastErrorCode: 'local_completion' }); }); it('executor error without integer statusもsendingに残さずretry_waitへ戻す', async () => { diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index c40b5a7..04b2ae9 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -28,6 +28,7 @@ import type { OfflineScope, } from './offline-repository'; import { canonicalOfflineReplicaRowKey, OFFLINE_REPOSITORY } from './offline-repository'; +import { OfflineReplicaTransientWriteError } from './offline-repository-concurrency'; import { canonicalOfflinePrincipalId, canonicalOfflineCommandIdentity, @@ -401,7 +402,7 @@ export class OfflineSyncService { const materializations: MaterializedOfflineEnqueue[] = []; for (const [index, entry] of prepared.entries()) { this.#assertEnqueueScope(session, entry.request.scopeId); - materializations.push(await this.#materializeEnqueue(session.userId, entry.request, replaced[index])); + materializations.push(await this.#materializeEnqueue(session.userId, entry.request, repository, replaced[index])); } const retained = knownCommands.filter((command) => !replaced.some((item) => item.commandId === command.commandId)); this.#assertDistinctBatchFootprints(materializations, retained, true); @@ -463,8 +464,8 @@ export class OfflineSyncService { ): Promise { const session = await this.#beginEnqueueSession(generation); this.#assertEnqueueScope(session, request.scopeId); - const materialization = await this.#materializeEnqueue(session.userId, request, replaced); - const currentCommands = await this.#commandsForUser(session.userId); + const materialization = await this.#materializeEnqueue(session.userId, request, repository, replaced); + const currentCommands = await this.#commandsForUser(session.userId, repository); const retainedCommands = replaced ? currentCommands.filter((command) => command.commandId !== replaced.commandId) : currentCommands; this.#assertDistinctBatchFootprints([materialization], retainedCommands); await this.#assertOutboxCapacity( @@ -487,14 +488,14 @@ export class OfflineSyncService { throw new Error('Prepared offline batch must contain at least one command.'); } const session = await this.#beginEnqueueSession(generation); - const currentCommands = await this.#commandsForUser(session.userId); + const currentCommands = await this.#commandsForUser(session.userId, repository); this.#rememberCreatedAt(currentCommands); const firstCreatedAt = Math.max(Date.now(), this.#lastCommandCreatedAt + 1); this.#lastCommandCreatedAt = firstCreatedAt + prepared.length - 1; const materializations: MaterializedOfflineEnqueue[] = []; for (const [index, entry] of prepared.entries()) { this.#assertEnqueueScope(session, entry.request.scopeId); - materializations.push(await this.#materializeEnqueue(session.userId, entry.request, undefined, firstCreatedAt + index)); + materializations.push(await this.#materializeEnqueue(session.userId, entry.request, repository, undefined, firstCreatedAt + index)); } this.#assertDistinctBatchFootprints(materializations, currentCommands); await this.#assertOutboxCapacity( @@ -533,13 +534,14 @@ export class OfflineSyncService { async #materializeEnqueue( userId: OfflinePrincipalId, request: EnqueueOfflineCommand, + repository: OfflineRepository, replaced?: OfflineCommand, createdAt?: number, ): Promise { const scope = { userId, scopeId: request.scopeId }; this.noteScope(scope); const commandIdentity = offlineCommandLookupIdentity(request.identity); - const normalized = await this.#normalizeEnqueueRequest(scope, request, commandIdentity); + const normalized = await this.#normalizeEnqueueRequest(scope, request, commandIdentity, repository); const commandId = crypto.randomUUID(); const sourceKey = this.#hooks.entityType(request); const localOnlyFootprint = this.#normalizedLocalOnlyFootprint(scope, request.localOnlyFootprint); @@ -557,7 +559,7 @@ export class OfflineSyncService { state: 'pending', attempts: 0, retryAt: null, - createdAt: replaced?.createdAt ?? createdAt ?? (await this.#nextCommandCreatedAt(userId)), + createdAt: replaced?.createdAt ?? createdAt ?? (await this.#nextCommandCreatedAt(userId, repository)), lastErrorCode: null, }; if (localOnlyFootprint.length > 0) command = { ...command, localOnlyFootprint }; @@ -582,10 +584,10 @@ export class OfflineSyncService { if (schema.identity.kind === 'naturalKey' && request.identity.kind !== 'natural') { throw new Error(`Offline replica source "${entityType}" requires natural identity.`); } - if (request.replicaMutation === 'delete' && !this.#repository.getReplicaRowIncludingPendingDelete) { + if (request.replicaMutation === 'delete' && !repository.getReplicaRowIncludingPendingDelete) { throw new Error('Offline repository does not support durable replica delete tombstones.'); } - const existing = await this.#getReplicaRowForSync(scope, entityType, commandIdentity); + const existing = await this.#getReplicaRowForSync(scope, entityType, commandIdentity, repository); const generatedIdentity = request.identity.kind === 'generated' ? request.identity : null; const initialRemoteId = this.#initialRemoteId( schema, @@ -632,7 +634,7 @@ export class OfflineSyncService { } } if (remoteIdentity !== null) { - const mapped = await this.#repository.getReplicaRowByRemoteIdentity(scope, entityType, remoteIdentity); + const mapped = await repository.getReplicaRowByRemoteIdentity(scope, entityType, remoteIdentity); if (mapped !== null && !commandIdentityMatchesReplicaRow(schema, mapped, commandIdentity)) { if ('remoteId' in remoteIdentity) { throw new Error(`Offline replica remote id ${String(remoteIdentity.remoteId)} is already mapped to another row.`); @@ -736,7 +738,7 @@ export class OfflineSyncService { putCommands: entries.map((entry) => entry.command), removeCommandIds, }); - await this.#refreshState().catch((error) => this.#reportError(error)); + await this.#refreshState(generation, repository).catch((error) => this.#reportError(error)); if (options.flush !== false && this.#canSynchronize()) this.#flushInBackground(); } @@ -1250,7 +1252,7 @@ export class OfflineSyncService { } const result = execution.result; if (!this.#isCurrent(generation)) return; - const completeCommand = async (): Promise => this.#completeCommand(commands, sending, result, generation); + const completeCommand = async (): Promise => this.#completeCommandWithRetry(commands, sending, result, generation); const completion = await completeCommand().then( () => ({ status: 'fulfilled' as const }), (error: unknown) => ({ status: 'rejected' as const, error }), @@ -1271,10 +1273,11 @@ export class OfflineSyncService { scope: OfflineScope, request: EnqueueOfflineCommand, commandIdentity: OfflineCommandIdentity, + repository: OfflineRepository, ): Promise<{ payload: T; baseRevision: string | number | null }> { let baseRevision = request.baseRevision ?? null; const sourceKey = this.#hooks.entityType(request); - const row = await this.#getReplicaRowForSync(scope, sourceKey, commandIdentity); + const row = await this.#getReplicaRowForSync(scope, sourceKey, commandIdentity, repository); if (row?.serverRevision != null && row.serverRevision !== baseRevision) { baseRevision = row.serverRevision; } @@ -1290,6 +1293,24 @@ export class OfflineSyncService { return this.#serializeReplicaMutation((repository) => this.#completeCommandLocked(commands, command, result, generation, repository)); } + async #completeCommandWithRetry( + commands: OfflineCommand[], + command: OfflineCommand, + result: OfflineCommandResult, + generation: number, + ): Promise { + return this.#completeCommand(commands, command, result, generation).catch(async (firstError: unknown) => { + if (!isRetryableLocalCompletionError(firstError)) throw firstError; + const retry = await this.#completeCommand(commands, command, result, generation).then( + () => ({ status: 'fulfilled' as const }), + (error: unknown) => ({ status: 'rejected' as const, error }), + ); + if (retry.status === 'fulfilled') return; + if (!isRetryableLocalCompletionError(retry.error)) throw retry.error; + throw new OfflineLocalCompletionError(retry.error, firstError); + }); + } + async #completeCommandLocked( commands: OfflineCommand[], command: OfflineCommand, @@ -1303,7 +1324,7 @@ export class OfflineSyncService { if (result.clearRemoteId === true && result.serverRevision !== undefined) { throw new Error('Offline command cannot return serverRevision and clearRemoteId together.'); } - const latestCommands = (await this.#readKnownCommands()).filter( + const latestCommands = (await this.#readKnownCommands(repository)).filter( (candidate) => this.#aggregateKey(candidate) === this.#aggregateKey(command), ); const latestIndex = latestCommands.findIndex((candidate) => candidate.commandId === command.commandId); @@ -1316,7 +1337,7 @@ export class OfflineSyncService { : revision === undefined ? following : following.map((item) => offlineCommandWithBaseRevision(item, revision)); - const current = await this.#rowForCommand(command); + const current = await this.#rowForCommand(command, repository); if (!this.#isCurrent(generation)) return; if (!current) { throw new Error(`Offline replica row disappeared while completing command ${command.commandId}.`); @@ -1524,7 +1545,7 @@ export class OfflineSyncService { ...command, state: 'retry_wait', retryAt, - lastErrorCode: status > 0 ? String(status) : 'network', + lastErrorCode: error instanceof OfflineLocalCompletionError ? 'local_completion' : status > 0 ? String(status) : 'network', serverCommitUnknown, }; } @@ -1724,8 +1745,8 @@ export class OfflineSyncService { return commands; } - async #nextCommandCreatedAt(userId: OfflinePrincipalId): Promise { - const commands = await this.#commandsForUser(userId); + async #nextCommandCreatedAt(userId: OfflinePrincipalId, repository: OfflineRepository = this.#repository): Promise { + const commands = await this.#commandsForUser(userId, repository); this.#rememberCreatedAt(commands); const createdAt = Math.max(Date.now(), this.#lastCommandCreatedAt + 1); this.#lastCommandCreatedAt = createdAt; @@ -1891,3 +1912,16 @@ export class OfflineSyncService { function compareOfflineCommands(left: OfflineCommand, right: OfflineCommand): number { return left.createdAt - right.createdAt || (left.commandId < right.commandId ? -1 : left.commandId > right.commandId ? 1 : 0); } + +class OfflineLocalCompletionError extends AggregateError { + constructor(retryError: unknown, firstError: unknown) { + super([firstError, retryError], 'Offline command reached the server but local acknowledgement could not be persisted.', { + cause: retryError, + }); + this.name = 'OfflineLocalCompletionError'; + } +} + +function isRetryableLocalCompletionError(error: unknown): boolean { + return error instanceof OfflineReplicaTransientWriteError; +} diff --git a/projects/kit/offline/src/lib/offline.interceptor.spec.ts b/projects/kit/offline/src/lib/offline.interceptor.spec.ts index e04578b..d7261b7 100644 --- a/projects/kit/offline/src/lib/offline.interceptor.spec.ts +++ b/projects/kit/offline/src/lib/offline.interceptor.spec.ts @@ -83,6 +83,45 @@ describe('offlineInterceptor', () => { expect(markApiSuccess).toHaveBeenCalledOnce(); }); + it('remote responseの投影をin-flight replica mutationの完了後まで待つ', async () => { + const coordinator = TestBed.inject(OfflineReplicaMutationCoordinator); + let releaseMutation!: () => void; + const mutationGate = new Promise((resolve) => (releaseMutation = resolve)); + let mutationStarted!: () => void; + const mutationReady = new Promise((resolve) => (mutationStarted = resolve)); + const mutation = coordinator.run(async () => { + mutationStarted(); + await mutationGate; + }); + await mutationReady; + + const transportResponse = new HttpResponse({ body: { value: 'remote' }, status: 200 }); + const projectResponse = vi.fn(async () => transportResponse); + resolve.mockReturnValue({ kind: 'read', readLocal: vi.fn(), projectResponse, serializeResponseProjection: true }); + + const projected = firstValueFrom(run(new HttpRequest('GET', '/bootstrap'), () => of(transportResponse))); + await new Promise((resolve) => queueMicrotask(resolve)); + expect(projectResponse).not.toHaveBeenCalled(); + + releaseMutation(); + await mutation; + await expect(projected).resolves.toBe(transportResponse); + expect(projectResponse).toHaveBeenCalledOnce(); + }); + + it('mutationを開始する投影はreplica laneの外で実行する', async () => { + const coordinator = TestBed.inject(OfflineReplicaMutationCoordinator); + const transportResponse = new HttpResponse({ body: { value: 'remote' }, status: 200 }); + const projectResponse = vi.fn(async () => { + await coordinator.run(async () => undefined); + return transportResponse; + }); + resolve.mockReturnValue({ kind: 'read', readLocal: vi.fn(), projectResponse }); + + await expect(firstValueFrom(run(new HttpRequest('GET', '/status'), () => of(transportResponse)))).resolves.toBe(transportResponse); + expect(projectResponse).toHaveBeenCalledOnce(); + }); + it('remote projection失敗はlocal fallbackで隠さない', async () => { const projectionError = new HttpErrorResponse({ status: 0, error: new Error('local persistence failed') }); const readLocal = vi.fn(); @@ -711,9 +750,9 @@ describe('offlineInterceptor', () => { const remoteError = new HttpErrorResponse({ status: 500, error: 'server error' }); resolve.mockReturnValue(fastestFirstPlan({ readLocal: vi.fn(async () => null) })); - await expect( - firstValueFrom(run(new HttpRequest('GET', '/bootstrap'), () => throwError(() => remoteError))), - ).rejects.toBe(remoteError); + await expect(firstValueFrom(run(new HttpRequest('GET', '/bootstrap'), () => throwError(() => remoteError)))).rejects.toBe( + remoteError, + ); }); }); diff --git a/projects/kit/offline/src/lib/offline.interceptor.ts b/projects/kit/offline/src/lib/offline.interceptor.ts index 3204eae..d5631a4 100644 --- a/projects/kit/offline/src/lib/offline.interceptor.ts +++ b/projects/kit/offline/src/lib/offline.interceptor.ts @@ -54,7 +54,7 @@ export const offlineInterceptor: HttpInterceptorFn = (request, next) => { if (plan.readStrategy === 'fastest-first') { return readFastestFirst(request, plan, transport, fallback, inject(ErrorHandler), inject(OfflineReplicaMutationCoordinator)); } - return readNetworkFirst(request, plan, transport, fallback); + return readNetworkFirst(request, plan, transport, fallback, inject(OfflineReplicaMutationCoordinator)); } if (LOCAL_FIRST_MUTATION_METHODS.has(request.method)) { if (!inject(OFFLINE_MUTATION_PERSISTENCE_ENABLED)()) return transport(); @@ -73,10 +73,11 @@ function readNetworkFirst( plan: OfflineReadRequestPlan, transport: () => Observable>, fallback: OfflineRequestFallbackService, + replicaMutations: OfflineReplicaMutationCoordinator, ): Observable> { return defer(transport).pipe( catchError((error: unknown) => fallback.handle(request, error, plan) ?? throwError(() => error)), - concatMap((event) => projectReadResponse(event, plan)), + concatMap((event) => projectReadResponse(event, plan, replicaMutations)), ); } @@ -108,8 +109,8 @@ function readLocalFirst( resolveLocalAttempt(plan, errorHandler, replicaMutations).pipe( concatMap((localResponse) => localResponse - ? concat(of(localResponse), drainRemoteAfterLocal(bufferedTransport$, plan)) - : drainRemoteNetworkFirst(bufferedTransport$, request, plan, fallback), + ? concat(of(localResponse), drainRemoteAfterLocal(bufferedTransport$, plan, replicaMutations)) + : drainRemoteNetworkFirst(bufferedTransport$, request, plan, fallback, replicaMutations), ), ), { connector: () => new ReplaySubject() }, @@ -133,13 +134,13 @@ function readFastestFirst( const localDecision$ = resolveLocalAttempt(plan, errorHandler, replicaMutations).pipe( concatMap((localResponse) => localResponse - ? concat(of(localResponse), drainRemoteAfterLocal(bufferedTransport$, plan)) - : drainRemoteNetworkFirst(bufferedTransport$, request, plan, fallback), + ? concat(of(localResponse), drainRemoteAfterLocal(bufferedTransport$, plan, replicaMutations)) + : drainRemoteNetworkFirst(bufferedTransport$, request, plan, fallback, replicaMutations), ), ); // Angular transport emits Sent/progress events before the response. // They are not usable read results and therefore must not win the race. - const remoteWinner$ = drainRemoteNetworkFirst(bufferedTransport$, request, plan, fallback).pipe( + const remoteWinner$ = drainRemoteNetworkFirst(bufferedTransport$, request, plan, fallback, replicaMutations).pipe( filter((event) => event instanceof AngularHttpResponse), // A remote error is not a usable response. Keep it buffered until the // local attempt decides whether it can satisfy the read. @@ -162,7 +163,7 @@ function resolveLocalAttempt( errorHandler.handleError(localError); return of(null); }), - concatMap((local) => (local ? tryProjectLocal(local, plan, errorHandler) : of(null))), + concatMap((local) => (local ? tryProjectLocal(local, plan, errorHandler, replicaMutations) : of(null))), take(1), ); } @@ -196,8 +197,9 @@ function tryProjectLocal( cached: AngularHttpResponse, plan: OfflineReadRequestPlan, errorHandler: ErrorHandler, + replicaMutations: OfflineReplicaMutationCoordinator, ): Observable | null> { - return emitTaggedLocalResponse(cached, plan).pipe( + return emitTaggedLocalResponse(cached, plan, replicaMutations).pipe( catchError((error: unknown) => { errorHandler.handleError(error); return of(null); @@ -205,18 +207,23 @@ function tryProjectLocal( ); } -function emitTaggedLocalResponse(cached: AngularHttpResponse, plan: OfflineReadRequestPlan): Observable> { - return projectReadResponse(cached.clone({ headers: cached.headers.set(OFFLINE_RESPONSE_HEADER, 'local') }), plan); +function emitTaggedLocalResponse( + cached: AngularHttpResponse, + plan: OfflineReadRequestPlan, + replicaMutations: OfflineReplicaMutationCoordinator, +): Observable> { + return projectReadResponse(cached.clone({ headers: cached.headers.set(OFFLINE_RESPONSE_HEADER, 'local') }), plan, replicaMutations); } function drainRemoteAfterLocal( bufferedTransport$: Observable, plan: OfflineReadRequestPlan, + replicaMutations: OfflineReplicaMutationCoordinator, ): Observable> { return bufferedTransport$.pipe( dematerialize(), catchError((error: unknown) => (isOfflineFallbackError(error) ? EMPTY : throwError(() => error))), - concatMap((event) => projectReadResponse(event, plan)), + concatMap((event) => projectReadResponse(event, plan, replicaMutations)), ); } @@ -225,18 +232,26 @@ function drainRemoteNetworkFirst( request: HttpRequest, plan: OfflineReadRequestPlan, fallback: OfflineRequestFallbackService, + replicaMutations: OfflineReplicaMutationCoordinator, ): Observable> { return bufferedTransport$.pipe( dematerialize(), catchError((error: unknown) => fallback.handle(request, error, plan) ?? throwError(() => error)), - concatMap((event) => projectReadResponse(event, plan)), + concatMap((event) => projectReadResponse(event, plan, replicaMutations)), ); } -function projectReadResponse(event: HttpEvent, plan: OfflineReadRequestPlan): Observable> { +function projectReadResponse( + event: HttpEvent, + plan: OfflineReadRequestPlan, + replicaMutations: OfflineReplicaMutationCoordinator, +): Observable> { if (!(event instanceof AngularHttpResponse) || !plan.projectResponse) return of(event); const source = event.headers.get(OFFLINE_RESPONSE_HEADER) === 'local' ? 'local' : 'remote'; - return from(plan.projectResponse(event, source)).pipe( + const projection = plan.serializeResponseProjection + ? replicaMutations.runSerializedRead(() => plan.projectResponse!(event, source)) + : plan.projectResponse(event, source); + return from(projection).pipe( map((response) => source === 'local' ? response.clone({ headers: response.headers.set(OFFLINE_RESPONSE_HEADER, 'local') }) : response, ), diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index 3e6ae61..9dfcc1a 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -44,7 +44,11 @@ import { type OfflineReplicaTransaction, type OfflineScope, } from './offline-repository'; -import { OFFLINE_REPOSITORY_ATOMIC_MUTATION } from './offline-repository-concurrency'; +import { + normalizeOfflineReplicaTransientWriteError, + OFFLINE_REPOSITORY_ATOMIC_MUTATION, + OfflineReplicaTransientWriteError, +} from './offline-repository-concurrency'; import { OfflineStorageUnavailableError } from './offline-storage'; import { COMMUNITY_SQLITE_ENCRYPTED, @@ -818,7 +822,9 @@ export class SqliteOfflineRepository implements OfflineRepository { } async #nativeTransaction(databaseId: string, run: () => Promise): Promise { - await this.#sqlite!.beginTransaction({ databaseId }); + await this.#sqlite!.beginTransaction({ databaseId }).catch((error: unknown) => { + throw normalizeOfflineReplicaTransientWriteError(error); + }); const execute = async (): Promise => run(); return execute() .then(async (result) => { @@ -826,16 +832,17 @@ export class SqliteOfflineRepository implements OfflineRepository { return result; }) .catch(async (error: unknown) => { + const normalizedError = normalizeOfflineReplicaTransientWriteError(error); const rollback = await this.#sqlite!.rollbackTransaction({ databaseId }).then( () => ({ status: 'fulfilled' as const }), (rollbackError: unknown) => ({ status: 'rejected' as const, rollbackError }), ); if (rollback.status === 'rejected') { - throw new AggregateError([error, rollback.rollbackError], 'Offline SQLite transaction and rollback both failed.', { - cause: error, + throw new AggregateError([normalizedError, rollback.rollbackError], 'Offline SQLite transaction and rollback both failed.', { + cause: normalizedError, }); } - throw error; + throw normalizedError; }); } @@ -1115,7 +1122,10 @@ export class SqliteOfflineRepository implements OfflineRepository { await this.#execute(databaseId, 'UPDATE offline_metadata SET schema_version = schema_version WHERE id = 1'); const actual = await this.#dataVersion(databaseId); if (actual !== expected) { - throw new Error('Offline replica changed through another SQLite connection; retry the operation from fresh state.'); + throw new OfflineReplicaTransientWriteError( + 'concurrent_revision', + 'Offline replica changed through another SQLite connection; retry the operation from fresh state.', + ); } return run(databaseId); });