mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
refactor(sync): compile-enforce the in-flight guard via a branded provider type
Multi-review (Architecture) hardening: the guard was threaded by an implicit
call-graph convention — a future entry point or write-helper caller that forgot
the _withTargetGuard shadow would silently re-open cross-target writes, the
worst failure class here. Introduce a phantom-branded GuardedFileSyncProvider
(FileSyncProvider & { [unique symbol]: true }) returned by _withTargetGuard and
required by every write-path helper param. Passing a raw provider to a write
path is now a compile error (verified: TS2345). Only createAdapter and the
intentionally-unguarded _deleteAllData keep the raw type. No runtime/behavior
change (brand is a phantom type); adapter suite 135/135.
This commit is contained in:
parent
de7ead4491
commit
0d46f1c2b9
1 changed files with 44 additions and 26 deletions
|
|
@ -85,6 +85,22 @@ import {
|
|||
*
|
||||
* @see FileBasedSyncData for the file schema
|
||||
*/
|
||||
declare const GUARDED_PROVIDER_BRAND: unique symbol;
|
||||
|
||||
/**
|
||||
* A file provider whose `uploadFile`/`removeFile` are wrapped by
|
||||
* `_withTargetGuard` — a write aborts if the target changed mid-operation. Every
|
||||
* write-path helper takes this branded type instead of the raw provider, so the
|
||||
* compiler rejects passing an unguarded provider to a write path: the in-flight
|
||||
* guard invariant is enforced at compile time, not by call-graph convention (a
|
||||
* future entry point or helper that forgets the guard is a build error, not a
|
||||
* silent cross-target write). Only the raw entry points `createAdapter` and the
|
||||
* intentionally-unguarded `_deleteAllData` keep `FileSyncProvider`. (Task 2.)
|
||||
*/
|
||||
type GuardedFileSyncProvider = FileSyncProvider<SyncProviderId> & {
|
||||
readonly [GUARDED_PROVIDER_BRAND]: true;
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FileBasedSyncAdapterService {
|
||||
private _encryptAndCompressHandler = new EncryptAndCompressHandlerService();
|
||||
|
|
@ -430,12 +446,14 @@ export class FileBasedSyncAdapterService {
|
|||
private _withTargetGuard(
|
||||
rawProvider: FileSyncProvider<SyncProviderId>,
|
||||
capturedGeneration: number,
|
||||
): FileSyncProvider<SyncProviderId> {
|
||||
): GuardedFileSyncProvider {
|
||||
const assertUnchanged = (): void => {
|
||||
if (this._targetGeneration !== capturedGeneration) {
|
||||
throw new FileSyncTargetChangedError(capturedGeneration, this._targetGeneration);
|
||||
}
|
||||
};
|
||||
// The Proxy is structurally a FileSyncProvider; the brand is a phantom type
|
||||
// with no runtime property, so cast through unknown.
|
||||
return new Proxy(rawProvider, {
|
||||
get: (target, prop, receiver) => {
|
||||
if (prop === 'uploadFile' || prop === 'removeFile') {
|
||||
|
|
@ -452,7 +470,7 @@ export class FileBasedSyncAdapterService {
|
|||
const value = Reflect.get(target, prop, receiver);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
}) as unknown as GuardedFileSyncProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -617,7 +635,7 @@ export class FileBasedSyncAdapterService {
|
|||
* Gets the current sync state from cache or by downloading.
|
||||
*/
|
||||
private async _getCurrentSyncState(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
providerKey: string,
|
||||
|
|
@ -754,7 +772,7 @@ export class FileBasedSyncAdapterService {
|
|||
* snapshot whose state never saw those ops.
|
||||
*/
|
||||
private async _uploadWithMismatchFallback(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
newData: FileBasedSyncData,
|
||||
|
|
@ -1585,7 +1603,7 @@ export class FileBasedSyncAdapterService {
|
|||
|
||||
/** Downloads + decodes `sync-ops.json` and validates its version. */
|
||||
private async _downloadOpsFile(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
): Promise<{ data: FileBasedOpsFile; rev: string }> {
|
||||
|
|
@ -1617,7 +1635,7 @@ export class FileBasedSyncAdapterService {
|
|||
* the immutable snapshot referenced by an ops file's `snapshotRef.file`.
|
||||
*/
|
||||
private async _downloadStateFile(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
path: string = FILE_BASED_SYNC_CONSTANTS.STATE_FILE,
|
||||
|
|
@ -1651,7 +1669,7 @@ export class FileBasedSyncAdapterService {
|
|||
* pre-#9040 clients that don't read `snapshotRef.file`.
|
||||
*/
|
||||
private async _writeStateFile(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
data: FileBasedStateFile,
|
||||
|
|
@ -1702,7 +1720,7 @@ export class FileBasedSyncAdapterService {
|
|||
* capability-gated).
|
||||
*/
|
||||
private async _removeGenStateFile(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
file: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
|
|
@ -1714,7 +1732,7 @@ export class FileBasedSyncAdapterService {
|
|||
|
||||
/** Copies the current `sync-state.json` to its `.bak` (non-fatal). */
|
||||
private async _backupStateFile(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
): Promise<void> {
|
||||
|
|
@ -1738,7 +1756,7 @@ export class FileBasedSyncAdapterService {
|
|||
|
||||
/** Recovers the snapshot from `sync-state.json.bak` (null if unusable). */
|
||||
private async _recoverStateFromBackup(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
): Promise<FileBasedStateFile | null> {
|
||||
|
|
@ -1777,7 +1795,7 @@ export class FileBasedSyncAdapterService {
|
|||
* Returns null when no snapshot validates the ref (caller signals a gap).
|
||||
*/
|
||||
private async _loadValidatedSnapshot(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
opsFile: FileBasedOpsFile,
|
||||
|
|
@ -1852,7 +1870,7 @@ export class FileBasedSyncAdapterService {
|
|||
* inline comment.
|
||||
*/
|
||||
private async _writeTombstoneAndNeutralizeBak(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
expectedLegacyRev?: string,
|
||||
|
|
@ -1895,7 +1913,7 @@ export class FileBasedSyncAdapterService {
|
|||
}
|
||||
|
||||
private async _writePendingSplitMigration(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
legacy: FileBasedSyncData,
|
||||
|
|
@ -1956,7 +1974,7 @@ export class FileBasedSyncAdapterService {
|
|||
}
|
||||
|
||||
private async _finalizeSplitMigrationMarker(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
pending: FileBasedOpsFile,
|
||||
|
|
@ -1990,7 +2008,7 @@ export class FileBasedSyncAdapterService {
|
|||
* used to build it or fails and causes the newer v2 payload to be re-imported.
|
||||
*/
|
||||
private async _resumePendingSplitMigration(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
initialPending: FileBasedOpsFile,
|
||||
|
|
@ -2106,7 +2124,7 @@ export class FileBasedSyncAdapterService {
|
|||
* rev), or null for a truly fresh folder.
|
||||
*/
|
||||
private async _maybeMigrateLegacyToSplit(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
clientId: string,
|
||||
|
|
@ -2159,7 +2177,7 @@ export class FileBasedSyncAdapterService {
|
|||
* to classify transient vs genuine concurrency and never force-overwrites.
|
||||
*/
|
||||
private async _uploadOpsFileWithMismatchFallback(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
newOpsFile: FileBasedOpsFile,
|
||||
|
|
@ -2219,7 +2237,7 @@ export class FileBasedSyncAdapterService {
|
|||
* then the trimmed `sync-ops.json` referencing it via snapshotRef.
|
||||
*/
|
||||
private async _uploadOpsSplit(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
ops: SyncOperation[],
|
||||
|
|
@ -2449,7 +2467,7 @@ export class FileBasedSyncAdapterService {
|
|||
* validates it against the ops file's snapshotRef (mismatch ⇒ gap).
|
||||
*/
|
||||
private async _downloadOpsSplit(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
sinceSeq: number,
|
||||
|
|
@ -2653,7 +2671,7 @@ export class FileBasedSyncAdapterService {
|
|||
* empty when the folder is truly fresh (or only a tombstone remains).
|
||||
*/
|
||||
private async _tryLegacyReadOnlyDownload(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
sinceSeq: number,
|
||||
|
|
@ -2708,7 +2726,7 @@ export class FileBasedSyncAdapterService {
|
|||
* `sync-data.json` so OFF clients don't diverge.
|
||||
*/
|
||||
private async _uploadSnapshotSplit(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
clientId: string,
|
||||
|
|
@ -2834,7 +2852,7 @@ export class FileBasedSyncAdapterService {
|
|||
* (conditional PUT) is unaffected.
|
||||
*/
|
||||
private async _writeBakFile<T>(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
bakPath: string,
|
||||
|
|
@ -2871,7 +2889,7 @@ export class FileBasedSyncAdapterService {
|
|||
* caller then surfaces its ORIGINAL corruption error.
|
||||
*/
|
||||
private async _readBakFile<T extends { version: number }>(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
bakPath: string,
|
||||
|
|
@ -2925,7 +2943,7 @@ export class FileBasedSyncAdapterService {
|
|||
* the writes leaves a valid old primary + new .bak — nothing stale to recover.)
|
||||
*/
|
||||
private async _forceUploadWithBakFirst(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
primaryPath: string,
|
||||
bakPath: string,
|
||||
encoded: string,
|
||||
|
|
@ -2946,7 +2964,7 @@ export class FileBasedSyncAdapterService {
|
|||
* absent" — the provider rejects the write if another client created it.
|
||||
*/
|
||||
private async _conditionalUploadRepairSnapshot(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
primaryPath: string,
|
||||
bakPath: string,
|
||||
encoded: string,
|
||||
|
|
@ -3072,7 +3090,7 @@ export class FileBasedSyncAdapterService {
|
|||
* @returns The sync data and its revision (ETag) for conditional upload
|
||||
*/
|
||||
private async _downloadSyncFile(
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
provider: GuardedFileSyncProvider,
|
||||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
): Promise<{ data: FileBasedSyncData; rev: string }> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue