diff --git a/docs/long-term-plans/sync-core-extraction-plan.md b/docs/long-term-plans/sync-core-extraction-plan.md index 2bf644f830..88c1395861 100644 --- a/docs/long-term-plans/sync-core-extraction-plan.md +++ b/docs/long-term-plans/sync-core-extraction-plan.md @@ -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` and uses `ActionDispatchPort` for its NgRx dispatch seam. @@ -680,6 +681,10 @@ them: - `OperationLogStoreService` implements `OperationStorePort`. +`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. diff --git a/packages/sync-core/src/index.ts b/packages/sync-core/src/index.ts index 3341aae189..1434a28629 100644 --- a/packages/sync-core/src/index.ts +++ b/packages/sync-core/src/index.ts @@ -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. diff --git a/packages/sync-core/src/ports.ts b/packages/sync-core/src/ports.ts index 47cc4ab45f..db6c92bf99 100644 --- a/packages/sync-core/src/ports.ts +++ b/packages/sync-core/src/ports.ts @@ -1,6 +1,8 @@ import type { ApplyOperationsOptions, ApplyOperationsResult } from './apply.types'; import type { Operation, OperationLogEntry } from './operation.types'; +export type SyncPortMeta = Record; + /** * Minimal action shape used at sync-core boundaries. * @@ -70,3 +72,53 @@ export interface OperationStorePort< markSynced(seqs: number[]): Promise; markRejected(opIds: string[]): Promise; } + +/** + * 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 { + 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 { + getSyncConfig(): Promise>; +} + +export interface ConflictUiDialogRequest { + conflictType: string; + scenario?: string; + reason?: string; + counts?: Record; + timestamps?: Record; + 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 { + showConflictDialog(request: ConflictUiDialogRequest): Promise; + notify?(notification: ConflictUiNotification): Promise | void; +} diff --git a/packages/sync-core/tests/ports.spec.ts b/packages/sync-core/tests/ports.spec.ts new file mode 100644 index 0000000000..7fef401ccb --- /dev/null +++ b/packages/sync-core/tests/ports.spec.ts @@ -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 = { + isEnabled: true, + syncProvider: 'super-sync', + isEncryptionEnabled: true, + isCompressionEnabled: false, + isManualSyncOnly: false, + syncInterval: 5, + }; + const port: SyncConfigPort = { + 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 = { + 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 }, + }); + }); +});