From bf6de8139708e4640e22829822d1c2271113b8c2 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 13 Aug 2026 13:08:14 +0900 Subject: [PATCH] Simplify offline reconciliation around immutable intents --- projects/kit/README.md | 25 +- ...offline-aggregate-intent-projector.spec.ts | 528 +++++++ .../lib/offline-aggregate-intent-projector.ts | 98 ++ .../src/lib/offline-command-executor.ts | 59 +- .../offline/src/lib/offline-contract.spec.ts | 70 +- .../lib/offline-coordinator.service.spec.ts | 2 +- .../src/lib/offline-natural-key.spec.ts | 10 +- .../kit/offline/src/lib/offline-provider.ts | 19 +- .../offline-replica-mutation-coordinator.ts | 232 ++- .../lib/offline-replica-pull.service.spec.ts | 599 ++++---- .../src/lib/offline-replica-pull.service.ts | 335 +++-- .../offline/src/lib/offline-replica-puller.ts | 20 +- .../src/lib/offline-repository.spec.ts | 13 +- .../kit/offline/src/lib/offline-repository.ts | 22 +- .../src/lib/offline-sync.service.spec.ts | 1328 ++++------------- .../offline/src/lib/offline-sync.service.ts | 545 +++---- .../offline/src/lib/offline-test-helpers.ts | 186 +++ .../src/lib/sqlite-offline-repository.spec.ts | 75 +- .../src/lib/sqlite-offline-repository.ts | 51 +- projects/kit/offline/src/public-api.ts | 1 + 20 files changed, 2195 insertions(+), 2023 deletions(-) create mode 100644 projects/kit/offline/src/lib/offline-aggregate-intent-projector.spec.ts create mode 100644 projects/kit/offline/src/lib/offline-aggregate-intent-projector.ts diff --git a/projects/kit/README.md b/projects/kit/README.md index 9f356c6..9a4decd 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -570,15 +570,22 @@ through unchanged: values that the server considers equal must first be canonica `OfflineScope.userId` accepts `number | string`. A type-tagged TEXT codec keeps numeric `7` and text `"7"` in different metadata, manifest, cursor, Outbox, and entity boundaries. -The write lifecycle is: update the replica immediately → append an outbox command in the same transaction → render -the optimistic value → replay in the background → validate the server revision → store the confirmed value and -revision. The server remains authoritative; SQLite is the durable local working database, not an HTTP response cache. +The write lifecycle is: persist the immutable Outbox command → rematerialize the aggregate from confirmed +base/localOnly values plus remaining FIFO intents → render that optimistic projection → replay in the background → +keep the transported command until pull acknowledges `commandId` → rematerialize again from the new confirmed +baseline. The server remains authoritative; SQLite is the durable local working database, not an HTTP response cache. + +Synchronized mode requires `aggregateIntentProjector`. Kit calls it inside `OfflineReplicaMutationCoordinator` after +enqueue, batch enqueue, replacement, discard, transport success, and pull acknowledgement. The adapter must derive +only from authoritative confirmed values plus the complete remaining intent chain. Do not call it from product code. +Commands persist payload/metadata and declared `localOnlyFootprint` keys only; optimistic snapshots and companion +before/after images are not durable truth. When an API response is assembled from a base replica row plus product-owned local-only view rows, use `enqueuePrepared()`. Its callback runs inside the shared replica mutation lane, so every read used to derive the -optimistic projection happens after earlier enqueue/ACK/pull applies. The base optimistic row, companion rows, and -Outbox command are then committed by one `transactReplica()` call. Preparation failure, invalid cross-scope rows, -duplicate row keys, non-JSON values, or an attempt to mutate commands/cursors leaves durable state unchanged. +payload happens after earlier enqueue/ACK/pull applies. Kit then rematerializes the aggregate and commits the +Outbox command with that projection in one `transactReplica()` call. Preparation failure, invalid footprint keys, +non-JSON values, or an attempt to mutate commands/cursors leaves durable state unchanged. ```ts await offlineSync.enqueuePrepared(async (repository) => { @@ -590,9 +597,8 @@ await offlineSync.enqueuePrepared(async (repository) => { identity: { kind: 'generated', localId: row.identity.localId }, operation: 'items.rename', payload: { title }, - optimisticValue: { ...row.values, title }, + localOnlyFootprint: [viewKey(scope, view)], }, - replicaTransaction: { putRows: [buildProductViewRow(scope, { ...view, title })] }, }; }); ``` @@ -624,7 +630,6 @@ await offlineSync.enqueue({ }, operation: 'favorite.delete', payload: { favTo: favorite.values.favTo }, - optimisticValue: favorite.values, baseRevision: favorite.serverRevision, replicaMutation: 'delete', }); @@ -654,7 +659,6 @@ await offlineSync.enqueue({ identity: { kind: 'generated', localId, remoteId: existingApiItem.id }, operation: 'items.delete', payload: { method: 'DELETE' }, - optimisticValue: existingApiItem, }); ``` @@ -765,6 +769,7 @@ provideOffline({ replicaSchema, replicaPuller: ProductReplicaPuller, commandExecutor: ProductCommandExecutor, + aggregateIntentProjector: ProductAggregateIntentProjector, // ...request policies, databaseName, createEncryptionKey }); diff --git a/projects/kit/offline/src/lib/offline-aggregate-intent-projector.spec.ts b/projects/kit/offline/src/lib/offline-aggregate-intent-projector.spec.ts new file mode 100644 index 0000000..024c6b2 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-aggregate-intent-projector.spec.ts @@ -0,0 +1,528 @@ +import { ErrorHandler, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + OFFLINE_AGGREGATE_INTENT_PROJECTOR, + type OfflineAggregateIntentProjectInput, + type OfflineAggregateIntentProjector, +} from './offline-aggregate-intent-projector'; +import { OFFLINE_COMMAND_EXECUTOR, OFFLINE_SYNC_CONTEXT } from './offline-command-executor'; +import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; +import { OfflineNetworkService } from './offline-network.service'; +import { OfflineReplicaPullService } from './offline-replica-pull.service'; +import { OfflineReplicaMutationCoordinator } from './offline-replica-mutation-coordinator'; +import { defineOfflineReplicaSchema, defineReplicaEntity, generatedId, integer, localOnly, text } from './offline-replica-schema'; +import { + canonicalOfflineReplicaIdentity, + OFFLINE_REPOSITORY, + type OfflineCommand, + type OfflineReplicaAddress, + type OfflineReplicaRow, + type OfflineRepository, + type OfflineScope, +} from './offline-repository'; +import { rematerializeTestAggregate } from './offline-test-helpers'; +import { OfflineSyncService } from './offline-sync.service'; + +const replicaSchema = defineOfflineReplicaSchema({ + version: 1, + entities: [ + defineReplicaEntity<{ id: number; qty: number }>()({ + table: 'items', + sourceKey: 'items', + scope: 'partition', + fields: { id: generatedId('integer'), qty: integer() }, + }), + defineReplicaEntity<{ qty: number }>()({ + table: 'item_views', + sourceKey: 'item_views', + scope: 'partition', + identity: localOnly(), + fields: { qty: integer() }, + }), + defineReplicaEntity<{ name: string }>()({ + table: 'item_attachments', + sourceKey: 'item_attachments', + scope: 'partition', + identity: localOnly(), + fields: { name: text() }, + }), + ], + migrations: [], +}); + +const scope: OfflineScope = { userId: 1, scopeId: '10' }; + +describe('OfflineAggregateIntentProjector', () => { + describe('OfflineReplicaMutationCoordinator validation', () => { + const baseRow = (overrides: Partial = {}): OfflineReplicaRow => ({ + ...scope, + sourceKey: 'items', + identity: { kind: 'generated', localId: 'item-1', remoteId: 7 }, + values: { id: 7, qty: 12 }, + confirmedValues: { id: 7, qty: 10 }, + serverRevision: 3, + fetchedAt: 1, + syncState: 'pending', + visibility: 'present', + ...overrides, + }); + const viewRow = (overrides: Partial = {}): OfflineReplicaRow => ({ + ...scope, + sourceKey: 'item_views', + identity: { kind: 'local', localId: 'item-1-view' }, + values: { qty: 12 }, + confirmedValues: { qty: 10 }, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + visibility: 'present', + ...overrides, + }); + + function coordinatorWith(project: OfflineAggregateIntentProjector['project']): OfflineReplicaMutationCoordinator { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + OfflineReplicaMutationCoordinator, + { provide: OFFLINE_KIT_OPTIONS, useValue: { databaseName: 'test-offline', replicaSchema } }, + { provide: OFFLINE_AGGREGATE_INTENT_PROJECTOR, useValue: { project } }, + ], + }); + return TestBed.inject(OfflineReplicaMutationCoordinator); + } + + it('rejects a base row that changes scope or identity and a localOnly row outside the footprint', () => { + const coordinator = coordinatorWith(() => ({ + baseRow: baseRow({ scopeId: '99', identity: { kind: 'generated', localId: 'other', remoteId: 7 } }), + putLocalOnlyRows: [viewRow({ scopeId: '99', identity: { kind: 'local', localId: 'foreign' } })], + })); + expect(() => + coordinator.projectAggregateIntent({ + baseRow: baseRow(), + localOnlyRows: [viewRow()], + commands: [], + }), + ).toThrow(/scope and source|aggregate identity/); + }); + + it('rejects undeclared localOnly output even when the base row is valid', () => { + const coordinator = coordinatorWith(() => ({ + baseRow: baseRow({ values: { id: 7, qty: 10 }, syncState: 'confirmed', visibility: 'present' }), + putLocalOnlyRows: [ + viewRow({ values: { qty: 10 }, syncState: 'confirmed' }), + viewRow({ identity: { kind: 'local', localId: 'extra' }, values: { qty: 10 }, confirmedValues: null }), + ], + })); + expect(() => + coordinator.projectAggregateIntent({ + baseRow: baseRow({ values: { id: 7, qty: 12 } }), + localOnlyRows: [viewRow()], + commands: [], + }), + ).toThrow('undeclared localOnly row'); + }); + + it('propagates projector failure without returning a projection', () => { + const coordinator = coordinatorWith(() => { + throw new Error('fold failed'); + }); + expect(() => + coordinator.projectAggregateIntent({ + baseRow: baseRow(), + localOnlyRows: [viewRow()], + commands: [], + }), + ).toThrow('fold failed'); + }); + + it('accepts conflict only for pull with pending commands', () => { + const coordinator = coordinatorWith(() => ({ kind: 'conflict', reason: 'revision_sensitive' })); + const command = { + ...scope, + commandId: 'cmd-1', + aggregateType: 'items', + sourceKey: 'items', + identity: { kind: 'generated' as const, localId: 'item-1' }, + operation: 'items.absolute', + payload: {}, + payloadHash: 'hash', + baseRevision: 1, + state: 'pending' as const, + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }; + + expect( + coordinator.projectAggregateIntent({ + baseRow: baseRow(), + localOnlyRows: [], + commands: [command], + trigger: 'pull', + incomingRevision: 2, + }), + ).toEqual({ kind: 'conflict', reason: 'revision_sensitive' }); + expect(() => + coordinator.projectAggregateIntent({ baseRow: baseRow(), localOnlyRows: [], commands: [command], trigger: 'local' }), + ).toThrow('valid only for pending commands during pull'); + }); + }); + + describe('discard rematerialization', () => { + let service: OfflineSyncService; + let commands: OfflineCommand[]; + let rows: OfflineReplicaRow[]; + let transactReplica: ReturnType; + let projectImpl: OfflineAggregateIntentProjector['project']; + + beforeEach(() => { + commands = []; + rows = []; + projectImpl = rematerializeTestAggregate; + transactReplica = vi.fn(async (transaction) => { + for (const row of transaction.putRows ?? []) { + rows = rows.filter( + (item) => + item.userId !== row.userId || + item.scopeId !== row.scopeId || + item.sourceKey !== row.sourceKey || + canonicalOfflineReplicaIdentity(item.identity) !== canonicalOfflineReplicaIdentity(row.identity), + ); + rows.push(structuredClone(row)); + } + for (const key of transaction.removeRows ?? []) { + rows = rows.filter( + (item) => + item.userId !== key.userId || + item.scopeId !== key.scopeId || + item.sourceKey !== key.sourceKey || + canonicalOfflineReplicaIdentity(item.identity) !== canonicalOfflineReplicaIdentity(key.identity), + ); + } + for (const command of transaction.putCommands ?? []) { + commands = commands.filter((item) => item.commandId !== command.commandId); + commands.push(structuredClone(command)); + } + commands = commands.filter((command) => !(transaction.removeCommandIds ?? []).includes(command.commandId)); + commands.sort((left, right) => left.createdAt - right.createdAt); + }); + const findRow = (queryScope: OfflineScope, sourceKey: string, identity: OfflineReplicaAddress) => + rows.find((item) => { + if (item.userId !== queryScope.userId || item.scopeId !== queryScope.scopeId || item.sourceKey !== sourceKey) return false; + if (identity.kind === 'generated') { + return item.identity.kind === 'generated' && item.identity.localId === identity.localId; + } + if (identity.kind === 'local') { + return item.identity.kind === 'local' && item.identity.localId === identity.localId; + } + return item.identity.kind === 'natural' && JSON.stringify(item.identity.naturalKey) === JSON.stringify(identity.naturalKey); + }) ?? null; + const repository = { + initialize: vi.fn(async () => undefined), + getCommands: vi.fn(async (queryScope: OfflineScope) => + commands.filter((item) => item.userId === queryScope.userId && item.scopeId === queryScope.scopeId), + ), + getCommandsForUser: vi.fn(async (userId: number) => commands.filter((item) => item.userId === userId)), + putCommand: vi.fn(async (command: OfflineCommand) => { + commands = commands.filter((item) => item.commandId !== command.commandId); + commands.push(structuredClone(command)); + commands.sort((left, right) => left.createdAt - right.createdAt); + }), + getReplicaRow: vi.fn(async (queryScope: OfflineScope, sourceKey: string, identity: OfflineReplicaAddress) => + findRow(queryScope, sourceKey, identity), + ), + getReplicaRowIncludingPendingDelete: vi.fn(async (queryScope: OfflineScope, sourceKey: string, identity: OfflineReplicaAddress) => + findRow(queryScope, sourceKey, identity), + ), + getReplicaRowByRemoteId: vi.fn(async () => null), + getReplicaRowByRemoteIdentity: vi.fn(async () => null), + getReplicaCursor: vi.fn(async () => null), + getReconciliationScopes: vi.fn(async () => []), + getPullAttentions: vi.fn(async () => []), + transactReplica, + } as unknown as OfflineRepository; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + OfflineSyncService, + { provide: OFFLINE_REPOSITORY, useValue: repository }, + { provide: OfflineNetworkService, useValue: { connected: signal(false) } }, + { provide: OFFLINE_KIT_OPTIONS, useValue: { databaseName: 'test-offline', replicaSchema } }, + { provide: OfflineReplicaPullService, useValue: { pull: vi.fn(async () => undefined) } }, + { provide: ErrorHandler, useValue: { handleError: vi.fn() } }, + { + provide: OFFLINE_COMMAND_HOOKS, + useValue: { entityType: (command: OfflineCommand) => command.aggregateType }, + }, + { + provide: OFFLINE_SYNC_CONTEXT, + useValue: { + getLocalSession: vi.fn(async () => ({ userId: 1, scopes: [scope] })), + getSession: vi.fn(async () => ({ userId: 1, scopes: [scope] })), + }, + }, + { + provide: OFFLINE_COMMAND_EXECUTOR, + useValue: { + execute: vi.fn(async () => ({ response: null })), + withServerRevision: (command: OfflineCommand) => command, + }, + }, + { + provide: OFFLINE_AGGREGATE_INTENT_PROJECTOR, + useValue: { project: (input: OfflineAggregateIntentProjectInput) => projectImpl(input) }, + }, + ], + }); + service = TestBed.inject(OfflineSyncService); + }); + + async function enqueueQty( + localId: string, + payload: { kind: 'delta' | 'stocktake'; qty: number }, + optimisticQty: number, + view?: OfflineReplicaRow, + ): Promise { + return service.enqueuePrepared( + async () => ({ + request: { + scopeId: scope.scopeId, + aggregateType: 'items', + identity: { kind: 'generated', localId }, + operation: 'inventory.changeQty', + payload, + localOnlyFootprint: view ? [view] : undefined, + }, + }), + { flush: false }, + ); + } + + it('quantity chain discard rematerializes remaining deltas onto confirmed qty', async () => { + const view: OfflineReplicaRow = { + ...scope, + sourceKey: 'item_views', + identity: { kind: 'local', localId: 'item-1-view' }, + values: { qty: 10 }, + confirmedValues: { qty: 10 }, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }; + rows.push( + { + ...scope, + sourceKey: 'items', + identity: { kind: 'generated', localId: 'item-1', remoteId: 7 }, + values: { id: 7, qty: 10 }, + confirmedValues: { id: 7, qty: 10 }, + serverRevision: 3, + fetchedAt: 1, + syncState: 'confirmed', + }, + view, + ); + const firstId = await enqueueQty('item-1', { kind: 'delta', qty: 2 }, 12, view); + await enqueueQty('item-1', { kind: 'delta', qty: 3 }, 15, view); + expect(rows.find((row) => row.sourceKey === 'items')?.values).toEqual({ id: 7, qty: 15 }); + + await service.discard(firstId, { flush: false }); + + expect(commands).toHaveLength(1); + expect(commands[0]?.payload).toEqual({ kind: 'delta', qty: 3 }); + expect(rows.find((row) => row.sourceKey === 'items')).toMatchObject({ + values: { id: 7, qty: 13 }, + confirmedValues: { id: 7, qty: 10 }, + syncState: 'pending', + }); + expect(rows.find((row) => row.sourceKey === 'item_views')).toMatchObject({ + values: { qty: 13 }, + confirmedValues: { qty: 10 }, + }); + }); + + it('stocktake then delta rematerializes the remaining intent after discard', async () => { + const view: OfflineReplicaRow = { + ...scope, + sourceKey: 'item_views', + identity: { kind: 'local', localId: 'item-1-view' }, + values: { qty: 10 }, + confirmedValues: { qty: 10 }, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }; + rows.push( + { + ...scope, + sourceKey: 'items', + identity: { kind: 'generated', localId: 'item-1', remoteId: 7 }, + values: { id: 7, qty: 10 }, + confirmedValues: { id: 7, qty: 10 }, + serverRevision: 3, + fetchedAt: 1, + syncState: 'confirmed', + }, + view, + ); + const stocktakeId = await enqueueQty('item-1', { kind: 'stocktake', qty: 7 }, 7, view); + const deltaId = await enqueueQty('item-1', { kind: 'delta', qty: -1 }, 6, view); + + await service.discard(stocktakeId, { flush: false }); + expect(rows.find((row) => row.sourceKey === 'items')?.values).toEqual({ id: 7, qty: 9 }); + + await service.discard(deltaId, { flush: false }); + expect(commands).toEqual([]); + expect(rows.find((row) => row.sourceKey === 'items')).toMatchObject({ + values: { id: 7, qty: 10 }, + syncState: 'confirmed', + }); + expect(rows.find((row) => row.sourceKey === 'item_views')).toMatchObject({ + values: { qty: 10 }, + syncState: 'confirmed', + }); + }); + + it('attachment/slip-style localOnly row is removed when its remaining chain no longer owns it', async () => { + const view: OfflineReplicaRow = { + ...scope, + sourceKey: 'item_views', + identity: { kind: 'local', localId: 'item-1-view' }, + values: { qty: 10 }, + confirmedValues: { qty: 10 }, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }; + const attachment: OfflineReplicaRow = { + ...scope, + sourceKey: 'item_attachments', + identity: { kind: 'local', localId: 'slip-1' }, + values: { name: 'pending-slip' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 2, + syncState: 'pending', + }; + rows.push( + { + ...scope, + sourceKey: 'items', + identity: { kind: 'generated', localId: 'item-1', remoteId: 7 }, + values: { id: 7, qty: 10 }, + confirmedValues: { id: 7, qty: 10 }, + serverRevision: 3, + fetchedAt: 1, + syncState: 'confirmed', + }, + view, + ); + const attachId = await service.enqueuePrepared( + async () => ({ + request: { + scopeId: scope.scopeId, + aggregateType: 'items', + identity: { kind: 'generated', localId: 'item-1' }, + operation: 'items.attach', + payload: { attachment: { id: 'slip-1', name: 'pending-slip' } }, + localOnlyFootprint: [view, attachment], + }, + }), + { flush: false }, + ); + expect(rows.some((row) => row.sourceKey === 'item_attachments')).toBe(true); + + await service.discard(attachId, { flush: false }); + + expect(commands).toEqual([]); + expect(rows.find((row) => row.sourceKey === 'item_attachments')).toBeUndefined(); + expect(rows.find((row) => row.sourceKey === 'item_views')).toMatchObject({ values: { qty: 10 }, syncState: 'confirmed' }); + }); + + it('projector failure leaves commands and replica rows unchanged', async () => { + rows.push({ + ...scope, + sourceKey: 'items', + identity: { kind: 'generated', localId: 'item-1', remoteId: 7 }, + values: { id: 7, qty: 10 }, + confirmedValues: { id: 7, qty: 10 }, + serverRevision: 3, + fetchedAt: 1, + syncState: 'confirmed', + }); + const commandId = await enqueueQty('item-1', { kind: 'delta', qty: 2 }, 12); + const beforeRows = structuredClone(rows); + const beforeCommands = structuredClone(commands); + const writes = transactReplica.mock.calls.length; + projectImpl = () => { + throw new Error('fold failed'); + }; + + await expect(service.discard(commandId, { flush: false })).rejects.toThrow('fold failed'); + expect(transactReplica).toHaveBeenCalledTimes(writes); + expect(rows).toEqual(beforeRows); + expect(commands).toEqual(beforeCommands); + }); + + it('scope or identity invalid output is rejected with zero writes', async () => { + rows.push({ + ...scope, + sourceKey: 'items', + identity: { kind: 'generated', localId: 'item-1', remoteId: 7 }, + values: { id: 7, qty: 10 }, + confirmedValues: { id: 7, qty: 10 }, + serverRevision: 3, + fetchedAt: 1, + syncState: 'confirmed', + }); + const commandId = await enqueueQty('item-1', { kind: 'delta', qty: 2 }, 12); + const beforeRows = structuredClone(rows); + const beforeCommands = structuredClone(commands); + const writes = transactReplica.mock.calls.length; + projectImpl = (input) => ({ + ...rematerializeTestAggregate(input), + baseRow: input.baseRow ? { ...input.baseRow, scopeId: '99', identity: { kind: 'generated', localId: 'other', remoteId: 8 } } : null, + }); + + await expect(service.discard(commandId, { flush: false })).rejects.toThrow(/scope and source|aggregate identity/); + expect(transactReplica).toHaveBeenCalledTimes(writes); + expect(rows).toEqual(beforeRows); + expect(commands).toEqual(beforeCommands); + }); + + it('current confirmedValues null removes the unconfirmed aggregate when no commands remain', async () => { + const view: OfflineReplicaRow = { + ...scope, + sourceKey: 'item_views', + identity: { kind: 'local', localId: 'draft-1-view' }, + values: { qty: 4 }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }; + const commandId = await service.enqueuePrepared( + async () => ({ + request: { + scopeId: scope.scopeId, + aggregateType: 'items', + identity: { kind: 'generated', localId: 'draft-1' }, + operation: 'items.create', + payload: { kind: 'delta', qty: 4 }, + localOnlyFootprint: [view], + }, + }), + { flush: false }, + ); + expect(rows.find((row) => row.sourceKey === 'items')?.confirmedValues).toBeNull(); + + await service.discard(commandId, { flush: false }); + + expect(commands).toEqual([]); + expect(rows).toEqual([]); + }); + }); +}); diff --git a/projects/kit/offline/src/lib/offline-aggregate-intent-projector.ts b/projects/kit/offline/src/lib/offline-aggregate-intent-projector.ts new file mode 100644 index 0000000..977248a --- /dev/null +++ b/projects/kit/offline/src/lib/offline-aggregate-intent-projector.ts @@ -0,0 +1,98 @@ +import { InjectionToken } from '@angular/core'; +import type { OfflineCommand, OfflineReplicaRow, OfflineReplicaRowKey, OfflineReplicaTransaction } from './offline-repository'; + +/** + * Authoritative inputs for rematerializing one aggregate from its remaining + * ordered Outbox chain. + * + * `baseRow` is the current replica row for the aggregate, including its latest + * `confirmedValues`. `localOnlyRows` are the current localOnly projection rows + * owned by that aggregate. `commands` is the complete remaining FIFO chain + * after the triggering mutation. + */ +export interface OfflineAggregateIntentProjectInput { + /** Current aggregate base replica row, or `null` when the row is absent. */ + readonly baseRow: OfflineReplicaRow | null; + /** Current authoritative localOnly projection rows for this aggregate. */ + readonly localOnlyRows: readonly OfflineReplicaRow[]; + /** Complete remaining ordered pending command chain for this aggregate. */ + readonly commands: readonly OfflineCommand[]; + /** Boundary that requested rematerialization. Only pull may return a conflict outcome. */ + readonly trigger?: 'local' | 'pull'; + /** Incoming authoritative revision when `trigger` is `pull`. */ + readonly incomingRevision?: string | number; +} + +/** + * Fully rematerialized aggregate projection. + * + * Kit writes `baseRow` as the sole base mutation (`null` removes the row) and + * applies only the localOnly put/remove lists. Domain folding belongs here; + * Kit never interprets command payloads. + */ +export interface OfflineAggregateIntentProjection { + /** Rematerialized base replica row, or `null` to remove the aggregate row. */ + readonly baseRow: OfflineReplicaRow | null; + /** LocalOnly projection rows to upsert for this aggregate. */ + readonly putLocalOnlyRows?: readonly OfflineReplicaRow[]; + /** LocalOnly projection rows to remove for this aggregate. */ + readonly removeLocalOnlyRows?: readonly OfflineReplicaRowKey[]; +} + +/** Product decision that pending intents cannot be replayed onto an incoming authoritative revision. */ +export interface OfflineAggregateIntentConflict { + readonly kind: 'conflict'; + readonly reason: string; +} + +/** Result of product-owned aggregate rematerialization. */ +export type OfflineAggregateIntentProjectResult = OfflineAggregateIntentProjection | OfflineAggregateIntentConflict; + +/** + * Product-owned pure rematerialization of one aggregate. + * + * Given the authoritative base row, current localOnly projection rows, and the + * remaining ordered {@link OfflineCommand} chain, return one fully materialized + * base row plus localOnly put/remove mutations. The adapter must derive only + * from confirmed base/localOnly values plus complete FIFO pending intents. It + * must not perform I/O; Kit supplies already-read replica state and validates + * scope, schema, identity, and footprints before writing. + */ +export interface OfflineAggregateIntentProjector { + /** Rematerializes one aggregate from authoritative replica state and remaining intents. */ + project(input: OfflineAggregateIntentProjectInput): OfflineAggregateIntentProjectResult; +} + +/** Returns whether the projector rejected replay onto an authoritative pull revision. */ +export function isOfflineAggregateIntentConflict(result: OfflineAggregateIntentProjectResult): result is OfflineAggregateIntentConflict { + return 'kind' in result && result.kind === 'conflict'; +} + +/** Required product adapter for aggregate rematerialization from remaining Outbox intents. */ +export const OFFLINE_AGGREGATE_INTENT_PROJECTOR = new InjectionToken('OFFLINE_AGGREGATE_INTENT_PROJECTOR'); + +/** + * Converts a validated aggregate projection into replica put/remove mutations. + * + * @param projection - Rematerialized aggregate returned by the product projector. + * @param baseRow - Current aggregate base row, used when the projection removes it. + */ +export function offlineAggregateIntentMutations( + projection: OfflineAggregateIntentProjection, + baseRow: OfflineReplicaRow | null, +): Pick { + const putRows: OfflineReplicaRow[] = []; + const removeRows: OfflineReplicaRowKey[] = []; + if (projection.baseRow) putRows.push(projection.baseRow); + else if (baseRow) { + removeRows.push({ + userId: baseRow.userId, + scopeId: baseRow.scopeId, + sourceKey: baseRow.sourceKey, + identity: baseRow.identity, + }); + } + putRows.push(...(projection.putLocalOnlyRows ?? [])); + removeRows.push(...(projection.removeLocalOnlyRows ?? [])); + return { putRows, removeRows }; +} diff --git a/projects/kit/offline/src/lib/offline-command-executor.ts b/projects/kit/offline/src/lib/offline-command-executor.ts index ee39988..3c398d8 100644 --- a/projects/kit/offline/src/lib/offline-command-executor.ts +++ b/projects/kit/offline/src/lib/offline-command-executor.ts @@ -1,11 +1,5 @@ import { InjectionToken } from '@angular/core'; -import type { - OfflineCommand, - OfflineOptimisticReplicaCompanion, - OfflineReplicaRow, - OfflineReplicaRowKey, - OfflineScope, -} from './offline-repository'; +import type { OfflineCommand, OfflineScope } from './offline-repository'; import type { OfflineCommandIdentity, OfflinePrincipalId, OfflineReplicaIdentity } from './offline-identity'; import type { OfflineGeneratedRemoteId, OfflineNaturalKey } from './offline-replica-schema'; @@ -14,16 +8,14 @@ export interface OfflineCommandResult { /** Remote id returned by a successful generated-identity mutation. */ remoteId?: OfflineGeneratedRemoteId; serverRevision?: string | number; - /** Full server-confirmed domain values after applying the mutation. */ - confirmedValues?: unknown; /** - * Server-confirmed local-only projection changes owned by this command. + * Full server-confirmed domain values after applying the mutation. * - * Kit validates that every row belongs to the command's declared optimistic - * companion footprint, then commits these changes atomically with the base - * row acknowledgement, command removal, and reconciliation marker. + * Kit does not treat this as the durable confirmed baseline while the + * command remains in the Outbox. Authoritative confirmed values come from + * pull acknowledgement of `commandId`. */ - confirmedCompanions?: readonly OfflineConfirmedReplicaCompanion[]; + confirmedValues?: unknown; /** Removes the local replica row after a confirmed server delete. */ removeReplica?: boolean; /** @@ -34,41 +26,16 @@ export interface OfflineCommandResult { response?: unknown; } -/** Local-only projection changes confirmed by one server acknowledgement. */ -export interface OfflineConfirmedReplicaCompanion { - /** Exact optimistic companion footprint entry owned by this command. */ - readonly key: OfflineReplicaRowKey; - /** - * Applies the acknowledged domain effect to the latest confirmed projection read inside Kit's - * ACK lane. Return `null` to remove the confirmed projection. - */ - readonly reduce: (latestConfirmedValues: unknown) => unknown | null; -} - /** Target identity resolved from the local replica immediately before transport. */ export type OfflineCommandTarget = | { readonly kind: 'generated'; readonly localId: string; readonly remoteId: OfflineGeneratedRemoteId | null } | { readonly kind: 'natural'; readonly naturalKey: OfflineNaturalKey }; -/** 不透明なoperationを製品APIへ送信し、local replicaへ投影するadapter。 */ /** Product adapter that sends commands and projects acknowledgements into entities. */ export interface OfflineCommandExecutor { /** Sends the command using `command.commandId` as its durable server-side idempotency key. */ execute(command: OfflineCommand, target: OfflineCommandTarget): Promise; withServerRevision(command: OfflineCommand, revision: string | number): OfflineCommand; - /** - * Reapplies a complete aggregate's pending intents to a newer confirmed - * value. Return null when any intent is revision-sensitive. The returned - * values correspond to the original FIFO command order. Kit alone updates - * command metadata; payload and idempotency identity remain immutable. - * Without this hook, revision changes conflict by default. - */ - rebasePendingCommands?( - commands: readonly OfflineCommand[], - confirmedValues: unknown, - revision: string | number, - companionRows: readonly OfflineReplicaRow[], - ): OfflinePendingRebase | null | Promise; /** * Whether this transport error authoritatively proves that this idempotency * key did not commit. Returning true may clear an ambiguity retained from an @@ -82,20 +49,6 @@ export interface OfflineCommandExecutor { withoutServerRevision?(command: OfflineCommand): OfflineCommand; } -/** Product projection result after safely replaying pending intents onto a newer confirmed revision. */ -export interface OfflinePendingRebase { - /** Recomputed projections in the original durable FIFO order. */ - steps: readonly OfflinePendingRebaseStep[]; -} - -/** Projection state produced for one immutable durable command in FIFO order. */ -export interface OfflinePendingRebaseStep { - /** Recomputed full aggregate value after this command's intent is applied. */ - optimisticValue: unknown; - /** Same footprint as the original command, rematerialized from the new confirmed value. */ - optimisticCompanions?: readonly OfflineOptimisticReplicaCompanion[]; -} - /** DI token for the product-specific command transport adapter. */ export const OFFLINE_COMMAND_EXECUTOR = new InjectionToken('OFFLINE_COMMAND_EXECUTOR'); diff --git a/projects/kit/offline/src/lib/offline-contract.spec.ts b/projects/kit/offline/src/lib/offline-contract.spec.ts index 80fde12..71ecb6f 100644 --- a/projects/kit/offline/src/lib/offline-contract.spec.ts +++ b/projects/kit/offline/src/lib/offline-contract.spec.ts @@ -8,28 +8,28 @@ import { offlineSessionManifestAllows } from './offline-session.service'; describe('shared offline boundary contracts', () => { it('normalizes database serverId exactly once at the pull transport boundary', () => { const normalized = normalizeOfflineReplicaPullPage({ - schemaVersion: 1, - schemaHash: 'hash', - changes: [ - { - sourceKey: 'items', - serverId: 42, - serverRevision: 3, - values: { id: 42 }, - deleted: false, - }, - { - sourceKey: 'favorites', - naturalKey: { from: 7, to: 21 }, - serverRevision: 4, - values: null, - deleted: true, - }, - ], - nextCursor: '2', - hasMore: false, - rebaselineRequired: true, - }); + schemaVersion: 1, + schemaHash: 'hash', + changes: [ + { + sourceKey: 'items', + serverId: 42, + serverRevision: 3, + values: { id: 42 }, + deleted: false, + }, + { + sourceKey: 'favorites', + naturalKey: { from: 7, to: 21 }, + serverRevision: 4, + values: null, + deleted: true, + }, + ], + nextCursor: '2', + hasMore: false, + rebaselineRequired: true, + }); expect(normalized.changes).toEqual([ { sourceKey: 'items', @@ -62,6 +62,32 @@ describe('shared offline boundary contracts', () => { expect(offlineSessionManifestAllows(manifest, 'uid-other', '10')).toBe(false); }); + it('requires an aggregate intent projector on the synchronized provider', () => { + const compileOnly = (): void => { + provideOffline({ + databaseName: 'intent-projector', + createEncryptionKey: async () => 'native-key', + replicaSchema: defineOfflineReplicaSchema({ version: 1, entities: [], migrations: [] }), + requestPolicies: [], + commandExecutor: class {} as never, + replicaPuller: class {} as never, + aggregateIntentProjector: class { + project() { + return { baseRow: null }; + } + }, + }); + }; + void compileOnly; + }); + + it('keeps the aggregate intent projector off the read-cache provider', () => { + type Options = import('./offline-provider').ProvideReadCacheOfflineOptions; + type HasProjector = 'aggregateIntentProjector' extends keyof Options ? true : false; + const readCacheOmitsProjector: HasProjector = false; + expect(readCacheOmitsProjector).toBe(false); + }); + it('supports a read cache without product dummy transport adapters', () => { const compileOnly = (): void => { provideOffline({ diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts index ff6fb67..0e591aa 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts @@ -263,7 +263,7 @@ describe('OfflineCoordinatorService', () => { describe('storage initialization failure', () => { const storageError = new OfflineStorageUnavailableError( 'core_schema_incompatible', - 'Unsupported offline storage schema version 999; expected 1.', + 'Unsupported offline storage schema version 999; expected 2.', ); it('default fails closed and does not start session or sync', async () => { diff --git a/projects/kit/offline/src/lib/offline-natural-key.spec.ts b/projects/kit/offline/src/lib/offline-natural-key.spec.ts index 89e1cfb..0ea8634 100644 --- a/projects/kit/offline/src/lib/offline-natural-key.spec.ts +++ b/projects/kit/offline/src/lib/offline-natural-key.spec.ts @@ -24,7 +24,8 @@ import { type OfflineRepository, type OfflineScope, } from './offline-repository'; -import { naturalCommandIdentity, naturalReplicaIdentity } from './offline-test-helpers'; +import { OFFLINE_AGGREGATE_INTENT_PROJECTOR } from './offline-aggregate-intent-projector'; +import { naturalCommandIdentity, naturalReplicaIdentity, rematerializeTestAggregate } from './offline-test-helpers'; class MemoryStorage { readonly values = new Map(); @@ -293,6 +294,7 @@ describe('natural-key pull reconciliation', () => { withServerRevision: (command: OfflineCommand, revision: string | number) => ({ ...command, baseRevision: revision }), }, }, + { provide: OFFLINE_AGGREGATE_INTENT_PROJECTOR, useValue: { project: rematerializeTestAggregate } }, ], }); const repository = TestBed.inject(OFFLINE_REPOSITORY) as OfflineRepository; @@ -308,8 +310,7 @@ describe('natural-key pull reconciliation', () => { sourceKey: 'natural_favorites', identity: naturalCommandIdentity(key42), operation: 'create', - payload: {}, - optimisticValue: { favFrom: 7, favTo: '42', label: 'optimistic' }, + payload: { favTo: '42', label: 'optimistic' }, payloadHash: 'hash', baseRevision: null, state: 'pending', @@ -348,8 +349,7 @@ describe('natural-key pull reconciliation', () => { sourceKey: 'natural_favorites', identity: naturalCommandIdentity(key42), operation: 'update', - payload: {}, - optimisticValue: { favFrom: 7, favTo: '42', label: 'pending edit' }, + payload: { favTo: '42', label: 'pending edit' }, payloadHash: 'hash-2', baseRevision: 2, state: 'pending', diff --git a/projects/kit/offline/src/lib/offline-provider.ts b/projects/kit/offline/src/lib/offline-provider.ts index 8f01ee4..281888a 100644 --- a/projects/kit/offline/src/lib/offline-provider.ts +++ b/projects/kit/offline/src/lib/offline-provider.ts @@ -16,6 +16,8 @@ import { } from './offline-repository'; import type { OfflineMutationRequestPolicy, OfflineRequestPolicy } from './offline-request-policy'; import { provideOfflineMutationRequestPolicy, provideOfflineRequestPolicy } from './offline-request-policy'; +import type { OfflineAggregateIntentProjector } from './offline-aggregate-intent-projector'; +import { OFFLINE_AGGREGATE_INTENT_PROJECTOR } from './offline-aggregate-intent-projector'; import type { OfflineReplicaProjector, OfflineReplicaPuller } from './offline-replica-puller'; import { OFFLINE_REPLICA_PROJECTOR, OFFLINE_REPLICA_PULLER } from './offline-replica-puller'; import { OfflineSessionService } from './offline-session.service'; @@ -51,6 +53,15 @@ export interface ProvideSynchronizedOfflineOptions extends ProvideOfflineOptions commandExecutor: Type; /** Product transport for explicit cursor-based server delta pulls. */ replicaPuller: Type; + /** + * Required pure adapter that rematerializes one aggregate from its + * authoritative confirmed base/localOnly values and remaining Outbox intents. + * + * Kit calls it inside {@link OfflineReplicaMutationCoordinator} after enqueue, + * replacement, discard, transport success, and pull acknowledgement. Do not + * call the projector from product code. + */ + aggregateIntentProjector: Type; } /** Server- or external-source read cache with no mutation transport or Outbox. */ @@ -128,6 +139,9 @@ export function provideOffline(options: ProvideOfflineOptions): EnvironmentProvi ...(options.replicaProjector ? [options.replicaProjector, { provide: OFFLINE_REPLICA_PROJECTOR, useExisting: options.replicaProjector }] : []), + ...(synchronized && 'aggregateIntentProjector' in options && options.aggregateIntentProjector + ? [options.aggregateIntentProjector, { provide: OFFLINE_AGGREGATE_INTENT_PROJECTOR, useExisting: options.aggregateIntentProjector }] + : []), ...options.requestPolicies.flatMap((policy) => provideOfflineRequestPolicy(policy)), ...(options.mutationPolicies ?? []).flatMap((policy) => provideOfflineMutationRequestPolicy(policy)), ...(options.providers ?? []), @@ -139,10 +153,7 @@ export function provideOffline(options: ProvideOfflineOptions): EnvironmentProvi } /** Prevents unsupported multi-tab Web writes from silently losing Outbox state. */ -export function assertSupportedOfflineMode( - platform: string, - mode: 'synchronized' | 'readCacheOnly', -): void { +export function assertSupportedOfflineMode(platform: string, mode: 'synchronized' | 'readCacheOnly'): void { if (!supportsSynchronizedOfflineRepository(platform) && mode === 'synchronized') { throw new Error( 'Offline synchronized mode is supported only by native repositories. Use readCacheOnly until the selected repository provides cross-context locking.', diff --git a/projects/kit/offline/src/lib/offline-replica-mutation-coordinator.ts b/projects/kit/offline/src/lib/offline-replica-mutation-coordinator.ts index 079362a..b061c3a 100644 --- a/projects/kit/offline/src/lib/offline-replica-mutation-coordinator.ts +++ b/projects/kit/offline/src/lib/offline-replica-mutation-coordinator.ts @@ -1,14 +1,38 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; +import { + OFFLINE_AGGREGATE_INTENT_PROJECTOR, + isOfflineAggregateIntentConflict, + type OfflineAggregateIntentProjectInput, + type OfflineAggregateIntentProjection, + type OfflineAggregateIntentProjectResult, +} from './offline-aggregate-intent-projector'; +import { canonicalOfflineReplicaIdentity, commandIdentityMatchesReplicaRow, type OfflineCommandIdentity } from './offline-identity'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; +import { + canonicalOfflineReplicaRowKey, + type OfflineCommand, + type OfflineReplicaRow, + type OfflineReplicaRowKey, + type OfflineScope, +} from './offline-repository'; +import type { OfflineReplicaEntitySchema } from './offline-replica-schema'; /** * Serializes only local replica read/derive/write critical sections. Network * transport must stay outside this coordinator so synchronization never holds * the local mutation lane while waiting on I/O. + * + * This coordinator is the sole caller and validator of aggregate rematerialization. + * Invoke {@link projectAggregateIntent} only from inside {@link run}; this + * method does not acquire the lane itself. */ @Injectable({ providedIn: 'root' }) export class OfflineReplicaMutationCoordinator { + readonly #projector = inject(OFFLINE_AGGREGATE_INTENT_PROJECTOR, { optional: true }); + readonly #options = inject(OFFLINE_KIT_OPTIONS, { optional: true }); #tail: Promise = Promise.resolve(); + /** Enqueues one local replica critical section behind any in-flight mutation. */ run(operation: () => Promise): Promise { const mutation = this.#tail.then(operation); this.#tail = mutation.then( @@ -18,7 +42,213 @@ export class OfflineReplicaMutationCoordinator { return mutation; } + /** Resolves after every currently queued replica mutation has settled. */ async drain(): Promise { await this.#tail; } + + /** + * Calls the product aggregate-intent projector and validates scope, schema, + * identity, and footprints. Throws before returning when the projector is + * missing or the projection is unsafe to write. + * + * @param input - Authoritative base row, current localOnly rows, and remaining commands. + */ + projectAggregateIntent(input: OfflineAggregateIntentProjectInput): OfflineAggregateIntentProjectResult { + if (!this.#projector) { + throw new Error('Offline aggregate intent projector is not configured.'); + } + if (!this.#options) { + throw new Error('Offline aggregate intent projector requires replica schema configuration.'); + } + const projection = this.#projector.project(input); + if (isOfflineAggregateIntentConflict(projection)) { + if (input.trigger !== 'pull' || input.commands.length === 0 || projection.reason.length === 0) { + throw new Error('Offline aggregate intent conflict is valid only for pending commands during pull.'); + } + return projection; + } + this.#assertAggregateIntentProjection(input, projection); + return projection; + } + + #assertAggregateIntentProjection(input: OfflineAggregateIntentProjectInput, projection: OfflineAggregateIntentProjection): void { + const expected = this.#expectedAggregate(input); + const footprint = this.#localOnlyFootprint(input); + this.#assertBaseProjection(input, projection, expected); + this.#assertLocalOnlyProjection(input, expected.scope, footprint, projection); + } + + #expectedAggregate(input: OfflineAggregateIntentProjectInput): { + scope: OfflineScope | null; + sourceKey: string | null; + commandIdentity: OfflineCommandIdentity | null; + } { + const command = input.commands[0] ?? null; + const base = input.baseRow; + const local = input.localOnlyRows[0] ?? null; + const scope = base + ? { userId: base.userId, scopeId: base.scopeId } + : command + ? { userId: command.userId, scopeId: command.scopeId } + : local + ? { userId: local.userId, scopeId: local.scopeId } + : null; + return { + scope, + sourceKey: base?.sourceKey ?? command?.sourceKey ?? null, + commandIdentity: command?.identity ?? null, + }; + } + + #assertBaseProjection( + input: OfflineAggregateIntentProjectInput, + projection: OfflineAggregateIntentProjection, + expected: { + scope: OfflineScope | null; + sourceKey: string | null; + commandIdentity: OfflineCommandIdentity | null; + }, + ): void { + const remaining = input.commands; + const base = input.baseRow; + if (remaining.length === 0) { + if (base?.confirmedValues == null) { + if (projection.baseRow !== null) { + throw new Error('Offline aggregate intent projector must remove a base row whose confirmedValues are null.'); + } + return; + } + this.#assertBaseRowIdentity(expected, projection.baseRow, base); + if (!sameJson(projection.baseRow!.values, base.confirmedValues)) { + throw new Error('Offline aggregate intent projector must restore confirmed values when no commands remain.'); + } + if (projection.baseRow!.syncState !== 'confirmed' || (projection.baseRow!.visibility ?? 'present') !== 'present') { + throw new Error('Offline aggregate intent projector must restore a confirmed present base row when no commands remain.'); + } + return; + } + if (!projection.baseRow) { + throw new Error('Offline aggregate intent projector must return a base row while pending commands remain.'); + } + this.#assertBaseRowIdentity(expected, projection.baseRow, base); + const last = remaining.at(-1)!; + if (projection.baseRow.syncState !== 'pending') { + throw new Error('Offline aggregate intent projector must keep a pending base row while commands remain.'); + } + const expectedVisibility = last.replicaMutation === 'delete' ? 'pending_delete' : 'present'; + if ((projection.baseRow.visibility ?? 'present') !== expectedVisibility) { + throw new Error('Offline aggregate intent projector must match remaining replica visibility.'); + } + } + + #assertBaseRowIdentity( + expected: { + scope: OfflineScope | null; + sourceKey: string | null; + commandIdentity: OfflineCommandIdentity | null; + }, + row: OfflineReplicaRow | null, + inputBase: OfflineReplicaRow | null, + ): void { + if (!row || !expected.scope || !expected.sourceKey) { + throw new Error('Offline aggregate intent projector base row must keep the aggregate identity.'); + } + const schema = this.#entitySchema(expected.sourceKey); + if (schema.identity.kind === 'localOnly') { + throw new Error(`Offline aggregate intent projector cannot rematerialize localOnly source "${expected.sourceKey}".`); + } + if (row.userId !== expected.scope.userId || row.scopeId !== expected.scope.scopeId || row.sourceKey !== expected.sourceKey) { + throw new Error('Offline aggregate intent projector base row must use the current scope and source.'); + } + if (inputBase) { + if (!sameJson(row.confirmedValues, inputBase.confirmedValues) || row.serverRevision !== inputBase.serverRevision) { + throw new Error('Offline aggregate intent projector base row must keep confirmedValues.'); + } + if (canonicalOfflineReplicaIdentity(row.identity) !== canonicalOfflineReplicaIdentity(inputBase.identity)) { + throw new Error('Offline aggregate intent projector base row must keep the aggregate identity.'); + } + return; + } + if (row.confirmedValues !== null) { + throw new Error('Offline aggregate intent projector base row must keep confirmedValues.'); + } + if (!expected.commandIdentity || !commandIdentityMatchesReplicaRow(schema, row, expected.commandIdentity)) { + throw new Error('Offline aggregate intent projector base row must keep the aggregate identity.'); + } + } + + #assertLocalOnlyProjection( + input: OfflineAggregateIntentProjectInput, + scope: OfflineScope | null, + footprint: ReadonlySet, + projection: OfflineAggregateIntentProjection, + ): void { + const currentByKey = new Map( + input.localOnlyRows.map((row) => [canonicalOfflineReplicaRowKey(this.#entitySchema(row.sourceKey), row), row] as const), + ); + const putRows = projection.putLocalOnlyRows ?? []; + const removeRows = projection.removeLocalOnlyRows ?? []; + const seen = new Set(); + for (const row of putRows) { + const key = this.#assertLocalOnlyMutation(scope, footprint, row); + if (seen.has(key)) throw new Error(`Offline aggregate intent projector contains duplicate localOnly row ${key}.`); + const current = currentByKey.get(key); + if (!sameJson(row.confirmedValues, current?.confirmedValues ?? null)) { + throw new Error('Offline aggregate intent projector localOnly rows must keep confirmedValues.'); + } + seen.add(key); + } + for (const row of removeRows) { + const key = this.#assertLocalOnlyMutation(scope, footprint, row); + if (seen.has(key)) throw new Error(`Offline aggregate intent projector contains duplicate localOnly row ${key}.`); + seen.add(key); + } + if (seen.size !== footprint.size) { + throw new Error('Offline aggregate intent projector must cover every localOnly footprint exactly once.'); + } + } + + #assertLocalOnlyMutation(scope: OfflineScope | null, footprint: ReadonlySet, row: OfflineReplicaRowKey): string { + const schema = this.#entitySchema(row.sourceKey); + if (schema.identity.kind !== 'localOnly') { + throw new Error(`Offline aggregate intent projector may only mutate localOnly source "${row.sourceKey}".`); + } + if (!scope || row.userId !== scope.userId || row.scopeId !== scope.scopeId || row.identity.kind !== 'local') { + throw new Error('Offline aggregate intent projector rows must use the current scope and local identity.'); + } + const key = canonicalOfflineReplicaRowKey(schema, row); + if (!footprint.has(key)) { + throw new Error(`Offline aggregate intent projector changed undeclared localOnly row ${key}.`); + } + return key; + } + + #localOnlyFootprint(input: OfflineAggregateIntentProjectInput): Set { + const keys = new Set(); + for (const row of input.localOnlyRows) { + keys.add(canonicalOfflineReplicaRowKey(this.#entitySchema(row.sourceKey), row)); + } + for (const command of input.commands) { + for (const key of commandFootprintKeys(command)) { + keys.add(canonicalOfflineReplicaRowKey(this.#entitySchema(key.sourceKey), key)); + } + } + return keys; + } + + #entitySchema(sourceKey: string): OfflineReplicaEntitySchema> { + const schema = this.#options?.replicaSchema.entities.find((entity) => entity.sourceKey === sourceKey); + if (!schema) throw new Error(`Unknown offline replica source key "${sourceKey}".`); + return schema; + } +} + +/** Declared localOnly keys persisted on one Outbox command. */ +export function commandFootprintKeys(command: Pick): readonly OfflineReplicaRowKey[] { + return command.localOnlyFootprint ?? []; +} + +function sameJson(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); } diff --git a/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts b/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts index 2de9f85..7d1c637 100644 --- a/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts @@ -14,7 +14,8 @@ import { } from './offline-replica-puller'; import { OfflineReplicaPullService, OfflineReplicaSchemaMismatchError } from './offline-replica-pull.service'; import { OfflineReplicaMutationCoordinator } from './offline-replica-mutation-coordinator'; -import { generatedCommandIdentity, generatedReplicaIdentity } from './offline-test-helpers'; +import { generatedCommandIdentity, generatedReplicaIdentity, rematerializeTestAggregate } from './offline-test-helpers'; +import { OFFLINE_AGGREGATE_INTENT_PROJECTOR, type OfflineAggregateIntentProjector } from './offline-aggregate-intent-projector'; import { defineOfflineReplicaSchema, defineReplicaEntity, @@ -102,6 +103,7 @@ describe('OfflineReplicaPullService', () => { let schemaHash: string; let pull: ReturnType Promise>>; let projector: { project: ReturnType }; + let aggregateProject: OfflineAggregateIntentProjector['project']; function page( changes: readonly OfflineReplicaChange[], @@ -163,13 +165,16 @@ describe('OfflineReplicaPullService', () => { provide: OFFLINE_COMMAND_EXECUTOR, useValue: { execute: vi.fn(), - rebasePendingCommands: vi.fn(() => null), withServerRevision: (command: OfflineCommand, revision: string | number) => ({ ...command, baseRevision: revision, }), }, }, + { + provide: OFFLINE_AGGREGATE_INTENT_PROJECTOR, + useValue: { project: (...args: Parameters) => aggregateProject(...args) }, + }, ], }); repository = TestBed.inject(OFFLINE_REPOSITORY); @@ -180,6 +185,7 @@ describe('OfflineReplicaPullService', () => { storage = new MemoryStorage(); pull = vi.fn(async () => page([])); projector = { project: vi.fn(async () => ({})) }; + aggregateProject = rematerializeTestAggregate; await seedReplicaMetadata(); configureTestBed(); await repository.initialize(); @@ -196,6 +202,7 @@ describe('OfflineReplicaPullService', () => { cursor: '', schemaVersion: replicaSchema.version, schemaHash, + reconciliationTargets: [], }); }); @@ -317,21 +324,9 @@ describe('OfflineReplicaPullService', () => { commandId: 'other-scope-command', aggregateType: 'test_items', sourceKey: 'test_items', - identity: { kind: 'generated', localId: 'other' }, + identity: { kind: 'generated', localId: 'shared' }, operation: 'test_items.update', - payload: {}, - optimisticValue: {}, - optimisticCompanions: [ - { - key: { - ...otherScope, - sourceKey: 'test_items', - identity: { kind: 'generated', localId: 'shared', remoteId: null }, - }, - before: companion, - after: companion, - }, - ], + payload: { title: 'Optimistic' }, payloadHash: 'hash', baseRevision: 1, state: 'pending', @@ -474,7 +469,6 @@ describe('OfflineReplicaPullService', () => { identity: { kind: 'generated', localId: 'race-local' }, operation: 'test_items.update', payload: { title: 'Optimistic edit' }, - optimisticValue: optimisticRow.values, payloadHash: 'hash', baseRevision: 2, state: 'pending', @@ -809,7 +803,7 @@ describe('OfflineReplicaPullService', () => { expect(run).not.toHaveBeenCalled(); expect(transactReplica).not.toHaveBeenCalled(); - expect(getCommands).not.toHaveBeenCalled(); + expect(getCommands).toHaveBeenCalledOnce(); await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'cursor-v0' }); }); @@ -854,7 +848,6 @@ describe('OfflineReplicaPullService', () => { identity: { kind: 'generated', localId: '019d-pending' }, operation: 'test_items.update', payload: { title: 'Optimistic draft' }, - optimisticValue: { id: 42, title: 'Optimistic draft' }, payloadHash: 'hash', baseRevision: 2, state: 'pending', @@ -878,7 +871,7 @@ describe('OfflineReplicaPullService', () => { await expect(repository.getCommands(scope)).resolves.toEqual([expect.objectContaining({ commandId: 'cmd-pending', state: 'pending' })]); }); - it('revision conflictはreplicaとcommandをconflictへ遷移する', async () => { + it('external revision rematerializes remaining pending intents onto the new confirmed baseline', async () => { await repository.transactReplica({ putRows: [ { @@ -895,13 +888,12 @@ describe('OfflineReplicaPullService', () => { putCommands: [ { ...scope, - commandId: 'cmd-conflict', + commandId: 'cmd-remaining', aggregateType: 'test_items', sourceKey: 'test_items', identity: { kind: 'generated', localId: '019d-conflict' }, operation: 'test_items.update', payload: { title: 'Local edit' }, - optimisticValue: { id: 42, title: 'Local edit' }, payloadHash: 'hash', baseRevision: 1, state: 'pending', @@ -917,324 +909,55 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-conflict'))).resolves.toMatchObject({ - syncState: 'conflict', + values: { title: 'Local edit' }, confirmedValues: { title: 'Remote truth' }, serverRevision: 9, + syncState: 'pending', }); await expect(repository.getCommands(scope)).resolves.toEqual([ expect.objectContaining({ - commandId: 'cmd-conflict', - state: 'conflict', - lastErrorCode: 'remote_revision', - retryAt: null, + commandId: 'cmd-remaining', + state: 'pending', + baseRevision: 9, }), ]); }); - it.each(['stale put', 'remove'] as const)( - 'rebase overlay wins over projector %s for the same canonical row', - async (projectionMutation) => { - const executor = TestBed.inject(OFFLINE_COMMAND_EXECUTOR); - const otherScope = { userId: scope.userId, scopeId: '20' }; - const oldView: OfflineReplicaRow = { - ...scope, - sourceKey: 'test_views', - identity: { kind: 'local', localId: 'view-42' }, - values: { title: 'Old optimistic view' }, - confirmedValues: { title: 'Old confirmed view' }, - serverRevision: null, - fetchedAt: 1, - syncState: 'pending', - }; - const rebase = vi.spyOn(executor, 'rebasePendingCommands').mockImplementation((commands, confirmed, revision) => ({ - steps: commands.map(() => ({ - optimisticValue: { ...(confirmed as Record), title: 'Rebased delta' }, - optimisticCompanions: [ - { - key: { - ...otherScope, - sourceKey: 'test_views', - identity: { kind: 'local', localId: 'view-42' }, - }, - before: null, - after: { - ...otherScope, - sourceKey: 'test_views', - identity: { kind: 'local', localId: 'view-42' }, - values: { title: 'Rebased view' }, - confirmedValues: { title: 'Remote view' }, - serverRevision: null, - fetchedAt: 9, - syncState: 'pending', - }, - }, - ], - })), - })); - await repository.transactReplica({ - putRows: [ - { - ...scope, - sourceKey: 'test_items', - identity: { kind: 'generated', localId: '019d-rebase', remoteId: 42 }, - values: { id: 42, title: 'Local delta' }, - confirmedValues: { id: 42, title: 'Old confirmed' }, - serverRevision: 1, - fetchedAt: 1, - syncState: 'pending', - }, - oldView, - ], - putCommands: [ - { - ...scope, - commandId: 'cmd-rebase', - aggregateType: 'test_items', - sourceKey: 'test_items', - identity: { kind: 'generated', localId: '019d-rebase' }, - operation: 'test_items.delta', - payload: { delta: 1 }, - optimisticValue: { id: 42, title: 'Local delta' }, - optimisticCompanions: [ - { - key: { ...otherScope, sourceKey: 'test_views', identity: { kind: 'local', localId: 'view-42' } }, - before: null, - after: oldView, - }, - ], - payloadHash: 'hash', - baseRevision: 1, - state: 'pending', - attempts: 0, - retryAt: null, - createdAt: 1, - lastErrorCode: null, - }, - ], - }); - projector.project.mockResolvedValue( - projectionMutation === 'stale put' - ? { - putRows: [ - { - ...oldView, - confirmedValues: { title: 'Remote view' }, - }, - ], - } - : { removeRows: [{ ...scope, sourceKey: 'test_views', identity: { kind: 'local', localId: 'view-42' } }] }, - ); - pull.mockResolvedValueOnce(page([itemChange(42, 'Remote truth', { serverRevision: 9 })], { nextCursor: 'cursor-v1' })); - - await service.pull(scope); - - expect(rebase).toHaveBeenCalledWith( - [expect.objectContaining({ commandId: 'cmd-rebase' })], - expect.objectContaining({ title: 'Remote truth' }), - 9, - projectionMutation === 'stale put' - ? [ - expect.objectContaining({ - values: { title: 'Old optimistic view' }, - confirmedValues: { title: 'Remote view' }, - }), - ] - : [], - ); - await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-rebase'))).resolves.toMatchObject({ - values: { title: 'Rebased delta' }, - confirmedValues: { title: 'Remote truth' }, - serverRevision: 9, - syncState: 'pending', - }); - await expect(repository.getCommands(scope)).resolves.toEqual([ - expect.objectContaining({ - commandId: 'cmd-rebase', - payload: { delta: 1 }, - payloadHash: 'hash', - baseRevision: 9, - state: 'pending', - lastErrorCode: null, - }), - ]); - await expect(repository.getReplicaRow(scope, 'test_views', { kind: 'local', localId: 'view-42' })).resolves.toMatchObject({ - values: { title: 'Rebased view' }, - confirmedValues: { title: 'Remote view' }, - }); - await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'cursor-v1' }); - }, - ); - - it('passes only each aggregate canonical companion footprint to its rebase reducer', async () => { - const executor = TestBed.inject(OFFLINE_COMMAND_EXECUTOR); - const view = (remoteId: number, title: string): OfflineReplicaRow => ({ - ...scope, - sourceKey: 'test_views', - identity: { kind: 'local', localId: `view-${remoteId}` }, - values: { title: `Optimistic ${title}` }, - confirmedValues: { title: `Old ${title}` }, - serverRevision: null, - fetchedAt: 1, - syncState: 'pending', - }); - const views = [view(42, 'A'), view(43, 'B')]; - const command = (remoteId: number, companion: OfflineReplicaRow): OfflineCommand => ({ + it('external revision rematerializes remaining intents onto the new confirmed base and localOnly rows', async () => { + const view: OfflineReplicaRow = { ...scope, - commandId: `cmd-${remoteId}`, - aggregateType: 'test_items', - sourceKey: 'test_items', - identity: { kind: 'generated', localId: `local-${remoteId}` }, - operation: 'test_items.delta', - payload: { delta: 1 }, - optimisticValue: { id: remoteId, title: `Local ${remoteId}` }, - optimisticCompanions: [{ key: companion, before: null, after: companion }], - payloadHash: `hash-${remoteId}`, - baseRevision: 1, - state: 'pending', - attempts: 0, - retryAt: null, - createdAt: remoteId, - lastErrorCode: null, - }); - const commands = [command(42, views[0]!), command(43, views[1]!)]; - const rebase = vi.spyOn(executor, 'rebasePendingCommands').mockImplementation((related, confirmed, revision, companions) => ({ - steps: related.map((current) => ({ - optimisticValue: confirmed, - optimisticCompanions: [ - { - key: current.optimisticCompanions![0]!.key, - before: current.optimisticCompanions![0]!.before, - after: { ...companions[0]!, serverRevision: revision }, - }, - ], - })), - })); - await repository.transactReplica({ - putRows: [ - ...views, - ...[42, 43].map((remoteId) => ({ - ...scope, - sourceKey: 'test_items', - identity: { kind: 'generated' as const, localId: `local-${remoteId}`, remoteId }, - values: { id: remoteId, title: `Local ${remoteId}` }, - confirmedValues: { id: remoteId, title: `Old ${remoteId}` }, - serverRevision: 1, - fetchedAt: 1, - syncState: 'pending' as const, - })), - ], - putCommands: commands, - }); - projector.project.mockResolvedValue({ - putRows: views.map((row, index) => ({ - ...row, - confirmedValues: { title: `Remote ${index === 0 ? 'A' : 'B'}` }, - })), - }); - pull.mockResolvedValueOnce( - page([itemChange(42, 'Remote 42', { serverRevision: 9 }), itemChange(43, 'Remote 43', { serverRevision: 10 })], { - nextCursor: 'cursor-v2', - }), - ); - - await service.pull(scope); - - expect(rebase).toHaveBeenCalledTimes(2); - expect(rebase.mock.calls.map((call) => call[3].map((row) => row.identity))).toEqual([ - [{ kind: 'local', localId: 'view-42' }], - [{ kind: 'local', localId: 'view-43' }], - ]); - }); - - it.each(['conflict', 'rejected', 'blocked_auth'] as const)('%s commandは自動rebaseせずattention stateを維持する', async (state) => { - const executor = TestBed.inject(OFFLINE_COMMAND_EXECUTOR); - const rebase = vi.spyOn(executor, 'rebasePendingCommands'); - await repository.transactReplica({ - putRows: [ - { - ...scope, - sourceKey: 'test_items', - identity: { kind: 'generated', localId: `019d-${state}`, remoteId: 42 }, - values: { id: 42, title: 'Local' }, - confirmedValues: { id: 42, title: 'Old' }, - serverRevision: 1, - fetchedAt: 1, - syncState: state, - }, - ], - putCommands: [ - { - ...scope, - commandId: `cmd-${state}`, - aggregateType: 'test_items', - sourceKey: 'test_items', - identity: { kind: 'generated', localId: `019d-${state}` }, - operation: 'test_items.delta', - payload: { delta: 1 }, - optimisticValue: { id: 42, title: 'Local' }, - payloadHash: 'hash', - baseRevision: 1, - state, - attempts: 1, - retryAt: null, - createdAt: 1, - lastErrorCode: state, - }, - ], - }); - pull.mockResolvedValueOnce(page([itemChange(42, 'Remote', { serverRevision: 9 })], { nextCursor: 'cursor-v1' })); - - await service.pull(scope); - - expect(rebase).not.toHaveBeenCalled(); - await expect(repository.getCommands(scope)).resolves.toEqual([ - expect.objectContaining({ commandId: `cmd-${state}`, state: 'conflict', lastErrorCode: 'remote_revision' }), - ]); - await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity(`019d-${state}`))).resolves.toMatchObject({ - syncState: 'conflict', - }); - }); - - it('rebase reducer receives a pending-delete companion from its own scope', async () => { - const otherScope = { userId: scope.userId, scopeId: '20' }; - const companion: OfflineReplicaRow = { - ...otherScope, sourceKey: 'test_views', - identity: { kind: 'local', localId: 'hidden-view' }, - values: { title: 'Hidden' }, - confirmedValues: { title: 'Confirmed hidden' }, + identity: { kind: 'local', localId: 'view-42' }, + values: { title: 'Local delta' }, + confirmedValues: { title: 'Old confirmed view' }, serverRevision: null, fetchedAt: 1, syncState: 'pending', - visibility: 'pending_delete', }; - const executor = TestBed.inject(OFFLINE_COMMAND_EXECUTOR); - const rebase = vi.spyOn(executor, 'rebasePendingCommands').mockReturnValue(null); await repository.transactReplica({ putRows: [ { ...scope, sourceKey: 'test_items', - identity: { kind: 'generated', localId: '019d-cross-scope', remoteId: 42 }, - values: { id: 42, title: 'Local' }, - confirmedValues: { id: 42, title: 'Old' }, + identity: { kind: 'generated', localId: '019d-rebase', remoteId: 42 }, + values: { id: 42, title: 'Local delta' }, + confirmedValues: { id: 42, title: 'Old confirmed' }, serverRevision: 1, fetchedAt: 1, syncState: 'pending', }, - companion, + view, ], putCommands: [ { ...scope, - commandId: 'cmd-cross-scope', + commandId: 'cmd-remaining', aggregateType: 'test_items', sourceKey: 'test_items', - identity: { kind: 'generated', localId: '019d-cross-scope' }, - operation: 'test_items.delta', - payload: { delta: 1 }, - optimisticValue: { id: 42, title: 'Local' }, - optimisticCompanions: [{ key: companion, before: null, after: companion }], + identity: { kind: 'generated', localId: '019d-rebase' }, + operation: 'test_items.update', + payload: { title: 'Local delta' }, + localOnlyFootprint: [view], payloadHash: 'hash', baseRevision: 1, state: 'pending', @@ -1245,13 +968,31 @@ describe('OfflineReplicaPullService', () => { }, ], }); - pull.mockResolvedValueOnce(page([itemChange(42, 'Remote', { serverRevision: 9 })], { nextCursor: 'cursor-v1' })); + projector.project.mockResolvedValue({ + putRows: [{ ...view, confirmedValues: { title: 'Remote view' } }], + }); + pull.mockResolvedValueOnce(page([itemChange(42, 'Remote truth', { serverRevision: 9 })], { nextCursor: 'cursor-v1' })); await service.pull(scope); - expect(rebase).toHaveBeenCalledWith(expect.any(Array), expect.anything(), 9, [ - expect.objectContaining({ scopeId: '20', visibility: 'pending_delete' }), + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-rebase'))).resolves.toMatchObject({ + values: { title: 'Local delta' }, + confirmedValues: { title: 'Remote truth' }, + serverRevision: 9, + syncState: 'pending', + }); + await expect(repository.getCommands(scope)).resolves.toEqual([ + expect.objectContaining({ + commandId: 'cmd-remaining', + payload: { title: 'Local delta' }, + baseRevision: 9, + state: 'pending', + }), ]); + await expect(repository.getReplicaRow(scope, 'test_views', { kind: 'local', localId: 'view-42' })).resolves.toMatchObject({ + values: { title: 'Local delta' }, + confirmedValues: { title: 'Remote view' }, + }); }); it('remote tombstone conflictはpending commandをremote_deleted conflictへ遷移する', async () => { @@ -1276,8 +1017,7 @@ describe('OfflineReplicaPullService', () => { sourceKey: 'test_items', identity: { kind: 'generated', localId: '019d-tombstone' }, operation: 'test_items.delete', - payload: {}, - optimisticValue: { id: 42, title: 'Pending delete' }, + payload: { title: 'Pending delete' }, payloadHash: 'hash', baseRevision: 1, state: 'pending', @@ -1332,7 +1072,6 @@ describe('OfflineReplicaPullService', () => { identity: generatedCommandIdentity(localId), operation: 'test_items.create', payload: { title: 'Draft create' }, - optimisticValue: { id: 0, title: 'Draft create' }, payloadHash: 'hash', baseRevision: null, state: 'pending', @@ -1362,6 +1101,99 @@ describe('OfflineReplicaPullService', () => { expect(await repository.getReplicaRows(scope, 'test_items')).toHaveLength(1); }); + it('journal retention後のrebaselineでもawaiting-pull targetをhydrateしACK changeと同じtransactionで除去する', async () => { + await seedPendingCreate(); + const current = (await repository.getCommands(scope))[0]!; + await repository.putCommand({ ...current, state: 'awaiting_pull' }); + const row = (await repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-create')))!; + await repository.transactReplica({ + putRows: [{ ...row, identity: { kind: 'generated', localId: '019d-create', remoteId: 42 } }], + }); + pull.mockImplementationOnce(async (request) => { + expect(request).toMatchObject({ + cursor: '', + reconciliationTargets: [ + { + commandId: 'cmd-create', + operation: 'test_items.create', + sourceKey: 'test_items', + identity: { remoteId: 42 }, + }, + ], + }); + return page([itemChange(42, 'Created after retention', { acknowledgedCommandIds: ['cmd-create'] })], { + nextCursor: 'snapshot-after-retention', + rebaselineRequired: true, + }); + }); + + await service.pull(scope); + + expect(await repository.getCommands(scope)).toEqual([]); + await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'snapshot-after-retention' }); + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-create'))).resolves.toMatchObject({ + confirmedValues: { title: 'Created after retention' }, + syncState: 'confirmed', + }); + }); + + it('generated deleteはrelease済みremote identityをdurable targetからreconcileする', async () => { + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-delete-retained', remoteId: null }, + values: { id: 42, title: 'Pending delete' }, + confirmedValues: { id: 42, title: 'Confirmed' }, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + visibility: 'pending_delete', + }, + ], + putCommands: [ + { + ...scope, + commandId: 'cmd-delete-retained', + aggregateType: 'test_items', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-delete-retained' }, + operation: 'test_items.delete', + payload: {}, + replicaMutation: 'delete', + payloadHash: 'hash', + baseRevision: 1, + state: 'awaiting_pull', + attempts: 1, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + reconciliationIdentity: { remoteId: 42 }, + }, + ], + }); + pull.mockImplementationOnce(async (request) => { + expect(request.reconciliationTargets).toEqual([ + expect.objectContaining({ commandId: 'cmd-delete-retained', identity: { remoteId: 42 } }), + ]); + return page([ + itemChange(43, 'Wrong delete', { deleted: true, serverRevision: 2, acknowledgedCommandIds: ['cmd-delete-retained'] }), + ]); + }); + + await expect(service.pull(scope)).rejects.toThrow('does not match the requested remote identity'); + await expect(repository.getCommands(scope)).resolves.toEqual([expect.objectContaining({ commandId: 'cmd-delete-retained' })]); + + pull.mockResolvedValueOnce( + page([itemChange(42, 'Deleted', { deleted: true, serverRevision: 2, acknowledgedCommandIds: ['cmd-delete-retained'] })]), + ); + await service.pull(scope); + + expect(await repository.getCommands(scope)).toEqual([]); + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-delete-retained'))).resolves.toBeNull(); + }); + it('update lost ACKはprefix commandを除去しfollowing commandをrebaseする', async () => { await repository.transactReplica({ putRows: [ @@ -1385,7 +1217,6 @@ describe('OfflineReplicaPullService', () => { identity: { kind: 'generated', localId: '019d-update' }, operation: 'test_items.update', payload: { title: 'First edit' }, - optimisticValue: { id: 42, title: 'First edit' }, payloadHash: 'hash-1', baseRevision: 1, state: 'pending', @@ -1402,7 +1233,6 @@ describe('OfflineReplicaPullService', () => { identity: { kind: 'generated', localId: '019d-update' }, operation: 'test_items.update', payload: { title: 'Follow-up edit' }, - optimisticValue: { id: 42, title: 'Follow-up edit' }, payloadHash: 'hash-2', baseRevision: 1, state: 'pending', @@ -1455,8 +1285,7 @@ describe('OfflineReplicaPullService', () => { sourceKey: 'test_items', identity: { kind: 'generated', localId: '019d-delete-ack' }, operation: 'test_items.delete', - payload: {}, - optimisticValue: { id: 42, title: 'Pending delete' }, + payload: { title: 'Pending delete' }, payloadHash: 'hash', baseRevision: 1, state: 'pending', @@ -1502,8 +1331,7 @@ describe('OfflineReplicaPullService', () => { sourceKey: 'test_items', identity: { kind: 'generated', localId: '019d-delete-superseded' }, operation: 'test_items.delete', - payload: {}, - optimisticValue: { id: 42, title: 'Pending delete' }, + payload: { title: 'Pending delete' }, replicaMutation: 'delete', payloadHash: 'hash-delete', baseRevision: 1, @@ -1521,7 +1349,6 @@ describe('OfflineReplicaPullService', () => { identity: { kind: 'generated', localId: '019d-delete-superseded' }, operation: 'test_items.update', payload: { title: 'Following upsert' }, - optimisticValue: { id: 42, title: 'Following upsert' }, replicaMutation: 'upsert', payloadHash: 'hash-following-upsert', baseRevision: 1, @@ -1578,8 +1405,7 @@ describe('OfflineReplicaPullService', () => { sourceKey: 'test_items', identity: { kind: 'generated', localId: '019d-server-id-immutable' }, operation: 'test_items.delete', - payload: {}, - optimisticValue: { id: 42, title: 'Pending delete' }, + payload: { title: 'Pending delete' }, replicaMutation: 'delete', payloadHash: 'hash', baseRevision: 1, @@ -1649,7 +1475,6 @@ describe('OfflineReplicaPullService', () => { identity: { kind: 'generated', localId: '019d-lost-update' }, operation: 'test_items.update', payload: { title: 'First edit' }, - optimisticValue: { id: 42, title: 'First edit' }, payloadHash: 'hash-1', baseRevision: 1, state: 'pending', @@ -1666,7 +1491,6 @@ describe('OfflineReplicaPullService', () => { identity: { kind: 'generated', localId: '019d-lost-update' }, operation: 'test_items.update', payload: { title: 'Follow-up edit' }, - optimisticValue: { id: 42, title: 'Follow-up edit' }, payloadHash: 'hash-2', baseRevision: 1, state: 'pending', @@ -1734,7 +1558,6 @@ describe('OfflineReplicaPullService', () => { identity: { kind: 'generated', localId: '019d-other-acks' }, operation: 'test_items.update', payload: { title: 'Local edit' }, - optimisticValue: { id: 42, title: 'Local edit' }, payloadHash: 'hash-local', baseRevision: 3, state: 'pending', @@ -1796,7 +1619,6 @@ describe('OfflineReplicaPullService', () => { identity: { kind: 'generated', localId: '019d-skip' }, operation: 'test_items.update', payload: { title: 'First edit' }, - optimisticValue: { id: 42, title: 'First edit' }, payloadHash: 'hash-1', baseRevision: 1, state: 'pending', @@ -1813,7 +1635,6 @@ describe('OfflineReplicaPullService', () => { identity: { kind: 'generated', localId: '019d-skip' }, operation: 'test_items.update', payload: { title: 'Second edit' }, - optimisticValue: { id: 42, title: 'Second edit' }, payloadHash: 'hash-2', baseRevision: 1, state: 'pending', @@ -1867,7 +1688,6 @@ describe('OfflineReplicaPullService', () => { identity: { kind: 'generated', localId: '019d-local-a' }, operation: 'test_items.create', payload: { title: 'Pending create A' }, - optimisticValue: { id: 0, title: 'Pending create A' }, payloadHash: 'hash', baseRevision: null, state: 'pending', @@ -1928,6 +1748,123 @@ describe('OfflineReplicaPullService', () => { }); }); + it('revision-sensitive projector conflict advances the cursor while preserving the local display', async () => { + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-sensitive', remoteId: 42 }, + values: { id: 42, title: 'Pending absolute intent' }, + confirmedValues: { id: 42, title: 'Old confirmed' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'pending', + }, + { + ...scope, + sourceKey: 'test_views', + identity: { kind: 'local', localId: 'view-sensitive' }, + values: { title: 'Optimistic derived' }, + confirmedValues: { title: 'Old derived' }, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }, + ], + putCommands: [ + { + ...scope, + commandId: 'cmd-sensitive', + aggregateType: 'test_items', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-sensitive' }, + operation: 'test_items.absolute', + payload: { title: 'Pending absolute intent' }, + payloadHash: 'hash', + baseRevision: 1, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + localOnlyFootprint: [ + { + ...scope, + sourceKey: 'test_views', + identity: { kind: 'local', localId: 'view-sensitive' }, + }, + ], + }, + ], + }); + aggregateProject = (input) => + input.trigger === 'pull' ? { kind: 'conflict', reason: 'revision_sensitive' } : rematerializeTestAggregate(input); + projector.project.mockResolvedValueOnce({ + putRows: [ + { + ...scope, + sourceKey: 'test_views', + identity: { kind: 'local', localId: 'view-sensitive' }, + values: { title: 'Remote derived' }, + confirmedValues: { title: 'Remote derived' }, + serverRevision: null, + fetchedAt: 2, + syncState: 'confirmed', + }, + ], + }); + pull.mockResolvedValueOnce(page([itemChange(42, 'Remote edit', { serverRevision: 2 })], { nextCursor: 'cursor-v2' })); + + await service.pull(scope); + + await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'cursor-v2' }); + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-sensitive'))).resolves.toMatchObject({ + values: { title: 'Pending absolute intent' }, + confirmedValues: { title: 'Remote edit' }, + serverRevision: 2, + syncState: 'conflict', + }); + await expect(repository.getCommands(scope)).resolves.toEqual([ + expect.objectContaining({ commandId: 'cmd-sensitive', state: 'conflict', lastErrorCode: 'revision_sensitive' }), + ]); + await expect(repository.getReplicaRow(scope, 'test_views', { kind: 'local', localId: 'view-sensitive' })).resolves.toMatchObject({ + values: { title: 'Optimistic derived' }, + confirmedValues: { title: 'Remote derived' }, + syncState: 'conflict', + }); + + projector.project.mockResolvedValueOnce({ + putRows: [ + { + ...scope, + sourceKey: 'test_views', + identity: { kind: 'local', localId: 'view-sensitive' }, + values: { title: 'Newer remote derived' }, + confirmedValues: { title: 'Newer remote derived' }, + serverRevision: null, + fetchedAt: 3, + syncState: 'confirmed', + }, + ], + }); + pull.mockResolvedValueOnce(page([itemChange(42, 'Newer remote edit', { serverRevision: 3 })], { nextCursor: 'cursor-v3' })); + + await service.pull(scope); + + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-sensitive'))).resolves.toMatchObject({ + values: { title: 'Pending absolute intent' }, + confirmedValues: { title: 'Newer remote edit' }, + serverRevision: 3, + syncState: 'conflict', + }); + await expect(repository.getReplicaRow(scope, 'test_views', { kind: 'local', localId: 'view-sensitive' })).resolves.toMatchObject({ + values: { title: 'Optimistic derived' }, + confirmedValues: { title: 'Newer remote derived' }, + syncState: 'conflict', + }); + }); + it('optional getCommandsForUser未実装repositoryでもpullがthrowしない', async () => { TestBed.resetTestingModule(); pull = vi.fn(async () => page([])); diff --git a/projects/kit/offline/src/lib/offline-replica-pull.service.ts b/projects/kit/offline/src/lib/offline-replica-pull.service.ts index ee85151..4f017b8 100644 --- a/projects/kit/offline/src/lib/offline-replica-pull.service.ts +++ b/projects/kit/offline/src/lib/offline-replica-pull.service.ts @@ -1,12 +1,15 @@ import { inject, Injectable } from '@angular/core'; +import { isOfflineAggregateIntentConflict, offlineAggregateIntentMutations } from './offline-aggregate-intent-projector'; +import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { OFFLINE_COMMAND_EXECUTOR } from './offline-command-executor'; -import { OfflineReplicaMutationCoordinator } from './offline-replica-mutation-coordinator'; +import { commandFootprintKeys, OfflineReplicaMutationCoordinator } from './offline-replica-mutation-coordinator'; import { OFFLINE_REPLICA_PROJECTOR, OFFLINE_REPLICA_PULLER, type OfflineReplicaChange, type OfflineReplicaPullPage, + type OfflineReplicaReconciliationTarget, } from './offline-replica-puller'; import { canonicalOfflineCommandIdentity, @@ -70,6 +73,7 @@ export class OfflineReplicaPullService { readonly #puller = inject(OFFLINE_REPLICA_PULLER); readonly #projector = inject(OFFLINE_REPLICA_PROJECTOR, { optional: true }); readonly #executor = inject(OFFLINE_COMMAND_EXECUTOR); + readonly #hooks = inject(OFFLINE_COMMAND_HOOKS); readonly #replicaMutations = inject(OfflineReplicaMutationCoordinator); #schemaHash: Promise | null = null; @@ -85,11 +89,14 @@ export class OfflineReplicaPullService { let rebaselinePending = false; for (;;) { + const reconciliationTargets = await this.#reconciliationTargets(scope); + const reconciliationTargetsById = new Map(reconciliationTargets.map((target) => [target.commandId, target] as const)); const page = await this.#puller.pull({ scope, cursor: requestCursor, schemaVersion: wireProtocol.version, schemaHash: wireProtocol.hash, + reconciliationTargets, }); this.#assertPullPage(page); this.#assertHandshake(page.schemaVersion, page.schemaHash, wireProtocol); @@ -123,10 +130,9 @@ export class OfflineReplicaPullService { this.#assertProjection(scope, projection); const putRows: OfflineReplicaRow[] = []; const removeRows: OfflineReplicaRowKey[] = []; - const rebasedPutRows: OfflineReplicaRow[] = []; - const rebasedRemoveRows: OfflineReplicaRowKey[] = []; const putCommands = new Map(); const removeCommandIds = new Set(); + const rematerializeAfter: OfflineCommand[] = []; if (rebaselinePending || page.rebaselineRequired) { removeRows.push(...(await this.#confirmedRowsForRebaseline(scope, userCommands))); } @@ -141,6 +147,19 @@ export class OfflineReplicaPullService { if (command.sourceKey !== change.sourceKey) { throw new Error(`Acknowledged command "${commandId}" does not target "${change.sourceKey}".`); } + const requested = reconciliationTargetsById.get(commandId); + if (command.state === 'awaiting_pull') { + if (!requested) { + throw new Error(`Acknowledged command "${commandId}" was not requested for reconciliation on this pull page.`); + } + const schema = this.#entitySchema(change.sourceKey); + if ( + canonicalOfflineRemoteIdentity(schema, requested.identity) !== + canonicalOfflineRemoteIdentity(schema, this.#identity(change)) + ) { + throw new Error(`Acknowledged command "${commandId}" does not match the requested remote identity.`); + } + } return command; }) .filter((command): command is OfflineCommand => command !== null); @@ -184,7 +203,7 @@ export class OfflineReplicaPullService { if (acknowledgedCommand) { this.#assertIdentityAssignment(schema, existing!, identity); - this.#applyAcknowledgement(change, existing!, related, putRows, removeRows, putCommands, removeCommandIds); + this.#applyAcknowledgement(change, existing!, related, putRows, removeRows, putCommands, removeCommandIds, rematerializeAfter); continue; } @@ -225,58 +244,43 @@ export class OfflineReplicaPullService { continue; } - const revisionChanged = related.some((command) => command.baseRevision !== change.serverRevision); - const rebaseEligible = related.every( - (command) => (command.state === 'pending' || command.state === 'retry_wait') && command.serverCommitUnknown !== true, - ); - const rebase = - revisionChanged && rebaseEligible - ? ((await this.#executor.rebasePendingCommands?.( - related, - confirmedValues, - change.serverRevision, - await this.#projectedCompanionRows(related, projection), - )) ?? null) - : null; - this.#assertRebasedSteps(related, rebase?.steps ?? null); - const conflicted = revisionChanged && rebase === null; - const rebasedCommands = rebase - ? related.map((command, index) => ({ - ...command, - baseRevision: change.serverRevision, - optimisticValue: rebase.steps[index]!.optimisticValue, - ...(rebase.steps[index]!.optimisticCompanions === undefined - ? {} - : { optimisticCompanions: rebase.steps[index]!.optimisticCompanions }), - })) - : []; - for (const command of rebasedCommands) putCommands.set(command.commandId, command); - this.#appendRebasedCompanionRows(rebasedCommands, rebasedPutRows, rebasedRemoveRows); - putRows.push({ + const remaining = related.filter((command) => !removeCommandIds.has(command.commandId)); + const confirmedRow: OfflineReplicaRow = { ...existing, - values: rebasedCommands.at(-1)?.optimisticValue ?? (hasPending ? existing.values : confirmedValues), confirmedValues, serverRevision: change.serverRevision, fetchedAt: Date.now(), - syncState: conflicted ? 'conflict' : hasPending ? 'pending' : 'confirmed', - }); - if (conflicted) { - for (const command of related) { - putCommands.set(command.commandId, { - ...command, - state: 'conflict', - retryAt: null, - lastErrorCode: 'remote_revision', - }); - } + values: remaining.length > 0 ? existing.values : confirmedValues, + syncState: remaining.length > 0 ? 'pending' : 'confirmed', + }; + putRows.push(confirmedRow); + if (remaining.length > 0) rematerializeAfter.push(remaining[0]!); + for (const command of remaining) { + putCommands.set(command.commandId, this.#executor.withServerRevision(command, change.serverRevision)); } } - const finalRows = this.#mergeRowMutations([ + const confirmedAndProjected = this.#mergeRowMutations([ { putRows, removeRows }, { putRows: projection?.putRows ?? [], removeRows: projection?.removeRows ?? [] }, - { putRows: rebasedPutRows, removeRows: rebasedRemoveRows }, ]); + const rematerialized = await this.#rematerializePendingAggregates( + scope, + userCommands, + scopeCommands, + confirmedAndProjected, + putCommands, + removeCommandIds, + rematerializeAfter, + ); + const finalRows = this.#mergeRowMutations([confirmedAndProjected, rematerialized]); + const removedCommands = [...removeCommandIds] + .map( + (commandId) => + userCommands.find((command) => command.commandId === commandId) ?? + scopeCommands.find((command) => command.commandId === commandId), + ) + .filter((command): command is OfflineCommand => command != null); await this.#repository.transactReplica({ putRows: finalRows.putRows, removeRows: finalRows.removeRows, @@ -284,6 +288,7 @@ export class OfflineReplicaPullService { removeCommandIds: [...removeCommandIds], putCursors: [{ ...scope, cursor: page.nextCursor }], }); + await Promise.all(removedCommands.map((command) => this.#hooks.onCommandRemoved?.(command).catch(() => undefined))); return page.nextCursor; }); if (applied !== page.nextCursor) { @@ -299,9 +304,39 @@ export class OfflineReplicaPullService { } } + async #reconciliationTargets(scope: OfflineScope): Promise { + const commands = (await this.#repository.getCommands(scope)).filter((command) => command.state === 'awaiting_pull'); + const targets: OfflineReplicaReconciliationTarget[] = []; + for (const command of commands) { + const row = + (await this.#repository.getReplicaRowIncludingPendingDelete?.(scope, command.sourceKey, command.identity)) ?? + (await this.#repository.getReplicaRow(scope, command.sourceKey, command.identity)); + if (!row) throw new Error(`Awaiting-pull command "${command.commandId}" has no replica row.`); + const identity = + command.reconciliationIdentity ?? + (row.identity.kind === 'generated' + ? row.identity.remoteId === null + ? null + : { remoteId: row.identity.remoteId } + : row.identity.kind === 'natural' + ? { naturalKey: row.identity.naturalKey } + : null); + if (!identity) throw new Error(`Awaiting-pull command "${command.commandId}" has no remote identity.`); + canonicalOfflineRemoteIdentity(this.#entitySchema(command.sourceKey), identity); + targets.push({ commandId: command.commandId, operation: command.operation, sourceKey: command.sourceKey, identity }); + } + return targets; + } + async #confirmedRowsForRebaseline(scope: OfflineScope, commands: readonly OfflineCommand[]): Promise { const preserved = new Set( - commands.flatMap((command) => (command.optimisticCompanions ?? []).map((companion) => this.#rowKey(companion.key))), + commands.flatMap((command) => { + const identity = + command.identity.kind === 'generated' + ? offlineGeneratedReplicaIdentity(command.identity.localId, null) + : { kind: 'natural' as const, naturalKey: command.identity.naturalKey }; + return [this.#rowKey({ ...command, identity }), ...commandFootprintKeys(command).map((key) => this.#rowKey(key))]; + }), ); const rows = ( await Promise.all(this.#options.replicaSchema.entities.map((entity) => this.#repository.getReplicaRows(scope, entity.sourceKey))) @@ -366,71 +401,138 @@ export class OfflineReplicaPullService { } } - #assertRebasedSteps( - original: readonly OfflineCommand[], - steps: readonly { optimisticCompanions?: readonly { key: OfflineReplicaRowKey }[] }[] | null, - ): void { - if (steps === null) return; - if (steps.length !== original.length) { - throw new Error('Rebased offline values must match the aggregate command count.'); - } - for (const [index, step] of steps.entries()) { - const originalKeys = (original[index]?.optimisticCompanions ?? []).map((item) => this.#rowKey(item.key)).sort(); - const rebasedKeys = (step.optimisticCompanions ?? []).map((item) => this.#rowKey(item.key)).sort(); - if (JSON.stringify(originalKeys) !== JSON.stringify(rebasedKeys)) { - throw new Error('Rebased offline companions must preserve each command footprint.'); + async #rematerializePendingAggregates( + scope: OfflineScope, + userCommands: readonly OfflineCommand[], + scopeCommands: readonly OfflineCommand[], + currentRows: { putRows: readonly OfflineReplicaRow[]; removeRows: readonly OfflineReplicaRowKey[] }, + putCommands: Map, + removeCommandIds: ReadonlySet, + seeds: readonly OfflineCommand[], + ): Promise<{ putRows: OfflineReplicaRow[]; removeRows: OfflineReplicaRowKey[] }> { + const remaining = [...userCommands, ...scopeCommands] + .filter((command, index, all) => all.findIndex((candidate) => candidate.commandId === command.commandId) === index) + .filter((command) => !removeCommandIds.has(command.commandId)) + .map((command) => putCommands.get(command.commandId) ?? command); + const affected = new Map(); + for (const command of [...seeds, ...remaining]) { + if (removeCommandIds.has(command.commandId) && !remaining.some((item) => this.#aggregateKey(item) === this.#aggregateKey(command))) { + affected.set(this.#aggregateKey(command), command); + continue; } - } - } - - #appendRebasedCompanionRows(commands: readonly OfflineCommand[], putRows: OfflineReplicaRow[], removeRows: OfflineReplicaRowKey[]): void { - const latest = new Map(); - for (const command of commands) { - for (const companion of command.optimisticCompanions ?? []) { - latest.set(this.#rowKey(companion.key), { key: companion.key, after: companion.after }); + if (!removeCommandIds.has(command.commandId) || seeds.some((seed) => seed.commandId === command.commandId)) { + affected.set(this.#aggregateKey(command), command); } } - for (const { key, after } of latest.values()) { - if (after) putRows.push(after); - else removeRows.push(key); + const putRows: OfflineReplicaRow[] = []; + const removeRows: OfflineReplicaRowKey[] = []; + const overlay = new Map(currentRows.putRows.map((row) => [this.#rowKey(row), row] as const)); + const removed = new Set(currentRows.removeRows.map((row) => this.#rowKey(row))); + for (const [key, seed] of affected) { + const remainingForAggregate = remaining.filter((command) => this.#aggregateKey(command) === key); + const existingConflict = remainingForAggregate.find((command) => command.state === 'conflict'); + const footprintCommands = [...remainingForAggregate, seed]; + const baseRow = await this.#overlayBaseRow(seed, overlay, removed); + const persistedLocalOnlyRows = await this.#persistedLocalOnlyRows(footprintCommands); + const localOnlyRows = await this.#overlayLocalOnlyRows(footprintCommands, overlay, removed); + const projection = existingConflict + ? { kind: 'conflict' as const, reason: existingConflict.lastErrorCode ?? 'remote_revision' } + : this.#replicaMutations.projectAggregateIntent({ + baseRow, + localOnlyRows, + commands: remainingForAggregate, + trigger: 'pull', + incomingRevision: baseRow?.serverRevision ?? undefined, + }); + if (isOfflineAggregateIntentConflict(projection)) { + if (!baseRow) throw new Error('Offline aggregate conflict requires an authoritative base row.'); + putRows.push({ ...baseRow, values: baseRow.values, syncState: 'conflict' }); + for (const command of remainingForAggregate) { + putCommands.set(command.commandId, { + ...command, + state: 'conflict', + retryAt: null, + lastErrorCode: projection.reason, + }); + } + const incomingByKey = new Map(localOnlyRows.map((row) => [this.#rowKey(row), row] as const)); + for (const previous of persistedLocalOnlyRows) { + const incoming = incomingByKey.get(this.#rowKey(previous)); + putRows.push({ + ...(incoming ?? previous), + values: previous.values, + confirmedValues: incoming?.confirmedValues ?? null, + syncState: 'conflict', + visibility: previous.visibility, + }); + } + continue; + } + const mutations = offlineAggregateIntentMutations(projection, baseRow); + putRows.push(...(mutations.putRows ?? [])); + removeRows.push(...(mutations.removeRows ?? [])); } + void scope; + return { putRows, removeRows }; } - async #currentCompanionRows(commands: readonly OfflineCommand[]): Promise { - const keys = new Map( - commands.flatMap((command) => - (command.optimisticCompanions ?? []).map((companion) => [this.#rowKey(companion.key), companion.key] as const), - ), + async #overlayBaseRow( + command: OfflineCommand, + overlay: ReadonlyMap, + removed: ReadonlySet, + ): Promise { + const schema = this.#entitySchema(command.sourceKey); + const identity = + command.identity.kind === 'generated' + ? offlineGeneratedReplicaIdentity(command.identity.localId, null) + : { kind: 'natural' as const, naturalKey: command.identity.naturalKey }; + const canonical = this.#rowKey({ ...command, identity }); + if (removed.has(canonical)) return null; + const overlaid = [...overlay.values()].find( + (row) => row.sourceKey === command.sourceKey && commandIdentityMatchesReplicaRow(schema, row, command.identity), ); - const rows = await Promise.all( - [...keys.values()].map((key) => { - const companionScope = { userId: key.userId, scopeId: key.scopeId }; - return ( - this.#repository.getReplicaRowIncludingPendingDelete?.(companionScope, key.sourceKey, key.identity) ?? - this.#repository.getReplicaRow(companionScope, key.sourceKey, key.identity) - ); - }), + if (overlaid) return overlaid; + const commandScope = { userId: command.userId, scopeId: command.scopeId }; + return ( + this.#repository.getReplicaRowIncludingPendingDelete?.(commandScope, command.sourceKey, command.identity) ?? + this.#repository.getReplicaRow(commandScope, command.sourceKey, command.identity) ); - return rows.filter((row): row is OfflineReplicaRow => row !== null); } - async #projectedCompanionRows( + async #overlayLocalOnlyRows( commands: readonly OfflineCommand[], - projection: { putRows?: readonly OfflineReplicaRow[]; removeRows?: readonly OfflineReplicaRowKey[] } | undefined, + overlay: ReadonlyMap, + removed: ReadonlySet, ): Promise { - const footprint = new Set( - commands.flatMap((command) => (command.optimisticCompanions ?? []).map((companion) => this.#rowKey(companion.key))), - ); - const rows = new Map((await this.#currentCompanionRows(commands)).map((row) => [this.#rowKey(row), row])); - for (const row of projection?.removeRows ?? []) { - const key = this.#rowKey(row); - if (footprint.has(key)) rows.delete(key); - } - for (const row of projection?.putRows ?? []) { - const key = this.#rowKey(row); - if (footprint.has(key)) rows.set(key, row); + const keys = new Map(); + for (const command of commands) { + for (const key of commandFootprintKeys(command)) keys.set(this.#rowKey(key), key); + } + const rows: OfflineReplicaRow[] = []; + for (const [canonical, key] of keys) { + if (removed.has(canonical)) continue; + const overlaid = overlay.get(canonical); + if (overlaid) { + rows.push(overlaid); + continue; + } + const rowScope = { userId: key.userId, scopeId: key.scopeId }; + const row = + (await this.#repository.getReplicaRowIncludingPendingDelete?.(rowScope, key.sourceKey, key.identity)) ?? + (await this.#repository.getReplicaRow(rowScope, key.sourceKey, key.identity)); + if (row) rows.push(row); } - return [...rows.values()]; + return rows; + } + + async #persistedLocalOnlyRows(commands: readonly OfflineCommand[]): Promise { + return this.#overlayLocalOnlyRows(commands, new Map(), new Set()); + } + + #aggregateKey(command: OfflineCommand): string { + const schema = this.#entitySchema(command.sourceKey); + const partition = schema.scope === 'user' ? 'user' : `partition:${command.scopeId}`; + return `${command.userId}:${partition}:${command.sourceKey}:${canonicalOfflineCommandIdentity(command.identity)}`; } #assertPullChange(change: unknown, index: number): void { @@ -480,11 +582,7 @@ export class OfflineReplicaPullService { } } - #assertHandshake( - version: number, - hash: string, - expected: { readonly version: number; readonly hash: string }, - ): void { + #assertHandshake(version: number, hash: string, expected: { readonly version: number; readonly hash: string }): void { if (version !== expected.version || hash !== expected.hash) { throw new OfflineReplicaSchemaMismatchError(expected.version, expected.hash, version, hash); } @@ -547,6 +645,7 @@ export class OfflineReplicaPullService { removeRows: OfflineReplicaRowKey[], putCommands: Map, removeCommandIds: Set, + rematerializeAfter: OfflineCommand[], ): void { const acknowledgedIds = new Set(change.acknowledgedCommandIds ?? []); const lastAcknowledgedIndex = related.reduce((last, command, index) => (acknowledgedIds.has(command.commandId) ? index : last), -1); @@ -569,30 +668,22 @@ export class OfflineReplicaPullService { for (const command of related.slice(0, lastAcknowledgedIndex + 1)) { removeCommandIds.add(command.commandId); } + rematerializeAfter.push(lastAcknowledgedCommand); if (change.deleted) { if (following.length > 0) { + putRows.push({ + ...row, + confirmedValues: null, + serverRevision: change.serverRevision, + syncState: acknowledgementSuperseded ? 'conflict' : 'pending', + visibility: following.at(-1)!.replicaMutation === 'delete' ? 'pending_delete' : 'present', + fetchedAt: Date.now(), + }); if (acknowledgementSuperseded) { - putRows.push({ - ...row, - confirmedValues: null, - serverRevision: change.serverRevision, - syncState: 'conflict', - fetchedAt: Date.now(), - }); for (const command of following) { putCommands.set(command.commandId, { ...command, state: 'conflict', lastErrorCode: 'remote_deleted' }); } - } else { - putRows.push({ - ...row, - values: following.at(-1)!.optimisticValue, - confirmedValues: null, - serverRevision: change.serverRevision, - syncState: 'pending', - visibility: following.at(-1)!.replicaMutation === 'delete' ? 'pending_delete' : 'present', - fetchedAt: Date.now(), - }); } } else { removeRows.push({ ...row, identity: row.identity }); @@ -606,7 +697,7 @@ export class OfflineReplicaPullService { putRows.push({ ...row, identity: row.identity.kind === 'generated' ? { ...row.identity, remoteId: change.remoteId ?? row.identity.remoteId } : row.identity, - values: following.length > 0 ? following.at(-1)!.optimisticValue : confirmedValues, + values: following.length > 0 ? row.values : confirmedValues, confirmedValues, serverRevision: change.serverRevision, fetchedAt: Date.now(), diff --git a/projects/kit/offline/src/lib/offline-replica-puller.ts b/projects/kit/offline/src/lib/offline-replica-puller.ts index 591da8d..c6d5519 100644 --- a/projects/kit/offline/src/lib/offline-replica-puller.ts +++ b/projects/kit/offline/src/lib/offline-replica-puller.ts @@ -1,10 +1,6 @@ import { InjectionToken } from '@angular/core'; import type { OfflineReplicaRow, OfflineReplicaRowKey, OfflineScope } from './offline-repository'; -import type { - OfflineGeneratedRemoteId, - OfflineNaturalKey, - OfflineReplicaRemoteIdentity, -} from './offline-replica-schema'; +import type { OfflineGeneratedRemoteId, OfflineNaturalKey, OfflineReplicaRemoteIdentity } from './offline-replica-schema'; /** Server pull request for one user or partition-scoped replica. */ export interface OfflineReplicaPullRequest { @@ -12,6 +8,20 @@ export interface OfflineReplicaPullRequest { cursor: string; schemaVersion: number; schemaHash: string; + /** + * Successfully transported commands that still require an authoritative row acknowledgement. + * The server must hydrate and authorize each target from primary state and acknowledge it only + * on the returned reconciliation change; it must never trust this client identity as proof. + */ + reconciliationTargets: readonly OfflineReplicaReconciliationTarget[]; +} + +/** One awaiting-pull command whose canonical state must be hydrated independently of journal retention. */ +export interface OfflineReplicaReconciliationTarget { + readonly commandId: string; + readonly operation: string; + readonly sourceKey: string; + readonly identity: OfflineReplicaRemoteIdentity; } /** One server-side replica mutation returned by an explicit pull page. */ diff --git a/projects/kit/offline/src/lib/offline-repository.spec.ts b/projects/kit/offline/src/lib/offline-repository.spec.ts index 4bc296b..e951f27 100644 --- a/projects/kit/offline/src/lib/offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/offline-repository.spec.ts @@ -313,7 +313,6 @@ describe('IonicOfflineRepository', () => { identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.update', payload: { title: 'Local item' }, - optimisticValue: { id: 42, title: 'Local item' }, payloadHash: 'hash', baseRevision: 7, state: 'pending', @@ -367,7 +366,6 @@ describe('IonicOfflineRepository', () => { identity: { kind: 'generated', localId: '019d-cccc' }, operation: 'test_items.delete', payload: {}, - optimisticValue: {}, payloadHash: 'hash', baseRevision: null, state: 'pending', @@ -408,7 +406,6 @@ describe('IonicOfflineRepository', () => { identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.update', payload: {}, - optimisticValue: {}, payloadHash: 'hash', baseRevision: null, state: 'pending', @@ -471,7 +468,6 @@ describe('IonicOfflineRepository', () => { identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.update', payload: { title: 'Local item' }, - optimisticValue: { id: 42, title: 'Local item' }, payloadHash: 'hash', baseRevision: 7, state: 'pending', @@ -827,7 +823,6 @@ describe('IonicOfflineRepository', () => { identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.update', payload: {}, - optimisticValue: {}, payloadHash: 'hash', baseRevision: null, state: 'pending' as const, @@ -852,7 +847,6 @@ describe('IonicOfflineRepository', () => { identity: { kind: 'generated', localId: 'legacy' }, operation: 'test_items.update', payload: {}, - optimisticValue: {}, payloadHash: 'hash', baseRevision: null, state: 'pending', @@ -892,7 +886,6 @@ describe('IonicOfflineRepository', () => { identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, payloadHash: 'hash', baseRevision: null, state: 'pending' as const, @@ -948,7 +941,6 @@ describe('IonicOfflineRepository', () => { identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.create', payload: { title: 'local' }, - optimisticValue: { id: 0, title: 'local' }, payloadHash: 'hash', baseRevision: null, state: 'pending', @@ -1030,7 +1022,7 @@ describe('IonicOfflineRepository', () => { storage.values.set('offline:metadata', { schemaVersion: 999, lastUserId: 1 }); storage.values.set('offline:outbox:commands', { stale: {} }); storage.values.set('firebaseToken', { token: 'keep' }); - await expect(repository.initialize()).rejects.toThrow('Unsupported offline storage schema version 999; expected 1'); + await expect(repository.initialize()).rejects.toThrow('Unsupported offline storage schema version 999; expected 2'); expect(storage.values.get('offline:outbox:commands')).toEqual({ stale: {} }); expect(storage.values.get('offline:metadata')).toEqual({ schemaVersion: 999, lastUserId: 1 }); expect(storage.values.get('firebaseToken')).toEqual({ token: 'keep' }); @@ -1062,7 +1054,6 @@ describe('IonicOfflineRepository', () => { identity: { kind: 'generated', localId: '019d-bbbb' }, operation: 'test_items.create', payload: { title: 'Local item' }, - optimisticValue: { id: 0, title: 'Local item' }, payloadHash: 'hash', baseRevision: null, state: 'pending', @@ -1100,7 +1091,6 @@ describe('IonicOfflineRepository', () => { identity: generatedCommandIdentity('delete-uuid'), operation: 'test_items.delete', payload: { id: 42 }, - optimisticValue: row.values, payloadHash: 'delete-hash', baseRevision: 7, replicaMutation: 'delete', @@ -2066,7 +2056,6 @@ describe('IonicOfflineRepository', () => { identity: generatedCommandIdentity('019d-snap'), operation: 'test_items.update', payload: {}, - optimisticValue: row.values, payloadHash: 'hash', baseRevision: 1, state: 'pending', diff --git a/projects/kit/offline/src/lib/offline-repository.ts b/projects/kit/offline/src/lib/offline-repository.ts index b7b2699..2002f3a 100644 --- a/projects/kit/offline/src/lib/offline-repository.ts +++ b/projects/kit/offline/src/lib/offline-repository.ts @@ -44,7 +44,7 @@ export { } from './offline-identity'; /** Current durable storage schema used by both web and native repositories. */ -export const OFFLINE_SCHEMA_VERSION = 1; +export const OFFLINE_SCHEMA_VERSION = 2; /** User and partition scope of all local offline data. */ export interface OfflineScope { @@ -58,7 +58,7 @@ export type OfflineReplicaVisibility = 'present' | 'pending_delete'; export type OfflineReplicaMutation = 'upsert' | 'delete'; /** Durable processing state of an outbox command. */ -export type OfflineCommandState = 'pending' | 'sending' | 'retry_wait' | 'blocked_auth' | 'rejected' | 'conflict'; +export type OfflineCommandState = 'pending' | 'sending' | 'retry_wait' | 'awaiting_pull' | 'blocked_auth' | 'rejected' | 'conflict'; interface OfflineCommandBase extends OfflineScope { commandId: string; @@ -69,10 +69,11 @@ interface OfflineCommandBase extends OfflineScope { identity: OfflineCommandIdentity; operation: string; payload: T; - /** Full optimistic entity value displayed while this command is pending. */ - optimisticValue: unknown; - /** Product-owned companion rows changed atomically with this command. */ - optimisticCompanions?: readonly OfflineOptimisticReplicaCompanion[]; + /** + * Declared localOnly projection rows this intent may create, update, or remove. + * Kit persists keys only; before/after images are not durable truth. + */ + localOnlyFootprint?: readonly OfflineReplicaRowKey[]; /** Durable intent used to preserve a hidden tombstone across restart and replay. */ replicaMutation?: OfflineReplicaMutation; payloadHash: string; @@ -84,13 +85,8 @@ interface OfflineCommandBase extends OfflineScope { lastErrorCode: string | null; /** True when transport started but the client cannot prove whether the server committed. */ serverCommitUnknown?: boolean; -} - -/** Durable before/after image used to reconcile product-owned derived rows. */ -export interface OfflineOptimisticReplicaCompanion { - key: OfflineReplicaRowKey; - before: OfflineReplicaRow | null; - after: OfflineReplicaRow | null; + /** Remote identity captured after transport for journal-independent authoritative reconciliation. */ + reconciliationIdentity?: OfflineReplicaRemoteIdentity; } export type OfflineCommand = OfflineCommandBase; 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 91384e1..8e9c793 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -1,6 +1,6 @@ import { ErrorHandler, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { OFFLINE_COMMAND_EXECUTOR, OFFLINE_SYNC_CONTEXT, @@ -32,7 +32,8 @@ import { type OfflineRepository, type OfflineScope, } from './offline-repository'; -import { generatedCommandIdentity } from './offline-test-helpers'; +import { generatedCommandIdentity, rematerializeTestAggregate } from './offline-test-helpers'; +import { OFFLINE_AGGREGATE_INTENT_PROJECTOR } from './offline-aggregate-intent-projector'; import { OfflineCommandInFlightError, OfflinePayloadValidationError, @@ -113,6 +114,13 @@ describe('OfflineSyncService', () => { ); const provesCommandNotCommitted = vi.fn((_error: unknown, _command: OfflineCommand) => false); + function expectAwaitingPull(count = commands.length): void { + expect(commands).toHaveLength(count); + expect(commands.every((command) => command.state === 'awaiting_pull')).toBe(true); + expect(service.pendingCount()).toBe(count); + expect(onCommandRemoved).not.toHaveBeenCalled(); + } + beforeEach(() => { commands = []; rows = []; @@ -322,6 +330,7 @@ describe('OfflineSyncService', () => { withoutServerRevision: (command: OfflineCommand) => ({ ...command, baseRevision: null }), }, }, + { provide: OFFLINE_AGGREGATE_INTENT_PROJECTOR, useValue: { project: rematerializeTestAggregate } }, // Fixed sample so backoff stays deterministic (except dedicated jitter unit tests). { provide: OFFLINE_RETRY_RANDOM, useValue: () => 0.5 }, ], @@ -329,6 +338,13 @@ describe('OfflineSyncService', () => { service = TestBed.inject(OfflineSyncService); }); + afterEach(() => { + connected.set(false); + service.revokeSession(); + vi.clearAllTimers(); + vi.useRealTimers(); + }); + it('readCacheOnly mode rejects enqueue before creating replica or Outbox state', async () => { options.mode = 'readCacheOnly'; @@ -340,7 +356,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'forbidden-write' }, operation: 'documents.create', payload: { title: 'write' }, - optimisticValue: { id: 0, title: 'write' }, }, { flush: false }, ), @@ -349,19 +364,19 @@ describe('OfflineSyncService', () => { expect(rows).toEqual([]); }); - it('prepared enqueue persists the base row, companion row, and Outbox command together', async () => { + it('prepared enqueue rematerializes the base row and declared localOnly footprint', async () => { const companion: OfflineReplicaRow = { userId: 1, scopeId: '10', sourceKey: 'document_views', identity: { kind: 'local', localId: 'view-1' }, - values: { title: 'Optimistic view' }, + values: { title: 'Baseline view' }, confirmedValues: { title: 'Baseline view' }, serverRevision: null, fetchedAt: 2, syncState: 'confirmed', }; - rows.push({ ...structuredClone(companion), values: { title: 'Baseline view' } }); + rows.push(structuredClone(companion)); await service.enqueuePrepared( async (repository) => { @@ -374,9 +389,8 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'prepared-1' }, operation: 'documents.create', payload: { title: 'Optimistic' }, - optimisticValue: { id: 0, title: 'Optimistic' }, + localOnlyFootprint: [companion], }, - replicaTransaction: { putRows: [companion] }, }; }, { flush: false }, @@ -385,14 +399,15 @@ describe('OfflineSyncService', () => { expect(rows).toEqual( expect.arrayContaining([ expect.objectContaining({ sourceKey: 'documents', values: { id: 0, title: 'Optimistic' } }), - expect.objectContaining({ sourceKey: 'document_views', values: { title: 'Optimistic view' } }), + expect.objectContaining({ + sourceKey: 'document_views', + values: { title: 'Optimistic' }, + confirmedValues: { title: 'Baseline view' }, + }), ]), ); - expect(commands[0]?.optimisticCompanions).toEqual([ - expect.objectContaining({ - before: expect.objectContaining({ values: { title: 'Baseline view' } }), - after: expect.objectContaining({ values: { title: 'Optimistic view' } }), - }), + expect(commands[0]?.localOnlyFootprint).toEqual([ + expect.objectContaining({ sourceKey: 'document_views', identity: { kind: 'local', localId: 'view-1' } }), ]); }); @@ -406,49 +421,35 @@ describe('OfflineSyncService', () => { expect(commands).toEqual([]); }); - it('prepared enqueue rejects duplicate or cross-scope companion rows before persistence', async () => { + it('prepared enqueue rejects duplicate or cross-scope localOnly footprint keys before persistence', async () => { const base = { userId: 1, scopeId: '10', sourceKey: 'document_views', identity: { kind: 'local' as const, localId: 'duplicate' }, - values: {}, - confirmedValues: null, - serverRevision: null, - fetchedAt: 1, - syncState: 'confirmed' as const, }; const request = { scopeId: '10', aggregateType: 'documents', identity: { kind: 'generated' as const, localId: 'prepared-invalid' }, operation: 'documents.create', - payload: {}, - optimisticValue: { id: 0, title: 'x' }, + payload: { title: 'x' }, }; await expect( service.enqueuePrepared(async () => ({ - request, - replicaTransaction: { putRows: [base, structuredClone(base)] }, + request: { ...request, localOnlyFootprint: [base, structuredClone(base)] }, })), ).rejects.toThrow('duplicate replica row'); await expect( service.enqueuePrepared(async () => ({ - request, - replicaTransaction: { putRows: [{ ...base, scopeId: '11' }] }, + request: { ...request, localOnlyFootprint: [{ ...base, scopeId: '11' }] }, })), ).rejects.toThrow('must use the command scope'); - await expect( - service.enqueuePrepared(async () => ({ - request, - replicaTransaction: { putCommands: [] } as never, - })), - ).rejects.toThrow('cannot mutate putCommands'); expect(rows).toEqual([]); expect(commands).toEqual([]); }); - it('discard restores a prepared companion before-image', async () => { + it('discard rematerializes remaining intents onto confirmed localOnly values', async () => { const before: OfflineReplicaRow = { userId: 1, scopeId: '10', @@ -468,10 +469,9 @@ describe('OfflineSyncService', () => { aggregateType: 'documents', identity: { kind: 'generated', localId: 'discard-prepared' }, operation: 'documents.create', - payload: {}, - optimisticValue: { id: 0, title: 'Optimistic' }, + payload: { title: 'Optimistic' }, + localOnlyFootprint: [before], }, - replicaTransaction: { putRows: [{ ...before, values: { title: 'Optimistic' } }] }, }), { flush: false }, ); @@ -504,10 +504,7 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'discard-race' }, operation: 'documents.update', payload: { title }, - optimisticValue: { id: 1, title }, - }, - replicaTransaction: { - putRows: [{ ...before, values: { title }, fetchedAt: index + 2 }], + localOnlyFootprint: [before], }, }), { flush: false }, @@ -551,7 +548,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'first' }, operation: 'documents.create', payload: { title: 'first' }, - optimisticValue: { id: 0, title: 'first' }, }, { flush: false }, ); @@ -564,7 +560,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'second' }, operation: 'documents.create', payload: { title: 'second' }, - optimisticValue: { id: 0, title: 'second' }, }, { flush: false }, ), @@ -584,7 +579,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'oversized' }, operation: 'documents.create', payload: { title: 'too large' }, - optimisticValue: { id: 0, title: 'too large' }, }, { flush: false }, ), @@ -601,9 +595,8 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId }, operation: 'documents.create', payload: { title }, - optimisticValue: { id: 0, title }, + localOnlyFootprint: companion ? [companion] : undefined, }, - replicaTransaction: companion ? { putRows: [companion] } : undefined, }); it('2件の成功は1回のtransactReplicaでFIFO createdAtを永続化する', async () => { @@ -705,8 +698,7 @@ describe('OfflineSyncService', () => { aggregateType: 'documents', identity: { kind: 'natural', naturalKey: { favFrom: 1, favTo: 'x' } }, operation: 'documents.create', - payload: {}, - optimisticValue: { id: 0, title: 'bad' }, + payload: { title: 'bad' }, }, }, ]), @@ -809,7 +801,7 @@ describe('OfflineSyncService', () => { await service.initialize({ flush: false }); let failRefresh = true; getCommands.mockImplementation(async (scope) => { - if (failRefresh) { + if (failRefresh && commands.length > 0) { failRefresh = false; throw new Error('postcommit read failed'); } @@ -843,7 +835,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'offline-local' }, operation: 'documents.create', payload: { title: 'offline' }, - optimisticValue: { id: 0, title: 'offline' }, }, { flush: false }, ); @@ -871,7 +862,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'reconnect-local' }, operation: 'documents.create', payload: { title: 'queued offline' }, - optimisticValue: { id: 0, title: 'queued offline' }, }, { flush: false }, ); @@ -880,7 +870,8 @@ describe('OfflineSyncService', () => { connected.set(true); await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce()); - await vi.waitFor(() => expect(service.pendingCount()).toBe(0)); + await vi.waitFor(() => expect(commands[0]?.state).toBe('awaiting_pull')); + expect(service.pendingCount()).toBe(1); }); it('session失効前に開始したenqueueを永続commitせずreset完了まで直列化する', async () => { @@ -904,7 +895,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'revoked' }, operation: 'documents.create', payload: { title: 'stale' }, - optimisticValue: { id: 0, title: 'stale' }, }, { flush: false }, ); @@ -950,7 +940,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 1 }, - optimisticValue: { seq: 1 }, }, { flush: false }, ); @@ -961,16 +950,14 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 2 }, - optimisticValue: { seq: 2 }, }, { flush: false }, ); connected.set(true); await service.flush(); expect(execute.mock.calls.map(([command]) => (command as OfflineCommand<{ seq: number }>).payload.seq)).toEqual([1, 2]); - expect(service.pendingCount()).toBe(0); expect(pull).toHaveBeenCalledTimes(2); - expect(onCommandRemoved).toHaveBeenCalledTimes(2); + expectAwaitingPull(2); }); it('送信成功後は同一scopeの複数aggregateを一度だけ再pullする', async () => { @@ -981,7 +968,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'post-pull-1' }, operation: 'documents.create', payload: { title: 'one' }, - optimisticValue: { id: 0, title: 'one' }, }, { flush: false }, ); @@ -992,7 +978,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'post-pull-2' }, operation: 'documents.create', payload: { title: 'two' }, - optimisticValue: { id: 0, title: 'two' }, }, { flush: false }, ); @@ -1006,7 +991,7 @@ describe('OfflineSyncService', () => { { userId: 1, scopeId: '10' }, { userId: 1, scopeId: '10' }, ]); - expect(commands).toEqual([]); + expectAwaitingPull(2); }); it('送信ACK後のpullが完了するまでflushを完了せずauthoritative projectionを公開する', async () => { @@ -1039,7 +1024,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'snap-back' }, operation: 'documents.create', payload: { title: 'optimistic projection' }, - optimisticValue: { id: 0, title: 'optimistic projection' }, }, { flush: false }, ); @@ -1047,17 +1031,16 @@ describe('OfflineSyncService', () => { connected.set(true); await service.flush(); - expect(commandCountsAtPull).toEqual([1, 0]); + expect(commandCountsAtPull).toEqual([1, 1]); expect(rows).toContainEqual( expect.objectContaining({ identity: expect.objectContaining({ localId: 'snap-back', remoteId: 55 }), values: { id: 55, title: 'authoritative projection' }, confirmedValues: { id: 55, title: 'authoritative projection' }, serverRevision: 3, - syncState: 'confirmed', }), ); - expect(service.pendingCount()).toBe(0); + expectAwaitingPull(1); }); it('送信後pull失敗ではcommit済みcommandを再送せず次flushのpre-pullで回収する', async () => { @@ -1070,20 +1053,19 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'post-pull-failure' }, operation: 'documents.create', payload: { title: 'one' }, - optimisticValue: { id: 0, title: 'one' }, }, { flush: false }, ); connected.set(true); await expect(service.flush()).rejects.toBe(postPullError); - expect(commands).toEqual([]); + expectAwaitingPull(1); expect(execute).toHaveBeenCalledOnce(); await service.flush(); expect(execute).toHaveBeenCalledOnce(); expect(pull).toHaveBeenCalledTimes(3); - expect(service.pendingCount()).toBe(0); + expectAwaitingPull(1); }); it('partial flushのACK後pull失敗scopeをOutbox削除後もreconnectで再pullする', async () => { @@ -1107,14 +1089,13 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'partial-post-pull-failure' }, operation: 'documents.create', payload: { title: 'one' }, - optimisticValue: { id: 0, title: 'one' }, }, { flush: false }, ); connected.set(true); await vi.waitFor(() => expect(handleError).toHaveBeenCalledWith(postPullError)); - expect(commands).toEqual([]); + expectAwaitingPull(1); expect(execute).toHaveBeenCalledOnce(); connected.set(false); @@ -1144,14 +1125,13 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'durable-post-pull-failure' }, operation: 'documents.create', payload: { title: 'one' }, - optimisticValue: { id: 0, title: 'one' }, }, { flush: false }, ); connected.set(true); await vi.waitFor(() => expect(handleError).toHaveBeenCalledWith(postPullError)); - expect(commands).toEqual([]); + expectAwaitingPull(1); expect(reconciliationScopes).toEqual([{ userId: 1, scopeId: '20' }]); connected.set(false); @@ -1183,7 +1163,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'scope-a' }, operation: 'documents.create', payload: { title: 'a' }, - optimisticValue: { id: 0, title: 'a' }, }, { flush: false }, ); @@ -1194,7 +1173,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'scope-b' }, operation: 'documents.create', payload: { title: 'b' }, - optimisticValue: { id: 0, title: 'b' }, }, { flush: false }, ); @@ -1206,7 +1184,13 @@ describe('OfflineSyncService', () => { expect(execute.mock.calls.map(([command]) => (command as OfflineCommand).identity)).toEqual([ expect.objectContaining({ localId: 'scope-b' }), ]); - expect(commands.map((command) => command.identity)).toEqual([expect.objectContaining({ localId: 'scope-a' })]); + expect(commands.map((command) => command.identity)).toEqual([ + expect.objectContaining({ localId: 'scope-a' }), + expect.objectContaining({ localId: 'scope-b' }), + ]); + expect(commands.find((command) => command.identity.kind === 'generated' && command.identity.localId === 'scope-b')?.state).toBe( + 'awaiting_pull', + ); }); it('pre-pull: 同一scopeのpull失敗ではそのscopeのcommandを送らない', async () => { @@ -1219,7 +1203,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'blocked-by-pull' }, operation: 'documents.create', payload: { title: 'blocked' }, - optimisticValue: { id: 0, title: 'blocked' }, }, { flush: false }, ); @@ -1262,7 +1245,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'scope-a-wait' }, operation: 'documents.create', payload: { title: 'a' }, - optimisticValue: { id: 0, title: 'a' }, }, { flush: false }, ); @@ -1273,7 +1255,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'scope-b-wait' }, operation: 'documents.create', payload: { title: 'b' }, - optimisticValue: { id: 0, title: 'b' }, }, { flush: false }, ); @@ -1286,7 +1267,13 @@ describe('OfflineSyncService', () => { releaseSend?.(); await flushRejected; expect(execute).toHaveBeenCalledOnce(); - expect(commands.map((command) => command.identity)).toEqual([expect.objectContaining({ localId: 'scope-a-wait' })]); + expect(commands.map((command) => command.identity)).toEqual([ + expect.objectContaining({ localId: 'scope-a-wait' }), + expect.objectContaining({ localId: 'scope-b-wait' }), + ]); + expect(commands.find((command) => command.identity.kind === 'generated' && command.identity.localId === 'scope-b-wait')?.state).toBe( + 'awaiting_pull', + ); }); it('status無しworker失敗でも他workerのACK完了までflushをrejectしない', async () => { @@ -1321,7 +1308,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'fail-early' }, operation: 'documents.create', payload: { title: 'fail' }, - optimisticValue: { id: 0, title: 'fail' }, }, { flush: false }, ); @@ -1332,7 +1318,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'succeed-deferred' }, operation: 'documents.create', payload: { title: 'ok' }, - optimisticValue: { id: 0, title: 'ok' }, }, { flush: false }, ); @@ -1344,8 +1329,12 @@ describe('OfflineSyncService', () => { expect(commands.some((command) => command.identity.kind === 'generated' && command.identity.localId === 'succeed-deferred')).toBe(true); releaseSuccess(); await flushRejected; - expect(commands.map((command) => command.identity)).toEqual([expect.objectContaining({ localId: 'fail-early' })]); + expect(commands.map((command) => command.identity)).toEqual([ + expect.objectContaining({ localId: 'fail-early' }), + expect.objectContaining({ localId: 'succeed-deferred' }), + ]); expect(commands[0]).toMatchObject({ state: 'retry_wait', serverCommitUnknown: true }); + expect(commands[1]).toMatchObject({ state: 'awaiting_pull' }); expect(service.syncState()).toBe('attention'); expect(pull.mock.calls.some((call) => call[0]?.scopeId === '20')).toBe(true); }); @@ -1369,7 +1358,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'scope-b-fatal' }, operation: 'documents.create', payload: { title: 'b' }, - optimisticValue: { id: 0, title: 'b' }, }, { flush: false }, ); @@ -1468,6 +1456,7 @@ describe('OfflineSyncService', () => { withoutServerRevision: (command: OfflineCommand) => ({ ...command, baseRevision: null }), }, }, + { provide: OFFLINE_AGGREGATE_INTENT_PROJECTOR, useValue: { project: rematerializeTestAggregate } }, { provide: OFFLINE_RETRY_RANDOM, useValue: () => 0.5 }, ], }); @@ -1500,7 +1489,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'pending-after-schema' }, operation: 'documents.create', payload: { title: 'pending' }, - optimisticValue: { id: 0, title: 'pending' }, }, { flush: false }, ); @@ -1514,10 +1502,10 @@ describe('OfflineSyncService', () => { pull.mockImplementation(async () => undefined); await service.flush(); expect(execute).toHaveBeenCalledOnce(); - expect(commands).toEqual([]); + expectAwaitingPull(1); expect(service.pullAttentions()).toEqual([]); expect(pullAttentions).toEqual([]); - expect(service.syncState()).toBe('idle'); + expect(service.syncState()).toBe('pending'); }); it('pre-pull HTTP 403はfailing scopeだけにauthorization attentionを付ける', async () => { @@ -1539,7 +1527,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'scope-b-403' }, operation: 'documents.create', payload: { title: 'b' }, - optimisticValue: { id: 0, title: 'b' }, }, { flush: false }, ); @@ -1655,7 +1642,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'scope-b-http-409' }, operation: 'documents.create', payload: { title: 'b' }, - optimisticValue: { id: 0, title: 'b' }, }, { flush: false }, ); @@ -1698,14 +1684,13 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: `fatal-skip-post-${_label.replace(/\s+/g, '-')}` }, operation: 'documents.create', payload: { title: 'seed' }, - optimisticValue: { id: 0, title: 'seed' }, }, { flush: false }, ); connected.set(true); await expect(service.flush()).rejects.toBe(postPullError); expect(execute).toHaveBeenCalledOnce(); - expect(commands).toEqual([]); + expectAwaitingPull(1); // Drop the transient post-pull retry so this case only asserts fatal does not arm a new one. vi.clearAllTimers(); @@ -1757,7 +1742,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: `post-fatal-a-${_label.replace(/\s+/g, '-')}` }, operation: 'documents.create', payload: { title: 'a' }, - optimisticValue: { id: 0, title: 'a' }, }, { flush: false }, ); @@ -1768,7 +1752,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: `post-fatal-b-${_label.replace(/\s+/g, '-')}` }, operation: 'documents.create', payload: { title: 'b' }, - optimisticValue: { id: 0, title: 'b' }, }, { flush: false }, ); @@ -1777,10 +1760,9 @@ describe('OfflineSyncService', () => { connected.set(true); await expect(service.flush()).rejects.toBe(fatalError); - // Both commands ACKed (removed); no resend path. + // Both commands transported; retained until pull acknowledges commandId. expect(execute).toHaveBeenCalledTimes(2); - expect(commands).toEqual([]); - expect(service.pendingCount()).toBe(0); + expectAwaitingPull(2); // Two pending post-pull scopes: first fatal stops the second immediately. expect(pullsByScope.get('10')).toBeGreaterThanOrEqual(1); expect(pullsByScope.get('20')).toBeGreaterThanOrEqual(1); @@ -1846,7 +1828,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId }, operation: 'documents.create', payload: { title: localId }, - optimisticValue: { id: 0, title: localId }, }, { flush: false }, ); @@ -1856,7 +1837,7 @@ describe('OfflineSyncService', () => { connected.set(true); await expect(service.flush()).rejects.toBe(fatal); expect(execute).toHaveBeenCalledTimes(3); - expect(commands).toEqual([]); + expectAwaitingPull(3); expect(postPullAttempts).toBe(2); expect([...pullsByScope.values()].filter((count) => count >= 2)).toHaveLength(2); expect([...pullsByScope.values()].filter((count) => count === 1)).toHaveLength(1); @@ -1905,7 +1886,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'stale-fatal-a' }, operation: 'documents.create', payload: { title: 'a' }, - optimisticValue: { id: 0, title: 'a' }, }, { flush: false }, ); @@ -1931,7 +1911,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'stale-fatal-b' }, operation: 'documents.create', payload: { title: 'b' }, - optimisticValue: { id: 0, title: 'b' }, }, { flush: false }, ); @@ -1957,8 +1936,12 @@ describe('OfflineSyncService', () => { await Promise.resolve(); await Promise.resolve(); expect(execute.mock.calls.length).toBeGreaterThan(executesBeforeRetry); - expect(service.pendingCount()).toBe(0); - expect(commands.some((command) => command.identity.kind === 'generated' && command.identity.localId === 'stale-fatal-b')).toBe(false); + expect( + commands.some( + (command) => + command.identity.kind === 'generated' && command.identity.localId === 'stale-fatal-b' && command.state !== 'awaiting_pull', + ), + ).toBe(false); } finally { vi.useRealTimers(); } @@ -1986,7 +1969,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'transient-isolated' }, operation: 'documents.create', payload: { title: 'b' }, - optimisticValue: { id: 0, title: 'b' }, }, { flush: false }, ); @@ -2028,7 +2010,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'prefer-fatal' }, operation: 'documents.create', payload: { title: 'c' }, - optimisticValue: { id: 0, title: 'c' }, }, { flush: false }, ); @@ -2058,7 +2039,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'scope-b-lookalike' }, operation: 'documents.create', payload: { title: 'b' }, - optimisticValue: { id: 0, title: 'b' }, }, { flush: false }, ); @@ -2095,7 +2075,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'documents.create', payload: { name: 'draft' }, - optimisticValue: { name: 'draft' }, }, { flush: false }, ); @@ -2109,9 +2088,10 @@ describe('OfflineSyncService', () => { expect(rows[0]).toMatchObject({ identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 38142 }, serverRevision: 1, - syncState: 'confirmed', - confirmedValues: { name: 'draft' }, + syncState: 'pending', + confirmedValues: null, }); + expectAwaitingPull(1); expect(commands.every((command) => !('remoteId' in command))).toBe(true); execute.mockResolvedValueOnce({ @@ -2126,7 +2106,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'documents.update', payload: { name: 'edited', revision: 1 }, - optimisticValue: { name: 'edited' }, baseRevision: 1, }, { flush: false }, @@ -2136,8 +2115,10 @@ describe('OfflineSyncService', () => { expect(rows[0]).toMatchObject({ identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 38142 }, serverRevision: 2, - confirmedValues: { name: 'edited' }, + confirmedValues: null, + syncState: 'pending', }); + expectAwaitingPull(2); expect(commands.every((command) => !('remoteId' in command))).toBe(true); }); @@ -2163,7 +2144,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'documents.create', payload: {}, - optimisticValue: {}, payloadHash: 'hash', baseRevision: null, state: 'sending', @@ -2200,7 +2180,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-restart-unknown' }, operation: 'documents.create', payload: {}, - optimisticValue: {}, payloadHash: 'hash', baseRevision: null, state: 'sending', @@ -2232,7 +2211,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-new' }, operation: 'documents.create', payload: { name: 'draft' }, - optimisticValue: { name: 'draft' }, }, { flush: false }, ); @@ -2260,12 +2238,11 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-existing' }, operation: 'documents.update', payload: { name: 'draft', revision: 4 }, - optimisticValue: { name: 'draft' }, baseRevision: 4, }, { flush: false }, ); - expect(rows[0]?.values).toEqual({ name: 'draft' }); + expect(rows[0]?.values).toEqual({ id: 38142, name: 'draft', revision: 4 }); await service.discard(commandId, { flush: false }); expect(rows[0]).toMatchObject({ values: { name: 'confirmed' }, @@ -2294,7 +2271,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'replace-failure' }, operation: 'documents.update', payload: { title: 'local conflict' }, - optimisticValue: { id: 12, title: 'local conflict' }, baseRevision: 3, }, { flush: false }, @@ -2331,7 +2307,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'replace-success' }, operation: 'documents.update', payload: { title: 'old local' }, - optimisticValue: { id: 13, title: 'old local' }, baseRevision: 4, }, { flush: false }, @@ -2348,7 +2323,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'replace-success' }, operation: 'documents.update', payload: { title: 'new local' }, - optimisticValue: { id: 13, title: 'new local' }, baseRevision: 4, }, }), @@ -2369,7 +2343,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'replace-ordered' }, operation: 'documents.update', payload: { title: 'first' }, - optimisticValue: { id: 14, title: 'first' }, }, { flush: false }, ); @@ -2380,7 +2353,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'replace-ordered' }, operation: 'documents.update', payload: { title: 'second' }, - optimisticValue: { id: 14, title: 'second' }, }, { flush: false }, ); @@ -2392,7 +2364,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated' as const, localId: 'replace-ordered' }, operation: 'documents.update', payload: { title: 'replacement' }, - optimisticValue: { id: 14, title: 'replacement' }, }, })); @@ -2401,7 +2372,7 @@ describe('OfflineSyncService', () => { expect(prepare).not.toHaveBeenCalled(); expect(commands.map((command) => command.commandId)).toHaveLength(2); expect(rows.find((row) => row.identity.kind === 'generated' && row.identity.localId === 'replace-ordered')?.values).toEqual({ - id: 14, + id: 0, title: 'second', }); }); @@ -2414,7 +2385,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'replace-chain' }, operation: 'documents.update', payload: { title: 'stocktake' }, - optimisticValue: { id: 15, title: 'stocktake' }, baseRevision: 1, }, { flush: false }, @@ -2426,7 +2396,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'replace-chain' }, operation: 'documents.update', payload: { title: 'later delta' }, - optimisticValue: { id: 15, title: 'later delta' }, baseRevision: 1, }, { flush: false }, @@ -2444,8 +2413,7 @@ describe('OfflineSyncService', () => { aggregateType: command.aggregateType, identity: command.identity, operation: command.operation, - payload: command.payload, - optimisticValue: { id: 15, title: index === 0 ? 'new stocktake' : 'new stocktake plus delta' }, + payload: { title: index === 0 ? 'new stocktake' : 'new stocktake plus delta' }, baseRevision: 2, }, })), @@ -2458,7 +2426,7 @@ describe('OfflineSyncService', () => { expect(commands.map((command) => command.state)).toEqual(['pending', 'pending']); expect(commands.map((command) => command.createdAt)).toEqual(originalCreatedAt); expect(rows.find((row) => row.identity.kind === 'generated' && row.identity.localId === 'replace-chain')?.values).toEqual({ - id: 15, + id: 0, title: 'new stocktake plus delta', }); }); @@ -2471,7 +2439,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'replace-chain-failure' }, operation: 'documents.update', payload: { title: 'first' }, - optimisticValue: { id: 16, title: 'first' }, }, { flush: false }, ); @@ -2482,7 +2449,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'replace-chain-failure' }, operation: 'documents.update', payload: { title: 'second' }, - optimisticValue: { id: 16, title: 'second' }, }, { flush: false }, ); @@ -2496,621 +2462,16 @@ describe('OfflineSyncService', () => { }), ).rejects.toThrow('chain preparation failed'); - expect(commands).toEqual(beforeCommands); - expect(rows).toEqual(beforeRows); - }); - - it('companionの対象集合を変えるreplacementを元commandと楽観値を残して拒否する', async () => { - const companion: OfflineReplicaRow = { - userId: 1, - scopeId: '10', - sourceKey: 'document_views', - identity: { kind: 'local', localId: 'replace-companion-view' }, - values: { title: 'baseline' }, - confirmedValues: { title: 'baseline' }, - serverRevision: null, - fetchedAt: 1, - syncState: 'confirmed', - }; - rows.push(companion); - const oldCommandId = await service.enqueuePrepared( - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'replace-companion' }, - operation: 'documents.update', - payload: { title: 'old local' }, - optimisticValue: { id: 15, title: 'old local' }, - }, - replicaTransaction: { - putRows: [{ ...companion, values: { title: 'old optimistic' }, syncState: 'pending' }], - }, - }), - { flush: false }, - ); - commands[0] = { ...commands[0]!, state: 'conflict' }; - - await expect( - service.replacePrepared( - oldCommandId, - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'replace-companion' }, - operation: 'documents.update', - payload: { title: 'new local' }, - optimisticValue: { id: 15, title: 'new local' }, - }, - }), - { flush: false }, - ), - ).rejects.toThrow('preserve the optimistic companion footprint'); - - expect(commands).toHaveLength(1); - expect(commands[0]?.commandId).toBe(oldCommandId); - expect(rows.find((row) => row.sourceKey === 'document_views')?.values).toEqual({ title: 'old optimistic' }); - }); - - it('replacement companionは元commandのbefore-imageを継承して破棄時に確定値へ戻す', async () => { - const companion: OfflineReplicaRow = { - userId: 1, - scopeId: '10', - sourceKey: 'document_views', - identity: { kind: 'local', localId: 'replace-remove-view' }, - values: { title: 'baseline' }, - confirmedValues: { title: 'baseline' }, - serverRevision: null, - fetchedAt: 1, - syncState: 'confirmed', - }; - rows.push(companion); - const oldCommandId = await service.enqueuePrepared( - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'replace-remove' }, - operation: 'documents.update', - payload: { title: 'old local' }, - optimisticValue: { id: 16, title: 'old local' }, - }, - replicaTransaction: { - putRows: [{ ...companion, values: { title: 'old optimistic' }, syncState: 'pending' }], - }, - }), - { flush: false }, - ); - commands[0] = { ...commands[0]!, state: 'conflict' }; - - const newCommandId = await service.replacePrepared( - oldCommandId, - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'replace-remove' }, - operation: 'documents.update', - payload: { title: 'remove companion' }, - optimisticValue: { id: 16, title: 'remove companion' }, - }, - replicaTransaction: { removeRows: [companion] }, - }), - { flush: false }, - ); - - expect(rows.find((row) => row.sourceKey === 'document_views')).toBeUndefined(); - await service.discard(newCommandId); - expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ - values: { title: 'baseline' }, - confirmedValues: { title: 'baseline' }, - syncState: 'confirmed', - }); - }); - - it('productが渡した楽観confirmedValuesを採用せずput replacement破棄時に確定値へ戻す', async () => { - const companion: OfflineReplicaRow = { - userId: 1, - scopeId: '10', - sourceKey: 'document_views', - identity: { kind: 'local', localId: 'replace-put-view' }, - values: { title: 'baseline' }, - confirmedValues: { title: 'baseline' }, - serverRevision: null, - fetchedAt: 1, - syncState: 'confirmed', - }; - rows.push(companion); - const oldCommandId = await service.enqueuePrepared( - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'replace-put' }, - operation: 'documents.update', - payload: { title: 'old local' }, - optimisticValue: { id: 17, title: 'old local' }, - }, - replicaTransaction: { - putRows: [ - { - ...companion, - values: { title: 'old optimistic' }, - confirmedValues: { title: 'old optimistic' }, - syncState: 'pending', - }, - ], - }, - }), - { flush: false }, - ); - commands[0] = { ...commands[0]!, state: 'conflict' }; - - const newCommandId = await service.replacePrepared( - oldCommandId, - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'replace-put' }, - operation: 'documents.update', - payload: { title: 'new local' }, - optimisticValue: { id: 17, title: 'new local' }, - }, - replicaTransaction: { - putRows: [ - { - ...companion, - values: { title: 'new optimistic' }, - confirmedValues: { title: 'new optimistic' }, - syncState: 'pending', - }, - ], - }, - }), - { flush: false }, - ); - - expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ - values: { title: 'new optimistic' }, - confirmedValues: { title: 'baseline' }, - }); - await service.discard(newCommandId); - expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ - values: { title: 'baseline' }, - confirmedValues: { title: 'baseline' }, - syncState: 'confirmed', - }); - }); - - it('conflict pull後の最新confirmedValuesをreplacementと破棄で維持する', async () => { - const companion: OfflineReplicaRow = { - userId: 1, - scopeId: '10', - sourceKey: 'document_views', - identity: { kind: 'local', localId: 'replace-pulled-view' }, - values: { title: 'baseline' }, - confirmedValues: { title: 'baseline' }, - serverRevision: 1, - fetchedAt: 1, - syncState: 'confirmed', - }; - rows.push(companion); - const oldCommandId = await service.enqueuePrepared( - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'replace-pulled' }, - operation: 'documents.update', - payload: { title: 'old local' }, - optimisticValue: { id: 18, title: 'old local' }, - }, - replicaTransaction: { - putRows: [{ ...companion, values: { title: 'old optimistic' }, syncState: 'pending' }], - }, - }), - { flush: false }, - ); - commands[0] = { ...commands[0]!, state: 'conflict' }; - const companionIndex = rows.findIndex((row) => row.sourceKey === 'document_views'); - rows[companionIndex] = { - ...rows[companionIndex]!, - values: { title: 'old optimistic' }, - confirmedValues: { title: 'latest server' }, - serverRevision: 2, - syncState: 'conflict', - }; - - const newCommandId = await service.replacePrepared( - oldCommandId, - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'replace-pulled' }, - operation: 'documents.update', - payload: { title: 'new local' }, - optimisticValue: { id: 18, title: 'new local' }, - }, - replicaTransaction: { - putRows: [ - { - ...companion, - values: { title: 'new optimistic' }, - confirmedValues: { title: 'new optimistic' }, - serverRevision: 2, - syncState: 'pending', - }, - ], - }, - }), - { flush: false }, - ); - - expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ - values: { title: 'new optimistic' }, - confirmedValues: { title: 'latest server' }, - serverRevision: 2, - }); - await service.discard(newCommandId); - expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ - values: { title: 'latest server' }, - confirmedValues: { title: 'latest server' }, - serverRevision: 2, - syncState: 'confirmed', - }); - }); - - it('baselineのないcompanionを連続更新しても全command破棄後にrowを残さない', async () => { - const companion: OfflineReplicaRow = { - userId: 1, - scopeId: '10', - sourceKey: 'document_views', - identity: { kind: 'local', localId: 'new-companion-view' }, - values: { title: 'unused input' }, - confirmedValues: null, - serverRevision: null, - fetchedAt: 1, - syncState: 'pending', - }; - const firstCommandId = await service.enqueuePrepared( - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'new-companion' }, - operation: 'documents.update', - payload: { title: 'first' }, - optimisticValue: { id: 19, title: 'first' }, - }, - replicaTransaction: { putRows: [{ ...companion, values: { title: 'first optimistic' } }] }, - }), - { flush: false }, - ); - const secondCommandId = await service.enqueuePrepared( - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'new-companion' }, - operation: 'documents.update', - payload: { title: 'second' }, - optimisticValue: { id: 19, title: 'second' }, - }, - replicaTransaction: { putRows: [{ ...companion, values: { title: 'second optimistic' } }] }, - }), - { flush: false }, - ); - - expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ - values: { title: 'second optimistic' }, - confirmedValues: null, - }); - await service.discard(secondCommandId); - expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ - values: { title: 'first optimistic' }, - confirmedValues: null, - }); - await service.discard(firstCommandId); - expect(rows.find((row) => row.sourceKey === 'document_views')).toBeUndefined(); - }); - - it('confirmedCompanions putはACK・command削除・reconciliation markerと同一transactReplicaで確定する', async () => { - const companion: OfflineReplicaRow = { - userId: 1, - scopeId: '10', - sourceKey: 'document_views', - identity: { kind: 'local', localId: 'confirmed-put-view' }, - values: { title: 'baseline' }, - confirmedValues: { title: 'baseline' }, - serverRevision: null, - fetchedAt: 1, - syncState: 'confirmed', - }; - rows.push(companion); - await service.enqueuePrepared( - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'confirmed-put' }, - operation: 'documents.update', - payload: { title: 'optimistic' }, - optimisticValue: { id: 20, title: 'optimistic' }, - baseRevision: 1, - }, - replicaTransaction: { - putRows: [{ ...companion, values: { title: 'optimistic view' }, syncState: 'pending' }], - }, - }), - { flush: false }, - ); - const repository = TestBed.inject(OFFLINE_REPOSITORY); - const transactReplica = vi.mocked(repository.transactReplica); - const callsBeforeFlush = transactReplica.mock.calls.length; - execute.mockResolvedValueOnce({ - serverRevision: 2, - confirmedValues: { id: 20, title: 'server' }, - confirmedCompanions: [ - { - key: companion, - reduce: () => ({ title: 'server view' }), - }, - ], - response: null, - }); - - connected.set(true); - await service.flush(); - - const ackCalls = transactReplica.mock.calls.slice(callsBeforeFlush).filter(([transaction]) => - (transaction.putReconciliationScopes ?? []).some((scope) => scope.userId === 1 && scope.scopeId === '10'), - ); - expect(ackCalls).toHaveLength(1); - expect(ackCalls[0]?.[0]).toMatchObject({ - putRows: expect.arrayContaining([ - expect.objectContaining({ - sourceKey: 'documents', - values: { id: 20, title: 'server' }, - confirmedValues: { id: 20, title: 'server' }, - syncState: 'confirmed', - }), - expect.objectContaining({ - sourceKey: 'document_views', - values: { title: 'server view' }, - confirmedValues: { title: 'server view' }, - syncState: 'confirmed', - visibility: 'present', - }), - ]), - removeCommandIds: [expect.any(String)], - putReconciliationScopes: [{ userId: 1, scopeId: '10' }], - }); - expect(commands).toEqual([]); - expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ - values: { title: 'server view' }, - confirmedValues: { title: 'server view' }, - syncState: 'confirmed', - visibility: 'present', - }); - }); - - it('confirmedCompanions removeはACKと同一transactionでcompanionを除く', async () => { - const companion: OfflineReplicaRow = { - userId: 1, - scopeId: '10', - sourceKey: 'document_views', - identity: { kind: 'local', localId: 'confirmed-remove-view' }, - values: { title: 'baseline' }, - confirmedValues: { title: 'baseline' }, - serverRevision: null, - fetchedAt: 1, - syncState: 'confirmed', - }; - rows.push(companion); - await service.enqueuePrepared( - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'confirmed-remove' }, - operation: 'documents.update', - payload: { title: 'drop view' }, - optimisticValue: { id: 21, title: 'drop view' }, - baseRevision: 1, - }, - replicaTransaction: { removeRows: [companion] }, - }), - { flush: false }, - ); - expect(rows.find((row) => row.sourceKey === 'document_views')).toBeUndefined(); - execute.mockResolvedValueOnce({ - serverRevision: 2, - confirmedValues: { id: 21, title: 'drop view' }, - confirmedCompanions: [ - { - key: companion, - reduce: () => null, - }, - ], - response: null, - }); - - connected.set(true); - await service.flush(); - - expect(commands).toEqual([]); - expect(rows.find((row) => row.sourceKey === 'document_views')).toBeUndefined(); - expect(rows.find((row) => row.sourceKey === 'documents')).toMatchObject({ - values: { id: 21, title: 'drop view' }, - confirmedValues: { id: 21, title: 'drop view' }, - syncState: 'confirmed', - }); - }); - - it('後続optimistic companionはconfirmedCompanionsの上にoverlayしconfirmedValuesはサーバ確定値を残す', async () => { - const companion: OfflineReplicaRow = { - userId: 1, - scopeId: '10', - sourceKey: 'document_views', - identity: { kind: 'local', localId: 'confirmed-overlay-view' }, - values: { title: 'baseline' }, - confirmedValues: { title: 'baseline' }, - serverRevision: null, - fetchedAt: 1, - syncState: 'confirmed', - }; - rows.push(companion); - await service.enqueuePrepared( - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'confirmed-overlay' }, - operation: 'documents.update', - payload: { title: 'first' }, - optimisticValue: { id: 22, title: 'first' }, - baseRevision: 1, - }, - replicaTransaction: { - putRows: [{ ...companion, values: { title: 'first optimistic' }, syncState: 'pending' }], - }, - }), - { flush: false }, - ); - await service.enqueuePrepared( - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'confirmed-overlay' }, - operation: 'documents.update', - payload: { title: 'second' }, - optimisticValue: { id: 22, title: 'second' }, - baseRevision: 1, - }, - replicaTransaction: { - putRows: [{ ...companion, values: { title: 'second optimistic' }, syncState: 'pending' }], - }, - }), - { flush: false }, - ); - const firstCommandId = commands[0]!.commandId; - const secondCommandId = commands[1]!.commandId; - execute - .mockResolvedValueOnce({ - serverRevision: 2, - confirmedValues: { id: 22, title: 'first server' }, - confirmedCompanions: [ - { - key: companion, - reduce: () => ({ title: 'server companion' }), - }, - ], - response: null, - }) - .mockRejectedValueOnce(Object.assign(new Error('hold later command'), { status: 0 })); - - connected.set(true); - await service.flush(); - - expect(commands).toHaveLength(1); - expect(commands[0]?.commandId).toBe(secondCommandId); - expect(commands[0]?.commandId).not.toBe(firstCommandId); - expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ - values: { title: 'second optimistic' }, - confirmedValues: { title: 'server companion' }, - syncState: 'pending', - }); - expect(rows.find((row) => row.sourceKey === 'documents')).toMatchObject({ - values: { id: 22, title: 'second' }, - confirmedValues: { id: 22, title: 'first server' }, - }); - }); - - it('footprint外のconfirmedCompanionsはACKせずtransaction mutationを起こさない', async () => { - const companion: OfflineReplicaRow = { - userId: 1, - scopeId: '10', - sourceKey: 'document_views', - identity: { kind: 'local', localId: 'declared-view' }, - values: { title: 'baseline' }, - confirmedValues: { title: 'baseline' }, - serverRevision: null, - fetchedAt: 1, - syncState: 'confirmed', - }; - rows.push(companion); - await service.enqueuePrepared( - async () => ({ - request: { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'undeclared-companion' }, - operation: 'documents.update', - payload: { title: 'optimistic' }, - optimisticValue: { id: 23, title: 'optimistic' }, - baseRevision: 1, - }, - replicaTransaction: { - putRows: [{ ...companion, values: { title: 'optimistic view' }, syncState: 'pending' }], - }, - }), - { flush: false }, - ); - const beforeCommandId = commands[0]!.commandId; - const beforeReconciliation = structuredClone(reconciliationScopes); - const repository = TestBed.inject(OFFLINE_REPOSITORY); - const transactReplica = vi.mocked(repository.transactReplica); - const callsBeforeFlush = transactReplica.mock.calls.length; - execute.mockResolvedValueOnce({ - serverRevision: 2, - confirmedValues: { id: 23, title: 'server' }, - confirmedCompanions: [ - { - key: { ...companion, identity: { kind: 'local', localId: 'undeclared-view' } }, - reduce: () => ({ title: 'smuggled' }), - }, - ], - response: null, - }); - - connected.set(true); - await expect(service.flush()).rejects.toThrow('undeclared companion'); - - const ackMutations = transactReplica.mock.calls.slice(callsBeforeFlush).filter( - ([transaction]) => - (transaction.removeCommandIds ?? []).length > 0 || - (transaction.putReconciliationScopes ?? []).length > 0 || - (transaction.putRows ?? []).some( - (row) => - row.sourceKey === 'document_views' && - (row.values as { title?: string } | null)?.title === 'smuggled', - ), - ); - expect(ackMutations).toEqual([]); - expect(commands).toHaveLength(1); - expect(commands[0]).toMatchObject({ - commandId: beforeCommandId, - serverCommitUnknown: true, - }); - expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ - identity: { kind: 'local', localId: 'declared-view' }, - values: { title: 'optimistic view' }, - confirmedValues: { title: 'baseline' }, - }); - expect(rows.find((row) => row.sourceKey === 'documents')?.confirmedValues).toBeNull(); - expect(reconciliationScopes).toEqual(beforeReconciliation); + expect(commands).toEqual(beforeCommands); + expect(rows).toEqual(beforeRows); }); - it('confirmedCompanionsはfootprintをすべて覆う必要がある', async () => { + it('replacement must preserve the declared localOnly footprint', async () => { const companion: OfflineReplicaRow = { userId: 1, scopeId: '10', sourceKey: 'document_views', - identity: { kind: 'local', localId: 'exact-coverage' }, + identity: { kind: 'local', localId: 'replace-companion-view' }, values: { title: 'baseline' }, confirmedValues: { title: 'baseline' }, serverRevision: null, @@ -3118,39 +2479,48 @@ describe('OfflineSyncService', () => { syncState: 'confirmed', }; rows.push(companion); - await service.enqueuePrepared( + const oldCommandId = await service.enqueuePrepared( async () => ({ request: { scopeId: '10', aggregateType: 'documents', - identity: { kind: 'generated', localId: 'exact-coverage-command' }, + identity: { kind: 'generated', localId: 'replace-companion' }, operation: 'documents.update', - payload: { title: 'optimistic' }, - optimisticValue: { id: 24, title: 'optimistic' }, - baseRevision: 1, + payload: { title: 'old local' }, + localOnlyFootprint: [companion], }, - replicaTransaction: { putRows: [{ ...companion, syncState: 'pending' }] }, }), { flush: false }, ); - const commandId = commands[0]!.commandId; - execute.mockResolvedValueOnce({ - confirmedCompanions: [], - response: null, - }); + commands[0] = { ...commands[0]!, state: 'conflict' }; - connected.set(true); - await expect(service.flush()).rejects.toThrow('must cover every optimistic companion exactly once'); - expect(commands).toContainEqual(expect.objectContaining({ commandId })); + await expect( + service.replacePrepared( + oldCommandId, + async () => ({ + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'replace-companion' }, + operation: 'documents.update', + payload: { title: 'new local' }, + }, + }), + { flush: false }, + ), + ).rejects.toThrow('preserve the localOnly footprint'); + expect(commands).toHaveLength(1); + expect(commands[0]?.commandId).toBe(oldCommandId); + expect(rows.find((row) => row.sourceKey === 'document_views')?.values).toEqual({ title: 'old local' }); }); - it('confirmedCompanions reducerは最新のconfirmedValuesを使う', async () => { + it('replacement rematerializes remaining intents and discard restores confirmed localOnly values', async () => { const companion: OfflineReplicaRow = { userId: 1, scopeId: '10', sourceKey: 'document_views', - identity: { kind: 'local', localId: 'latest-confirmed' }, + identity: { kind: 'local', localId: 'replace-put-view' }, values: { title: 'baseline' }, confirmedValues: { title: 'baseline' }, serverRevision: null, @@ -3158,165 +2528,163 @@ describe('OfflineSyncService', () => { syncState: 'confirmed', }; rows.push(companion); - await service.enqueuePrepared( + const oldCommandId = await service.enqueuePrepared( async () => ({ request: { scopeId: '10', aggregateType: 'documents', - identity: { kind: 'generated', localId: 'latest-confirmed-command' }, + identity: { kind: 'generated', localId: 'replace-put' }, operation: 'documents.update', - payload: { title: 'optimistic' }, - optimisticValue: { id: 25, title: 'optimistic' }, - baseRevision: 1, - }, - replicaTransaction: { - putRows: [{ ...companion, values: { title: 'optimistic view' }, syncState: 'pending' }], + payload: { title: 'old local' }, + localOnlyFootprint: [companion], }, }), { flush: false }, ); - const current = rows.find((row) => row.sourceKey === 'document_views')!; - current.confirmedValues = { title: 'mutated confirmed' }; - const seen: unknown[] = []; - execute.mockResolvedValueOnce({ - serverRevision: 2, - confirmedValues: { id: 25, title: 'server' }, - confirmedCompanions: [ - { - key: companion, - reduce: (latestConfirmedValues) => { - seen.push(latestConfirmedValues); - return { title: 'reduced', from: (latestConfirmedValues as { title: string }).title }; - }, - }, - ], - response: null, - }); + commands[0] = { ...commands[0]!, state: 'conflict' }; - connected.set(true); - await service.flush(); + const newCommandId = await service.replacePrepared( + oldCommandId, + async () => ({ + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'replace-put' }, + operation: 'documents.update', + payload: { title: 'new local' }, + localOnlyFootprint: [companion], + }, + }), + { flush: false }, + ); - expect(seen).toEqual([{ title: 'mutated confirmed' }]); expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ - values: { title: 'reduced', from: 'mutated confirmed' }, - confirmedValues: { title: 'reduced', from: 'mutated confirmed' }, + values: { title: 'new local' }, + confirmedValues: { title: 'baseline' }, + }); + await service.discard(newCommandId); + expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ + values: { title: 'baseline' }, + confirmedValues: { title: 'baseline' }, syncState: 'confirmed', - visibility: 'present', }); }); - it('current companionの明示的なconfirmed absenceを旧beforeへfallbackしない', async () => { + it('replacement keeps the latest confirmedValues after a conflict pull', async () => { const companion: OfflineReplicaRow = { userId: 1, scopeId: '10', sourceKey: 'document_views', - identity: { kind: 'local', localId: 'confirmed-absence' }, + identity: { kind: 'local', localId: 'replace-pulled-view' }, values: { title: 'baseline' }, confirmedValues: { title: 'baseline' }, - serverRevision: null, + serverRevision: 1, fetchedAt: 1, syncState: 'confirmed', }; rows.push(companion); - await service.enqueuePrepared( + const oldCommandId = await service.enqueuePrepared( async () => ({ request: { scopeId: '10', aggregateType: 'documents', - identity: { kind: 'generated', localId: 'confirmed-absence-command' }, + identity: { kind: 'generated', localId: 'replace-pulled' }, operation: 'documents.update', - payload: {}, - optimisticValue: { id: 27 }, + payload: { title: 'old local' }, + localOnlyFootprint: [companion], }, - replicaTransaction: { putRows: [{ ...companion, syncState: 'pending' }] }, }), { flush: false }, ); - rows.find((row) => row.sourceKey === 'document_views')!.confirmedValues = null; - const seen: unknown[] = []; - execute.mockResolvedValueOnce({ - confirmedCompanions: [ - { - key: companion, - reduce: (latest) => { - seen.push(latest); - throw new Error('authoritative companion is absent'); - }, + commands[0] = { ...commands[0]!, state: 'conflict' }; + const companionIndex = rows.findIndex((row) => row.sourceKey === 'document_views'); + rows[companionIndex] = { + ...rows[companionIndex]!, + values: { title: 'old local' }, + confirmedValues: { title: 'latest server' }, + serverRevision: 2, + syncState: 'conflict', + }; + + const newCommandId = await service.replacePrepared( + oldCommandId, + async () => ({ + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'replace-pulled' }, + operation: 'documents.update', + payload: { title: 'new local' }, + localOnlyFootprint: [companion], }, - ], - }); + }), + { flush: false }, + ); - connected.set(true); - await expect(service.flush()).rejects.toThrow('authoritative companion is absent'); - expect(seen).toEqual([null]); - expect(commands).toHaveLength(1); + expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ + values: { title: 'new local' }, + confirmedValues: { title: 'latest server' }, + serverRevision: 2, + }); + await service.discard(newCommandId); + expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ + values: { title: 'latest server' }, + confirmedValues: { title: 'latest server' }, + serverRevision: 2, + syncState: 'confirmed', + }); }); - it('confirmedCompanions putはcanonicalなconfirmed syncStateとpresent visibilityを書く', async () => { + it('discards a create-only localOnly row when no remaining command owns it', async () => { const companion: OfflineReplicaRow = { userId: 1, scopeId: '10', sourceKey: 'document_views', - identity: { kind: 'local', localId: 'canonical-visibility' }, - values: { title: 'baseline' }, - confirmedValues: { title: 'baseline' }, + identity: { kind: 'local', localId: 'new-companion-view' }, + values: { title: 'unused input' }, + confirmedValues: null, serverRevision: null, fetchedAt: 1, - syncState: 'confirmed', - visibility: 'pending_delete', + syncState: 'pending', }; - rows.push(companion); - await service.enqueuePrepared( + const firstCommandId = await service.enqueuePrepared( async () => ({ request: { scopeId: '10', aggregateType: 'documents', - identity: { kind: 'generated', localId: 'canonical-visibility-command' }, + identity: { kind: 'generated', localId: 'new-companion' }, operation: 'documents.update', - payload: { title: 'optimistic' }, - optimisticValue: { id: 26, title: 'optimistic' }, - baseRevision: 1, + payload: { title: 'first' }, + localOnlyFootprint: [companion], }, - replicaTransaction: { - putRows: [ - { - ...companion, - values: { title: 'optimistic view' }, - syncState: 'pending', - visibility: 'pending_delete', - }, - ], + }), + { flush: false }, + ); + const secondCommandId = await service.enqueuePrepared( + async () => ({ + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'new-companion' }, + operation: 'documents.update', + payload: { title: 'second' }, + localOnlyFootprint: [companion], }, }), { flush: false }, ); - const repository = TestBed.inject(OFFLINE_REPOSITORY); - const transactReplica = vi.mocked(repository.transactReplica); - const callsBeforeFlush = transactReplica.mock.calls.length; - execute.mockResolvedValueOnce({ - serverRevision: 2, - confirmedValues: { id: 26, title: 'server' }, - confirmedCompanions: [{ key: companion, reduce: () => ({ title: 'canonical' }) }], - response: null, - }); - - connected.set(true); - await service.flush(); - const ackCalls = transactReplica.mock.calls.slice(callsBeforeFlush).filter(([transaction]) => - (transaction.putReconciliationScopes ?? []).some((scope) => scope.userId === 1 && scope.scopeId === '10'), - ); - expect(ackCalls[0]?.[0]?.putRows).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - sourceKey: 'document_views', - values: { title: 'canonical' }, - confirmedValues: { title: 'canonical' }, - syncState: 'confirmed', - visibility: 'present', - }), - ]), - ); + expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ + values: { title: 'second' }, + confirmedValues: null, + }); + await service.discard(secondCommandId); + expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ + values: { title: 'first' }, + confirmedValues: null, + }); + await service.discard(firstCommandId); + expect(rows.find((row) => row.sourceKey === 'document_views')).toBeUndefined(); }); it('同一ミリ秒のDate.nowでもcreatedAtは単調増加で保存する', async () => { @@ -3328,7 +2696,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 1 }, - optimisticValue: { seq: 1 }, }, { flush: false }, ); @@ -3339,7 +2706,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '2' }, operation: 'documents.upsert', payload: { seq: 2 }, - optimisticValue: { seq: 2 }, }, { flush: false }, ); @@ -3357,7 +2723,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { あ: 3, z: 1, ä: 2 }, - optimisticValue: {}, }, { flush: false }, ); @@ -3368,7 +2733,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '2' }, operation: 'documents.upsert', payload: { ä: 2, あ: 3, z: 1 }, - optimisticValue: {}, }, { flush: false }, ); @@ -3384,7 +2748,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { value: undefined }, - optimisticValue: {}, }, { flush: false }, ), @@ -3405,7 +2768,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -3424,7 +2786,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'ambiguous-retry' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -3442,7 +2803,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'pending-unknown' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -3461,7 +2821,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'safe-retry' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -3577,6 +2936,7 @@ describe('OfflineSyncService', () => { withServerRevision: (command: OfflineCommand) => command, }, }, + { provide: OFFLINE_AGGREGATE_INTENT_PROJECTOR, useValue: { project: rematerializeTestAggregate } }, ], }); service = TestBed.inject(OfflineSyncService); @@ -3587,7 +2947,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'jitter' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -3615,7 +2974,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'manual-retry' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -3626,7 +2984,7 @@ describe('OfflineSyncService', () => { await service.retryNow(commandId); expect(execute).toHaveBeenCalledTimes(2); - expect(service.pendingCommands()).toEqual([]); + expectAwaitingPull(1); }); it('retryNowは再認証後のblocked_authを解除して選択したcommandを再送する', async () => { @@ -3638,7 +2996,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'reauth-retry' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -3649,7 +3006,7 @@ describe('OfflineSyncService', () => { await service.retryNow(commandId); expect(execute).toHaveBeenCalledTimes(2); - expect(service.pendingCommands()).toEqual([]); + expectAwaitingPull(1); }); it('retryNow待機中にACK削除されたcommandを古いsnapshotから復活させない', async () => { @@ -3661,7 +3018,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'retry-ack-race' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -3708,7 +3064,6 @@ describe('OfflineSyncService', () => { identity: generatedCommandIdentity(`delete-${status}`), operation: 'documents.delete', payload: {}, - optimisticValue: { name: 'confirmed' }, replicaMutation: 'delete', }, { flush: false }, @@ -3729,7 +3084,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 1 }, - optimisticValue: { seq: 1 }, }, { flush: false }, ); @@ -3740,7 +3094,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 2 }, - optimisticValue: { seq: 2 }, }, { flush: false }, ); @@ -3752,8 +3105,7 @@ describe('OfflineSyncService', () => { resolveExecute({ response: null, serverRevision: 2 }); await flush; expect(execute).toHaveBeenCalledTimes(2); - expect(commands).toEqual([]); - expect(service.pendingCount()).toBe(0); + expectAwaitingPull(2); }); it('executor送信中のsingle discardを拒否してoptimistic rowとcommandを保持する', async () => { @@ -3765,8 +3117,7 @@ describe('OfflineSyncService', () => { aggregateType: 'documents', identity: { kind: 'generated', localId: 'discard-in-flight' }, operation: 'documents.upsert', - payload: {}, - optimisticValue: { title: 'pending' }, + payload: { title: 'pending' }, }, { flush: false }, ); @@ -3776,7 +3127,7 @@ describe('OfflineSyncService', () => { await expect(service.discard(commandId, { flush: false })).rejects.toBeInstanceOf(OfflineCommandInFlightError); expect(commands).toEqual([expect.objectContaining({ commandId, state: 'sending' })]); - expect(rows).toEqual([expect.objectContaining({ values: { title: 'pending' } })]); + expect(rows).toEqual([expect.objectContaining({ values: { id: 0, title: 'pending' } })]); resolveExecute({ response: null }); await flush; @@ -3791,7 +3142,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'discard-response-loss' }, operation: 'documents.upsert', payload: {}, - optimisticValue: { title: 'pending' }, }, { flush: false }, ); @@ -3825,7 +3175,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: `ambiguous-${status}` }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -3844,7 +3193,7 @@ describe('OfflineSyncService', () => { expect(execute).toHaveBeenCalledTimes(3); expect(execute.mock.calls.map(([command]) => command.commandId)).toEqual([commandId, commandId, commandId]); - expect(service.pendingCommands()).toEqual([]); + expectAwaitingPull(1); }); it('executorが同じkeyの未commitを証明した競合はambiguityを解除して通常解決へ渡す', async () => { @@ -3857,7 +3206,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'authoritative-no-commit' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -3883,7 +3231,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 1 }, - optimisticValue: { seq: 1 }, }, { flush: false }, ); @@ -3894,7 +3241,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 2 }, - optimisticValue: { seq: 2 }, }, { flush: false }, ); @@ -3923,7 +3269,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 1 }, - optimisticValue: { seq: 1 }, }, { flush: false }, ); @@ -3940,7 +3285,7 @@ describe('OfflineSyncService', () => { connected.set(true); await service.flush(); expect(execute).toHaveBeenCalledTimes(2); - expect(service.pendingCount()).toBe(0); + expectAwaitingPull(1); }); it('background flush failureはErrorHandlerへ渡し、await flushはrejectする', async () => { @@ -3969,7 +3314,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 1 }, - optimisticValue: { seq: 1 }, }, { flush: false }, ); @@ -3988,7 +3332,7 @@ describe('OfflineSyncService', () => { connected.set(true); await service.flush(); expect(execute).toHaveBeenCalledOnce(); - expect(service.pendingCount()).toBe(0); + expectAwaitingPull(1); }); it('local replica row lookup rejectionでもsendingに残さずretry_waitへ戻す', async () => { @@ -4000,7 +3344,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -4035,7 +3378,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'pretransport-discard-all' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -4062,7 +3404,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'discard-hook-failure' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -4083,7 +3424,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'discard-all-hook-failure' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -4102,7 +3442,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'claim-race' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -4144,7 +3483,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'cancel-before-claim' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -4172,7 +3510,7 @@ describe('OfflineSyncService', () => { const repository = TestBed.inject(OFFLINE_REPOSITORY) as OfflineRepository; const originalTransact = vi.mocked(repository.transactReplica).getMockImplementation()!; vi.mocked(repository.transactReplica).mockImplementation(async (transaction) => { - if ((transaction.removeCommandIds?.length ?? 0) > 0) { + if (transaction.putCommands?.some((command) => command.state === 'awaiting_pull')) { throw new Error('transaction failed'); } return originalTransact(transaction); @@ -4184,7 +3522,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -4206,7 +3543,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -4224,7 +3560,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -4242,7 +3577,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-invalid-id' }, operation: 'documents.create', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -4264,8 +3598,7 @@ describe('OfflineSyncService', () => { aggregateType: 'natural_documents', identity: { kind: 'natural', naturalKey: { favFrom: 7, favTo: '42' } }, operation: 'natural_documents.create', - payload: {}, - optimisticValue: { favFrom: 7, favTo: '42', title: 'local' }, + payload: { favTo: '42', title: 'local' }, }, { flush: false }, ); @@ -4284,34 +3617,13 @@ describe('OfflineSyncService', () => { aggregateType: 'natural_documents', identity: { kind: 'generated', localId: 'immutable-uuid', remoteIdHint: 99 }, operation: 'natural_documents.create', - payload: {}, - optimisticValue: { favFrom: 7, favTo: '42', title: 'local' }, + payload: { favTo: '42', title: 'local' }, }, { flush: false }, ), ).rejects.toThrow('Offline replica source "natural_documents" requires natural identity.'); }); - it('natural identityとoptimistic valueのkey不一致を永続化前に拒否する', async () => { - options.replicaSchema = naturalReplicaSchema; - - await expect( - service.enqueue( - { - scopeId: '10', - aggregateType: 'natural_documents', - identity: { kind: 'natural', naturalKey: { favFrom: 7, favTo: '22' } }, - operation: 'natural_documents.create', - payload: {}, - optimisticValue: { favFrom: 7, favTo: '21', title: 'local' }, - }, - { flush: false }, - ), - ).rejects.toThrow('Offline command naturalKey must match optimistic values for "natural_documents".'); - expect(commands).toEqual([]); - expect(rows).toEqual([]); - }); - it('empty generated localIdを永続化前に拒否する', async () => { await expect( service.enqueue( @@ -4320,8 +3632,7 @@ describe('OfflineSyncService', () => { aggregateType: 'documents', identity: { kind: 'generated', localId: '' }, operation: 'documents.create', - payload: {}, - optimisticValue: { id: 0, title: 'local' }, + payload: { title: 'local' }, }, { flush: false }, ), @@ -4350,7 +3661,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-existing' }, operation: 'documents.update', payload: {}, - optimisticValue: {}, baseRevision: 1, }, { flush: false }, @@ -4367,7 +3677,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-adopted', remoteId: 38142 }, operation: 'documents.update', payload: { name: 'adopted' }, - optimisticValue: { name: 'adopted' }, }, { flush: false }, ); @@ -4386,7 +3695,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-adopted', remoteId: 38142 }, operation: 'documents.delete', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -4417,7 +3725,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-delete' }, operation: 'documents.delete', payload: { id: 38142 }, - optimisticValue: { name: 'confirmed' }, baseRevision: 4, replicaMutation: 'delete', }, @@ -4434,8 +3741,8 @@ describe('OfflineSyncService', () => { execute.mockResolvedValueOnce({ removeReplica: true, response: null }); connected.set(true); await service.flush(); - expect(rows).toEqual([]); - expect(commands).toEqual([]); + expect(rows[0]).toMatchObject({ visibility: 'pending_delete', confirmedValues: { name: 'confirmed' } }); + expectAwaitingPull(1); }); it('delete intentはexecutorがremoveReplicaを省略しても成功ACKでphysical removeする', async () => { @@ -4458,7 +3765,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-delete-without-projection' }, operation: 'documents.delete', payload: { id: 38142 }, - optimisticValue: { name: 'confirmed' }, baseRevision: 4, replicaMutation: 'delete', }, @@ -4469,8 +3775,8 @@ describe('OfflineSyncService', () => { connected.set(true); await service.flush(); - expect(rows).toEqual([]); - expect(commands).toEqual([]); + expect(rows[0]).toMatchObject({ visibility: 'pending_delete' }); + expectAwaitingPull(1); }); it('delete後のqueued recreateはlocalIdを維持してremoteIdを再割当できる', async () => { @@ -4493,7 +3799,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'stable-local-id' }, operation: 'documents.delete', payload: {}, - optimisticValue: rows[0]!.values, replicaMutation: 'delete', }, { flush: false }, @@ -4504,13 +3809,10 @@ describe('OfflineSyncService', () => { aggregateType: 'documents', identity: { kind: 'generated', localId: 'stable-local-id' }, operation: 'documents.create', - payload: {}, - optimisticValue: { name: 'recreated', presentation: 'pending' }, + payload: { name: 'recreated', presentation: null }, }, { flush: false }, ); - // A feed/cache integration may patch local-only values while delete is in flight. - rows[0] = { ...rows[0]!, values: { name: 'recreated', presentation: null } }; execute.mockResolvedValueOnce({ removeReplica: true, clearRemoteId: true, response: null }).mockImplementationOnce(async () => { expect(rows[0]).toMatchObject({ @@ -4519,7 +3821,6 @@ describe('OfflineSyncService', () => { }); return { remoteId: 43, - confirmedValues: { name: 'recreated', presentation: null }, response: null, }; }); @@ -4534,12 +3835,11 @@ describe('OfflineSyncService', () => { expect(rows).toEqual([ expect.objectContaining({ identity: { kind: 'generated', localId: 'stable-local-id', remoteId: 43 }, - serverRevision: null, - values: { name: 'recreated', presentation: null }, - syncState: 'confirmed', + values: expect.objectContaining({ name: 'recreated', presentation: null }), + syncState: 'pending', }), ]); - expect(commands).toEqual([]); + expectAwaitingPull(2); }); it('delete送信中にenqueueされたrecreateをACK完了時に保持する', async () => { @@ -4566,7 +3866,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'race-local-id' }, operation: 'documents.delete', payload: {}, - optimisticValue: { name: 'confirmed' }, replicaMutation: 'delete', }, { flush: false }, @@ -4582,7 +3881,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'race-local-id' }, operation: 'documents.create', payload: {}, - optimisticValue: { name: 'recreated' }, }, { flush: false }, ); @@ -4596,11 +3894,11 @@ describe('OfflineSyncService', () => { expect(rows).toEqual([ expect.objectContaining({ identity: { kind: 'generated', localId: 'race-local-id', remoteId: 43 }, - values: { name: 'recreated' }, - syncState: 'confirmed', + values: expect.objectContaining({ name: 'confirmed' }), + syncState: 'pending', }), ]); - expect(commands).toEqual([]); + expectAwaitingPull(2); }); it('serialized cache projectionはACK current read中に割り込まず解放後のrowを読む', async () => { @@ -4629,8 +3927,7 @@ describe('OfflineSyncService', () => { aggregateType: 'documents', identity: { kind: 'generated', localId: 'serialized-cache-local-id' }, operation: 'documents.delete', - payload: {}, - optimisticValue: { name: 'confirmed', presentation: 'pending' }, + payload: { presentation: 'pending' }, replicaMutation: 'delete', }, { flush: false }, @@ -4644,8 +3941,7 @@ describe('OfflineSyncService', () => { aggregateType: 'documents', identity: { kind: 'generated', localId: 'serialized-cache-local-id' }, operation: 'documents.create', - payload: {}, - optimisticValue: { name: 'recreated', presentation: 'pending' }, + payload: { presentation: 'pending' }, }, { flush: false }, ); @@ -4663,7 +3959,7 @@ describe('OfflineSyncService', () => { }); expect(current).toMatchObject({ identity: { kind: 'generated', remoteId: null }, - values: { name: 'recreated', presentation: 'pending' }, + values: { name: 'confirmed', presentation: 'pending' }, }); await repository.transactReplica({ putRows: [{ ...current!, values: { name: 'recreated', presentation: null } }], @@ -4675,9 +3971,11 @@ describe('OfflineSyncService', () => { expect(rows).toEqual([ expect.objectContaining({ identity: { kind: 'generated', localId: 'serialized-cache-local-id', remoteId: 43 }, - values: { name: 'recreated', presentation: null }, + values: expect.objectContaining({ name: 'confirmed', presentation: 'pending' }), + syncState: 'pending', }), ]); + expectAwaitingPull(2); }); it('delete ACK後のstale remoteIdHintは採用せずrecreateをremoteId nullから開始する', async () => { @@ -4703,14 +4001,17 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'complete-first-local-id' }, operation: 'documents.delete', payload: {}, - optimisticValue: { name: 'confirmed' }, replicaMutation: 'delete', }, { flush: false }, ); connected.set(true); await service.flush(); - expect(rows).toEqual([]); + expect(rows[0]).toMatchObject({ + identity: { kind: 'generated', localId: 'complete-first-local-id', remoteId: null }, + visibility: 'pending_delete', + }); + expectAwaitingPull(1); await service.enqueue( { @@ -4719,7 +4020,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'complete-first-local-id', remoteIdHint: 42 }, operation: 'documents.create', payload: {}, - optimisticValue: { name: 'recreated' }, }, { flush: false }, ); @@ -4732,9 +4032,11 @@ describe('OfflineSyncService', () => { expect(rows).toEqual([ expect.objectContaining({ identity: { kind: 'generated', localId: 'complete-first-local-id', remoteId: 43 }, - values: { name: 'recreated' }, + values: expect.objectContaining({ name: 'confirmed' }), + syncState: 'pending', }), ]); + expectAwaitingPull(2); }); it('TEXT remoteIdのdelete ACK後recreateを同じlocalId・remoteId nullで送り新UUIDへ収束する', async () => { @@ -4760,8 +4062,7 @@ describe('OfflineSyncService', () => { aggregateType: 'text_documents', identity: { kind: 'generated', localId }, operation: 'text_documents.delete', - payload: {}, - optimisticValue: { id: oldRemoteId, title: 'old' }, + payload: { title: 'old' }, replicaMutation: 'delete', }, { flush: false }, @@ -4773,7 +4074,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId }, operation: 'text_documents.create', payload: { title: 'new' }, - optimisticValue: { id: '', title: 'new' }, }, { flush: false }, ); @@ -4794,8 +4094,10 @@ describe('OfflineSyncService', () => { expect.objectContaining({ identity: { kind: 'generated', localId, remoteId: newRemoteId }, values: { id: newRemoteId, title: 'new' }, + syncState: 'pending', }), ]); + expectAwaitingPull(2); }); it('clearRemoteIdはconfirmed delete以外では拒否する', async () => { @@ -4818,7 +4120,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'invalid-clear' }, operation: 'documents.update', payload: {}, - optimisticValue: { name: 'updated' }, }, { flush: false }, ); @@ -4847,7 +4148,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'invalid-clear-revision' }, operation: 'documents.delete', payload: {}, - optimisticValue: rows[0]!.values, replicaMutation: 'delete', }, { flush: false }, @@ -4859,7 +4159,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'invalid-clear-revision' }, operation: 'documents.create', payload: {}, - optimisticValue: { name: 'recreated' }, }, { flush: false }, ); @@ -4898,7 +4197,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-delete-discard' }, operation: 'documents.delete', payload: { id: 38142 }, - optimisticValue: { name: 'confirmed' }, baseRevision: 4, replicaMutation: 'delete', }, @@ -4938,7 +4236,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'delete-server-id' }, operation: 'documents.delete', payload: {}, - optimisticValue: { name: 'confirmed' }, replicaMutation: 'delete', }, { flush: false }, @@ -4969,8 +4266,7 @@ describe('OfflineSyncService', () => { aggregateType: 'natural_documents', identity: { kind: 'natural', naturalKey: { favFrom: 7, favTo: '42' } }, operation: 'natural_documents.delete', - payload: {}, - optimisticValue: { favFrom: 7, favTo: '42', title: 'confirmed' }, + payload: { favTo: '42', title: 'confirmed' }, replicaMutation: 'delete', }, { flush: false }, @@ -4999,7 +4295,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'delete-then-upsert' }, operation: 'documents.delete', payload: {}, - optimisticValue: { name: 'confirmed' }, replicaMutation: 'delete', }, { flush: false }, @@ -5011,7 +4306,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'delete-then-upsert' }, operation: 'documents.update', payload: {}, - optimisticValue: { name: 'later optimistic' }, }, { flush: false }, ); @@ -5019,10 +4313,17 @@ describe('OfflineSyncService', () => { execute.mockRejectedValueOnce({ status: 422 }); connected.set(true); await service.flush(); - expect(rows[0]).toMatchObject({ confirmedValues: null, visibility: 'present' }); + expect(rows[0]).toMatchObject({ + confirmedValues: { name: 'old confirmed baseline' }, + visibility: 'present', + }); await service.discard(followingId, { flush: false }); - expect(rows).toEqual([]); + expect(rows[0]).toMatchObject({ + confirmedValues: { name: 'old confirmed baseline' }, + visibility: 'pending_delete', + }); + expect(commands).toEqual([expect.objectContaining({ replicaMutation: 'delete', state: 'awaiting_pull' })]); }); it('tombstone read APIを持たないcustom repositoryではdelete enqueueを明示rejectする', async () => { @@ -5038,7 +4339,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'missing-tombstone-api' }, operation: 'documents.delete', payload: {}, - optimisticValue: {}, replicaMutation: 'delete', }, { flush: false }, @@ -5060,7 +4360,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-invalid', remoteId }, operation: 'documents.update', payload: {}, - optimisticValue: {}, }, { flush: false }, ), @@ -5089,7 +4388,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-new', remoteId: 38142 }, operation: 'documents.update', payload: {}, - optimisticValue: {}, }, { flush: false }, ), @@ -5116,7 +4414,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-same', remoteId: 38142 }, operation: 'documents.update', payload: { name: 'draft' }, - optimisticValue: { name: 'draft' }, baseRevision: 1, }, { flush: false }, @@ -5133,7 +4430,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-adopted', remoteId: 38142 }, operation: 'documents.update', payload: { name: 'adopted' }, - optimisticValue: { name: 'adopted' }, }, { flush: false }, ); @@ -5150,7 +4446,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-adopted', remoteId: 38142 }, operation: 'documents.update', payload: { name: 'adopted' }, - optimisticValue: { name: 'adopted' }, }, { flush: false }, ); @@ -5168,7 +4463,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: {}, - optimisticValue: {}, }, { flush: false }, ); @@ -5377,6 +4671,7 @@ describe('OfflineSyncService', () => { }), }, }, + { provide: OFFLINE_AGGREGATE_INTENT_PROJECTOR, useValue: { project: rematerializeTestAggregate } }, { provide: OFFLINE_RETRY_RANDOM, useValue: () => 0.5 }, ], }); @@ -5407,7 +4702,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'G10 edit' }, - optimisticValue: { id: 42, title: 'G10 edit' }, baseRevision: 1, }, { flush: false }, @@ -5419,7 +4713,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'G11 edit' }, - optimisticValue: { id: 42, title: 'G11 edit' }, baseRevision: 1, }, { flush: false }, @@ -5432,7 +4725,6 @@ describe('OfflineSyncService', () => { expect(execute.mock.calls[1]?.[0]).toMatchObject({ commandId: secondId, baseRevision: 2, - optimisticValue: { id: 42, title: 'G11 edit' }, }); expect( findReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', { @@ -5441,7 +4733,7 @@ describe('OfflineSyncService', () => { }), ).toMatchObject({ values: { title: 'G11 edit' }, - confirmedValues: { title: 'G10 edit' }, + confirmedValues: { title: 'Baseline' }, serverRevision: 2, syncState: 'pending', }); @@ -5451,7 +4743,7 @@ describe('OfflineSyncService', () => { { title: 'G10 edit' }, { title: 'G11 edit' }, ]); - expect(service.pendingCount()).toBe(0); + expectAwaitingPull(2); }); it('pre-pull: head scope成功/後続scope失敗の同一user-scoped aggregateは成功prefixだけ送る', async () => { @@ -5466,7 +4758,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'A' }, - optimisticValue: { id: 42, title: 'A' }, baseRevision: 1, }, { flush: false }, @@ -5478,7 +4769,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'B' }, - optimisticValue: { id: 42, title: 'B' }, baseRevision: 1, }, { flush: false }, @@ -5489,8 +4779,10 @@ describe('OfflineSyncService', () => { expect(execute).toHaveBeenCalledOnce(); expect(execute.mock.calls[0]?.[0]).toMatchObject({ scopeId: '10', payload: { title: 'A' } }); - expect(commands).toHaveLength(1); - expect(commands[0]).toMatchObject({ scopeId: '11', optimisticValue: { title: 'B' }, state: 'pending' }); + expect(commands).toHaveLength(2); + expect(commands[0]).toMatchObject({ scopeId: '10', state: 'awaiting_pull' }); + expect(commands[1]).toMatchObject({ scopeId: '11', state: 'pending' }); + expect(rows.find((row) => row.sourceKey === 'test_items')?.values).toEqual(expect.objectContaining({ title: 'B' })); }); it('pre-pull: head scope失敗/後続scope成功の同一user-scoped aggregateは一切送らない', async () => { @@ -5505,7 +4797,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'A' }, - optimisticValue: { id: 42, title: 'A' }, baseRevision: 1, }, { flush: false }, @@ -5517,7 +4808,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'B' }, - optimisticValue: { id: 42, title: 'B' }, baseRevision: 1, }, { flush: false }, @@ -5542,7 +4832,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'A' }, - optimisticValue: { id: 42, title: 'A' }, baseRevision: 1, }, { flush: false }, @@ -5554,7 +4843,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'B' }, - optimisticValue: { id: 42, title: 'B' }, baseRevision: 1, }, { flush: false }, @@ -5563,8 +4851,9 @@ describe('OfflineSyncService', () => { connected.set(true); await expect(service.flush()).rejects.toBe(scope11Error); expect(execute).toHaveBeenCalledOnce(); - expect(commands).toHaveLength(1); - expect(commands[0]).toMatchObject({ scopeId: '11', payload: { title: 'B' } }); + expect(commands).toHaveLength(2); + expect(commands.find((command) => command.scopeId === '10')).toMatchObject({ state: 'awaiting_pull' }); + expect(commands.find((command) => command.scopeId === '11')).toMatchObject({ state: 'pending', payload: { title: 'B' } }); pull.mockResolvedValue(undefined); execute.mockClear(); @@ -5572,7 +4861,7 @@ describe('OfflineSyncService', () => { expect(execute).toHaveBeenCalledOnce(); expect(execute.mock.calls[0]?.[0]).toMatchObject({ scopeId: '11', payload: { title: 'B' } }); - expect(service.pendingCount()).toBe(0); + expectAwaitingPull(2); }); it('pre-pull: user-scoped aggregateでもfatalは成功scopeを送らず残りscopeを止める', async () => { @@ -5587,7 +4876,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'B' }, - optimisticValue: { id: 42, title: 'B' }, baseRevision: 1, }, { flush: false }, @@ -5607,7 +4895,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'G10 edit' }, - optimisticValue: { id: 42, title: 'G10 edit' }, baseRevision: 1, }, { flush: false }, @@ -5619,14 +4906,13 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'G11 edit' }, - optimisticValue: { id: 42, title: 'G11 edit' }, baseRevision: 1, }, { flush: false }, ); await service.discard(firstId, { flush: false }); expect(commands).toHaveLength(1); - expect(commands[0]).toMatchObject({ scopeId: '11', optimisticValue: { id: 42, title: 'G11 edit' } }); + expect(commands[0]).toMatchObject({ scopeId: '11' }); expect( findReplicaRow({ userId: 1, scopeId: '11' }, 'test_items', { kind: 'generated', @@ -5663,10 +4949,9 @@ describe('OfflineSyncService', () => { aggregateType: 'test_items', identity: { kind: 'generated', localId: 'batch-scope-10' }, operation: 'test_items.create', - payload: {}, - optimisticValue: { id: 0, title: 'A' }, + payload: { title: 'A' }, + localOnlyFootprint: [companion('10', 'A')], }, - replicaTransaction: { putRows: [companion('10', 'A')] }, }, { request: { @@ -5674,10 +4959,9 @@ describe('OfflineSyncService', () => { aggregateType: 'test_items', identity: { kind: 'generated', localId: 'batch-scope-11' }, operation: 'test_items.create', - payload: {}, - optimisticValue: { id: 0, title: 'B' }, + payload: { title: 'B' }, + localOnlyFootprint: [companion('11', 'B')], }, - replicaTransaction: { putRows: [companion('11', 'B')] }, }, ], { flush: false }, @@ -5708,10 +4992,9 @@ describe('OfflineSyncService', () => { aggregateType: 'test_items', identity: { kind: 'generated', localId: 'scope-10-item' }, operation: 'test_items.create', - payload: {}, - optimisticValue: { id: 0, title: 'A' }, + payload: { title: 'A' }, + localOnlyFootprint: [companion('10', 'A')], }, - replicaTransaction: { putRows: [companion('10', 'A')] }, }), { flush: false }, ); @@ -5724,10 +5007,9 @@ describe('OfflineSyncService', () => { aggregateType: 'test_items', identity: { kind: 'generated', localId: 'scope-11-item' }, operation: 'test_items.create', - payload: {}, - optimisticValue: { id: 0, title: 'B' }, + payload: { title: 'B' }, + localOnlyFootprint: [companion('11', 'B')], }, - replicaTransaction: { putRows: [companion('11', 'B')] }, }), { flush: false }, ), @@ -5772,7 +5054,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-group-same' }, operation: 'test_group_items.update', payload: { name: 'G10 name' }, - optimisticValue: { id: 56, name: 'G10 name' }, baseRevision: 1, }, { flush: false }, @@ -5784,7 +5065,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: '019d-group-same' }, operation: 'test_group_items.update', payload: { name: 'G11 name' }, - optimisticValue: { id: 55, name: 'G11 name' }, baseRevision: 1, }, { flush: false }, @@ -5795,7 +5075,7 @@ describe('OfflineSyncService', () => { resolveFirst({ serverRevision: 2, confirmedValues: { id: 56, name: 'G10 name' }, response: null }); await flush; expect(execute.mock.calls.map(([command]) => command.scopeId).sort()).toEqual(['10', '11']); - expect(service.pendingCount()).toBe(0); + expectAwaitingPull(2); }); }); @@ -5829,7 +5109,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'outbox-scope' }, operation: 'documents.create', payload: { title: 'queued' }, - optimisticValue: { id: 0, title: 'queued' }, }, { flush: false }, ); @@ -5968,7 +5247,6 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'retry-foreground-policy' }, operation: 'documents.create', payload: { title: 'queued' }, - optimisticValue: { id: 0, title: 'queued' }, }, { flush: false }, ); @@ -5991,6 +5269,10 @@ describe('OfflineSyncService', () => { }); it('discard preserves foreground policy so reconnect automatic flush stays partial', async () => { + await service.refreshSession(['10']); + await vi.waitFor(() => expect(pull).toHaveBeenCalledWith({ userId: 1, scopeId: '10' })); + pull.mockClear(); + const commandId = await service.enqueue( { scopeId: '10', @@ -5998,15 +5280,9 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'discard-foreground-policy' }, operation: 'documents.create', payload: { title: 'queued' }, - optimisticValue: { id: 0, title: 'queued' }, }, { flush: false }, ); - - await service.refreshSession(['10']); - await vi.waitFor(() => expect(pull).toHaveBeenCalledWith({ userId: 1, scopeId: '10' })); - pull.mockClear(); - await service.discard(commandId, { flush: false }); pull.mockClear(); @@ -6019,6 +5295,10 @@ describe('OfflineSyncService', () => { }); it('discardAllPending preserves foreground policy so reconnect automatic flush stays partial', async () => { + await service.refreshSession(['10']); + await vi.waitFor(() => expect(pull).toHaveBeenCalledWith({ userId: 1, scopeId: '10' })); + pull.mockClear(); + await service.enqueue( { scopeId: '10', @@ -6026,15 +5306,9 @@ describe('OfflineSyncService', () => { identity: { kind: 'generated', localId: 'discard-all-foreground-policy' }, operation: 'documents.create', payload: { title: 'queued' }, - optimisticValue: { id: 0, title: 'queued' }, }, { flush: false }, ); - - await service.refreshSession(['10']); - await vi.waitFor(() => expect(pull).toHaveBeenCalledWith({ userId: 1, scopeId: '10' })); - pull.mockClear(); - await service.discardAllPending(); pull.mockClear(); diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index 829e19f..2be54ee 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -12,7 +12,8 @@ import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { OfflineNetworkService } from './offline-network.service'; import { OfflineReplicaPullService, OfflineReplicaSchemaMismatchError } from './offline-replica-pull.service'; -import { OfflineReplicaMutationCoordinator } from './offline-replica-mutation-coordinator'; +import { isOfflineAggregateIntentConflict, offlineAggregateIntentMutations } from './offline-aggregate-intent-projector'; +import { commandFootprintKeys, OfflineReplicaMutationCoordinator } from './offline-replica-mutation-coordinator'; import type { OfflineCommand, OfflinePullAttention, @@ -21,7 +22,6 @@ import type { OfflineReplicaRow, OfflineReplicaRowKey, OfflineReplicaTransaction, - OfflineOptimisticReplicaCompanion, OfflineRepository, OfflineScope, } from './offline-repository'; @@ -32,7 +32,6 @@ import { commandIdentityFromReplicaIdentity, commandIdentityMatchesReplicaRow, offlineGeneratedReplicaIdentity, - offlineNaturalReplicaIdentity, type OfflineCommandIdentity, type OfflinePrincipalId, } from './offline-identity'; @@ -47,31 +46,30 @@ import { /** Aggregate synchronization state exposed to application UI. */ export type OfflineSyncState = 'idle' | 'pending' | 'syncing' | 'attention'; -/** Mutation and optimistic entity materialization appended atomically to the outbox. */ +/** Mutation appended atomically to the outbox; Kit rematerializes optimistic replica state. */ export interface EnqueueOfflineCommand { scopeId: string; aggregateType: string; identity: EnqueueOfflineCommandIdentity; operation: string; payload: T; - /** Full local entity value committed to the replica before the command is exposed to the UI. */ - optimisticValue: unknown; /** * Optimistically hides an existing DB row while retaining its identity and * confirmed baseline for durable replay, conflict handling, and discard. */ replicaMutation?: 'upsert' | 'delete'; baseRevision?: string | number | null; + /** Declared localOnly projection rows this intent may create, update, or remove. */ + localOnlyFootprint?: readonly OfflineReplicaRowKey[]; } /** * A command prepared from replica reads while enqueue/ACK projection is - * serialized. Only product-owned row changes are accepted here; the sync - * service owns Outbox and cursor changes. + * serialized. Kit rematerializes the aggregate; the prepare callback must not + * supply replica row images as independent truth. */ export interface PreparedOfflineCommand { request: EnqueueOfflineCommand; - replicaTransaction?: Pick; } export interface PreparedOfflineBatchOptions { @@ -80,11 +78,11 @@ export interface PreparedOfflineBatchOptions { assertCurrent?: () => void; } -/** Validated optimistic projection ready for a single Outbox commit. */ +/** Validated Outbox command ready for a rematerialized replica commit. */ interface MaterializedOfflineEnqueue { command: OfflineCommand; - optimisticRow: OfflineReplicaRow; - optimisticCompanions: readonly OfflineOptimisticReplicaCompanion[]; + /** Identity-bearing base row used when the aggregate does not yet exist locally. */ + seedBaseRow?: OfflineReplicaRow | null; } /** Raised before persistence when an outbox payload is not losslessly JSON serializable. */ @@ -273,8 +271,8 @@ export class OfflineSyncService { } /** - * Reads, derives, and commits the base optimistic row, product companion - * rows, and Outbox command as one serialized replica transaction. + * Reads, derives, and commits one Outbox command, then rematerializes its + * aggregate from confirmed replica values plus remaining FIFO intents. */ enqueuePrepared( prepare: (repository: OfflineRepository) => Promise>, @@ -285,7 +283,7 @@ export class OfflineSyncService { await this.initialize(); if (!this.#isCurrent(generation)) throw new Error('Offline session changed before prepared enqueue.'); const prepared = await prepare(this.#repository); - return this.#enqueue(prepared.request, options, generation, prepared.replicaTransaction); + return this.#enqueue(prepared.request, options, generation); }); } @@ -336,7 +334,7 @@ export class OfflineSyncService { throw new Error('Offline replacement requires the command to be the only pending intent for its aggregate.'); } const prepared = await prepare(this.#repository); - return this.#enqueue(prepared.request, options, generation, prepared.replicaTransaction, replaced); + return this.#enqueue(prepared.request, options, generation, replaced); }); } @@ -370,7 +368,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, entry.replicaTransaction, replaced[index])); + materializations.push(await this.#materializeEnqueue(session.userId, entry.request, replaced[index])); } const retained = knownCommands.filter((command) => !replaced.some((item) => item.commandId === command.commandId)); this.#assertDistinctBatchFootprints(materializations, retained, true); @@ -426,12 +424,11 @@ export class OfflineSyncService { request: EnqueueOfflineCommand, options: { flush?: boolean }, generation: number, - 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); + const materialization = await this.#materializeEnqueue(session.userId, request, replaced); const currentCommands = await this.#commandsForUser(session.userId); const retainedCommands = replaced ? currentCommands.filter((command) => command.commandId !== replaced.commandId) : currentCommands; this.#assertDistinctBatchFootprints([materialization], retainedCommands); @@ -461,9 +458,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, entry.replicaTransaction, undefined, firstCreatedAt + index), - ); + materializations.push(await this.#materializeEnqueue(session.userId, entry.request, undefined, firstCreatedAt + index)); } this.#assertDistinctBatchFootprints(materializations, currentCommands); await this.#assertOutboxCapacity( @@ -502,7 +497,6 @@ export class OfflineSyncService { async #materializeEnqueue( userId: OfflinePrincipalId, request: EnqueueOfflineCommand, - replicaTransaction?: Pick, replaced?: OfflineCommand, createdAt?: number, ): Promise { @@ -510,9 +504,10 @@ export class OfflineSyncService { this.noteScope(scope); const commandIdentity = offlineCommandLookupIdentity(request.identity); const normalized = await this.#normalizeEnqueueRequest(scope, request, commandIdentity); - const optimisticValue = request.optimisticValue; const commandId = crypto.randomUUID(); const sourceKey = this.#hooks.entityType(request); + const localOnlyFootprint = this.#normalizedLocalOnlyFootprint(scope, request.localOnlyFootprint); + if (replaced) this.#assertReplacementFootprint(replaced, localOnlyFootprint); let command: OfflineCommand = { ...scope, commandId, @@ -521,7 +516,6 @@ export class OfflineSyncService { identity: commandIdentity, operation: request.operation, payload: normalized.payload, - optimisticValue, replicaMutation: request.replicaMutation ?? 'upsert', payloadHash: await this.#payloadHash(normalized.payload), baseRevision: normalized.baseRevision, @@ -531,6 +525,7 @@ export class OfflineSyncService { createdAt: replaced?.createdAt ?? createdAt ?? (await this.#nextCommandCreatedAt(userId)), lastErrorCode: null, }; + if (localOnlyFootprint.length > 0) command = { ...command, localOnlyFootprint }; if ( replaced && (replaced.userId !== command.userId || @@ -567,13 +562,19 @@ export class OfflineSyncService { existing?.identity.kind === 'generated' ? existing.identity.remoteId : null, generatedIdentity?.remoteIdHint, ); - const naturalKey = offlineNaturalKeyFromValues(schema, optimisticValue); + const naturalKey = + schema.identity.kind === 'naturalKey' && request.identity.kind === 'natural' + ? request.identity.naturalKey + : existing + ? offlineNaturalKeyFromValues(schema, existing.values) + : null; if ( schema.identity.kind === 'naturalKey' && - canonicalOfflineRemoteIdentity(schema, { naturalKey: request.identity.kind === 'natural' ? request.identity.naturalKey : {} }) !== + request.identity.kind === 'natural' && + canonicalOfflineRemoteIdentity(schema, { naturalKey: request.identity.naturalKey }) !== canonicalOfflineRemoteIdentity(schema, { naturalKey: naturalKey! }) ) { - throw new Error(`Offline command naturalKey must match optimistic values for "${entityType}".`); + throw new Error(`Offline command naturalKey must match replica identity for "${entityType}".`); } const remoteIdentity = schema.identity.kind === 'generated' @@ -592,7 +593,7 @@ export class OfflineSyncService { existing.identity.kind === 'natural' ? existing.identity.naturalKey : offlineNaturalKeyFromValues(schema, existing.values)!, }) !== canonicalValuesKey ) { - throw new Error(`Offline replica naturalKey is immutable and must match optimistic values for "${entityType}".`); + throw new Error(`Offline replica naturalKey is immutable and must match command identity for "${entityType}".`); } } if (remoteIdentity !== null) { @@ -604,26 +605,33 @@ export class OfflineSyncService { throw new Error(`Offline replica remote identity is already mapped to another row.`); } } - const rowIdentity: import('./offline-identity').OfflineReplicaIdentity = - schema.identity.kind === 'naturalKey' - ? offlineNaturalReplicaIdentity(schema, optimisticValue) - : offlineGeneratedReplicaIdentity(generatedIdentity!.localId, initialRemoteId); - const optimisticRow: OfflineReplicaRow = { - ...scope, - sourceKey: entityType, - identity: rowIdentity, - values: optimisticValue, - confirmedValues: existing?.confirmedValues ?? existing?.values ?? null, - serverRevision: existing?.serverRevision ?? normalized.baseRevision, - fetchedAt: Date.now(), - syncState: 'pending', - visibility: request.replicaMutation === 'delete' ? 'pending_delete' : 'present', - }; - const preparedCompanions = await this.#prepareOptimisticCompanions(scope, optimisticRow, replicaTransaction); - const optimisticCompanions = replaced ? this.#replacementCompanions(replaced, preparedCompanions) : preparedCompanions; - if (replicaTransaction) this.#canonicalJson(replicaTransaction); - if (optimisticCompanions.length > 0) command = { ...command, optimisticCompanions }; - return { command, optimisticRow, optimisticCompanions }; + this.#canonicalJson(normalized.payload); + const seedBaseRow = existing + ? undefined + : request.identity.kind === 'generated' + ? { + ...scope, + sourceKey, + identity: { kind: 'generated' as const, localId: request.identity.localId, remoteId: initialRemoteId }, + values: {}, + confirmedValues: null, + serverRevision: null, + fetchedAt: Date.now(), + syncState: 'pending' as const, + } + : request.identity.kind === 'natural' + ? { + ...scope, + sourceKey, + identity: { kind: 'natural' as const, naturalKey: request.identity.naturalKey }, + values: { ...request.identity.naturalKey }, + confirmedValues: null, + serverRevision: null, + fetchedAt: Date.now(), + syncState: 'pending' as const, + } + : null; + return { command, seedBaseRow }; } #assertDistinctBatchFootprints( @@ -644,10 +652,7 @@ export class OfflineSyncService { 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)), - ]) { + for (const key of this.#commandFootprintKeys(entry.command)) { if (replicaKeys.has(key) && !allowOneAggregate) { throw new Error('Prepared offline batch contains overlapping replica footprints.'); } @@ -665,10 +670,7 @@ export class OfflineSyncService { command.identity.kind === 'generated' ? offlineGeneratedReplicaIdentity(command.identity.localId, null) : ({ kind: 'natural', naturalKey: command.identity.naturalKey } as const); - return [ - this.#replicaRowKey({ ...command, identity }), - ...(command.optimisticCompanions ?? []).map((companion) => this.#replicaRowKey(companion.key)), - ]; + return [this.#replicaRowKey({ ...command, identity }), ...commandFootprintKeys(command).map((key) => this.#replicaRowKey(key))]; } async #commitMaterializedEnqueues( @@ -680,12 +682,21 @@ export class OfflineSyncService { if (generation !== this.#generation) { throw new Error('Offline session changed before the command could be persisted'); } + const known = await this.#readKnownCommands(); + const remaining = [ + ...known.filter((command) => !(removeCommandIds ?? []).includes(command.commandId)), + ...entries.map((entry) => entry.command), + ].sort(compareOfflineCommands); + const affected = new Map(); + const seeds = new Map(); + for (const entry of entries) { + affected.set(this.#aggregateKey(entry.command), entry.command); + if (entry.seedBaseRow !== undefined) seeds.set(this.#aggregateKey(entry.command), entry.seedBaseRow); + } + const rematerialized = await this.#rematerializeAffectedAggregates(affected, remaining, seeds); await this.#repository.transactReplica({ - 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]))), + putRows: rematerialized.putRows, + removeRows: rematerialized.removeRows, putCommands: entries.map((entry) => entry.command), removeCommandIds, }); @@ -693,87 +704,39 @@ export class OfflineSyncService { if (options.flush !== false && this.#network.connected()) this.#flushInBackground(); } - async #prepareOptimisticCompanions( + #normalizedLocalOnlyFootprint( scope: OfflineScope, - optimisticRow: OfflineReplicaRow, - transaction?: Pick, - ): Promise { - if (!transaction) return []; - const runtimeTransaction = transaction as OfflineReplicaTransaction; - const unsupported = Object.keys(runtimeTransaction).filter((key) => key !== 'putRows' && key !== 'removeRows'); - if (unsupported.length > 0) { - throw new Error(`Prepared offline enqueue cannot mutate ${unsupported.join(', ')}.`); - } - const putRows = transaction.putRows ?? []; - const removeRows = transaction.removeRows ?? []; - const baseKey = this.#replicaRowKey(optimisticRow); - const seen = new Set([baseKey]); - const mutations: { key: OfflineReplicaRowKey; after: OfflineReplicaRow | null }[] = []; - for (const row of putRows) { - this.#assertCompanionScope(scope, row); - const key = this.#replicaRowKey(row); - if (seen.has(key)) throw new Error(`Prepared offline enqueue contains duplicate replica row ${key}.`); - seen.add(key); - mutations.push({ key: this.#minimalReplicaRowKey(row), after: row }); - } - for (const row of removeRows) { - this.#assertCompanionScope(scope, row); - const key = this.#replicaRowKey(row); - if (seen.has(key)) throw new Error(`Prepared offline enqueue contains duplicate replica row ${key}.`); - seen.add(key); - mutations.push({ key: this.#minimalReplicaRowKey(row), after: null }); - } - return Promise.all( - mutations.map(async ({ key, after }) => { - const before = - (await this.#repository.getReplicaRowIncludingPendingDelete?.(scope, key.sourceKey, key.identity)) ?? - (await this.#repository.getReplicaRow(scope, key.sourceKey, key.identity)); - return { key, before, after: this.#optimisticCompanionAfter(after, before) }; - }), - ); + footprint: readonly OfflineReplicaRowKey[] | undefined, + ): readonly OfflineReplicaRowKey[] { + if (!footprint || footprint.length === 0) return []; + const seen = new Set(); + return footprint.map((key) => { + this.#assertLocalOnlyFootprintKey(scope, key); + const canonical = this.#replicaRowKey(key); + if (seen.has(canonical)) throw new Error(`Prepared offline enqueue contains duplicate replica row ${canonical}.`); + seen.add(canonical); + return this.#minimalReplicaRowKey(key); + }); } - #assertCompanionScope(scope: OfflineScope, key: OfflineReplicaRowKey): void { + #assertLocalOnlyFootprintKey(scope: OfflineScope, key: OfflineReplicaRowKey): void { if (key.userId !== scope.userId || key.scopeId !== scope.scopeId) { - throw new Error('Prepared offline enqueue companion rows must use the command scope.'); + throw new Error('Prepared offline enqueue localOnly footprint must use the command scope.'); + } + if (this.#entitySchema(key.sourceKey).identity.kind !== 'localOnly') { + throw new Error(`Prepared offline enqueue footprint may only declare localOnly source "${key.sourceKey}".`); + } + if (key.identity.kind !== 'local') { + throw new Error('Prepared offline enqueue footprint must use local identity.'); } } - #replacementCompanions( - replaced: OfflineCommand, - optimisticCompanions: readonly OfflineOptimisticReplicaCompanion[], - ): OfflineOptimisticReplicaCompanion[] { - const previousByKey = new Map( - (replaced.optimisticCompanions ?? []).map((companion) => [this.#replicaRowKey(companion.key), companion]), - ); - const previousKeys = new Set(previousByKey.keys()); - const replacementKeys = new Set(optimisticCompanions.map((companion) => this.#replicaRowKey(companion.key))); - if (previousKeys.size !== replacementKeys.size || [...previousKeys].some((key) => !replacementKeys.has(key))) { - throw new Error('Offline replacement must preserve the optimistic companion footprint.'); - } - return optimisticCompanions.map((companion) => { - const previousBefore = previousByKey.get(this.#replicaRowKey(companion.key))!.before; - const before = this.#replacementCompanionBefore(companion.before, previousBefore); - return { ...companion, before, after: this.#optimisticCompanionAfter(companion.after, before) }; - }); - } - - #replacementCompanionBefore(current: OfflineReplicaRow | null, historical: OfflineReplicaRow | null): OfflineReplicaRow | null { - const confirmedValues = current ? current.confirmedValues : (historical?.confirmedValues ?? null); - if (confirmedValues === null) return null; - const source = current ?? historical!; - return { - ...source, - values: confirmedValues, - confirmedValues, - syncState: 'confirmed', - visibility: 'present', - }; - } - - #optimisticCompanionAfter(after: OfflineReplicaRow | null, before: OfflineReplicaRow | null): OfflineReplicaRow | null { - if (!after) return null; - return { ...after, confirmedValues: before?.confirmedValues ?? null }; + #assertReplacementFootprint(replaced: OfflineCommand, next: readonly OfflineReplicaRowKey[]): void { + const previous = new Set(commandFootprintKeys(replaced).map((key) => this.#replicaRowKey(key))); + const incoming = new Set(next.map((key) => this.#replicaRowKey(key))); + if (previous.size !== incoming.size || [...previous].some((key) => !incoming.has(key))) { + throw new Error('Offline replacement must preserve the localOnly footprint.'); + } } #minimalReplicaRowKey(key: OfflineReplicaRowKey): OfflineReplicaRowKey { @@ -886,7 +849,9 @@ export class OfflineSyncService { } #assertDiscardable(commands: readonly OfflineCommand[]): void { - const ambiguous = commands.filter((command) => command.state === 'sending' || command.serverCommitUnknown === true); + const ambiguous = commands.filter( + (command) => command.state === 'sending' || command.state === 'awaiting_pull' || command.serverCommitUnknown === true, + ); if (ambiguous.length > 0) { throw new OfflineCommandInFlightError(ambiguous.map((command) => command.commandId)); } @@ -975,12 +940,17 @@ export class OfflineSyncService { const dirtyScopes = new Map(); const sendWorkerFailures: unknown[] = []; while (this.#network.connected() && this.#isCurrent(generation) && fatalPullFailure === null) { - const groups = this.#eligibleAggregateGroups(await this.#readKnownCommands()).filter((group) => { - const head = group[0]; - return head !== undefined && pulledScopeKeys.has(this.#scopeKey(head)); - }); + const known = await this.#readKnownCommands(); + const groups = this.#eligibleAggregateGroups(known).filter((group) => + group.some( + (command) => + (command.state === 'pending' || command.state === 'retry_wait') && + pulledScopeKeys.has(this.#scopeKey({ userId: command.userId, scopeId: command.scopeId })), + ), + ); if (!this.#isCurrent(generation)) return; if (groups.length === 0) break; + const pendingBefore = known.filter((command) => command.state === 'pending' || command.state === 'retry_wait').length; let cursor = 0; const workers = Array.from({ length: Math.min(MAX_PARALLEL_AGGREGATES, groups.length) }, async () => { while (cursor < groups.length) { @@ -994,6 +964,10 @@ export class OfflineSyncService { if (result.status === 'rejected') sendWorkerFailures.push(result.reason); } if (sendWorkerFailures.length > 0) break; + const pendingAfter = (await this.#readKnownCommands()).filter( + (command) => command.state === 'pending' || command.state === 'retry_wait', + ).length; + if (pendingAfter >= pendingBefore) break; } for (const scope of dirtyScopes.values()) { this.#pendingPullScopes.set(this.#scopeKey(scope), scope); @@ -1040,6 +1014,13 @@ export class OfflineSyncService { throw failures[0]; } if (this.#isCurrent(generation)) this.#coldReconciliationRequired = false; + if (this.#isCurrent(generation)) { + for (const command of await this.#readKnownCommands()) { + if (command.state !== 'awaiting_pull') continue; + const scope = { userId: command.userId, scopeId: command.scopeId }; + this.#pendingPullScopes.set(this.#scopeKey(scope), scope); + } + } } #isFatalPullFailure(error: unknown): boolean { @@ -1120,10 +1101,9 @@ export class OfflineSyncService { group.push(command); groups.set(key, group); } - return [...groups.values()].filter((group) => { - const head = group[0]; - return head?.state === 'pending' || (head?.state === 'retry_wait' && (head.retryAt ?? 0) <= now); - }); + return [...groups.values()].filter((group) => + group.some((command) => command.state === 'pending' || (command.state === 'retry_wait' && (command.retryAt ?? 0) <= now)), + ); } async #sendAggregate( @@ -1134,6 +1114,7 @@ export class OfflineSyncService { ): Promise { for (const command of commands) { if (!this.#isCurrent(generation)) return; + if (command.state === 'awaiting_pull') continue; if (command.state === 'retry_wait' && (command.retryAt ?? 0) > Date.now()) break; if (!['pending', 'retry_wait'].includes(command.state)) break; // User-scoped aggregates ignore scopeId in the FIFO key, so later commands may @@ -1184,7 +1165,6 @@ export class OfflineSyncService { throw error; } if (this.#isCurrent(generation)) { - await this.#hooks.onCommandRemoved?.(sending).catch((error) => this.#reportError(error)); const scope = { userId: sending.userId, scopeId: sending.scopeId }; dirtyScopes.set(this.#scopeKey(scope), scope); } @@ -1202,7 +1182,23 @@ export class OfflineSyncService { const row = await this.#getReplicaRowForSync(scope, sourceKey, commandIdentity); if (row?.serverRevision != null && row.serverRevision !== baseRevision) { const rebased = this.#executor.withServerRevision( - { ...scope, ...request, sourceKey, identity: commandIdentity, payload, baseRevision } as OfflineCommand, + { + ...scope, + commandId: '', + aggregateType: request.aggregateType, + sourceKey, + identity: commandIdentity, + operation: request.operation, + payload, + replicaMutation: request.replicaMutation ?? 'upsert', + payloadHash: '', + baseRevision, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 0, + lastErrorCode: null, + }, row.serverRevision, ); baseRevision = row.serverRevision; @@ -1232,9 +1228,6 @@ export class OfflineSyncService { if (result.clearRemoteId === true && result.serverRevision !== undefined) { throw new Error('Offline command cannot return serverRevision and clearRemoteId together.'); } - // An enqueue may have completed while transport was in flight. Re-read the - // aggregate immediately before the atomic acknowledgement transaction so a - // delete ACK cannot remove a row that has already been re-added locally. const latestCommands = (await this.#readKnownCommands()).filter( (candidate) => this.#aggregateKey(candidate) === this.#aggregateKey(command), ); @@ -1253,7 +1246,6 @@ export class OfflineSyncService { : revision === undefined ? following : following.map((item) => this.#executor.withServerRevision(item, revision)); - latestCommands.splice(latestIndex + 1, rebased.length, ...rebased); const current = await this.#rowForCommand(command); if (!this.#isCurrent(generation)) return; if (!current) { @@ -1264,14 +1256,28 @@ export class OfflineSyncService { if (result.clearRemoteId === true && !removesReplica) { throw new Error('Offline command can clear remoteId only for a confirmed replica removal.'); } - const confirmedValues = removesReplica ? null : (result.confirmedValues ?? command.optimisticValue); const schema = this.#entitySchema(current.sourceKey); this.#assertCommandResultIdentity(schema, current, result); const resolvedRemoteId = this.#resolvedRemoteId(current, result); - const row = { + const reconciliationIdentity = + current.identity.kind === 'generated' + ? resolvedRemoteId === null + ? undefined + : { remoteId: resolvedRemoteId } + : current.identity.kind === 'natural' + ? { naturalKey: current.identity.naturalKey } + : undefined; + const awaitingPull: OfflineCommand = { + ...command, + state: 'awaiting_pull', + retryAt: null, + lastErrorCode: null, + serverCommitUnknown: false, + ...(reconciliationIdentity ? { reconciliationIdentity } : {}), + }; + latestCommands.splice(latestIndex, 1 + rebased.length, awaitingPull, ...rebased); + const identityUpdatedBase: OfflineReplicaRow = { ...current, - values: rebased.length > 0 ? current.values : confirmedValues, - confirmedValues, identity: current.identity.kind === 'generated' ? { @@ -1281,22 +1287,13 @@ export class OfflineSyncService { : current.identity, serverRevision: result.clearRemoteId === true ? null : (revision ?? current.serverRevision), fetchedAt: Date.now(), - syncState: rebased.length > 0 ? ('pending' as const) : ('confirmed' as const), - visibility: rebased.at(-1)?.replicaMutation === 'delete' ? ('pending_delete' as const) : ('present' as const), }; - const companionTransaction = await this.#companionTransactionAfterAcknowledgement( - command, - rebased, - result.confirmedCompanions, - ); + const rematerialized = await this.#rematerializeAggregate(awaitingPull, latestCommands, identityUpdatedBase); if (!this.#isCurrent(generation)) return; await this.#repository.transactReplica({ - putRows: [...(removesReplica && rebased.length === 0 ? [] : [row]), ...(companionTransaction.putRows ?? [])], + putRows: rematerialized.putRows, releaseRemoteIds: - result.clearRemoteId === true && - current.identity.kind === 'generated' && - current.identity.remoteId !== null && - !(removesReplica && rebased.length === 0) + result.clearRemoteId === true && current.identity.kind === 'generated' && current.identity.remoteId !== null ? [ { userId: current.userId, @@ -1307,9 +1304,8 @@ export class OfflineSyncService { }, ] : undefined, - removeRows: [...(removesReplica && rebased.length === 0 ? [current] : []), ...(companionTransaction.removeRows ?? [])], - putCommands: rebased, - removeCommandIds: [command.commandId], + removeRows: rematerialized.removeRows, + putCommands: [awaitingPull, ...rebased], putReconciliationScopes: [{ userId: command.userId, scopeId: command.scopeId }], }); const scope = { userId: command.userId, scopeId: command.scopeId }; @@ -1350,179 +1346,73 @@ export class OfflineSyncService { const discardedIds = new Set(discarded.map((command) => command.commandId)); const affected = new Map(); for (const command of discarded) affected.set(this.#aggregateKey(command), command); - const putRows: OfflineReplicaRow[] = []; - const removeRows: OfflineReplicaRowKey[] = []; - const companionRows = new Map(); - const companionRemovals = new Map(); - for (const [key, command] of affected) { - const row = await this.#rowForCommand(command); - const remaining = all.filter((item) => !discardedIds.has(item.commandId) && this.#aggregateKey(item) === key); - if (row) { - if (remaining.length === 0 && row.confirmedValues === null) { - removeRows.push(row); - } else { - putRows.push({ - ...row, - values: remaining.length > 0 ? remaining.at(-1)!.optimisticValue : row.confirmedValues, - syncState: remaining.length > 0 ? 'pending' : 'confirmed', - visibility: remaining.at(-1)?.replicaMutation === 'delete' ? 'pending_delete' : 'present', - }); - } - } - const aggregateCommands = all.filter((item) => this.#aggregateKey(item) === key); - const remainingAggregateCommands = aggregateCommands.filter((item) => !discardedIds.has(item.commandId)); - for (const companion of this.#companionsAfterDiscard(aggregateCommands, remainingAggregateCommands)) { - const companionKey = this.#replicaRowKey(companion.key); - const hasRemaining = remainingAggregateCommands.some((remainingCommand) => - (remainingCommand.optimisticCompanions ?? []).some((candidate) => this.#replicaRowKey(candidate.key) === companionKey), - ); - const current = await this.#getCompanionRow(companion.key); - const after = hasRemaining - ? companion.after && current - ? { ...companion.after, confirmedValues: current.confirmedValues } - : companion.after - : current - ? current.confirmedValues === null - ? null - : { - ...current, - values: current.confirmedValues, - syncState: 'confirmed' as const, - visibility: 'present' as const, - } - : companion.after; - if (after) { - companionRows.set(companionKey, after); - companionRemovals.delete(companionKey); - } else { - companionRows.delete(companionKey); - companionRemovals.set(companionKey, companion.key); - } - } - } + const remaining = all.filter((item) => !discardedIds.has(item.commandId)); + const rematerialized = await this.#rematerializeAffectedAggregates(affected, remaining); await this.#repository.transactReplica({ - putRows: [...putRows, ...companionRows.values()], - removeRows: [...removeRows, ...companionRemovals.values()], + putRows: rematerialized.putRows, + removeRows: rematerialized.removeRows, removeCommandIds: [...discardedIds], }); } - #getCompanionRow(key: OfflineReplicaRowKey): Promise { - const scope = { userId: key.userId, scopeId: key.scopeId }; - return ( - this.#repository.getReplicaRowIncludingPendingDelete?.(scope, key.sourceKey, key.identity) ?? - this.#repository.getReplicaRow(scope, key.sourceKey, key.identity) - ); - } - - async #companionTransactionAfterAcknowledgement( - acknowledged: OfflineCommand, - following: readonly OfflineCommand[], - confirmed: OfflineCommandResult['confirmedCompanions'], + async #rematerializeAffectedAggregates( + affected: ReadonlyMap, + remaining: readonly OfflineCommand[], + seeds: ReadonlyMap = new Map(), ): Promise> { - const latest = new Map(); - for (const command of following) { - for (const companion of command.optimisticCompanions ?? []) { - latest.set(this.#replicaRowKey(companion.key), companion); - } - } - // Exact legacy path: absent confirmedCompanions only reapplies later overlays as stored. - if (confirmed === undefined) { - return { - putRows: [...latest.values()].flatMap((companion) => (companion.after ? [companion.after] : [])), - removeRows: [...latest.values()].flatMap((companion) => (companion.after ? [] : [companion.key])), - }; - } - const footprint = new Set( - (acknowledged.optimisticCompanions ?? []).map((companion) => this.#replicaRowKey(companion.key)), - ); - const confirmedRows = new Map(); - const confirmedRemovals = new Map(); - const acknowledgedCompanions = new Map( - (acknowledged.optimisticCompanions ?? []).map((companion) => [this.#replicaRowKey(companion.key), companion]), - ); - for (const mutation of confirmed) { - const key = this.#replicaRowKey(mutation.key); - this.#assertConfirmedCompanionKey(footprint, key); - if (confirmedRows.has(key) || confirmedRemovals.has(key)) { - throw new Error(`Offline command result contains duplicate companion mutation ${key}.`); - } - const declared = acknowledgedCompanions.get(key)!; - const current = await this.#getCompanionRow(mutation.key); - const row = current ?? declared.before ?? declared.after; - if (!row) { - throw new Error(`Offline command result cannot resolve companion ${key}.`); - } - const latestConfirmed = current ? current.confirmedValues : (declared.before?.confirmedValues ?? null); - const reduced = mutation.reduce(latestConfirmed); - if (reduced === null) { - confirmedRemovals.set(key, mutation.key); - } else { - confirmedRows.set(key, { - ...row, - values: reduced, - confirmedValues: reduced, - syncState: 'confirmed', - visibility: 'present', - }); - } - } - if (confirmedRows.size + confirmedRemovals.size !== footprint.size) { - throw new Error('Offline command result must cover every optimistic companion exactly once.'); - } - const keys = new Set([...confirmedRows.keys(), ...confirmedRemovals.keys(), ...latest.keys()]); const putRows: OfflineReplicaRow[] = []; const removeRows: OfflineReplicaRowKey[] = []; - for (const key of keys) { - const overlay = latest.get(key); - const confirmedRow = confirmedRows.get(key); - if (overlay) { - if (overlay.after) { - if (confirmedRow) { - putRows.push({ ...overlay.after, confirmedValues: confirmedRow.confirmedValues }); - } else if (confirmedRemovals.has(key)) { - putRows.push({ ...overlay.after, confirmedValues: null }); - } else { - putRows.push(overlay.after); - } - } else { - removeRows.push(overlay.key); - } - } else if (confirmedRow) { - putRows.push(confirmedRow); - } else { - removeRows.push(confirmedRemovals.get(key)!); - } + for (const [key, command] of affected) { + const remainingForAggregate = remaining.filter((item) => this.#aggregateKey(item) === key); + const footprintCommands = [...remainingForAggregate, ...[...affected.values()].filter((item) => this.#aggregateKey(item) === key)]; + const rematerialized = await this.#rematerializeAggregate( + command, + remainingForAggregate, + seeds.has(key) ? seeds.get(key) : undefined, + footprintCommands, + ); + putRows.push(...(rematerialized.putRows ?? [])); + removeRows.push(...(rematerialized.removeRows ?? [])); } return { putRows, removeRows }; } - #assertConfirmedCompanionKey(footprint: ReadonlySet, key: string): void { - if (!footprint.has(key)) { - throw new Error(`Offline command result changed undeclared companion ${key}.`); + async #rematerializeAggregate( + command: OfflineCommand, + remaining: readonly OfflineCommand[], + baseRow = undefined as OfflineReplicaRow | null | undefined, + footprintCommands = remaining, + ): Promise> { + const current = baseRow === undefined ? await this.#rowForCommand(command) : baseRow; + const projection = this.#replicaMutations.projectAggregateIntent({ + baseRow: current, + localOnlyRows: await this.#localOnlyRowsForCommands(footprintCommands), + commands: remaining, + trigger: 'local', + }); + if (isOfflineAggregateIntentConflict(projection)) { + throw new Error('Offline aggregate intent projector cannot return conflict outside pull reconciliation.'); } + return offlineAggregateIntentMutations(projection, current); } - #companionsAfterDiscard(all: readonly OfflineCommand[], remaining: readonly OfflineCommand[]): OfflineOptimisticReplicaCompanion[] { - const keys = new Map(); - for (const command of all) { - for (const companion of command.optimisticCompanions ?? []) { - const key = this.#replicaRowKey(companion.key); - const previous = keys.get(key); - keys.set(key, previous ? { ...companion, before: previous.before } : companion); - } - } - for (const command of remaining) { - for (const companion of command.optimisticCompanions ?? []) { - const key = this.#replicaRowKey(companion.key); - const original = keys.get(key); - keys.set(key, { ...companion, before: original?.before ?? companion.before }); + async #localOnlyRowsForCommands(commands: readonly OfflineCommand[]): Promise { + const keys = new Map(); + for (const command of commands) { + for (const key of commandFootprintKeys(command)) { + keys.set(this.#replicaRowKey(key), key); } } - const remainingKeys = new Set( - remaining.flatMap((command) => (command.optimisticCompanions ?? []).map((item) => this.#replicaRowKey(item.key))), + const rows = await Promise.all([...keys.values()].map((key) => this.#getLocalOnlyRow(key))); + return rows.filter((row): row is OfflineReplicaRow => row !== null); + } + + #getLocalOnlyRow(key: OfflineReplicaRowKey): Promise { + const scope = { userId: key.userId, scopeId: key.scopeId }; + return ( + this.#repository.getReplicaRowIncludingPendingDelete?.(scope, key.sourceKey, key.identity) ?? + this.#repository.getReplicaRow(scope, key.sourceKey, key.identity) ); - return [...keys.entries()].map(([key, companion]) => (remainingKeys.has(key) ? companion : { ...companion, after: companion.before })); } #aggregateKey(command: OfflineCommand): string { @@ -1659,11 +1549,10 @@ export class OfflineSyncService { if (schema.identity.kind !== 'generated' && result.remoteId !== undefined) { throw new Error(`Offline command returned generated remote id for source "${schema.sourceKey}" without generated identity.`); } - if (schema.identity.kind === 'naturalKey') { - const confirmedValues = result.confirmedValues ?? current.values; + if (schema.identity.kind === 'naturalKey' && result.confirmedValues !== undefined) { const currentKey = current.identity.kind === 'natural' ? current.identity.naturalKey : offlineNaturalKeyFromValues(schema, current.values)!; - const confirmedKey = offlineNaturalKeyFromValues(schema, confirmedValues)!; + const confirmedKey = offlineNaturalKeyFromValues(schema, result.confirmedValues)!; if ( canonicalOfflineRemoteIdentity(schema, { naturalKey: currentKey }) !== canonicalOfflineRemoteIdentity(schema, { naturalKey: confirmedKey }) diff --git a/projects/kit/offline/src/lib/offline-test-helpers.ts b/projects/kit/offline/src/lib/offline-test-helpers.ts index c48ac1a..d416c0b 100644 --- a/projects/kit/offline/src/lib/offline-test-helpers.ts +++ b/projects/kit/offline/src/lib/offline-test-helpers.ts @@ -1,5 +1,7 @@ +import type { OfflineAggregateIntentProjectInput, OfflineAggregateIntentProjection } from './offline-aggregate-intent-projector'; import type { OfflineCommandIdentity, OfflineReplicaIdentity } from './offline-identity'; import type { OfflineGeneratedRemoteId, OfflineNaturalKey } from './offline-replica-schema'; +import type { OfflineReplicaRow, OfflineReplicaRowKey } from './offline-repository'; /** Generated replica/command identity for tests. */ export function generatedReplicaIdentity(localId: string, remoteId: OfflineGeneratedRemoteId | null = null): OfflineReplicaIdentity { @@ -20,3 +22,187 @@ export function generatedCommandIdentity(localId: string): OfflineCommandIdentit export function naturalCommandIdentity(naturalKey: OfflineNaturalKey): OfflineCommandIdentity { return { kind: 'natural', naturalKey }; } + +function rowKey(row: OfflineReplicaRowKey): string { + const identity = + row.identity.kind === 'local' || row.identity.kind === 'generated' ? row.identity.localId : JSON.stringify(row.identity.naturalKey); + return `${row.userId}:${row.scopeId}:${row.sourceKey}:${identity}`; +} + +function asRecord(value: unknown): Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? { ...(value as Record) } : {}; +} + +function foldPayload(values: Record, payload: unknown): Record { + const patch = asRecord(payload); + const { method: _method, kind, attachment: _attachment, removeAttachmentId: _remove, ...rest } = patch; + if (kind === 'delta' && typeof rest['qty'] === 'number') { + const qty = typeof values['qty'] === 'number' ? values['qty'] : 0; + return { ...values, ...rest, qty: qty + (rest['qty'] as number) }; + } + if (kind === 'stocktake' && typeof rest['qty'] === 'number') { + return { ...values, ...rest }; + } + return { ...values, ...rest }; +} + +/** + * Deterministic test projector: folds remaining payloads onto confirmed values + * and covers every declared localOnly footprint exactly once. + */ +export function rematerializeTestAggregate(input: OfflineAggregateIntentProjectInput): OfflineAggregateIntentProjection { + const footprint = new Map(); + for (const row of input.localOnlyRows) footprint.set(rowKey(row), row); + for (const command of input.commands) { + for (const key of command.localOnlyFootprint ?? []) { + if (!footprint.has(rowKey(key))) footprint.set(rowKey(key), key); + } + } + const currentByKey = new Map(input.localOnlyRows.map((row) => [rowKey(row), row] as const)); + const putLocalOnlyRows: OfflineReplicaRow[] = []; + const removeLocalOnlyRows: OfflineReplicaRowKey[] = []; + + if (input.commands.length === 0) { + for (const ref of footprint.values()) { + const current = 'values' in ref ? ref : currentByKey.get(rowKey(ref)); + if (current && 'confirmedValues' in current && current.confirmedValues != null) { + putLocalOnlyRows.push({ + ...current, + values: current.confirmedValues, + syncState: 'confirmed', + visibility: 'present', + }); + } else { + removeLocalOnlyRows.push(current ?? ref); + } + } + if (!input.baseRow || input.baseRow.confirmedValues == null) { + return { baseRow: null, putLocalOnlyRows, removeLocalOnlyRows }; + } + return { + baseRow: { + ...input.baseRow, + values: input.baseRow.confirmedValues, + syncState: 'confirmed', + visibility: 'present', + }, + putLocalOnlyRows, + removeLocalOnlyRows, + }; + } + + const first = input.commands[0]!; + const last = input.commands.at(-1)!; + let values = asRecord(input.baseRow?.confirmedValues); + const generatedRemoteId = + input.baseRow?.identity.kind === 'generated' ? input.baseRow.identity.remoteId : first.identity.kind === 'generated' ? null : undefined; + if ((input.baseRow == null || input.baseRow.confirmedValues == null) && first.identity.kind === 'generated') { + values = { id: generatedRemoteId ?? 0, ...values }; + } + if ((input.baseRow == null || input.baseRow.confirmedValues == null) && first.identity.kind === 'natural') { + values = { ...first.identity.naturalKey, ...values }; + } + const attachments = new Map(); + for (const row of input.localOnlyRows) { + if (row.identity.kind === 'local' && typeof (row.values as { name?: string }).name === 'string') { + attachments.set(row.identity.localId, (row.values as { name: string }).name); + } + } + for (const command of input.commands) { + values = foldPayload(values, command.payload); + const payload = asRecord(command.payload); + const attachment = payload['attachment']; + if (attachment && typeof attachment === 'object' && !Array.isArray(attachment)) { + const item = attachment as { id?: unknown; name?: unknown }; + if (typeof item.id === 'string' && typeof item.name === 'string') attachments.set(item.id, item.name); + } + if (typeof payload['removeAttachmentId'] === 'string') attachments.delete(payload['removeAttachmentId']); + } + if (generatedRemoteId != null) values = { ...values, id: generatedRemoteId }; + + const baseRow: OfflineReplicaRow = input.baseRow + ? { + ...input.baseRow, + values: { ...asRecord(input.baseRow.values), ...values }, + syncState: 'pending', + visibility: last.replicaMutation === 'delete' ? 'pending_delete' : 'present', + } + : { + userId: first.userId, + scopeId: first.scopeId, + sourceKey: first.sourceKey, + identity: + first.identity.kind === 'generated' + ? { kind: 'generated', localId: first.identity.localId, remoteId: null } + : { kind: 'natural', naturalKey: first.identity.naturalKey }, + values, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + visibility: last.replicaMutation === 'delete' ? 'pending_delete' : 'present', + }; + + for (const ref of footprint.values()) { + const current = 'values' in ref ? ref : currentByKey.get(rowKey(ref)); + if (ref.sourceKey.endsWith('_views') || ref.sourceKey.endsWith('_view')) { + const title = typeof values['title'] === 'string' ? values['title'] : undefined; + const qty = typeof values['qty'] === 'number' ? values['qty'] : undefined; + const viewValues = { + ...(current && 'values' in current ? asRecord(current.values) : {}), + ...(title !== undefined ? { title } : {}), + ...(qty !== undefined ? { qty } : {}), + }; + const source = current && 'values' in current ? current : null; + if (!source && Object.keys(viewValues).length === 0) { + removeLocalOnlyRows.push(ref); + continue; + } + putLocalOnlyRows.push({ + ...(source ?? { + ...ref, + values: viewValues, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending' as const, + }), + values: viewValues, + confirmedValues: source?.confirmedValues ?? null, + syncState: 'pending', + visibility: 'present', + }); + continue; + } + if (ref.identity.kind === 'local') { + const name = attachments.get(ref.identity.localId); + if (name === undefined) { + removeLocalOnlyRows.push(current ?? ref); + continue; + } + const source = current && 'values' in current ? current : null; + putLocalOnlyRows.push({ + ...(source ?? { + ...ref, + values: { name }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending' as const, + }), + values: { name }, + confirmedValues: source?.confirmedValues ?? null, + syncState: 'pending', + visibility: 'present', + }); + continue; + } + if (current && 'confirmedValues' in current && current.confirmedValues != null) { + putLocalOnlyRows.push({ ...current, values: current.confirmedValues, syncState: 'pending', visibility: 'present' }); + } else { + removeLocalOnlyRows.push(current ?? ref); + } + } + + return { baseRow, putLocalOnlyRows, removeLocalOnlyRows }; +} diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts index 4bc6e01..be928c1 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts @@ -267,18 +267,6 @@ describe('SqliteOfflineRepository community sqlite driver', () => { }); }); - it('legacy SQLite outboxの送信中と複数回試行済みの最終失敗をcolumn追加時にcommit不明へbackfillする', async () => { - const repository = createRepository(); - - await repository.initialize(); - - expect(plugin.execute).toHaveBeenCalledWith( - expect.objectContaining({ - statement: expect.stringContaining("OR (attempts >= 2 AND state IN ('blocked_auth', 'conflict', 'rejected'))"), - }), - ); - }); - it('暗号鍵の生成関数をcommunity driverへ渡す', async () => { const createEncryptionKey = vi.fn(async () => 'first-install-secret'); const repository = createRepository(createEncryptionKey); @@ -382,7 +370,6 @@ describe('SqliteOfflineRepository community sqlite driver', () => { identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.update', payload: {}, - optimisticValue: {}, payloadHash: 'hash', baseRevision: null, state: 'pending', @@ -400,7 +387,6 @@ describe('SqliteOfflineRepository community sqlite driver', () => { identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.update', payload: {}, - optimisticValue: {}, payloadHash: 'hash', baseRevision: null, state: 'pending', @@ -439,7 +425,6 @@ describe('SqliteOfflineRepository community sqlite driver', () => { identity: { kind: 'generated', localId: 'delete-uuid' }, operation: 'test_items.delete', payload: { id: 42 }, - optimisticValue: { title: 'confirmed' }, payloadHash: 'hash', baseRevision: 4, replicaMutation: 'delete', @@ -457,28 +442,14 @@ describe('SqliteOfflineRepository community sqlite driver', () => { expect(insert?.values).toContain('delete'); }); - it('persists and restores prepared companion metadata while old rows remain readable', async () => { + it('persists and restores declared localOnly footprint keys', async () => { const repository = createRepository(); await repository.initialize(); const companion = { - key: { - userId: 1, - scopeId: '10', - sourceKey: 'local_projections', - identity: { kind: 'local' as const, localId: 'view-1' }, - }, - before: null, - after: { - userId: 1, - scopeId: '10', - sourceKey: 'local_projections', - identity: { kind: 'local' as const, localId: 'view-1' }, - values: { feedKey: 'optimistic' }, - confirmedValues: null, - serverRevision: null, - fetchedAt: 1, - syncState: 'confirmed' as const, - }, + userId: 1, + scopeId: '10', + sourceKey: 'local_projections', + identity: { kind: 'local' as const, localId: 'view-1' }, }; const command: OfflineCommand = { userId: 1, @@ -489,8 +460,7 @@ describe('SqliteOfflineRepository community sqlite driver', () => { identity: { kind: 'generated', localId: 'prepared-local' }, operation: 'test_items.update', payload: { title: 'Optimistic' }, - optimisticValue: { id: 42, title: 'Optimistic' }, - optimisticCompanions: [companion], + localOnlyFootprint: [companion], payloadHash: 'hash', baseRevision: 1, state: 'pending', @@ -498,13 +468,16 @@ describe('SqliteOfflineRepository community sqlite driver', () => { retryAt: null, createdAt: 1, lastErrorCode: null, + reconciliationIdentity: { remoteId: 42 }, }; await repository.putCommand(command); const insert = plugin.execute.mock.calls .map(([options]) => options as { statement: string; values?: unknown[] }) .find(({ statement }) => statement.startsWith('INSERT INTO offline_sync_commands')); - expect(insert?.statement).toContain('optimistic_companions_json'); + expect(insert?.statement).toContain('local_only_footprint_json'); expect(insert?.values).toContain(JSON.stringify([companion])); + expect(insert?.statement).toContain('reconciliation_identity_json'); + expect(insert?.values).toContain(JSON.stringify(command.reconciliationIdentity)); const sqliteRow = { command_id: command.commandId, @@ -515,8 +488,7 @@ describe('SqliteOfflineRepository community sqlite driver', () => { identity_json: JSON.stringify(command.identity), operation: command.operation, payload_json: JSON.stringify(command.payload), - optimistic_value_json: JSON.stringify(command.optimisticValue), - optimistic_companions_json: JSON.stringify([companion]), + local_only_footprint_json: JSON.stringify([companion]), replica_mutation: 'upsert', payload_hash: command.payloadHash, base_revision_json: JSON.stringify(command.baseRevision), @@ -525,27 +497,22 @@ describe('SqliteOfflineRepository community sqlite driver', () => { retry_at: command.retryAt, created_at: command.createdAt, last_error_code: command.lastErrorCode, + server_commit_unknown: 0, + reconciliation_identity_json: JSON.stringify(command.reconciliationIdentity), }; plugin.query.mockResolvedValueOnce({ rows: [sqliteRow] }); await expect(repository.getCommands({ userId: 1, scopeId: '10' })).resolves.toEqual([ - expect.objectContaining({ optimisticCompanions: [companion] }), + expect.objectContaining({ + localOnlyFootprint: [companion], + reconciliationIdentity: { remoteId: 42 }, + }), ]); - plugin.query.mockResolvedValueOnce({ rows: [{ ...sqliteRow, optimistic_companions_json: null }] }); + plugin.query.mockResolvedValueOnce({ rows: [{ ...sqliteRow, local_only_footprint_json: null }] }); await expect(repository.getCommands({ userId: 1, scopeId: '10' })).resolves.toEqual([ - expect.not.objectContaining({ optimisticCompanions: expect.anything() }), + expect.not.objectContaining({ localOnlyFootprint: expect.anything() }), ]); }); - it('adds the nullable companion column to an existing version-1 native database', async () => { - const repository = createRepository(); - await repository.initialize(); - expect(plugin.execute).toHaveBeenCalledWith( - expect.objectContaining({ - statement: 'ALTER TABLE offline_sync_commands ADD COLUMN optimistic_companions_json TEXT', - }), - ); - }); - it('replicaとoutboxを単一transactionで更新する', async () => { const repository = createRepository(); await repository.initialize(); @@ -1142,7 +1109,6 @@ describe('SqliteOfflineRepository replica rows', () => { identity: { kind: 'generated', localId: '019d-bbbb' }, operation: 'test_items.create', payload: { title: 'Local item' }, - optimisticValue: { id: 0, title: 'Local item' }, payloadHash: 'hash', baseRevision: null, state: 'pending', @@ -1566,7 +1532,6 @@ describe('SqliteOfflineRepository replica rows', () => { identity: { kind: 'generated', localId: '019d-lease-read' }, operation: 'test_items.update', payload: {}, - optimisticValue: {}, payloadHash: 'hash', baseRevision: null, state: 'pending', @@ -2009,7 +1974,6 @@ describe('SqliteOfflineRepository replica rows', () => { identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.update', payload: {}, - optimisticValue: {}, payloadHash: 'hash', baseRevision: null, state: 'pending', @@ -2128,7 +2092,6 @@ describe('SqliteOfflineRepository replica rows', () => { identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.update', payload: {}, - optimisticValue: {}, payloadHash: 'hash', baseRevision: null, state: 'pending', diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index 06e652c..7b97555 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -161,8 +161,7 @@ const SCHEMA = [ identity_json TEXT NOT NULL, operation TEXT NOT NULL, payload_json TEXT NOT NULL, - optimistic_value_json TEXT NOT NULL, - optimistic_companions_json TEXT, + local_only_footprint_json TEXT, replica_mutation TEXT NOT NULL DEFAULT 'upsert', payload_hash TEXT NOT NULL, base_revision_json TEXT, @@ -171,7 +170,8 @@ const SCHEMA = [ retry_at INTEGER, created_at INTEGER NOT NULL, last_error_code TEXT, - server_commit_unknown INTEGER NOT NULL DEFAULT 0 + server_commit_unknown INTEGER NOT NULL DEFAULT 0, + reconciliation_identity_json TEXT )`, `CREATE INDEX IF NOT EXISTS offline_sync_commands_scope_created ON offline_sync_commands (user_id, scope_id, created_at)`, @@ -448,10 +448,7 @@ export class SqliteOfflineRepository implements OfflineRepository { async #open(): Promise { try { if (!this.#sqlite) { - throw new OfflineStorageUnavailableError( - 'storage_unavailable', - 'Native offline storage requires a community SQLite connection', - ); + throw new OfflineStorageUnavailableError('storage_unavailable', 'Native offline storage requires a community SQLite connection'); } const { databaseId } = await this.#sqlite.open({ databaseName: this.#options.databaseName, @@ -459,19 +456,6 @@ export class SqliteOfflineRepository implements OfflineRepository { }); this.#databaseId = databaseId; for (const statement of SCHEMA) await this.#execute(databaseId, statement); - const commandColumns = await this.#queryDatabase(databaseId, 'PRAGMA table_info(offline_sync_commands)'); - if (!commandColumns.some((row) => row['name'] === 'optimistic_companions_json')) { - await this.#execute(databaseId, 'ALTER TABLE offline_sync_commands ADD COLUMN optimistic_companions_json TEXT'); - } - if (!commandColumns.some((row) => row['name'] === 'server_commit_unknown')) { - await this.#execute(databaseId, 'ALTER TABLE offline_sync_commands ADD COLUMN server_commit_unknown INTEGER NOT NULL DEFAULT 0'); - await this.#execute( - databaseId, - `UPDATE offline_sync_commands SET server_commit_unknown = 1 - WHERE state IN ('sending', 'retry_wait') - OR (attempts >= 2 AND state IN ('blocked_auth', 'conflict', 'rejected'))`, - ); - } const metadata = await this.#queryDatabase(databaseId, 'SELECT schema_version FROM offline_metadata WHERE id = 1'); if (metadata.length === 0) { await this.#execute(databaseId, 'INSERT INTO offline_metadata (id, schema_version, last_user_id) VALUES (1, ?, NULL)', [ @@ -545,9 +529,7 @@ export class SqliteOfflineRepository implements OfflineRepository { }); } - #wrapCreateEncryptionKey( - createEncryptionKey: (() => Promise) | undefined, - ): (() => Promise) | undefined { + #wrapCreateEncryptionKey(createEncryptionKey: (() => Promise) | undefined): (() => Promise) | undefined { if (!createEncryptionKey) return undefined; return async () => { try { @@ -833,10 +815,9 @@ export class SqliteOfflineRepository implements OfflineRepository { identity: parseOfflineCommandIdentity(this.#parse(row['identity_json'])), operation: this.#string(row['operation']), payload: this.#parse(row['payload_json']), - optimisticValue: this.#parse(row['optimistic_value_json']), - ...(row['optimistic_companions_json'] === null || row['optimistic_companions_json'] === undefined + ...(row['local_only_footprint_json'] === null || row['local_only_footprint_json'] === undefined ? {} - : { optimisticCompanions: this.#parse(row['optimistic_companions_json']) }), + : { localOnlyFootprint: this.#parse(row['local_only_footprint_json']) }), replicaMutation: this.#string(row['replica_mutation']) as 'upsert' | 'delete', payloadHash: this.#string(row['payload_hash']), baseRevision: this.#parseNullable(row['base_revision_json']), @@ -846,6 +827,9 @@ export class SqliteOfflineRepository implements OfflineRepository { createdAt: this.#number(row['created_at']), lastErrorCode: this.#stringOrNull(row['last_error_code']), serverCommitUnknown: this.#numberOrNull(row['server_commit_unknown']) === 1, + ...(row['reconciliation_identity_json'] === null || row['reconciliation_identity_json'] === undefined + ? {} + : { reconciliationIdentity: this.#parse(row['reconciliation_identity_json']) as OfflineCommand['reconciliationIdentity'] }), }; } @@ -853,20 +837,21 @@ export class SqliteOfflineRepository implements OfflineRepository { return this.#execute( databaseId, `INSERT INTO offline_sync_commands - (command_id, user_id, scope_id, aggregate_type, source_key, identity_json, operation, payload_json, optimistic_value_json, - optimistic_companions_json, replica_mutation, payload_hash, base_revision_json, state, attempts, retry_at, created_at, last_error_code, - server_commit_unknown) + (command_id, user_id, scope_id, aggregate_type, source_key, identity_json, operation, payload_json, + local_only_footprint_json, replica_mutation, payload_hash, base_revision_json, state, attempts, retry_at, created_at, last_error_code, + server_commit_unknown, reconciliation_identity_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(command_id) DO UPDATE SET user_id = excluded.user_id, scope_id = excluded.scope_id, aggregate_type = excluded.aggregate_type, source_key = excluded.source_key, identity_json = excluded.identity_json, operation = excluded.operation, payload_json = excluded.payload_json, - optimistic_value_json = excluded.optimistic_value_json, optimistic_companions_json = excluded.optimistic_companions_json, + local_only_footprint_json = excluded.local_only_footprint_json, replica_mutation = excluded.replica_mutation, payload_hash = excluded.payload_hash, base_revision_json = excluded.base_revision_json, state = excluded.state, attempts = excluded.attempts, retry_at = excluded.retry_at, created_at = excluded.created_at, last_error_code = excluded.last_error_code, - server_commit_unknown = excluded.server_commit_unknown`, + server_commit_unknown = excluded.server_commit_unknown, + reconciliation_identity_json = excluded.reconciliation_identity_json`, [ command.commandId, canonicalOfflinePrincipalId(command.userId), @@ -876,8 +861,7 @@ export class SqliteOfflineRepository implements OfflineRepository { serializeOfflineCommandIdentity(command.identity), command.operation, JSON.stringify(command.payload), - JSON.stringify(command.optimisticValue), - command.optimisticCompanions === undefined ? null : JSON.stringify(command.optimisticCompanions), + command.localOnlyFootprint === undefined ? null : JSON.stringify(command.localOnlyFootprint), command.replicaMutation ?? 'upsert', command.payloadHash, this.#stringifyNullable(command.baseRevision), @@ -887,6 +871,7 @@ export class SqliteOfflineRepository implements OfflineRepository { command.createdAt, command.lastErrorCode, command.serverCommitUnknown === true ? 1 : 0, + command.reconciliationIdentity === undefined ? null : JSON.stringify(command.reconciliationIdentity), ], ); } diff --git a/projects/kit/offline/src/public-api.ts b/projects/kit/offline/src/public-api.ts index f055791..9107fec 100644 --- a/projects/kit/offline/src/public-api.ts +++ b/projects/kit/offline/src/public-api.ts @@ -1,6 +1,7 @@ /** Standard scoped local replica and outbox runtime for offline-capable Ionic applications. */ export * from './lib/offline-replica-schema'; export * from './lib/offline-identity'; +export * from './lib/offline-aggregate-intent-projector'; export * from './lib/offline-replica-puller'; export * from './lib/offline-replica-pull.service'; export * from './lib/offline-replica-mutation-coordinator';