refactor(sync-core): add ui and config port contracts

This commit is contained in:
Johannes Millan 2026-05-12 11:13:59 +02:00
parent a59aa39931
commit fd5a0b6945
4 changed files with 125 additions and 4 deletions

View file

@ -156,8 +156,9 @@ Current extraction state and remaining immediate debt:
- PR 4a is present with `packages/sync-core/src/ports.ts`. The package now
exports minimal contracts for operation application, action dispatch,
remote-apply windows, deferred local action flushing, archive side effects,
and operation-store persistence. The existing Angular services satisfy those
contracts app-side.
operation-store persistence, conflict UI, and sync configuration. The existing
Angular services satisfy the replay/storage contracts app-side; conflict UI
and sync config are currently type-only contracts for future adapters.
- PR 4b's current small helper set is present: remote-apply crash-safety
ordering, upload last-server-sequence planning, full-state snapshot upload
follow-up partitioning, download gap/full-state/encryption planning, and
@ -669,8 +670,8 @@ Introduce orchestration ports without moving the orchestrators yet. This reduces
the risk of the later service moves.
Status: implemented for the current branch slice. `@sp/sync-core` exports the
first minimal port contracts, and these app services now explicitly satisfy
them:
first minimal replay/storage port contracts, and these app services now
explicitly satisfy them:
- `OperationApplierService` implements `OperationApplyPort<Operation>` and uses
`ActionDispatchPort<SyncActionLike>` for its NgRx dispatch seam.
@ -680,6 +681,10 @@ them:
- `OperationLogStoreService` implements
`OperationStorePort<Operation, OperationLogEntry>`.
`ConflictUiPort` and `SyncConfigPort` are also exported as type-only package
contracts. Runtime Angular adapters are intentionally still pending because no
orchestration has moved behind them yet.
This is contract-only: NgRx dispatch, hydration windows, archive IndexedDB
handling, and deferred local action processing remain app-side.

View file

@ -119,11 +119,18 @@ export type {
export type {
ActionDispatchPort,
ArchiveSideEffectPort,
ConflictUiDialogRequest,
ConflictUiNotification,
ConflictUiNotificationSeverity,
ConflictUiPort,
DeferredLocalActionsPort,
OperationApplyPort,
OperationStorePort,
RemoteApplyWindowPort,
SyncActionLike,
SyncConfigPort,
SyncConfigSnapshot,
SyncPortMeta,
} from './ports';
// Conflict-resolution helpers.

View file

@ -1,6 +1,8 @@
import type { ApplyOperationsOptions, ApplyOperationsResult } from './apply.types';
import type { Operation, OperationLogEntry } from './operation.types';
export type SyncPortMeta = Record<string, string | number | boolean | null | undefined>;
/**
* Minimal action shape used at sync-core boundaries.
*
@ -70,3 +72,53 @@ export interface OperationStorePort<
markSynced(seqs: number[]): Promise<void>;
markRejected(opIds: string[]): Promise<void>;
}
/**
* Domain-free sync configuration snapshot.
*
* Provider IDs stay plain strings at the package boundary. Host applications can
* narrow them in their adapter layer.
*/
export interface SyncConfigSnapshot<TProviderId extends string = string> {
isEnabled: boolean;
syncProvider: TProviderId | null;
isEncryptionEnabled?: boolean;
isCompressionEnabled?: boolean;
isManualSyncOnly?: boolean;
syncInterval?: number;
}
/**
* Port for reading host sync configuration without importing framework store
* selectors or host provider enums.
*/
export interface SyncConfigPort<TProviderId extends string = string> {
getSyncConfig(): Promise<SyncConfigSnapshot<TProviderId>>;
}
export interface ConflictUiDialogRequest {
conflictType: string;
scenario?: string;
reason?: string;
counts?: Record<string, number>;
timestamps?: Record<string, number>;
meta?: SyncPortMeta;
}
export type ConflictUiNotificationSeverity = 'info' | 'warning' | 'error';
export interface ConflictUiNotification {
severity: ConflictUiNotificationSeverity;
message: string;
reason?: string;
meta?: SyncPortMeta;
}
/**
* Port for conflict dialogs/snacks. Resolutions are strings so the host owns
* user-facing choices such as USE_LOCAL, USE_REMOTE, or CANCEL.
*/
export interface ConflictUiPort<TResolution extends string = string> {
showConflictDialog(request: ConflictUiDialogRequest): Promise<TResolution>;
notify?(notification: ConflictUiNotification): Promise<void> | void;
}

View file

@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest';
import type { ConflictUiPort, SyncConfigPort, SyncConfigSnapshot } from '../src';
describe('sync-core ports', () => {
it('keeps sync config provider IDs as host-owned strings', async () => {
type ProviderId = 'super-sync' | 'file-sync';
const config: SyncConfigSnapshot<ProviderId> = {
isEnabled: true,
syncProvider: 'super-sync',
isEncryptionEnabled: true,
isCompressionEnabled: false,
isManualSyncOnly: false,
syncInterval: 5,
};
const port: SyncConfigPort<ProviderId> = {
getSyncConfig: vi.fn().mockResolvedValue(config),
};
await expect(port.getSyncConfig()).resolves.toBe(config);
expect(port.getSyncConfig).toHaveBeenCalledOnce();
});
it('keeps conflict UI reasons and resolutions host-owned strings', async () => {
type Resolution = 'USE_LOCAL' | 'USE_REMOTE' | 'CANCEL';
const notify = vi.fn();
const port: ConflictUiPort<Resolution> = {
showConflictDialog: vi.fn().mockResolvedValue('USE_LOCAL'),
notify,
};
await expect(
port.showConflictDialog({
conflictType: 'sync-import',
scenario: 'LOCAL_IMPORT_FILTERS_REMOTE',
reason: 'BACKUP_RESTORE',
counts: { filteredOps: 3 },
timestamps: { localImport: 123 },
meta: { providerId: 'super-sync' },
}),
).resolves.toBe('USE_LOCAL');
port.notify?.({
severity: 'warning',
message: 'sync-conflict',
reason: 'BACKUP_RESTORE',
meta: { filteredOps: 3 },
});
expect(port.showConflictDialog).toHaveBeenCalledOnce();
expect(notify).toHaveBeenCalledWith({
severity: 'warning',
message: 'sync-conflict',
reason: 'BACKUP_RESTORE',
meta: { filteredOps: 3 },
});
});
});