diff --git a/projects/kit/offline/src/lib/offline-repository.spec.ts b/projects/kit/offline/src/lib/offline-repository.spec.ts index ef254dd..4bc296b 100644 --- a/projects/kit/offline/src/lib/offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/offline-repository.spec.ts @@ -1428,6 +1428,42 @@ describe('IonicOfflineRepository', () => { }); }); + describe('pull attentions', () => { + it('put/get/removeとtransactionでuser+scope attentionを永続化する', async () => { + await repository.putPullAttention!({ + userId: 1, + scopeId: '10', + reason: 'schema_upgrade_required', + }); + await repository.transactReplica({ + putPullAttentions: [{ userId: 1, scopeId: '20', reason: 'authorization_required', status: 403 }], + }); + expect(await repository.getPullAttentions!(1)).toEqual([ + { userId: 1, scopeId: '10', reason: 'schema_upgrade_required' }, + { userId: 1, scopeId: '20', reason: 'authorization_required', status: 403 }, + ]); + await repository.removePullAttention!({ userId: 1, scopeId: '10' }); + await repository.transactReplica({ removePullAttentions: [{ userId: 1, scopeId: '20' }] }); + expect(await repository.getPullAttentions!(1)).toEqual([]); + }); + + it('clearScopeとclearUserはpull attentionを隔離削除する', async () => { + await repository.transactReplica({ + putPullAttentions: [ + { userId: 1, scopeId: '10', reason: 'schema_upgrade_required' }, + { userId: 1, scopeId: '11', reason: 'authorization_required', status: 401 }, + { userId: 2, scopeId: '10', reason: 'authorization_required', status: 403 }, + ], + }); + await repository.clearScope({ userId: 1, scopeId: '10' }); + expect(await repository.getPullAttentions!(1)).toEqual([{ userId: 1, scopeId: '11', reason: 'authorization_required', status: 401 }]); + expect(await repository.getPullAttentions!(2)).toEqual([{ userId: 2, scopeId: '10', reason: 'authorization_required', status: 403 }]); + await repository.clearUser(1); + expect(await repository.getPullAttentions!(1)).toEqual([]); + expect(await repository.getPullAttentions!(2)).toEqual([{ userId: 2, scopeId: '10', reason: 'authorization_required', status: 403 }]); + }); + }); + describe('replica remoteId uniqueness', () => { const scope = { userId: 1, scopeId: '10' }; const groupRow = { diff --git a/projects/kit/offline/src/lib/offline-repository.ts b/projects/kit/offline/src/lib/offline-repository.ts index 33a25f8..b7b2699 100644 --- a/projects/kit/offline/src/lib/offline-repository.ts +++ b/projects/kit/offline/src/lib/offline-repository.ts @@ -140,6 +140,22 @@ export interface OfflineReplicaCursor extends OfflineScope { cursor: string; } +/** + * Closed set of durable reasons a scope cannot be pulled until the client recovers + * (app upgrade or re-authorization). Independent of Outbox command state. + */ +export type OfflinePullAttentionReason = 'schema_upgrade_required' | 'authorization_required'; + +/** + * First-class durable attention for a fatal pull failure scoped to user+scope. + * Survives restart even when the Outbox is empty. + */ +export interface OfflinePullAttention extends OfflineScope { + reason: OfflinePullAttentionReason; + /** Optional HTTP status that produced this attention (for example 401, 403, or 409). */ + status?: number; +} + /** Atomic changes applied to the local replica and durable outbox together. */ export interface OfflineReplicaTransaction { putRows?: readonly OfflineReplicaRow[]; @@ -156,6 +172,10 @@ export interface OfflineReplicaTransaction { putReconciliationScopes?: readonly OfflineScope[]; /** Scopes whose authoritative post-acknowledgement pull completed successfully. */ removeReconciliationScopes?: readonly OfflineScope[]; + /** Durable fatal-pull attentions to upsert for user+scope. */ + putPullAttentions?: readonly OfflinePullAttention[]; + /** Scopes whose fatal-pull attentions should be removed after a successful pull. */ + removePullAttentions?: readonly OfflineScope[]; } /** @@ -190,6 +210,7 @@ export interface OfflineRepositoryReader { ): Promise | null>; getReplicaCursor(scope: OfflineScope): Promise; getReconciliationScopes?(userId: OfflinePrincipalId): Promise; + getPullAttentions?(userId: OfflinePrincipalId): Promise; getCommands(scope: OfflineScope): Promise; getCommandsForUser?(userId: OfflinePrincipalId): Promise; } @@ -225,11 +246,17 @@ export interface OfflineRepository { ): Promise | null>; getReplicaCursor(scope: OfflineScope): Promise; getReconciliationScopes?(userId: OfflinePrincipalId): Promise; + /** Durable fatal-pull attentions for the principal, ordered by scope id. */ + getPullAttentions?(userId: OfflinePrincipalId): Promise; getCommands(scope: OfflineScope): Promise; getCommandsForUser?(userId: OfflinePrincipalId): Promise; putCommand(command: OfflineCommand): Promise; replaceCommand(command: OfflineCommand): Promise; removeCommand(commandId: string): Promise; + /** Upserts a durable fatal-pull attention for user+scope. */ + putPullAttention?(attention: OfflinePullAttention): Promise; + /** Removes a durable fatal-pull attention for user+scope when present. */ + removePullAttention?(scope: OfflineScope): Promise; clearUser(userId: OfflinePrincipalId): Promise; clearScope(scope: OfflineScope): Promise; transactReplica(transaction: OfflineReplicaTransaction): Promise; @@ -288,6 +315,7 @@ const OUTBOX_KEY = 'offline:outbox:commands'; const REPLICA_TRANSACTION_KEY = 'offline:replica:transaction'; const REPLICA_SCHEMA_MIGRATION_KEY = 'offline:replica:schema-migration'; const RECONCILIATION_SCOPES_KEY = 'offline:replica:reconciliation-scopes'; +const PULL_ATTENTIONS_KEY = 'offline:replica:pull-attentions'; function compareOfflineCommands(left: OfflineCommand, right: OfflineCommand): number { return left.createdAt - right.createdAt || (left.commandId < right.commandId ? -1 : left.commandId > right.commandId ? 1 : 0); @@ -383,6 +411,10 @@ export class IonicOfflineRepository implements OfflineRepository { return this.#withCommittedRead(() => this.#readReconciliationScopes(userId)); } + async getPullAttentions(userId: OfflinePrincipalId): Promise { + return this.#withCommittedRead(() => this.#readPullAttentions(userId)); + } + async getCommands(scope: OfflineScope): Promise { return this.#withCommittedRead(() => this.#readCommands(scope)); } @@ -466,6 +498,13 @@ export class IonicOfflineRepository implements OfflineRepository { return Object.values(scopes).filter((scope) => scope.userId === userId); } + async #readPullAttentions(userId: OfflinePrincipalId): Promise { + const attentions = await this.#readRecord(PULL_ATTENTIONS_KEY); + return Object.values(attentions) + .filter((attention) => attention.userId === userId) + .sort((left, right) => (left.scopeId < right.scopeId ? -1 : left.scopeId > right.scopeId ? 1 : 0)); + } + async #readCommands(scope: OfflineScope): Promise { const commands = await this.#readRecord(OUTBOX_KEY); return Object.values(commands) @@ -496,6 +535,7 @@ export class IonicOfflineRepository implements OfflineRepository { getReplicaRowByRemoteIdentity: (scope, sourceKey, identity) => this.#readReplicaRowByRemoteIdentity(scope, sourceKey, identity), getReplicaCursor: (scope) => this.#readReplicaCursor(scope), getReconciliationScopes: (userId) => this.#readReconciliationScopes(userId), + getPullAttentions: (userId) => this.#readPullAttentions(userId), getCommands: (scope) => this.#readCommands(scope), getCommandsForUser: (userId) => this.#readCommandsForUser(userId), }; @@ -554,6 +594,22 @@ export class IonicOfflineRepository implements OfflineRepository { }); } + async putPullAttention(attention: OfflinePullAttention): Promise { + await this.initialize(); + await this.#mutateRecord(PULL_ATTENTIONS_KEY, (attentions) => { + attentions[this.#cursorKey(attention)] = attention; + return attentions; + }); + } + + async removePullAttention(scope: OfflineScope): Promise { + await this.initialize(); + await this.#mutateRecord(PULL_ATTENTIONS_KEY, (attentions) => { + delete attentions[this.#cursorKey(scope)]; + return attentions; + }); + } + async clearUser(userId: OfflinePrincipalId): Promise { await this.initialize(); await this.#enqueueWrite(async () => { @@ -567,6 +623,7 @@ export class IonicOfflineRepository implements OfflineRepository { this.#filterRecordNow(OUTBOX_KEY, (value) => value.userId !== userId), this.#filterRecordNow(CURSORS_KEY, (_value, key) => !key.startsWith(`${canonicalOfflinePrincipalId(userId)}:`)), this.#filterRecordNow(RECONCILIATION_SCOPES_KEY, (value) => value.userId !== userId), + this.#filterRecordNow(PULL_ATTENTIONS_KEY, (value) => value.userId !== userId), ]); const metadata = await this.#metadata(); if (metadata.lastUserId === userId) { @@ -594,6 +651,7 @@ export class IonicOfflineRepository implements OfflineRepository { }), this.#filterRecordNow(CURSORS_KEY, (_value, key) => key !== this.#cursorKey(scope)), this.#filterRecordNow(RECONCILIATION_SCOPES_KEY, (value) => !belongsToGroup(value)), + this.#filterRecordNow(PULL_ATTENTIONS_KEY, (value) => !belongsToGroup(value)), ]); }); } @@ -781,11 +839,12 @@ export class IonicOfflineRepository implements OfflineRepository { async #applyReplicaTransaction(transaction: OfflineReplicaTransaction, journal: boolean): Promise { await this.#assertReplicaSchemaLocked(); for (const row of transaction.putRows ?? []) this.#validateReplicaRow(row); - const [rows, commands, cursors, reconciliationScopes] = await Promise.all([ + const [rows, commands, cursors, reconciliationScopes, pullAttentions] = await Promise.all([ this.#readRecord(ROWS_KEY), this.#readRecord(OUTBOX_KEY), this.#readRecord(CURSORS_KEY), this.#readRecord(RECONCILIATION_SCOPES_KEY), + this.#readRecord(PULL_ATTENTIONS_KEY), ]); const identityCheckRows = { ...rows }; const releases = new Map(); @@ -841,11 +900,18 @@ export class IonicOfflineRepository implements OfflineRepository { for (const scope of transaction.removeReconciliationScopes ?? []) { delete reconciliationScopes[this.#cursorKey(scope)]; } + for (const attention of transaction.putPullAttentions ?? []) { + pullAttentions[this.#cursorKey(attention)] = attention; + } + for (const scope of transaction.removePullAttentions ?? []) { + delete pullAttentions[this.#cursorKey(scope)]; + } await Promise.all([ this.#storage.set(ROWS_KEY, rows), this.#storage.set(OUTBOX_KEY, commands), this.#storage.set(CURSORS_KEY, cursors), this.#storage.set(RECONCILIATION_SCOPES_KEY, reconciliationScopes), + this.#storage.set(PULL_ATTENTIONS_KEY, pullAttentions), ]); await this.#writeAffectedRowPartitions(rows, transaction); await this.#storage.remove(REPLICA_TRANSACTION_KEY); 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 f2a5b37..23dfc76 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -26,6 +26,7 @@ import { OFFLINE_REPOSITORY, type OfflineCommand, type OfflineCommandIdentity, + type OfflinePullAttention, type OfflineReplicaAddress, type OfflineReplicaRow, type OfflineRepository, @@ -96,6 +97,7 @@ describe('OfflineSyncService', () => { let commands: OfflineCommand[]; let rows: OfflineReplicaRow[]; let reconciliationScopes: OfflineScope[]; + let pullAttentions: OfflinePullAttention[]; let connected: ReturnType>; let session: { userId: number; scopes: OfflineScope[] } | null; let localSession: { userId: number; scopes: OfflineScope[] } | null | undefined; @@ -115,6 +117,7 @@ describe('OfflineSyncService', () => { commands = []; rows = []; reconciliationScopes = []; + pullAttentions = []; connected = signal(false); session = { userId: 1, scopes: [{ userId: 1, scopeId: '10' }] }; localSession = undefined; @@ -218,6 +221,31 @@ describe('OfflineSyncService', () => { getReconciliationScopes: vi.fn(async (userId: number) => reconciliationScopes.filter((scope) => scope.userId === userId).map((scope) => ({ ...scope })), ), + getPullAttentions: vi.fn(async (userId: number) => + pullAttentions.filter((attention) => attention.userId === userId).map((attention) => structuredClone(attention)), + ), + putPullAttention: vi.fn(async (attention: OfflinePullAttention) => { + pullAttentions = pullAttentions.filter( + (candidate) => candidate.userId !== attention.userId || candidate.scopeId !== attention.scopeId, + ); + pullAttentions.push(structuredClone(attention)); + }), + removePullAttention: vi.fn(async (scope: OfflineScope) => { + pullAttentions = pullAttentions.filter((candidate) => candidate.userId !== scope.userId || candidate.scopeId !== scope.scopeId); + }), + clearUser: vi.fn(async (userId: number) => { + commands = commands.filter((item) => item.userId !== userId); + rows = rows.filter((item) => item.userId !== userId); + reconciliationScopes = reconciliationScopes.filter((scope) => scope.userId !== userId); + pullAttentions = pullAttentions.filter((attention) => attention.userId !== userId); + }), + clearScope: vi.fn(async (scope: OfflineScope) => { + commands = commands.filter((item) => item.userId !== scope.userId || item.scopeId !== scope.scopeId); + reconciliationScopes = reconciliationScopes.filter( + (candidate) => candidate.userId !== scope.userId || candidate.scopeId !== scope.scopeId, + ); + pullAttentions = pullAttentions.filter((candidate) => candidate.userId !== scope.userId || candidate.scopeId !== scope.scopeId); + }), transactReplica: vi.fn(async (transaction) => { for (const row of transaction.putRows ?? []) { rows = rows.filter( @@ -254,6 +282,15 @@ describe('OfflineSyncService', () => { (candidate) => candidate.userId !== scope.userId || candidate.scopeId !== scope.scopeId, ); } + for (const attention of transaction.putPullAttentions ?? []) { + pullAttentions = pullAttentions.filter( + (candidate) => candidate.userId !== attention.userId || candidate.scopeId !== attention.scopeId, + ); + pullAttentions.push(structuredClone(attention)); + } + for (const scope of transaction.removePullAttentions ?? []) { + pullAttentions = pullAttentions.filter((candidate) => candidate.userId !== scope.userId || candidate.scopeId !== scope.scopeId); + } commands.sort((left, right) => left.createdAt - right.createdAt); }), } as unknown as OfflineRepository; @@ -1341,6 +1378,262 @@ describe('OfflineSyncService', () => { await expect(service.flush()).rejects.toBe(schemaError); expect(execute).not.toHaveBeenCalled(); expect(pull.mock.calls.map((call) => call[0]?.scopeId)).toEqual(['10']); + expect(service.syncState()).toBe('attention'); + expect(service.pullAttentions()).toEqual([ + { userId: 1, scopeId: '10', reason: 'schema_upgrade_required' }, + { userId: 1, scopeId: '20', reason: 'schema_upgrade_required' }, + ]); + + connected.set(false); + await service.resetSession(); + await service.refreshLocalSession(); + expect(service.syncState()).toBe('attention'); + expect(execute).not.toHaveBeenCalled(); + + pull.mockResolvedValue(undefined); + connected.set(true); + await expect(service.flush()).resolves.toBeUndefined(); + expect(service.pullAttentions()).toEqual([]); + expect(execute).toHaveBeenCalledOnce(); + }); + + it('empty Outboxのschema fatalはattentionとして可視でrestart後もdurable、pendingは未送信のまま', async () => { + session = { + userId: 1, + scopes: [ + { userId: 1, scopeId: '10' }, + { userId: 1, scopeId: '20' }, + ], + }; + const schemaError = new OfflineReplicaSchemaMismatchError(1, 'abc', 2, 'def'); + pull.mockImplementation(async (scope) => { + if (scope.scopeId === '10') throw schemaError; + }); + + connected.set(true); + await expect(service.flush()).rejects.toBe(schemaError); + expect(commands).toEqual([]); + expect(service.syncState()).toBe('attention'); + expect(service.pullAttentions()).toEqual([ + { userId: 1, scopeId: '10', reason: 'schema_upgrade_required' }, + { userId: 1, scopeId: '20', reason: 'schema_upgrade_required' }, + ]); + expect(pullAttentions).toEqual(service.pullAttentions()); + + TestBed.resetTestingModule(); + // Rebuild with the same durable pullAttentions / empty Outbox to simulate restart. + connected = signal(false); + pull = vi.fn(async () => undefined); + handleError = vi.fn(); + const repository = { + initialize: vi.fn(async () => undefined), + getCommands: vi.fn(async () => []), + getCommandsForUser: vi.fn(async () => []), + putCommand: vi.fn(), + replaceCommand: vi.fn(), + removeCommand: vi.fn(), + getReplicaRow: vi.fn(async () => null), + getReplicaRowIncludingPendingDelete: vi.fn(async () => null), + getReplicaRowByRemoteId: vi.fn(async () => null), + getReplicaRowByRemoteIdentity: vi.fn(async () => null), + getReplicaCursor: vi.fn(async () => null), + getReconciliationScopes: vi.fn(async () => []), + getPullAttentions: vi.fn(async (userId: number) => + pullAttentions.filter((attention) => attention.userId === userId).map((attention) => structuredClone(attention)), + ), + transactReplica: vi.fn(async () => undefined), + } as unknown as OfflineRepository; + TestBed.configureTestingModule({ + providers: [ + OfflineSyncService, + { provide: OFFLINE_REPOSITORY, useValue: repository }, + { provide: OfflineNetworkService, useValue: { connected } }, + { provide: OFFLINE_KIT_OPTIONS, useValue: options }, + { provide: OfflineReplicaPullService, useValue: { pull } }, + { provide: ErrorHandler, useValue: { handleError } }, + { provide: OFFLINE_COMMAND_HOOKS, useValue: { entityType: (command: OfflineCommand) => command.aggregateType } }, + { + provide: OFFLINE_SYNC_CONTEXT, + useValue: { + getLocalSession: vi.fn(async () => session), + getSession: vi.fn(async () => session), + }, + }, + { + provide: OFFLINE_COMMAND_EXECUTOR, + useValue: { + execute, + provesCommandNotCommitted, + withServerRevision: (command: OfflineCommand) => command, + withoutServerRevision: (command: OfflineCommand) => ({ ...command, baseRevision: null }), + }, + }, + { provide: OFFLINE_RETRY_RANDOM, useValue: () => 0.5 }, + ], + }); + service = TestBed.inject(OfflineSyncService); + await service.refreshSession(); + expect(service.syncState()).toBe('attention'); + expect(service.pullAttentions()).toEqual([ + { userId: 1, scopeId: '10', reason: 'schema_upgrade_required' }, + { userId: 1, scopeId: '20', reason: 'schema_upgrade_required' }, + ]); + expect(execute).not.toHaveBeenCalled(); + }); + + it('schema fatal後の互換pullはattentionを消してpendingを送信する', async () => { + session = { + userId: 1, + scopes: [ + { userId: 1, scopeId: '10' }, + { userId: 1, scopeId: '20' }, + ], + }; + const schemaError = new OfflineReplicaSchemaMismatchError(1, 'abc', 2, 'def'); + pull.mockImplementation(async (scope) => { + if (scope.scopeId === '10') throw schemaError; + }); + await service.enqueue( + { + scopeId: '20', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'pending-after-schema' }, + operation: 'documents.create', + payload: { title: 'pending' }, + optimisticValue: { id: 0, title: 'pending' }, + }, + { flush: false }, + ); + connected.set(true); + await expect(service.flush()).rejects.toBe(schemaError); + expect(execute).not.toHaveBeenCalled(); + expect(commands).toHaveLength(1); + expect(commands[0]?.state).toBe('pending'); + expect(service.syncState()).toBe('attention'); + + pull.mockImplementation(async () => undefined); + await service.flush(); + expect(execute).toHaveBeenCalledOnce(); + expect(commands).toEqual([]); + expect(service.pullAttentions()).toEqual([]); + expect(pullAttentions).toEqual([]); + expect(service.syncState()).toBe('idle'); + }); + + it('pre-pull HTTP 403はfailing scopeだけにauthorization attentionを付ける', async () => { + session = { + userId: 1, + scopes: [ + { userId: 1, scopeId: '10' }, + { userId: 1, scopeId: '20' }, + ], + }; + const forbidden = { status: 403, message: 'Forbidden' }; + pull.mockImplementation(async (scope) => { + if (scope.scopeId === '10') throw forbidden; + }); + await service.enqueue( + { + scopeId: '20', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'scope-b-403' }, + operation: 'documents.create', + payload: { title: 'b' }, + optimisticValue: { id: 0, title: 'b' }, + }, + { flush: false }, + ); + connected.set(true); + await expect(service.flush()).rejects.toBe(forbidden); + expect(service.pullAttentions()).toEqual([{ userId: 1, scopeId: '10', reason: 'authorization_required', status: 403 }]); + expect(execute).not.toHaveBeenCalled(); + expect(commands).toHaveLength(1); + expect(commands[0]?.state).toBe('pending'); + }); + + it('pre-pull HTTP 401はprincipal全scopeにauthorization attentionを付ける', async () => { + session = { + userId: 1, + scopes: [ + { userId: 1, scopeId: '10' }, + { userId: 1, scopeId: '20' }, + ], + }; + const unauthorized = { status: 401, message: 'Unauthorized' }; + pull.mockImplementation(async (scope) => { + if (scope.scopeId === '10') throw unauthorized; + }); + connected.set(true); + await expect(service.flush()).rejects.toBe(unauthorized); + expect(service.pullAttentions()).toEqual([ + { userId: 1, scopeId: '10', reason: 'authorization_required', status: 401 }, + { userId: 1, scopeId: '20', reason: 'authorization_required', status: 401 }, + ]); + }); + + it('clearUserはdurable pull attentionを削除する', async () => { + pullAttentions = [ + { userId: 1, scopeId: '10', reason: 'schema_upgrade_required' }, + { userId: 2, scopeId: '10', reason: 'authorization_required', status: 401 }, + ]; + const repository = TestBed.inject(OFFLINE_REPOSITORY); + await repository.clearUser!(1); + expect(pullAttentions).toEqual([{ userId: 2, scopeId: '10', reason: 'authorization_required', status: 401 }]); + }); + + it('遅延した旧世代のfatal pull attentionは新世代のattentionを上書きしない', async () => { + session = { + userId: 1, + scopes: [ + { userId: 1, scopeId: '10' }, + { userId: 1, scopeId: '20' }, + ], + }; + let releaseFatal!: (error: unknown) => void; + let pullStarted!: () => void; + const pullEntered = new Promise((resolve) => { + pullStarted = resolve; + }); + const blockedPull = new Promise((_resolve, reject) => { + releaseFatal = reject; + }); + // Avoid unhandled rejection if the gate is abandoned mid-test. + void blockedPull.catch(() => undefined); + pull.mockImplementation(async (scope) => { + if (scope.userId === 1 && scope.scopeId === '10') { + pullStarted(); + await blockedPull; + } + }); + connected.set(true); + const flushA = service.flush(); + const flushAResult = flushA.then( + () => null, + (error: unknown) => error, + ); + await pullEntered; + + // Stay offline before revoke so the network effect cannot arm a background flush. + connected.set(false); + // Transition generation only after the old flush is blocked inside pull. + service.revokeSession(); + pullAttentions = [{ userId: 2, scopeId: '30', reason: 'authorization_required', status: 403 }]; + session = { + userId: 2, + scopes: [ + { userId: 2, scopeId: '10' }, + { userId: 2, scopeId: '30' }, + ], + }; + pull.mockImplementation(async () => undefined); + await service.refreshSession(); + expect(service.pullAttentions()).toEqual([{ userId: 2, scopeId: '30', reason: 'authorization_required', status: 403 }]); + + const staleFatal = new OfflineReplicaSchemaMismatchError(1, 'abc', 2, 'def'); + releaseFatal(staleFatal); + expect(await flushAResult).toBe(staleFatal); + expect(pullAttentions).toEqual([{ userId: 2, scopeId: '30', reason: 'authorization_required', status: 403 }]); + expect(service.pullAttentions()).toEqual([{ userId: 2, scopeId: '30', reason: 'authorization_required', status: 403 }]); }); it('pre-pull HTTP 409はschema mismatch fatalとして残りscopeを止め成功scopeも送らない', async () => { @@ -2735,6 +3028,7 @@ describe('OfflineSyncService', () => { getReplicaRowByRemoteIdentity: vi.fn(async () => null), getReplicaCursor: vi.fn(async () => null), getReconciliationScopes: vi.fn(async () => []), + getPullAttentions: vi.fn(async () => []), transactReplica: vi.fn(async (transaction) => { for (const command of transaction.putCommands ?? []) { commands = commands.filter((item) => item.commandId !== command.commandId); @@ -4444,6 +4738,7 @@ describe('OfflineSyncService', () => { commands = []; rows = []; reconciliationScopes = []; + pullAttentions = []; connected = signal(false); session = multiScopeSession; beforePutCommand = null; @@ -4506,6 +4801,9 @@ describe('OfflineSyncService', () => { getReconciliationScopes: vi.fn(async (userId: number) => reconciliationScopes.filter((scope) => scope.userId === userId).map((scope) => ({ ...scope })), ), + getPullAttentions: vi.fn(async (userId: number) => + pullAttentions.filter((attention) => attention.userId === userId).map((attention) => structuredClone(attention)), + ), transactReplica: vi.fn(async (transaction) => { for (const row of transaction.putRows ?? []) { const existing = findReplicaRow(row, row.sourceKey, row.identity); @@ -4543,6 +4841,15 @@ describe('OfflineSyncService', () => { (candidate) => candidate.userId !== scope.userId || candidate.scopeId !== scope.scopeId, ); } + for (const attention of transaction.putPullAttentions ?? []) { + pullAttentions = pullAttentions.filter( + (candidate) => candidate.userId !== attention.userId || candidate.scopeId !== attention.scopeId, + ); + pullAttentions.push(structuredClone(attention)); + } + for (const scope of transaction.removePullAttentions ?? []) { + pullAttentions = pullAttentions.filter((candidate) => candidate.userId !== scope.userId || candidate.scopeId !== scope.scopeId); + } commands.sort(compareCommands); }), } as unknown as OfflineRepository; @@ -5034,6 +5341,35 @@ describe('OfflineSyncService', () => { expect(pull).not.toHaveBeenCalledWith({ userId: 1, scopeId: '20' }); }); + it('pulls every durable attention scope during partial recovery and clears recovered attentions', async () => { + pullAttentions = [ + { userId: 1, scopeId: '10', reason: 'authorization_required', status: 401 }, + { userId: 1, scopeId: '20', reason: 'authorization_required', status: 401 }, + ]; + + await service.refreshSession(['10']); + + await vi.waitFor(() => expect(pull.mock.calls.length).toBeGreaterThanOrEqual(2)); + expect(pull).toHaveBeenCalledWith({ userId: 1, scopeId: '10' }); + expect(pull).toHaveBeenCalledWith({ userId: 1, scopeId: '20' }); + expect(pull).not.toHaveBeenCalledWith({ userId: 1, scopeId: '30' }); + await vi.waitFor(() => expect(service.pullAttentions()).toEqual([])); + }); + + it('prunes durable attentions for scopes removed from the active session', async () => { + pullAttentions = [ + { userId: 1, scopeId: '10', reason: 'authorization_required', status: 403 }, + { userId: 1, scopeId: '99', reason: 'authorization_required', status: 403 }, + ]; + + await service.refreshSession(['10']); + + await vi.waitFor(() => + expect(service.pullAttentions()).toEqual([{ userId: 1, scopeId: '10', reason: 'authorization_required', status: 403 }]), + ); + expect(pullAttentions.some((attention) => attention.scopeId === '99')).toBe(false); + }); + it('reconnect automatic flush respects foreground policy', async () => { await service.refreshSession(['10']); await vi.waitFor(() => expect(pull).toHaveBeenCalledWith({ userId: 1, scopeId: '10' })); diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index 57b749e..330842f 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -15,6 +15,8 @@ import { OfflineReplicaPullService, OfflineReplicaSchemaMismatchError } from './ import { OfflineReplicaMutationCoordinator } from './offline-replica-mutation-coordinator'; import type { OfflineCommand, + OfflinePullAttention, + OfflinePullAttentionReason, OfflineReplicaSyncState, OfflineReplicaRow, OfflineReplicaRowKey, @@ -159,6 +161,7 @@ export class OfflineSyncService { readonly #errorHandler = inject(ErrorHandler); readonly #retryRandom = inject(OFFLINE_RETRY_RANDOM); readonly #commands = signal([]); + readonly #pullAttentions = signal([]); readonly #knownScopes = new Map(); /** ACKed scopes whose authoritative post-send pull has not completed yet. */ readonly #pendingPullScopes = new Map(); @@ -179,7 +182,10 @@ export class OfflineSyncService { readonly pendingCommands = this.#commands.asReadonly(); readonly pendingCount = computed(() => this.pendingCommands().length); readonly conflicts = computed(() => this.#commands().filter((command) => command.state === 'conflict')); + /** Durable fatal-pull attentions for the active principal, restored across restart. */ + readonly pullAttentions = this.#pullAttentions.asReadonly(); readonly syncState = computed(() => { + if (this.#pullAttentions().length > 0) return 'attention'; const commands = this.#commands(); if (commands.some((command) => ['blocked_auth', 'rejected', 'conflict'].includes(command.state))) return 'attention'; // Any non-sending ambiguous commit (including restart-normalized pending+unknown) needs attention. @@ -251,6 +257,7 @@ export class OfflineSyncService { this.#knownScopes.clear(); this.#pendingPullScopes.clear(); this.#commands.set([]); + this.#pullAttentions.set([]); this.#scheduleRetry(null); } @@ -957,6 +964,7 @@ export class OfflineSyncService { if (this.#isFatalPullFailure(error)) { // Auth/upgrade-driven recovery only: stop remaining scopes immediately. fatalPullFailure = fatalPullFailure ?? error; + await this.#persistFatalPullAttentions(error, scope, pullScopes, generation); break; } if (this.#pendingPullScopes.has(this.#scopeKey(scope))) { @@ -994,7 +1002,8 @@ export class OfflineSyncService { // Fatal pre-pull skips pending post-pulls; recovery is auth/upgrade-driven, not timer retry. // Fatal post-send pull stops remaining pending scopes the same way (ACK preserved, no resend). if (fatalPullFailure === null) { - for (const scope of this.#pendingPullScopes.values()) { + const postPullScopes = [...this.#pendingPullScopes.values()]; + for (const scope of postPullScopes) { if (!this.#isCurrent(generation) || !this.#network.connected()) break; try { // A command response may contain only the aggregate's base row. Pull @@ -1005,6 +1014,7 @@ export class OfflineSyncService { } catch (error) { if (this.#isFatalPullFailure(error)) { fatalPullFailure = fatalPullFailure ?? error; + await this.#persistFatalPullAttentions(error, scope, postPullScopes, generation); break; } postPullFailures.push(error); @@ -1040,6 +1050,50 @@ export class OfflineSyncService { return error instanceof OfflineReplicaSchemaMismatchError; } + #pullAttentionReason(error: unknown): OfflinePullAttentionReason | null { + if (error instanceof OfflineReplicaSchemaMismatchError) return 'schema_upgrade_required'; + const status = this.#errorStatus(error); + if (status === 409) return 'schema_upgrade_required'; + if (status === 401 || status === 403) return 'authorization_required'; + return null; + } + + /** + * Persists durable pull attentions for a current-generation fatal pull. + * Schema/409 marks all attempted pull scopes; 401 marks every known principal scope; + * 403 marks only the failing scope. Stale generations must not mutate a newer session. + */ + async #persistFatalPullAttentions( + error: unknown, + failingScope: OfflineScope, + attemptedScopes: readonly OfflineScope[], + generation: number, + ): Promise { + if (!this.#isCurrent(generation)) return; + const reason = this.#pullAttentionReason(error); + if (reason === null) return; + const status = this.#errorStatus(error); + // Snapshot synchronously before any await so a concurrent session switch cannot retarget scopes. + const scoped = + status === 403 + ? [failingScope] + : status === 401 + ? [...this.#knownScopes.values()].filter((scope) => scope.userId === failingScope.userId) + : attemptedScopes.filter((scope) => scope.userId === failingScope.userId); + const scopes = scoped.length > 0 ? scoped : [failingScope]; + const attentions: OfflinePullAttention[] = scopes.map((scope) => { + const attention: OfflinePullAttention = { + userId: scope.userId, + scopeId: scope.scopeId, + reason, + }; + if (status > 0) attention.status = status; + return attention; + }); + if (!this.#isCurrent(generation)) return; + await this.#repository.transactReplica({ putPullAttentions: attentions }); + } + #setForegroundScopePolicy(foregroundScopeIds?: readonly string[]): void { this.#foregroundScopePolicy = foregroundScopeIds !== undefined ? foregroundScopeIds : null; } @@ -1047,10 +1101,12 @@ export class OfflineSyncService { #scopesForPartialPull(foregroundScopeIds: readonly string[], commands: readonly OfflineCommand[]): OfflineScope[] { const foregroundScopeSet = new Set(foregroundScopeIds); const outboxScopeKeys = new Set(commands.map((command) => this.#scopeKey({ userId: command.userId, scopeId: command.scopeId }))); + const attentionScopeKeys = new Set(this.#pullAttentions().map((attention) => this.#scopeKey(attention))); return [...this.#knownScopes.values()].filter( (scope) => foregroundScopeSet.has(scope.scopeId) || outboxScopeKeys.has(this.#scopeKey(scope)) || + attentionScopeKeys.has(this.#scopeKey(scope)) || this.#pendingPullScopes.has(this.#scopeKey(scope)), ); } @@ -1551,6 +1607,7 @@ export class OfflineSyncService { this.#knownScopes.clear(); for (const scope of session.scopes) this.#knownScopes.set(this.#scopeKey(scope), scope); await this.#restorePendingPullScopes(session.userId, generation); + await this.#prunePullAttentions(session.userId, generation); return true; } @@ -1560,6 +1617,7 @@ export class OfflineSyncService { if (!session) { this.#activeUserId = null; this.#knownScopes.clear(); + this.#pullAttentions.set([]); return true; } this.#assertSessionPrincipalBoundary(session); @@ -1567,6 +1625,7 @@ export class OfflineSyncService { this.#knownScopes.clear(); for (const scope of session.scopes) this.#knownScopes.set(this.#scopeKey(scope), scope); await this.#restorePendingPullScopes(session.userId, generation); + await this.#prunePullAttentions(session.userId, generation); return true; } @@ -1578,6 +1637,7 @@ export class OfflineSyncService { if (this.#activeUserId === userId) return; this.#knownScopes.clear(); this.#pendingPullScopes.clear(); + this.#pullAttentions.set([]); this.#activeUserId = userId; this.#lastCommandCreatedAt = 0; } @@ -1613,8 +1673,11 @@ export class OfflineSyncService { async #refreshState(generation = this.#generation): Promise { const commands = await this.#readKnownCommands(); + const attentions = + this.#activeUserId !== null && this.#repository.getPullAttentions ? await this.#repository.getPullAttentions(this.#activeUserId) : []; if (!this.#isCurrent(generation)) return; this.#commands.set(commands); + this.#pullAttentions.set(attentions); const nextRetry = commands .filter((command) => command.state === 'retry_wait' && command.retryAt !== null) .reduce((earliest, command) => Math.min(earliest ?? command.retryAt!, command.retryAt!), null); @@ -1714,9 +1777,23 @@ export class OfflineSyncService { } } + async #prunePullAttentions(userId: OfflinePrincipalId, generation: number): Promise { + if (!this.#repository.getPullAttentions) return; + const attentions = await this.#repository.getPullAttentions(userId); + if (!this.#isCurrent(generation) || this.#activeUserId !== userId) return; + const currentKeys = new Set(this.#knownScopes.keys()); + const revoked = attentions.filter((attention) => !currentKeys.has(this.#scopeKey(attention))); + if (revoked.length > 0) { + await this.#repository.transactReplica({ removePullAttentions: revoked }); + } + } + async #markScopeReconciled(scope: OfflineScope, generation: number): Promise { if (!this.#isCurrent(generation)) return; - await this.#repository.transactReplica({ removeReconciliationScopes: [scope] }); + await this.#repository.transactReplica({ + removeReconciliationScopes: [scope], + removePullAttentions: [scope], + }); if (!this.#isCurrent(generation)) return; this.#pendingPullScopes.delete(this.#scopeKey(scope)); } 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 08b99f3..e6ee00f 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts @@ -288,16 +288,82 @@ describe('SqliteOfflineRepository community sqlite driver', () => { const deletes = plugin.execute.mock.calls .map(([options]) => options as { statement: string; values?: unknown[] }) .filter(({ statement }) => statement.startsWith('DELETE FROM')); - expect(deletes).toHaveLength(2); + expect(deletes).toHaveLength(3); expect(deletes.map(({ values }) => values)).toEqual([ [canonicalOfflinePrincipalId(7), '8'], [canonicalOfflinePrincipalId(7), '8'], + [canonicalOfflinePrincipalId(7), '8'], ]); expect(plugin.beginTransaction).toHaveBeenCalledOnce(); expect(plugin.commitTransaction).toHaveBeenCalledOnce(); expect(plugin.rollbackTransaction).not.toHaveBeenCalled(); }); + it('pull attentionをput/getしtransactionでupsertする', async () => { + const repository = createRepository(); + await repository.initialize(); + expect(plugin.execute).toHaveBeenCalledWith( + expect.objectContaining({ + statement: expect.stringContaining('CREATE TABLE IF NOT EXISTS offline_pull_attentions'), + }), + ); + await repository.putPullAttention!({ + userId: 1, + scopeId: '10', + reason: 'schema_upgrade_required', + }); + expect(plugin.execute).toHaveBeenCalledWith( + expect.objectContaining({ + statement: expect.stringContaining('INSERT INTO offline_pull_attentions'), + values: [canonicalOfflinePrincipalId(1), '10', 'schema_upgrade_required', null], + }), + ); + plugin.query.mockImplementation(async ({ statement }: { statement: string }) => { + if (statement.includes('offline_pull_attentions')) { + return { + columns: ['scope_id', 'reason', 'status'], + rows: [['10', 'schema_upgrade_required', null]], + }; + } + if (statement.includes('offline_replica_schema_metadata')) { + return { + columns: ['version', 'schema_hash'], + rows: [[storedReplicaMetadata!.version, storedReplicaMetadata!.schemaHash]], + }; + } + if (statement.startsWith('PRAGMA table_info')) return { rows: [{ name: 'next_local_id' }] }; + return { rows: [] }; + }); + await expect(repository.getPullAttentions!(1)).resolves.toEqual([{ userId: 1, scopeId: '10', reason: 'schema_upgrade_required' }]); + await expect(repository.runReadSnapshot((reader) => reader.getPullAttentions!(1))).resolves.toEqual([ + { userId: 1, scopeId: '10', reason: 'schema_upgrade_required' }, + ]); + await repository.transactReplica({ + putPullAttentions: [{ userId: 1, scopeId: '10', reason: 'authorization_required', status: 401 }], + removePullAttentions: [{ userId: 1, scopeId: '20' }], + }); + expect(plugin.execute).toHaveBeenCalledWith( + expect.objectContaining({ + statement: expect.stringContaining('INSERT INTO offline_pull_attentions'), + values: [canonicalOfflinePrincipalId(1), '10', 'authorization_required', 401], + }), + ); + expect(plugin.execute).toHaveBeenCalledWith( + expect.objectContaining({ + statement: 'DELETE FROM offline_pull_attentions WHERE user_id = ? AND scope_id = ?', + values: [canonicalOfflinePrincipalId(1), '20'], + }), + ); + plugin.execute.mockClear(); + await repository.clearUser(1); + expect(plugin.execute).toHaveBeenCalledWith( + expect.objectContaining({ + statement: 'DELETE FROM offline_pull_attentions WHERE user_id = ?', + values: [canonicalOfflinePrincipalId(1)], + }), + ); + }); + it('getCommandsはcreated_atとcommand_id昇順でSQL ORDER BYする', async () => { const repository = createRepository(); await repository.initialize(); diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index fb2bf25..d573989 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -32,6 +32,8 @@ import { type OfflineCommand, type OfflineCommandIdentity, type OfflinePrincipalId, + type OfflinePullAttention, + type OfflinePullAttentionReason, type OfflineReplicaAddress, type OfflineReplicaCursor, type OfflineReplicaIdentity, @@ -187,6 +189,13 @@ const SCHEMA = [ user_id TEXT NOT NULL, scope_id TEXT NOT NULL, PRIMARY KEY (user_id, scope_id) +)`, + `CREATE TABLE IF NOT EXISTS offline_pull_attentions ( + user_id TEXT NOT NULL, + scope_id TEXT NOT NULL, + reason TEXT NOT NULL, + status INTEGER, + PRIMARY KEY (user_id, scope_id) )`, ]; @@ -282,6 +291,10 @@ export class SqliteOfflineRepository implements OfflineRepository { return this.#withCommittedRead(() => this.#readReconciliationScopes(userId)); } + async getPullAttentions(userId: OfflinePrincipalId): Promise { + return this.#withCommittedRead(() => this.#readPullAttentions(userId)); + } + async getCommands(scope: OfflineScope): Promise { return this.#withCommittedRead(() => this.#readCommands(scope)); } @@ -306,6 +319,26 @@ export class SqliteOfflineRepository implements OfflineRepository { await this.#write('DELETE FROM offline_sync_commands WHERE command_id = ?', [commandId]); } + async putPullAttention(attention: OfflinePullAttention): Promise { + await this.#write( + `INSERT INTO offline_pull_attentions (user_id, scope_id, reason, status) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, scope_id) DO UPDATE SET reason = excluded.reason, status = excluded.status`, + [ + canonicalOfflinePrincipalId(attention.userId), + attention.scopeId, + attention.reason, + attention.status === undefined ? null : attention.status, + ], + ); + } + + async removePullAttention(scope: OfflineScope): Promise { + await this.#write('DELETE FROM offline_pull_attentions WHERE user_id = ? AND scope_id = ?', [ + canonicalOfflinePrincipalId(scope.userId), + scope.scopeId, + ]); + } + async clearUser(userId: OfflinePrincipalId): Promise { await this.#transaction(async (database) => { const principal = canonicalOfflinePrincipalId(userId); @@ -313,6 +346,7 @@ export class SqliteOfflineRepository implements OfflineRepository { await this.#execute(database, 'DELETE FROM offline_sync_commands WHERE user_id = ?', [principal]); await this.#execute(database, 'DELETE FROM offline_replica_cursors WHERE user_id = ?', [principal]); await this.#execute(database, 'DELETE FROM offline_reconciliation_scopes WHERE user_id = ?', [principal]); + await this.#execute(database, 'DELETE FROM offline_pull_attentions WHERE user_id = ?', [principal]); for (const entity of this.#options.replicaSchema.entities) { await this.#execute(database, `DELETE FROM ${entity.tableName} WHERE _offline_user_id = ?`, [principal]); } @@ -336,6 +370,7 @@ export class SqliteOfflineRepository implements OfflineRepository { } await this.#execute(database, 'DELETE FROM offline_replica_cursors WHERE user_id = ? AND scope_id = ?', values); await this.#execute(database, 'DELETE FROM offline_reconciliation_scopes WHERE user_id = ? AND scope_id = ?', values); + await this.#execute(database, 'DELETE FROM offline_pull_attentions WHERE user_id = ? AND scope_id = ?', values); for (const entity of this.#options.replicaSchema.entities) { if (entity.scope !== 'partition') continue; await this.#execute(database, `DELETE FROM ${entity.tableName} WHERE _offline_user_id = ? AND _offline_scope_id = ?`, values); @@ -387,6 +422,25 @@ export class SqliteOfflineRepository implements OfflineRepository { scope.scopeId, ]); } + for (const attention of transaction.putPullAttentions ?? []) { + await this.#execute( + databaseId, + `INSERT INTO offline_pull_attentions (user_id, scope_id, reason, status) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, scope_id) DO UPDATE SET reason = excluded.reason, status = excluded.status`, + [ + canonicalOfflinePrincipalId(attention.userId), + attention.scopeId, + attention.reason, + attention.status === undefined ? null : attention.status, + ], + ); + } + for (const scope of transaction.removePullAttentions ?? []) { + await this.#execute(databaseId, 'DELETE FROM offline_pull_attentions WHERE user_id = ? AND scope_id = ?', [ + canonicalOfflinePrincipalId(scope.userId), + scope.scopeId, + ]); + } }); } @@ -593,6 +647,22 @@ export class SqliteOfflineRepository implements OfflineRepository { return rows.map((row) => ({ userId, scopeId: this.#string(row['scope_id']) })); } + async #readPullAttentions(userId: OfflinePrincipalId): Promise { + const rows = await this.#query('SELECT scope_id, reason, status FROM offline_pull_attentions WHERE user_id = ? ORDER BY scope_id', [ + canonicalOfflinePrincipalId(userId), + ]); + return rows.map((row) => { + const status = row['status']; + const attention: OfflinePullAttention = { + userId, + scopeId: this.#string(row['scope_id']), + reason: this.#string(row['reason']) as OfflinePullAttentionReason, + }; + if (status !== null && status !== undefined) attention.status = this.#number(status); + return attention; + }); + } + async #readCommands(scope: OfflineScope): Promise { const rows = await this.#query( 'SELECT * FROM offline_sync_commands WHERE user_id = ? AND scope_id = ? ORDER BY created_at ASC, command_id ASC', @@ -632,6 +702,7 @@ export class SqliteOfflineRepository implements OfflineRepository { getReplicaRowByRemoteIdentity: (scope, sourceKey, identity) => this.#readReplicaRowByRemoteIdentity(scope, sourceKey, identity), getReplicaCursor: (scope) => this.#readReplicaCursor(scope), getReconciliationScopes: (userId) => this.#readReconciliationScopes(userId), + getPullAttentions: (userId) => this.#readPullAttentions(userId), getCommands: (scope) => this.#readCommands(scope), getCommandsForUser: (userId) => this.#readCommandsForUser(userId), };