Skip to content
Merged
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
6 changes: 4 additions & 2 deletions projects/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -578,8 +578,10 @@ baseline. The server remains authoritative; SQLite is the durable local working
Synchronized mode requires `aggregateIntentProjector`. Kit calls it inside `OfflineReplicaMutationCoordinator` after
enqueue, batch enqueue, replacement, discard, transport success, and pull acknowledgement. The adapter must derive
only from authoritative confirmed values plus the complete remaining intent chain. Do not call it from product code.
Commands persist payload/metadata and declared `localOnlyFootprint` keys only; optimistic snapshots and companion
before/after images are not durable truth.
Commands persist an immutable payload plus Kit-owned metadata (`baseRevision`, state, footprint keys). Kit updates
only `baseRevision` when a newer server revision is known, and sets it to `null` after a generated remote identity is
released; the product executor always receives the original payload. Optimistic snapshots and companion before/after
images are not durable truth.

When an API response is assembled from a base replica row plus product-owned local-only view rows, use
`enqueuePrepared()`. Its callback runs inside the shared replica mutation lane, so every read used to derive the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@ describe('OfflineAggregateIntentProjector', () => {
identity: { kind: 'generated' as const, localId: 'item-1' },
operation: 'items.absolute',
payload: {},
payloadHash: 'hash',
baseRevision: 1,
state: 'pending' as const,
attempts: 0,
Expand Down Expand Up @@ -268,7 +267,6 @@ describe('OfflineAggregateIntentProjector', () => {
provide: OFFLINE_COMMAND_EXECUTOR,
useValue: {
execute: vi.fn(async () => ({ response: null })),
withServerRevision: (command: OfflineCommand) => command,
},
},
{
Expand Down
17 changes: 11 additions & 6 deletions projects/kit/offline/src/lib/offline-command-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,23 @@ export type OfflineCommandTarget =
export interface OfflineCommandExecutor {
/** Sends the command using `command.commandId` as its durable server-side idempotency key. */
execute(command: OfflineCommand, target: OfflineCommandTarget): Promise<OfflineCommandResult>;
withServerRevision(command: OfflineCommand, revision: string | number): OfflineCommand;
/**
* Whether this transport error authoritatively proves that this idempotency
* key did not commit. Returning true may clear an ambiguity retained from an
* earlier response-loss attempt and expose normal conflict resolution.
*/
provesCommandNotCommitted?(error: unknown, command: OfflineCommand): boolean;
/**
* Removes the deleted remote row's revision from a queued recreate.
* Required only when `clearRemoteId` completes while later commands remain.
*/
withoutServerRevision?(command: OfflineCommand): OfflineCommand;
}

/**
* Returns a command whose only changed field is `baseRevision`.
*
* Payload and every other field are copied unchanged. Kit uses this when a
* newer server revision is known, and passes `null` after a generated remote
* identity is released.
*/
export function offlineCommandWithBaseRevision(command: OfflineCommand, baseRevision: string | number | null): OfflineCommand {
return { ...command, baseRevision };
}

/** DI token for the product-specific command transport adapter. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { OfflineCoordinatorService } from './offline-coordinator.service';
import { OFFLINE_KIT_OPTIONS } from './offline-kit-options';
import { OfflineNetworkService } from './offline-network.service';
import { OFFLINE_REPOSITORY, type OfflineScope } from './offline-repository';
import { OFFLINE_REPOSITORY, OFFLINE_SCHEMA_VERSION, type OfflineScope } from './offline-repository';
import { OfflineSessionService, type OfflineSessionManifest } from './offline-session.service';
import { OfflineStorageUnavailableError } from './offline-storage';
import { OfflineSyncService } from './offline-sync.service';
Expand Down Expand Up @@ -263,7 +263,7 @@ describe('OfflineCoordinatorService', () => {
describe('storage initialization failure', () => {
const storageError = new OfflineStorageUnavailableError(
'core_schema_incompatible',
'Unsupported offline storage schema version 999; expected 2.',
`Unsupported offline storage schema version 999; expected ${OFFLINE_SCHEMA_VERSION}.`,
);

it('default fails closed and does not start session or sync', async () => {
Expand Down
10 changes: 0 additions & 10 deletions projects/kit/offline/src/lib/offline-natural-key.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { KitStorageService } from '@rdlabo/ionic-angular-kit';
import { describe, expect, it, vi } from 'vitest';
import { OFFLINE_COMMAND_EXECUTOR } from './offline-command-executor';
import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks';
import { OFFLINE_KIT_OPTIONS } from './offline-kit-options';
import { OFFLINE_REPLICA_PULLER, type OfflineReplicaPullPage } from './offline-replica-puller';
Expand Down Expand Up @@ -287,13 +286,6 @@ describe('natural-key pull reconciliation', () => {
{ provide: OFFLINE_REPOSITORY, useExisting: IonicOfflineRepository },
{ provide: OFFLINE_REPLICA_PULLER, useValue: { pull } },
{ provide: OFFLINE_COMMAND_HOOKS, useValue: { entityType: (command: OfflineCommand) => command.aggregateType } },
{
provide: OFFLINE_COMMAND_EXECUTOR,
useValue: {
execute: vi.fn(),
withServerRevision: (command: OfflineCommand, revision: string | number) => ({ ...command, baseRevision: revision }),
},
},
{ provide: OFFLINE_AGGREGATE_INTENT_PROJECTOR, useValue: { project: rematerializeTestAggregate } },
],
});
Expand All @@ -311,7 +303,6 @@ describe('natural-key pull reconciliation', () => {
identity: naturalCommandIdentity(key42),
operation: 'create',
payload: { favTo: '42', label: 'optimistic' },
payloadHash: 'hash',
baseRevision: null,
state: 'pending',
attempts: 1,
Expand Down Expand Up @@ -350,7 +341,6 @@ describe('natural-key pull reconciliation', () => {
identity: naturalCommandIdentity(key42),
operation: 'update',
payload: { favTo: '42', label: 'pending edit' },
payloadHash: 'hash-2',
baseRevision: 2,
state: 'pending',
attempts: 0,
Expand Down
1 change: 0 additions & 1 deletion projects/kit/offline/src/lib/offline-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ const READ_CACHE_ONLY_COMMAND_EXECUTOR: OfflineCommandExecutor = {
execute: async (): Promise<OfflineCommandResult> => {
throw new Error('This offline provider is configured as a read-only cache.');
},
withServerRevision: (command) => command,
};

const READ_CACHE_ONLY_REPLICA_PULLER: OfflineReplicaPuller = {
Expand Down
Loading