From 3d69634bf0510060dcd81a380b151a4e015eddaa Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 25 Jul 2026 21:05:46 +0900 Subject: [PATCH] feat(offline): compose runtime capabilities --- projects/kit/README.md | 39 +++- .../offline/src/lib/offline-capabilities.ts | 72 ++++++++ .../offline/src/lib/offline-provider.spec.ts | 172 ++++++++++++++++++ .../kit/offline/src/lib/offline-provider.ts | 99 ++++++++-- .../lib/offline-replica-pull.service.spec.ts | 12 ++ .../src/lib/offline-replica-pull.service.ts | 16 ++ .../lib/offline-replica-query.service.spec.ts | 94 ++++++++++ .../src/lib/offline-replica-query.service.ts | 42 +++++ .../kit/offline/src/lib/offline-repository.ts | 6 + .../src/lib/offline-sync.service.spec.ts | 103 +++++++++-- .../offline/src/lib/offline-sync.service.ts | 56 ++++-- .../src/lib/sqlite-offline-repository.spec.ts | 11 +- .../src/lib/sqlite-offline-repository.ts | 21 ++- projects/kit/offline/src/public-api.ts | 18 +- 14 files changed, 699 insertions(+), 62 deletions(-) create mode 100644 projects/kit/offline/src/lib/offline-capabilities.ts create mode 100644 projects/kit/offline/src/lib/offline-provider.spec.ts create mode 100644 projects/kit/offline/src/lib/offline-replica-query.service.spec.ts create mode 100644 projects/kit/offline/src/lib/offline-replica-query.service.ts diff --git a/projects/kit/README.md b/projects/kit/README.md index 315ffb5..44734aa 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -391,12 +391,31 @@ A fleet-canonical HTTP interceptor with: The optional `offline` entry point provides a user/group-scoped local replica, durable outbox, authenticated session boundary, cursor-based delta pull, aggregate-ordered replay, optimistic updates, retry classification, and a -read-only request-policy interceptor. Applications provide URL/DTO read policies, a replica puller, and a command -executor through `provideOffline(...)`. +read-only request-policy interceptor. Applications install only the capabilities they use through +`provideOffline(...)`; read-only products do not need dummy pullers or command executors. Mutations are queued explicitly with `OfflineSyncService.enqueue`, not through HTTP interceptor policy. Web storage uses Ionic Storage; iOS and Android use encrypted `@capacitor-community/sqlite`. Importing either the primary entry point or `/offline` does not pull the optional native SQLite plugin into web-only applications. +Use capability-based setup for new integrations. Pull, outbox, and HTTP read fallback are independent: + +```ts +provideOffline({ + databaseName: 'product-offline', + replicaSchema, + capabilities: [ + withOfflineReplicaPull(ProductReplicaPuller), + withOfflineOutbox({ executor: ProductCommandExecutor, hooks: ProductCommandHooks }), + withOfflineReadFallback(ProductRequestPolicy), + ], +}); +``` + +A read-only cache installs only `withOfflineReadFallback(...)`. Calling `enqueue()` without +`withOfflineOutbox(...)` fails immediately with `OfflineCapabilityError`; synchronization never invokes a missing +pull or outbox transport. The original `commandExecutor` / `replicaPuller` / `requestPolicies` configuration remains +supported with identical behavior for already shipped integrations. + For cold-start offline route access, `OfflineCoordinatorService.activateOfflineSession()` restores only a manifest that is bound to a non-null authentication-provider subject. Supplying a currently known subject also rejects a different user on a shared device. It activates local replica writes and durable outbox enqueue, but remote pull and @@ -536,11 +555,18 @@ await offlineSync.enqueue({ aggregateLocalId: localId, serverId: existingApiItem.id, operation: 'items.delete', + effect: 'delete', payload: { method: 'DELETE' }, optimisticValue: existingApiItem, }); ``` +`effect: 'delete'` is the durable optimistic tombstone marker; it must not be inferred from the operation name. +Use `OfflineReplicaQueryService.getVisibleRows(...)` for product projections so pending deletes are consistently +hidden. Rejected or conflicted deletes remain visible for resolution. Commands written by older application +versions have no `effect` and are read as `upsert`. After a successful delete, the kit removes the replica row even +when the executor omits the legacy `removeReplica` result flag. + The mapping is immutable and unique inside its effective replica scope. Reassigning one `localId` to another `serverId`, or assigning the same `serverId` to another `localId`, rejects before persistence. Web storage enforces the same rule transactionally as SQLite's unique indexes: group-scoped entities are unique per user/group/source, @@ -553,6 +579,9 @@ replica schema version/hash and advances a durable user/group cursor in the same mismatch, malformed row, or non-advancing cursor rejects synchronization without advancing that cursor. If a remote revision changed while a local command is pending, the optimistic row remains visible and both row and command move to `conflict`; the new server value is retained as the confirmed baseline. +For every non-deleted change, the value mapped with `serverId()` must equal the change metadata's `serverId`; the kit +validates this before persisting rows or advancing the cursor. Product pullers only convert REST values into the +declared DB/select shape and must not duplicate this identity check. The command adapter must send `commandId` as the server-side idempotency key. The server persists that key with the mutation and returns all keys represented by a delta row as `acknowledgedCommandIds`. This correlation is required: @@ -603,10 +632,10 @@ const replicaSchema = defineOfflineReplicaSchema({ }); provideOffline({ + databaseName: 'product-offline', replicaSchema, - replicaPuller: ProductReplicaPuller, - commandExecutor: ProductCommandExecutor, - // ...request policies, databaseName, createEncryptionKey + capabilities: [withOfflineReplicaPull(ProductReplicaPuller), withOfflineOutbox({ executor: ProductCommandExecutor })], + // ...createEncryptionKey }); ``` diff --git a/projects/kit/offline/src/lib/offline-capabilities.ts b/projects/kit/offline/src/lib/offline-capabilities.ts new file mode 100644 index 0000000..a61aa5b --- /dev/null +++ b/projects/kit/offline/src/lib/offline-capabilities.ts @@ -0,0 +1,72 @@ +import type { Provider, Type } from '@angular/core'; +import { InjectionToken } from '@angular/core'; +import type { OfflineCommandExecutor } from './offline-command-executor'; +import { OFFLINE_COMMAND_EXECUTOR } from './offline-command-executor'; +import type { OfflineCommandHooks } from './offline-command-hooks'; +import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; +import type { OfflineRequestPolicy } from './offline-request-policy'; +import { provideOfflineRequestPolicy } from './offline-request-policy'; +import type { OfflineReplicaPuller } from './offline-replica-puller'; +import { OFFLINE_REPLICA_PULLER } from './offline-replica-puller'; + +/** Enabled offline runtime features resolved from `provideOffline` configuration. */ +export interface OfflineRuntimeCapabilities { + /** Explicit server delta pull transport is configured. */ + replicaPull: boolean; + /** Durable outbox enqueue and replay transport is configured. */ + outbox: boolean; +} + +/** DI token for runtime offline capability flags. */ +export const OFFLINE_RUNTIME_CAPABILITIES = new InjectionToken('OFFLINE_RUNTIME_CAPABILITIES', { + providedIn: 'root', + factory: () => ({ + replicaPull: true, + outbox: true, + }), +}); + +/** Tagged offline capability kinds registered through `provideOffline`. */ +export type OfflineCapabilityKind = 'replicaPull' | 'outbox' | 'readFallback'; + +/** One tagged offline capability and its DI providers. */ +export interface OfflineCapability { + readonly kind: TKind; + readonly providers: readonly Provider[]; +} + +/** Registers explicit replica pull transport. */ +export function withOfflineReplicaPull(puller: Type): OfflineCapability<'replicaPull'> { + return { + kind: 'replicaPull', + providers: [puller, { provide: OFFLINE_REPLICA_PULLER, useExisting: puller }], + }; +} + +/** Registers durable outbox transport and optional projection hooks. */ +export function withOfflineOutbox(options: { + executor: Type; + hooks?: Type; +}): OfflineCapability<'outbox'> { + const providers: Provider[] = [options.executor, { provide: OFFLINE_COMMAND_EXECUTOR, useExisting: options.executor }]; + if (options.hooks) { + providers.push(options.hooks, { provide: OFFLINE_COMMAND_HOOKS, useExisting: options.hooks }); + } + return { kind: 'outbox', providers }; +} + +/** Registers one or more HTTP read policies for the offline interceptor. */ +export function withOfflineReadFallback(...policies: readonly Type[]): OfflineCapability<'readFallback'> { + return { + kind: 'readFallback', + providers: policies.flatMap((policy) => provideOfflineRequestPolicy(policy)), + }; +} + +/** Raised when an operation requires a capability that was not configured. */ +export class OfflineCapabilityError extends Error { + constructor(message: string) { + super(message); + this.name = 'OfflineCapabilityError'; + } +} diff --git a/projects/kit/offline/src/lib/offline-provider.spec.ts b/projects/kit/offline/src/lib/offline-provider.spec.ts new file mode 100644 index 0000000..dc38695 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-provider.spec.ts @@ -0,0 +1,172 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + OFFLINE_COMMAND_EXECUTOR, + type OfflineCommandExecutor, + type OfflineCommandResult, + type OfflineCommandTarget, +} from './offline-command-executor'; +import { + OfflineCapabilityError, + OFFLINE_RUNTIME_CAPABILITIES, + withOfflineOutbox, + withOfflineReadFallback, + withOfflineReplicaPull, +} from './offline-capabilities'; +import type { OfflineCommand } from './offline-repository'; +import type { OfflineRequestPolicy } from './offline-request-policy'; +import type { OfflineReplicaPuller } from './offline-replica-puller'; +import { defineOfflineReplicaSchema, defineReplicaEntity, serverId, text } from './offline-replica-schema'; +import { OFFLINE_REPLICA_PULLER } from './offline-replica-puller'; +import { resolveOfflineSetup } from './offline-provider'; + +const replicaSchema = defineOfflineReplicaSchema({ + version: 1, + entities: [ + defineReplicaEntity<{ id: number; title: string }>()({ + table: 'documents', + sourceKey: 'documents', + scope: 'group', + fields: { + id: serverId(), + title: text(), + }, + }), + ], + migrations: [], +}); + +@Injectable() +class TestRequestPolicy implements OfflineRequestPolicy { + resolve() { + return null; + } +} + +@Injectable() +class TestReplicaPuller implements OfflineReplicaPuller { + pull = async () => ({ + schemaVersion: 1, + schemaHash: 'test', + changes: [], + nextCursor: '', + hasMore: false, + }); +} + +@Injectable() +class TestCommandExecutor implements OfflineCommandExecutor { + execute(_command: OfflineCommand, _target: OfflineCommandTarget): Promise { + return Promise.resolve({ response: null }); + } + + withServerRevision(command: OfflineCommand, _revision: string | number): OfflineCommand { + return command; + } +} + +describe('resolveOfflineSetup', () => { + afterEach(() => TestBed.resetTestingModule()); + + it('capabilitiesだけでrequest policy runtimeを構成できる', () => { + const setup = resolveOfflineSetup({ + databaseName: 'capability-test', + replicaSchema, + capabilities: [withOfflineReadFallback(TestRequestPolicy)], + }); + expect(setup.runtime).toEqual({ replicaPull: false, outbox: false }); + }); + + it('legacy optionsは既存の全capabilityを有効にする', () => { + const setup = resolveOfflineSetup({ + databaseName: 'legacy-test', + replicaSchema, + commandExecutor: TestCommandExecutor, + replicaPuller: TestReplicaPuller, + requestPolicies: [TestRequestPolicy], + }); + expect(setup.runtime).toEqual({ replicaPull: true, outbox: true }); + }); + + it('同じ単一transport capabilityの重複登録を拒否する', () => { + expect(() => + resolveOfflineSetup({ + databaseName: 'duplicate-test', + replicaSchema, + capabilities: [withOfflineReplicaPull(TestReplicaPuller), withOfflineReplicaPull(TestReplicaPuller)], + }), + ).toThrow('provideOffline accepts at most one replicaPull capability.'); + }); + + it('outbox capabilityが無い場合は内部disabled executorを提供する', async () => { + const setup = resolveOfflineSetup({ + databaseName: 'capability-test', + replicaSchema, + capabilities: [withOfflineReadFallback(TestRequestPolicy)], + }); + TestBed.configureTestingModule({ providers: [...setup.adapterProviders] }); + await expect(TestBed.inject(OFFLINE_COMMAND_EXECUTOR).execute({} as OfflineCommand, { localId: 'x', serverId: null })).rejects.toThrow( + OfflineCapabilityError, + ); + }); + + it('replica pull capabilityが無い場合は内部disabled pullerを提供する', async () => { + const setup = resolveOfflineSetup({ + databaseName: 'capability-test', + replicaSchema, + capabilities: [withOfflineReadFallback(TestRequestPolicy)], + }); + TestBed.configureTestingModule({ providers: [...setup.adapterProviders] }); + await expect( + TestBed.inject(OFFLINE_REPLICA_PULLER).pull({ + scope: { userId: 1, groupId: 1 }, + cursor: '', + schemaVersion: 1, + schemaHash: 'test', + }), + ).rejects.toThrow(OfflineCapabilityError); + }); + + it('legacy optionsはproduct adapterをそのまま登録する', () => { + const setup = resolveOfflineSetup({ + databaseName: 'legacy-test', + replicaSchema, + commandExecutor: TestCommandExecutor, + replicaPuller: TestReplicaPuller, + requestPolicies: [TestRequestPolicy], + }); + TestBed.configureTestingModule({ + providers: [ + TestCommandExecutor, + TestReplicaPuller, + { provide: OFFLINE_RUNTIME_CAPABILITIES, useValue: setup.runtime }, + ...setup.adapterProviders, + ], + }); + expect(TestBed.inject(OFFLINE_RUNTIME_CAPABILITIES)).toEqual({ + replicaPull: true, + outbox: true, + }); + expect(TestBed.inject(OFFLINE_COMMAND_EXECUTOR)).toBeInstanceOf(TestCommandExecutor); + expect(TestBed.inject(OFFLINE_REPLICA_PULLER)).toBeInstanceOf(TestReplicaPuller); + }); +}); + +describe('offline capability builders', () => { + afterEach(() => TestBed.resetTestingModule()); + + it('outbox capabilityはexecutor providerを返す', () => { + const capability = withOfflineOutbox({ executor: TestCommandExecutor }); + expect(capability.kind).toBe('outbox'); + TestBed.configureTestingModule({ providers: [TestCommandExecutor, ...capability.providers] }); + expect(TestBed.inject(OFFLINE_COMMAND_EXECUTOR)).toBeInstanceOf(TestCommandExecutor); + }); + + it('replica pull capabilityはpuller providerを返す', () => { + const capability = withOfflineReplicaPull(TestReplicaPuller); + expect(capability.kind).toBe('replicaPull'); + TestBed.configureTestingModule({ providers: [TestReplicaPuller, ...capability.providers] }); + expect(TestBed.inject(OFFLINE_REPLICA_PULLER)).toBeInstanceOf(TestReplicaPuller); + }); +}); diff --git a/projects/kit/offline/src/lib/offline-provider.ts b/projects/kit/offline/src/lib/offline-provider.ts index 22f8375..cc92194 100644 --- a/projects/kit/offline/src/lib/offline-provider.ts +++ b/projects/kit/offline/src/lib/offline-provider.ts @@ -5,6 +5,8 @@ import type { OfflineCommandExecutor } from './offline-command-executor'; import { OFFLINE_COMMAND_EXECUTOR, OFFLINE_SYNC_CONTEXT } from './offline-command-executor'; import type { OfflineCommandHooks } from './offline-command-hooks'; import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; +import type { OfflineCapability, OfflineRuntimeCapabilities } from './offline-capabilities'; +import { OfflineCapabilityError, OFFLINE_RUNTIME_CAPABILITIES } from './offline-capabilities'; import type { OfflineKitOptions } from './offline-kit-options'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { OfflineCoordinatorService } from './offline-coordinator.service'; @@ -21,8 +23,16 @@ import { SqliteOfflineRepository, } from './sqlite-offline-repository'; -/** Configuration for the standard offline repository, outbox, and request-policy runtime. */ -export interface ProvideOfflineOptions extends OfflineKitOptions { +/** Shared offline runtime settings for legacy and capability-based setup. */ +export interface ProvideOfflineBaseOptions extends OfflineKitOptions { + /** Optional additional providers required by product adapters. */ + providers?: readonly Provider[]; + /** Application-installed `@capacitor-community/sqlite` connection. Required only on iOS and Android. */ + sqliteConnection?: CommunitySqliteConnection; +} + +/** Existing full-runtime configuration with explicit adapter types. */ +export interface ProvideOfflineOptions extends ProvideOfflineBaseOptions { /** Product adapter that sends opaque commands to its API. */ commandExecutor: Type; /** Product transport for explicit cursor-based server delta pulls. */ @@ -31,12 +41,33 @@ export interface ProvideOfflineOptions extends OfflineKitOptions { requestPolicies: readonly Type[]; /** Optional product hooks for entity projection and command cleanup. */ commandHooks?: Type; - /** Optional additional providers required by product adapters. */ - providers?: readonly Provider[]; - /** Application-installed `@capacitor-community/sqlite` connection. Required only on iOS and Android. */ - sqliteConnection?: CommunitySqliteConnection; } +/** Explicit alias for the existing full-runtime configuration. */ +export type ProvideOfflineLegacyOptions = ProvideOfflineOptions; + +/** Capability-based `provideOffline` configuration without dummy adapters. */ +export interface ProvideOfflineCapabilityOptions extends ProvideOfflineBaseOptions { + /** Tagged offline capabilities to enable. */ + capabilities: readonly OfflineCapability[]; +} + +/** Accepted legacy or capability-based configuration for the offline runtime. */ +export type ProvideOfflineConfiguration = ProvideOfflineOptions | ProvideOfflineCapabilityOptions; + +const DISABLED_OFFLINE_COMMAND_EXECUTOR: OfflineCommandExecutor = { + execute: async () => { + throw new OfflineCapabilityError('Offline outbox capability is not enabled.'); + }, + withServerRevision: (command) => command, +}; + +const DISABLED_OFFLINE_REPLICA_PULLER: OfflineReplicaPuller = { + pull: async () => { + throw new OfflineCapabilityError('Offline replica pull capability is not enabled.'); + }, +}; + /** * Provide the standard scoped offline runtime. * @@ -45,10 +76,9 @@ export interface ProvideOfflineOptions extends OfflineKitOptions { * URL/DTO policy and command execution; the kit owns persistence, ordering, retries, and session * isolation. */ -export function provideOffline(options: ProvideOfflineOptions): EnvironmentProviders { +export function provideOffline(options: ProvideOfflineConfiguration): EnvironmentProviders { + const { runtime, adapterProviders } = resolveOfflineSetup(options); return makeEnvironmentProviders([ - options.commandExecutor, - options.replicaPuller, { provide: OFFLINE_KIT_OPTIONS, useValue: { @@ -57,6 +87,10 @@ export function provideOffline(options: ProvideOfflineOptions): EnvironmentProvi replicaSchema: options.replicaSchema, }, }, + { + provide: OFFLINE_RUNTIME_CAPABILITIES, + useValue: runtime, + }, { provide: COMMUNITY_SQLITE, useValue: options.sqliteConnection ? createCommunitySqliteDriver(options.sqliteConnection) : null, @@ -66,11 +100,50 @@ export function provideOffline(options: ProvideOfflineOptions): EnvironmentProvi useFactory: () => selectOfflineRepository(Capacitor.getPlatform(), inject(IonicOfflineRepository), inject(SqliteOfflineRepository)), }, { provide: OFFLINE_SYNC_CONTEXT, useExisting: OfflineSessionService }, - { provide: OFFLINE_COMMAND_EXECUTOR, useExisting: options.commandExecutor }, - { provide: OFFLINE_REPLICA_PULLER, useExisting: options.replicaPuller }, - ...(options.commandHooks ? [options.commandHooks, { provide: OFFLINE_COMMAND_HOOKS, useExisting: options.commandHooks }] : []), - ...options.requestPolicies.flatMap((policy) => provideOfflineRequestPolicy(policy)), + ...adapterProviders, ...(options.providers ?? []), provideAppInitializer(() => inject(OfflineCoordinatorService).initialize()), ]); } + +/** Resolves runtime flags and adapter providers from legacy or capability-based setup. */ +export function resolveOfflineSetup(options: ProvideOfflineConfiguration): { + runtime: OfflineRuntimeCapabilities; + adapterProviders: Provider[]; +} { + if ('capabilities' in options && options.capabilities !== undefined) { + const runtime = { + replicaPull: options.capabilities.some((capability) => capability.kind === 'replicaPull'), + outbox: options.capabilities.some((capability) => capability.kind === 'outbox'), + }; + for (const kind of ['replicaPull', 'outbox'] as const) { + if (options.capabilities.filter((capability) => capability.kind === kind).length > 1) { + throw new Error(`provideOffline accepts at most one ${kind} capability.`); + } + } + const adapterProviders: Provider[] = [...options.capabilities.flatMap((capability) => capability.providers)]; + if (!runtime.outbox) { + adapterProviders.push({ provide: OFFLINE_COMMAND_EXECUTOR, useValue: DISABLED_OFFLINE_COMMAND_EXECUTOR }); + } + if (!runtime.replicaPull) { + adapterProviders.push({ provide: OFFLINE_REPLICA_PULLER, useValue: DISABLED_OFFLINE_REPLICA_PULLER }); + } + return { runtime, adapterProviders }; + } + + const legacy = options as ProvideOfflineOptions; + const adapterProviders: Provider[] = [ + legacy.commandExecutor, + legacy.replicaPuller, + { provide: OFFLINE_COMMAND_EXECUTOR, useExisting: legacy.commandExecutor }, + { provide: OFFLINE_REPLICA_PULLER, useExisting: legacy.replicaPuller }, + ...legacy.requestPolicies.flatMap((policy) => provideOfflineRequestPolicy(policy)), + ]; + if (legacy.commandHooks) { + adapterProviders.push(legacy.commandHooks, { provide: OFFLINE_COMMAND_HOOKS, useExisting: legacy.commandHooks }); + } + return { + runtime: { replicaPull: true, outbox: true }, + adapterProviders, + }; +} 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 01f358a..36d5cf1 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 @@ -314,6 +314,18 @@ describe('OfflineReplicaPullService', () => { ); }); + it('schemaのserverId fieldとchange metadataが不一致ならrejectしcursorを進めない', async () => { + await expectPullRejectsPreservingCursor( + () => + pull.mockResolvedValueOnce( + page([itemChange(42, 'Created', { values: { id: 99, title: 'Created' } })], { + nextCursor: 'cursor-v1', + }), + ), + 'Offline replica server id mismatch for "test_items": metadata=42, values=99.', + ); + }); + describe('pull page boundary validation', () => { it('malformed nextCursorはrejectしcursorを進めない', async () => { await expectPullRejectsPreservingCursor( 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 a317bf1..18f976a 100644 --- a/projects/kit/offline/src/lib/offline-replica-pull.service.ts +++ b/projects/kit/offline/src/lib/offline-replica-pull.service.ts @@ -49,6 +49,7 @@ export class OfflineReplicaPullService { for (const change of changes) { const schema = this.#entitySchema(change.sourceKey); + this.#assertServerIdentity(schema, change); const commands = schema.scope === 'user' ? userCommands : scopeCommands; const acknowledged = (change.acknowledgedCommandIds ?? []) .map((commandId) => { @@ -223,6 +224,21 @@ export class OfflineReplicaPullService { return projectOfflineReplicaValues(schema, change.values); } + #assertServerIdentity(schema: OfflineReplicaEntitySchema>, change: OfflineReplicaChange): void { + if (change.deleted || change.values === null) return; + const serverIdField = schema.fields.find((field) => field.policy === 'serverId'); + if (!serverIdField) { + throw new Error(`Offline replica source "${change.sourceKey}" does not define a serverId field.`); + } + if (!isPlainObject(change.values)) return; + const value = change.values[serverIdField.sourceKey]; + if (value !== change.serverId) { + throw new Error( + `Offline replica server id mismatch for "${change.sourceKey}": metadata=${change.serverId}, values=${String(value)}.`, + ); + } + } + #collapseChanges(changes: readonly OfflineReplicaChange[]): OfflineReplicaChange[] { const collapsed = new Map(); for (const change of changes) { diff --git a/projects/kit/offline/src/lib/offline-replica-query.service.spec.ts b/projects/kit/offline/src/lib/offline-replica-query.service.spec.ts new file mode 100644 index 0000000..cb24a81 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-replica-query.service.spec.ts @@ -0,0 +1,94 @@ +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; +import { OfflineReplicaQueryService } from './offline-replica-query.service'; +import { defineOfflineReplicaSchema, defineReplicaEntity, serverId, text } from './offline-replica-schema'; +import { OFFLINE_REPOSITORY, type OfflineCommand, type OfflineReplicaRow, type OfflineScope } from './offline-repository'; + +const scope: OfflineScope = { userId: 1, groupId: 10 }; +const row: OfflineReplicaRow<{ title: string }> = { + ...scope, + sourceKey: 'documents', + localId: 'local-1', + serverId: 42, + values: { title: 'Document' }, + confirmedValues: { title: 'Document' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'pending', +}; +const schema = defineOfflineReplicaSchema({ + version: 1, + entities: [ + defineReplicaEntity<{ id: number; title: string }>()({ + table: 'documents', + sourceKey: 'documents', + scope: 'group', + fields: { id: serverId(), title: text() }, + }), + ], + migrations: [], +}); + +function command(effect: OfflineCommand['effect'], state: OfflineCommand['state'] = 'pending'): OfflineCommand { + return { + ...scope, + commandId: 'command-1', + aggregateType: 'documents', + aggregateLocalId: row.localId, + operation: 'documents.remove', + effect, + payload: {}, + optimisticValue: row.values, + payloadHash: 'hash', + baseRevision: 1, + state, + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }; +} + +describe('OfflineReplicaQueryService', () => { + let commands: OfflineCommand[]; + let service: OfflineReplicaQueryService; + + beforeEach(() => { + commands = []; + TestBed.configureTestingModule({ + providers: [ + OfflineReplicaQueryService, + { provide: OFFLINE_KIT_OPTIONS, useValue: { databaseName: 'query-test', replicaSchema: schema } }, + { + provide: OFFLINE_REPOSITORY, + useValue: { + getReplicaRows: vi.fn(async () => [row]), + getCommands: vi.fn(async () => commands), + }, + }, + { + provide: OFFLINE_COMMAND_HOOKS, + useValue: { entityType: (item: OfflineCommand) => item.aggregateType }, + }, + ], + }); + service = TestBed.inject(OfflineReplicaQueryService); + }); + + it('pending deleteをlocal tombstoneとして通常のprojectionから隠す', async () => { + commands = [command('delete')]; + await expect(service.getVisibleRows(scope, 'documents')).resolves.toEqual([]); + }); + + it.each(['rejected', 'conflict'] as const)('%s deleteは解決可能なserver rowを表示する', async (state) => { + commands = [command('delete', state)]; + await expect(service.getVisibleRows(scope, 'documents')).resolves.toEqual([row]); + }); + + it('旧commandのeffect未指定はupsertとして表示する', async () => { + commands = [command(undefined)]; + await expect(service.getVisibleRows(scope, 'documents')).resolves.toEqual([row]); + }); +}); diff --git a/projects/kit/offline/src/lib/offline-replica-query.service.ts b/projects/kit/offline/src/lib/offline-replica-query.service.ts new file mode 100644 index 0000000..cecd089 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-replica-query.service.ts @@ -0,0 +1,42 @@ +import { inject, Injectable } from '@angular/core'; +import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; +import { OFFLINE_REPOSITORY, type OfflineCommand, type OfflineReplicaRow, type OfflineScope } from './offline-repository'; + +/** High-level local replica reads that consistently apply optimistic visibility rules. */ +@Injectable({ providedIn: 'root' }) +export class OfflineReplicaQueryService { + readonly #repository = inject(OFFLINE_REPOSITORY); + readonly #options = inject(OFFLINE_KIT_OPTIONS); + readonly #hooks = inject(OFFLINE_COMMAND_HOOKS); + + /** + * Returns the current optimistic rows visible to product projections. + * + * Pending delete commands act as local tombstones. Rejected or conflicted deletes remain visible so + * products can present and resolve them instead of silently hiding server-confirmed data. + */ + async getVisibleRows(scope: OfflineScope, sourceKey: string): Promise[]> { + const schema = this.#options.replicaSchema.entities.find((entity) => entity.sourceKey === sourceKey); + if (!schema) throw new Error(`Unknown offline replica source key "${sourceKey}".`); + const [rows, commands] = await Promise.all([ + this.#repository.getReplicaRows(scope, sourceKey), + schema.scope === 'user' && this.#repository.getCommandsForUser + ? this.#repository.getCommandsForUser(scope.userId) + : this.#repository.getCommands(scope), + ]); + const hiddenLocalIds = new Set( + commands.filter((command) => this.#isVisibleDelete(command, sourceKey)).map((command) => command.aggregateLocalId), + ); + return rows.filter((row) => !hiddenLocalIds.has(row.localId)); + } + + #isVisibleDelete(command: OfflineCommand, sourceKey: string): boolean { + return ( + command.effect === 'delete' && + command.state !== 'rejected' && + command.state !== 'conflict' && + this.#hooks.entityType(command) === sourceKey + ); + } +} diff --git a/projects/kit/offline/src/lib/offline-repository.ts b/projects/kit/offline/src/lib/offline-repository.ts index 0017c83..65619d9 100644 --- a/projects/kit/offline/src/lib/offline-repository.ts +++ b/projects/kit/offline/src/lib/offline-repository.ts @@ -24,6 +24,8 @@ export type OfflineReplicaSyncState = 'confirmed' | 'pending' | 'blocked_auth' | /** Durable processing state of an outbox command. */ export type OfflineCommandState = 'pending' | 'sending' | 'retry_wait' | 'blocked_auth' | 'rejected' | 'conflict'; +/** Optimistic effect applied to the aggregate while an outbox command is pending. */ +export type OfflineCommandEffect = 'upsert' | 'delete'; /** Product-agnostic mutation persisted in the outbox by local id. */ export interface OfflineCommand extends OfflineScope { @@ -32,6 +34,10 @@ export interface OfflineCommand extends OfflineScope { /** Immutable local id of the target. The outbox never persists a server id. */ aggregateLocalId: string; operation: string; + /** + * Explicit optimistic effect. Missing means `upsert` for commands persisted by older application versions. + */ + effect?: OfflineCommandEffect; payload: T; /** Full optimistic entity value displayed while this command is pending. */ optimisticValue: unknown; 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 1e1f952..5d3b9f3 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -8,6 +8,7 @@ import { type OfflineCommandTarget, } from './offline-command-executor'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; +import { OfflineCapabilityError, OFFLINE_RUNTIME_CAPABILITIES } from './offline-capabilities'; import { OfflineNetworkService } from './offline-network.service'; import { OfflineReplicaPullService } from './offline-replica-pull.service'; import { defineOfflineReplicaSchema, defineReplicaEntity, serverId, text } from './offline-replica-schema'; @@ -51,18 +52,7 @@ describe('OfflineSyncService', () => { async (_command: OfflineCommand, _target: OfflineCommandTarget): Promise => ({ response: null }), ); - beforeEach(() => { - commands = []; - rows = []; - connected = signal(false); - session = { userId: 1, scopes: [{ userId: 1, groupId: 10 }] }; - localSession = undefined; - beforePutCommand = null; - beforeGetReplicaRow = null; - pull = vi.fn(async () => undefined); - handleError = vi.fn(); - execute.mockReset(); - execute.mockResolvedValue({ response: null }); + function configureService(runtime = { replicaPull: true, outbox: true }): OfflineSyncService { const repository = { initialize: vi.fn(async () => undefined), getCommands: vi.fn(async (scope: OfflineScope) => @@ -147,9 +137,25 @@ describe('OfflineSyncService', () => { provide: OFFLINE_COMMAND_EXECUTOR, useValue: { execute, withServerRevision: (command: OfflineCommand) => command }, }, + { provide: OFFLINE_RUNTIME_CAPABILITIES, useValue: runtime }, ], }); - service = TestBed.inject(OfflineSyncService); + return TestBed.inject(OfflineSyncService); + } + + beforeEach(() => { + commands = []; + rows = []; + connected = signal(false); + session = { userId: 1, scopes: [{ userId: 1, groupId: 10 }] }; + localSession = undefined; + beforePutCommand = null; + beforeGetReplicaRow = null; + pull = vi.fn(async () => undefined); + handleError = vi.fn(); + execute.mockReset(); + execute.mockResolvedValue({ response: null }); + service = configureService(); }); it('local sessionはoutboxへenqueueできるがremote session確立までは送信しない', async () => { @@ -816,7 +822,7 @@ describe('OfflineSyncService', () => { }); }); - it('採用済みserverIdはflush時にdelete操作のexecutor targetへ渡す', async () => { + it('明示delete effectは採用済みserverIdをexecutorへ渡し、成功後にreplicaを除く', async () => { await service.enqueue( { groupId: 10, @@ -824,15 +830,18 @@ describe('OfflineSyncService', () => { aggregateLocalId: '019d-adopted', serverId: 38142, operation: 'documents.delete', + effect: 'delete', payload: {}, optimisticValue: {}, }, { flush: false }, ); + expect(commands[0]?.effect).toBe('delete'); connected.set(true); - execute.mockResolvedValueOnce({ removeReplica: true, response: null }); + execute.mockResolvedValueOnce({ response: null }); await service.flush(); expect(execute.mock.calls[0]?.[1]).toEqual({ localId: '019d-adopted', serverId: 38142 }); + expect(rows).toEqual([]); }); it.each([0, -1, 1.5])('enqueue時の不正serverId %sは永続化前にrejectする', async (serverId) => { @@ -1278,4 +1287,68 @@ describe('OfflineSyncService', () => { expect(service.pendingCount()).toBe(0); }); }); + + it('outbox capabilityが無い場合はenqueueを拒否する', async () => { + TestBed.resetTestingModule(); + service = configureService({ replicaPull: true, outbox: false }); + await expect( + service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: 'no-outbox', + operation: 'documents.create', + payload: { title: 'blocked' }, + optimisticValue: { id: 0, title: 'blocked' }, + }, + { flush: false }, + ), + ).rejects.toThrow(OfflineCapabilityError); + }); + + it('replica pull capabilityが無い場合はflushでpullしない', async () => { + TestBed.resetTestingModule(); + service = configureService({ replicaPull: false, outbox: true }); + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: 'no-pull', + operation: 'documents.create', + payload: { title: 'local' }, + optimisticValue: { id: 0, title: 'local' }, + }, + { flush: false }, + ); + connected.set(true); + await service.flush(); + expect(pull).not.toHaveBeenCalled(); + }); + + it('outbox capabilityが無い場合はflushでcommandを送信しない', async () => { + TestBed.resetTestingModule(); + service = configureService({ replicaPull: true, outbox: false }); + commands.push({ + userId: 1, + groupId: 10, + commandId: 'existing-command', + aggregateType: 'documents', + aggregateLocalId: 'existing-local', + operation: 'documents.create', + payload: { title: 'queued' }, + optimisticValue: { id: 0, title: 'queued' }, + payloadHash: 'hash', + baseRevision: null, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }); + await service.initialize(); + connected.set(true); + await service.flush(); + expect(execute).not.toHaveBeenCalled(); + expect(pull).toHaveBeenCalled(); + }); }); diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index c1e97ad..992092d 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -1,4 +1,5 @@ import { computed, effect, ErrorHandler, inject, Injectable, signal } from '@angular/core'; +import { OfflineCapabilityError, OFFLINE_RUNTIME_CAPABILITIES } from './offline-capabilities'; import { OFFLINE_COMMAND_EXECUTOR, OFFLINE_SYNC_CONTEXT, @@ -9,7 +10,14 @@ 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 type { OfflineCommand, OfflineReplicaSyncState, OfflineReplicaRow, OfflineReplicaRowKey, OfflineScope } from './offline-repository'; +import type { + OfflineCommand, + OfflineCommandEffect, + OfflineReplicaSyncState, + OfflineReplicaRow, + OfflineReplicaRowKey, + OfflineScope, +} from './offline-repository'; import { OFFLINE_REPOSITORY } from './offline-repository'; /** Aggregate synchronization state exposed to application UI. */ @@ -24,6 +32,8 @@ export interface EnqueueOfflineCommand { /** Known immutable server identity when adopting an entity before its first replica pull. */ serverId?: number | null; operation: string; + /** Whether the optimistic row remains visible or acts as a local tombstone until confirmation. */ + effect?: OfflineCommandEffect; payload: T; /** Full local entity value committed to the replica before the command is exposed to the UI. */ optimisticValue: unknown; @@ -50,6 +60,7 @@ export class OfflineSyncService { readonly #context = inject(OFFLINE_SYNC_CONTEXT); readonly #hooks = inject(OFFLINE_COMMAND_HOOKS); readonly #options = inject(OFFLINE_KIT_OPTIONS); + readonly #capabilities = inject(OFFLINE_RUNTIME_CAPABILITIES); readonly #pull = inject(OfflineReplicaPullService); readonly #errorHandler = inject(ErrorHandler); readonly #commands = signal([]); @@ -147,6 +158,9 @@ export class OfflineSyncService { } async #enqueue(request: EnqueueOfflineCommand, options: { flush?: boolean }, generation: number): Promise { + if (!this.#capabilities.outbox) { + throw new OfflineCapabilityError('Cannot enqueue offline commands without the outbox capability.'); + } await this.initialize(); const session = await this.#getLocalSession(); if (!session) throw new Error('Cannot enqueue an offline command without an authenticated user'); @@ -164,6 +178,7 @@ export class OfflineSyncService { aggregateType: request.aggregateType, aggregateLocalId, operation: request.operation, + effect: request.effect ?? 'upsert', payload: normalized.payload, optimisticValue, payloadHash: await this.#payloadHash(normalized.payload), @@ -249,22 +264,26 @@ export class OfflineSyncService { return; } if (!(await this.#discoverScopes(generation))) return; - for (const scope of this.#knownScopes.values()) { - if (!this.#isCurrent(generation) || !this.#network.connected()) return; - await this.#pull.pull(scope); + if (this.#capabilities.replicaPull) { + for (const scope of this.#knownScopes.values()) { + if (!this.#isCurrent(generation) || !this.#network.connected()) return; + await this.#pull.pull(scope); + } } - while (this.#network.connected() && this.#isCurrent(generation)) { - const groups = this.#eligibleAggregateGroups(await this.#readKnownCommands()); - if (!this.#isCurrent(generation)) return; - if (groups.length === 0) break; - let cursor = 0; - const workers = Array.from({ length: Math.min(MAX_PARALLEL_AGGREGATES, groups.length) }, async () => { - while (cursor < groups.length) { - const group = groups[cursor++]; - if (group && this.#isCurrent(generation)) await this.#sendAggregate(group, generation); - } - }); - await Promise.all(workers); + if (this.#capabilities.outbox) { + while (this.#network.connected() && this.#isCurrent(generation)) { + const groups = this.#eligibleAggregateGroups(await this.#readKnownCommands()); + if (!this.#isCurrent(generation)) return; + if (groups.length === 0) break; + let cursor = 0; + const workers = Array.from({ length: Math.min(MAX_PARALLEL_AGGREGATES, groups.length) }, async () => { + while (cursor < groups.length) { + const group = groups[cursor++]; + if (group && this.#isCurrent(generation)) await this.#sendAggregate(group, generation); + } + }); + await Promise.all(workers); + } } await this.#refreshState(generation); } @@ -370,9 +389,10 @@ export class OfflineSyncService { syncState: rebased.length > 0 ? ('pending' as const) : ('confirmed' as const), }; if (!this.#isCurrent(generation)) return; + const removeReplica = result.removeReplica ?? command.effect === 'delete'; await this.#repository.transactReplica({ - putRows: result.removeReplica ? undefined : [row], - removeRows: result.removeReplica ? [current] : undefined, + putRows: removeReplica ? undefined : [row], + removeRows: removeReplica ? [current] : undefined, putCommands: rebased, removeCommandIds: [command.commandId], }); 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 576c5e6..cb84306 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts @@ -676,6 +676,7 @@ describe('SqliteOfflineRepository replica rows', () => { aggregateType: 'test_items', aggregateLocalId: '019d-bbbb', operation: 'test_items.create', + effect: 'delete', payload: { title: 'Local item' }, optimisticValue: { id: 0, title: 'Local item' }, payloadHash: 'hash', @@ -699,11 +700,11 @@ describe('SqliteOfflineRepository replica rows', () => { expect(upsert?.statement).toContain('title'); expect(upsert?.statement).not.toContain('value_json'); expect(upsert?.values).toEqual(['019d-bbbb', 1, null, null, null, 'pending', 1, 'Local item']); - expect( - plugin.execute.mock.calls.some(([options]) => - (options as { statement: string }).statement.startsWith('INSERT INTO offline_sync_commands'), - ), - ).toBe(true); + const commandUpsert = plugin.execute.mock.calls.find(([options]) => + (options as { statement: string }).statement.startsWith('INSERT INTO offline_sync_commands'), + )?.[0] as { statement: string; values?: unknown[] }; + expect(commandUpsert.statement).toContain('effect'); + expect(commandUpsert.values).toContain('delete'); }); it('local-only projectionをserver_idなしのDDL/SQLでround-tripしserverId lookupはnullを返す', async () => { diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index 6a47507..f7834a2 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -121,6 +121,7 @@ const SCHEMA = [ aggregate_type TEXT NOT NULL, aggregate_local_id TEXT NOT NULL, operation TEXT NOT NULL, + effect TEXT NOT NULL DEFAULT 'upsert', payload_json TEXT NOT NULL, optimistic_value_json TEXT NOT NULL, payload_hash TEXT NOT NULL, @@ -326,6 +327,9 @@ export class SqliteOfflineRepository implements OfflineRepository { await this.#execute(databaseId, 'ALTER TABLE offline_sync_commands ADD COLUMN optimistic_value_json TEXT'); await this.#execute(databaseId, 'UPDATE offline_sync_commands SET optimistic_value_json = payload_json'); } + if (!commandColumns.some((column) => column['name'] === 'effect')) { + await this.#execute(databaseId, "ALTER TABLE offline_sync_commands ADD COLUMN effect TEXT NOT NULL DEFAULT 'upsert'"); + } await this.#execute( databaseId, `INSERT INTO offline_metadata (id, schema_version, last_user_id) VALUES (1, ?, NULL) @@ -454,6 +458,7 @@ export class SqliteOfflineRepository implements OfflineRepository { aggregateType: this.#string(row['aggregate_type']), aggregateLocalId: this.#string(row['aggregate_local_id']), operation: this.#string(row['operation']), + effect: this.#commandEffect(row['effect']), payload: this.#parse(row['payload_json']), optimisticValue: this.#parse(row['optimistic_value_json']), payloadHash: this.#string(row['payload_hash']), @@ -470,13 +475,14 @@ export class SqliteOfflineRepository implements OfflineRepository { return this.#execute( databaseId, `INSERT INTO offline_sync_commands - (command_id, user_id, group_id, aggregate_type, aggregate_local_id, operation, payload_json, optimistic_value_json, - payload_hash, base_revision_json, state, attempts, retry_at, created_at, last_error_code) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (command_id, user_id, group_id, aggregate_type, aggregate_local_id, operation, effect, payload_json, + optimistic_value_json, payload_hash, base_revision_json, state, attempts, retry_at, created_at, last_error_code) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(command_id) DO UPDATE SET user_id = excluded.user_id, group_id = excluded.group_id, aggregate_type = excluded.aggregate_type, - aggregate_local_id = excluded.aggregate_local_id, operation = excluded.operation, payload_json = excluded.payload_json, - optimistic_value_json = excluded.optimistic_value_json, payload_hash = excluded.payload_hash, + aggregate_local_id = excluded.aggregate_local_id, operation = excluded.operation, effect = excluded.effect, + payload_json = excluded.payload_json, optimistic_value_json = excluded.optimistic_value_json, + 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`, [ @@ -486,6 +492,7 @@ export class SqliteOfflineRepository implements OfflineRepository { command.aggregateType, command.aggregateLocalId, command.operation, + command.effect ?? 'upsert', JSON.stringify(command.payload), JSON.stringify(command.optimisticValue), command.payloadHash, @@ -499,6 +506,10 @@ export class SqliteOfflineRepository implements OfflineRepository { ); } + #commandEffect(value: unknown): OfflineCommand['effect'] { + return value === 'delete' ? 'delete' : 'upsert'; + } + #putReplicaRow(databaseId: string, row: OfflineReplicaRow): Promise { const schema = this.#resolveReplicaEntitySchema(row.sourceKey); assertOfflineReplicaServerId(schema, row.serverId); diff --git a/projects/kit/offline/src/public-api.ts b/projects/kit/offline/src/public-api.ts index 1a3ea42..6951252 100644 --- a/projects/kit/offline/src/public-api.ts +++ b/projects/kit/offline/src/public-api.ts @@ -1,13 +1,29 @@ /** Standard scoped local replica and outbox runtime for offline-capable Ionic applications. */ +export { + OfflineCapabilityError, + type OfflineCapability, + type OfflineCapabilityKind, + withOfflineOutbox, + withOfflineReadFallback, + withOfflineReplicaPull, +} from './lib/offline-capabilities'; export * from './lib/offline-replica-schema'; export * from './lib/offline-replica-puller'; export * from './lib/offline-replica-pull.service'; +export * from './lib/offline-replica-query.service'; export * from './lib/offline-command-executor'; export * from './lib/offline-command-hooks'; export * from './lib/offline-coordinator.service'; export * from './lib/offline-kit-options'; export * from './lib/offline-network.service'; -export * from './lib/offline-provider'; +export { + provideOffline, + type ProvideOfflineBaseOptions, + type ProvideOfflineCapabilityOptions, + type ProvideOfflineConfiguration, + type ProvideOfflineLegacyOptions, + type ProvideOfflineOptions, +} from './lib/offline-provider'; export * from './lib/offline-repository'; export * from './lib/offline-request-policy'; export * from './lib/offline-session.service';