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 31ed76c..3857980 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -24,7 +24,12 @@ import { type OfflineScope, } from './offline-repository'; import { generatedCommandIdentity } from './offline-test-helpers'; -import { OfflineCommandInFlightError, OfflinePayloadValidationError, OfflineSyncService } from './offline-sync.service'; +import { + OfflineCommandInFlightError, + OfflinePayloadValidationError, + OfflineSyncService, + type PreparedOfflineCommand, +} from './offline-sync.service'; const replicaSchema = defineOfflineReplicaSchema({ version: 1, @@ -532,6 +537,221 @@ describe('OfflineSyncService', () => { expect(rows).toEqual([]); }); + describe('enqueuePreparedBatch', () => { + const prepared = (localId: string, title: string, companion?: OfflineReplicaRow): PreparedOfflineCommand => ({ + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId }, + operation: 'documents.create', + payload: { title }, + optimisticValue: { id: 0, title }, + }, + replicaTransaction: companion ? { putRows: [companion] } : undefined, + }); + + it('2件の成功は1回のtransactReplicaでFIFO createdAtを永続化する', async () => { + const repository = TestBed.inject(OFFLINE_REPOSITORY); + const transactReplica = vi.mocked(repository.transactReplica); + + const commandIds = await service.enqueuePreparedBatch(async () => [prepared('batch-a', 'A'), prepared('batch-b', 'B')], { + flush: false, + }); + + expect(commandIds).toHaveLength(2); + expect(transactReplica).toHaveBeenCalledTimes(1); + expect(commands).toHaveLength(2); + expect(commands.map((command) => (command.identity.kind === 'generated' ? command.identity.localId : ''))).toEqual([ + 'batch-a', + 'batch-b', + ]); + expect(commands[0]!.createdAt).toBeLessThan(commands[1]!.createdAt); + expect(commands.map((command) => command.commandId)).toEqual([...commandIds]); + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + identity: expect.objectContaining({ localId: 'batch-a' }), + values: { id: 0, title: 'A' }, + }), + expect.objectContaining({ + identity: expect.objectContaining({ localId: 'batch-b' }), + values: { id: 0, title: 'B' }, + }), + ]), + ); + }); + + it('同一principalの複数scopeを1回のtransactionで受け付ける', async () => { + localSession = { + userId: 1, + scopes: [ + { userId: 1, scopeId: '10' }, + { userId: 1, scopeId: '20' }, + ], + }; + const repository = TestBed.inject(OFFLINE_REPOSITORY); + const transactReplica = vi.mocked(repository.transactReplica); + + await service.enqueuePreparedBatch( + async () => [ + prepared('scope-10', 'A'), + { ...prepared('scope-20', 'B'), request: { ...prepared('scope-20', 'B').request, scopeId: '20' } }, + ], + { flush: false }, + ); + + expect(transactReplica).toHaveBeenCalledTimes(1); + expect(commands.map(({ scopeId }) => scopeId)).toEqual(['10', '20']); + }); + + it('k番目のprepare/validation失敗では一切書き込まない', async () => { + const repository = TestBed.inject(OFFLINE_REPOSITORY); + const transactReplica = vi.mocked(repository.transactReplica); + + await expect( + service.enqueuePreparedBatch(async () => { + throw new Error('prepare failed at second derive'); + }), + ).rejects.toThrow('prepare failed at second derive'); + expect(transactReplica).not.toHaveBeenCalled(); + expect(commands).toEqual([]); + expect(rows).toEqual([]); + + await expect( + service.enqueuePreparedBatch(async () => [ + prepared('batch-ok', 'ok'), + { + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'natural', naturalKey: { favFrom: 1, favTo: 'x' } }, + operation: 'documents.create', + payload: {}, + optimisticValue: { id: 0, title: 'bad' }, + }, + }, + ]), + ).rejects.toThrow('requires generated identity'); + expect(transactReplica).not.toHaveBeenCalled(); + expect(commands).toEqual([]); + expect(rows).toEqual([]); + }); + + it('aggregateまたはreplica footprintの重複を拒否して書き込まない', async () => { + const repository = TestBed.inject(OFFLINE_REPOSITORY); + const transactReplica = vi.mocked(repository.transactReplica); + const companion = (localId: string): OfflineReplicaRow => ({ + userId: 1, + scopeId: '10', + sourceKey: 'document_views', + identity: { kind: 'local', localId }, + values: { title: localId }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }); + + await expect( + service.enqueuePreparedBatch(async () => [prepared('same-aggregate', 'one'), prepared('same-aggregate', 'two')], { + flush: false, + }), + ).rejects.toThrow('overlapping aggregate intents'); + expect(transactReplica).not.toHaveBeenCalled(); + + await expect( + service.enqueuePreparedBatch( + async () => [prepared('doc-a', 'A', companion('shared-view')), prepared('doc-b', 'B', companion('shared-view'))], + { flush: false }, + ), + ).rejects.toThrow('overlapping replica footprints'); + expect(transactReplica).not.toHaveBeenCalled(); + expect(commands).toEqual([]); + expect(rows).toEqual([]); + }); + + it('空batchを拒否し、件数とシリアライズbyteを合算してcapacity判定する', async () => { + const repository = TestBed.inject(OFFLINE_REPOSITORY); + const transactReplica = vi.mocked(repository.transactReplica); + + await expect(service.enqueuePreparedBatch(async () => [], { flush: false })).rejects.toThrow( + 'Prepared offline batch must contain at least one command.', + ); + expect(transactReplica).not.toHaveBeenCalled(); + + options.outboxLimits = { maxCommandsPerUser: 1 }; + await expect( + service.enqueuePreparedBatch(async () => [prepared('cap-a', 'A'), prepared('cap-b', 'B')], { flush: false }), + ).rejects.toMatchObject({ name: 'OfflineOutboxCapacityError', reason: 'command_count' }); + expect(transactReplica).not.toHaveBeenCalled(); + expect(commands).toEqual([]); + expect(rows).toEqual([]); + + options.outboxLimits = { maxBytesPerUser: 1 }; + await expect( + service.enqueuePreparedBatch(async () => [prepared('byte-a', 'A'), prepared('byte-b', 'B')], { flush: false }), + ).rejects.toMatchObject({ name: 'OfflineOutboxCapacityError', reason: 'serialized_bytes' }); + expect(transactReplica).not.toHaveBeenCalled(); + expect(commands).toEqual([]); + expect(rows).toEqual([]); + }); + + it('generation/session失効では永続化前に失敗し状態を残さない', async () => { + let releasePrepare: (() => void) | undefined; + let prepareStarted: (() => void) | undefined; + const started = new Promise((resolve) => { + prepareStarted = resolve; + }); + const gate = new Promise((resolve) => { + releasePrepare = resolve; + }); + const repository = TestBed.inject(OFFLINE_REPOSITORY); + const transactReplica = vi.mocked(repository.transactReplica); + + const enqueue = service.enqueuePreparedBatch(async () => { + prepareStarted?.(); + await gate; + return [prepared('revoked-batch', 'stale')]; + }); + await started; + + service.revokeSession(); + releasePrepare?.(); + + await expect(enqueue).rejects.toThrow('Offline session changed'); + expect(transactReplica).not.toHaveBeenCalled(); + expect(commands).toEqual([]); + expect(rows).toEqual([]); + }); + + it('durable commit後のstate refreshが失敗してもIDを返し後続refreshで収束する', async () => { + const repository = TestBed.inject(OFFLINE_REPOSITORY); + const getCommands = vi.mocked(repository.getCommands); + await service.initialize({ flush: false }); + let failRefresh = true; + getCommands.mockImplementation(async (scope) => { + if (failRefresh) { + failRefresh = false; + throw new Error('postcommit read failed'); + } + return commands.filter((item) => item.userId === scope.userId && item.scopeId === scope.scopeId); + }); + + const commandIds = await service.enqueuePreparedBatch(async () => [prepared('committed-a', 'A'), prepared('committed-b', 'B')], { + flush: false, + }); + + expect(commandIds).toHaveLength(2); + expect(commands.map(({ commandId }) => commandId)).toEqual([...commandIds]); + expect(rows).toHaveLength(2); + expect(handleError).toHaveBeenCalledWith(expect.objectContaining({ message: 'postcommit read failed' })); + expect(service.pendingCount()).toBe(0); + + await service.reloadPendingCommands(); + expect(service.pendingCount()).toBe(2); + }); + }); + it('local sessionはoutboxへenqueueできるがremote session確立までは送信しない', async () => { localSession = { userId: 1, scopes: [{ userId: 1, scopeId: '10' }] }; session = null; diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index 08e65f9..598636a 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -73,6 +73,13 @@ export interface PreparedOfflineCommand { replicaTransaction?: Pick; } +/** Validated optimistic projection ready for a single Outbox commit. */ +interface MaterializedOfflineEnqueue { + command: OfflineCommand; + optimisticRow: OfflineReplicaRow; + optimisticCompanions: readonly OfflineOptimisticReplicaCompanion[]; +} + /** Raised before persistence when an outbox payload is not losslessly JSON serializable. */ export class OfflinePayloadValidationError extends Error { constructor(message = 'Offline command payload must contain only JSON values') { @@ -245,6 +252,27 @@ export class OfflineSyncService { }); } + /** + * Prepares and commits multiple Outbox commands as one serialized replica + * transaction under a single captured session generation. + * + * All entries are prepared and validated before any write. Empty batches, + * overlapping aggregate intents, and overlapping replica footprints are + * rejected with no durable state change. + */ + enqueuePreparedBatch( + prepare: (repository: OfflineRepository) => Promise[]>, + options: { flush?: boolean } = {}, + ): Promise { + const generation = this.#generation; + return this.#serializeReplicaMutation(async () => { + await this.initialize(); + if (!this.#isCurrent(generation)) throw new Error('Offline session changed before prepared batch enqueue.'); + const prepared = await prepare(this.#repository); + return this.#enqueuePreparedBatch(prepared, options, generation); + }); + } + /** * Atomically replaces a resolved durable command with a newly prepared command. * @@ -313,6 +341,38 @@ export class OfflineSyncService { replicaTransaction?: Pick, replaced?: OfflineCommand, ): Promise { + const session = await this.#beginEnqueueSession(generation); + this.#assertEnqueueScope(session, request.scopeId); + const materialization = await this.#materializeEnqueue(session.userId, request, replicaTransaction, replaced); + await this.#assertOutboxCapacity(session.userId, [materialization.command], replaced?.commandId); + await this.#commitMaterializedEnqueues([materialization], generation, options, replaced ? [replaced.commandId] : undefined); + return materialization.command.commandId; + } + + async #enqueuePreparedBatch( + prepared: readonly PreparedOfflineCommand[], + options: { flush?: boolean }, + generation: number, + ): Promise { + if (prepared.length === 0) { + throw new Error('Prepared offline batch must contain at least one command.'); + } + const session = await this.#beginEnqueueSession(generation); + const materializations: MaterializedOfflineEnqueue[] = []; + for (const entry of prepared) { + this.#assertEnqueueScope(session, entry.request.scopeId); + materializations.push(await this.#materializeEnqueue(session.userId, entry.request, entry.replicaTransaction)); + } + this.#assertDistinctBatchFootprints(materializations); + await this.#assertOutboxCapacity( + session.userId, + materializations.map((item) => item.command), + ); + await this.#commitMaterializedEnqueues(materializations, generation, options); + return materializations.map((item) => item.command.commandId); + } + + async #beginEnqueueSession(generation: number): Promise { await this.initialize(); if (this.#options.mode === 'readCacheOnly') { throw new Error('This offline provider is configured as a read-only cache and cannot enqueue commands.'); @@ -320,12 +380,27 @@ export class OfflineSyncService { const session = await this.#getLocalSession(); if (!session) throw new Error('Cannot enqueue an offline command without an authenticated user'); this.#assertSessionPrincipalBoundary(session); - const userId = session.userId; - this.#setActiveUser(userId); - const scope = { userId, scopeId: request.scopeId }; - if (!session.scopes.some((candidate) => candidate.userId === userId && candidate.scopeId === request.scopeId)) { - throw new Error(`Offline sync session does not include scope "${request.scopeId}".`); + this.#setActiveUser(session.userId); + if (!this.#isCurrent(generation)) { + throw new Error('Offline session changed before the command could be persisted'); + } + return session; + } + + #assertEnqueueScope(session: OfflineSyncSession, scopeId: string): void { + if (!session.scopes.some((candidate) => candidate.userId === session.userId && candidate.scopeId === scopeId)) { + throw new Error(`Offline sync session does not include scope "${scopeId}".`); } + this.noteScope({ userId: session.userId, scopeId }); + } + + async #materializeEnqueue( + userId: OfflinePrincipalId, + request: EnqueueOfflineCommand, + replicaTransaction?: Pick, + replaced?: OfflineCommand, + ): Promise { + const scope = { userId, scopeId: request.scopeId }; this.noteScope(scope); const commandIdentity = offlineCommandLookupIdentity(request.identity); const normalized = await this.#normalizeEnqueueRequest(scope, request, commandIdentity); @@ -442,19 +517,50 @@ export class OfflineSyncService { const optimisticCompanions = replaced ? this.#replacementCompanions(replaced, preparedCompanions) : preparedCompanions; if (replicaTransaction) this.#canonicalJson(replicaTransaction); if (optimisticCompanions.length > 0) command = { ...command, optimisticCompanions }; - await this.#assertOutboxCapacity(userId, command, replaced?.commandId); + return { command, optimisticRow, optimisticCompanions }; + } + + #assertDistinctBatchFootprints(entries: readonly MaterializedOfflineEnqueue[]): void { + const aggregates = new Set(); + const replicaKeys = new Set(); + for (const entry of entries) { + const aggregate = this.#aggregateKey(entry.command); + if (aggregates.has(aggregate)) { + throw new Error('Prepared offline batch contains overlapping aggregate intents.'); + } + aggregates.add(aggregate); + for (const key of [ + this.#replicaRowKey(entry.optimisticRow), + ...entry.optimisticCompanions.map((companion) => this.#replicaRowKey(companion.key)), + ]) { + if (replicaKeys.has(key)) { + throw new Error('Prepared offline batch contains overlapping replica footprints.'); + } + replicaKeys.add(key); + } + } + } + + async #commitMaterializedEnqueues( + entries: readonly MaterializedOfflineEnqueue[], + generation: number, + options: { flush?: boolean }, + removeCommandIds?: readonly string[], + ): Promise { if (generation !== this.#generation) { throw new Error('Offline session changed before the command could be persisted'); } await this.#repository.transactReplica({ - putRows: [optimisticRow, ...optimisticCompanions.flatMap((companion) => (companion.after ? [companion.after] : []))], - removeRows: optimisticCompanions.flatMap((companion) => (companion.after ? [] : [companion.key])), - putCommands: [command], - removeCommandIds: replaced ? [replaced.commandId] : undefined, + putRows: entries.flatMap((entry) => [ + entry.optimisticRow, + ...entry.optimisticCompanions.flatMap((companion) => (companion.after ? [companion.after] : [])), + ]), + removeRows: entries.flatMap((entry) => entry.optimisticCompanions.flatMap((companion) => (companion.after ? [] : [companion.key]))), + putCommands: entries.map((entry) => entry.command), + removeCommandIds, }); - await this.#refreshState(); + await this.#refreshState().catch((error) => this.#reportError(error)); if (options.flush !== false && this.#network.connected()) this.#flushInBackground(); - return commandId; } async #prepareOptimisticCompanions( @@ -553,7 +659,11 @@ export class OfflineSyncService { return `${canonicalOfflinePrincipalId(key.userId)}:${key.scopeId}:${key.sourceKey}:${canonicalOfflineReplicaIdentity(key.identity)}`; } - async #assertOutboxCapacity(userId: OfflinePrincipalId, command: OfflineCommand, excludingCommandId?: string): Promise { + async #assertOutboxCapacity( + userId: OfflinePrincipalId, + newCommands: readonly OfflineCommand[], + excludingCommandId?: string, + ): Promise { const currentCommands = this.#repository.getCommandsForUser ? await this.#repository.getCommandsForUser(userId) : ( @@ -567,10 +677,10 @@ export class OfflineSyncService { const maxCommands = this.#options.outboxLimits?.maxCommandsPerUser ?? DEFAULT_MAX_OUTBOX_COMMANDS_PER_USER; const maxBytes = this.#options.outboxLimits?.maxBytesPerUser ?? DEFAULT_MAX_OUTBOX_BYTES_PER_USER; const currentBytes = this.#serializedOutboxBytes(commands); - if (commands.length >= maxCommands) { + if (commands.length + newCommands.length > maxCommands) { throw new OfflineOutboxCapacityError('command_count', commands.length, currentBytes); } - const nextBytes = currentBytes + this.#serializedOutboxBytes([command]); + const nextBytes = this.#serializedOutboxBytes([...commands, ...newCommands]); if (nextBytes > maxBytes) { throw new OfflineOutboxCapacityError('serialized_bytes', commands.length, currentBytes); }