Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions projects/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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
});
```

Expand Down
72 changes: 72 additions & 0 deletions projects/kit/offline/src/lib/offline-capabilities.ts
Original file line number Diff line number Diff line change
@@ -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<OfflineRuntimeCapabilities>('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<TKind extends OfflineCapabilityKind = OfflineCapabilityKind> {
readonly kind: TKind;
readonly providers: readonly Provider[];
}

/** Registers explicit replica pull transport. */
export function withOfflineReplicaPull(puller: Type<OfflineReplicaPuller>): 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<OfflineCommandExecutor>;
hooks?: Type<OfflineCommandHooks>;
}): 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<OfflineRequestPolicy>[]): 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';
}
}
172 changes: 172 additions & 0 deletions projects/kit/offline/src/lib/offline-provider.spec.ts
Original file line number Diff line number Diff line change
@@ -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<OfflineCommandResult> {
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);
});
});
Loading