mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* fix(sync): harden file-based .bak recovery and split gap detection Post-merge review of the SPAP-8/9/10/11 series found five defects in the file-based sync adapter; all fixed here with regression tests proven red/green against the previous code. Design cross-checked by a 7-reviewer multi-agent pass; its findings are folded in. - Split gap detection suppressed a syncVersion reset when the remote clock was EQUAL OR GREATER_THAN the last-seen clock — the exact bug the SPAP-9 review follow-up removed from the single-file path. A dominating client's snapshot reset (which compacts ops this client never saw) was treated as cosmetic, skipping snapshot hydration and silently diverging. Now EQUAL only, matching the single-file path. - .bak recovery staged the CORRUPT primary's rev, which setLastServerSeq then promoted to _lastSeenRevs: every later poll's SPAP-10 pre-check read "unchanged" and skipped the re-download while the upload path (no .bak recovery) kept failing on the corrupt primary — sync wedged until another client rewrote the file. A recovery download now never stages/promotes the rev (each poll re-recovers and re-seeds the heal cache), and every site that rewrites _lastSeenRevs drops any stale staged rev via the shared _commitLastSeenRev. - Snapshot uploads (force-upload / "Use Local" / E2EE re-encryption) left the pre-snapshot .bak behind. After a password rotation the stale old-key .bak was silently "recovered" by a still-old-key client (suppressing its wrong-password prompt) and heal-uploaded back over the new-key primary, reverting the rotation. Snapshot uploads now write the same payload to .bak FIRST, then the primary (_forceUploadWithBakFirst; deliberately FATAL — aborting pre-primary leaves the remote consistent for a retry). Applies to sync-data.json.bak and sync-ops.json.bak; sync-state.json.bak is deliberately exempt: its adoption is ref-validated (EQUAL clock vs snapshotRef), so a stale copy is inert, and it must keep serving the compaction crash window it exists for. - Recovery additionally refuses a PLAINTEXT .bak when encryption is expected: decoding trusts the file's own prefix flags, so a plaintext .bak decodes even under a wrong/rotated key — the same wrong-password-suppression class via mode (rather than key) mismatch. - The split migration wrote the v3 tombstone BEFORE neutralizing the legacy .bak (best-effort): a crash between the writes left a live v2 .bak that an OFF client's recovery would resurrect over the tombstone, forking the folder. Neutralize-first, fatal. Residual: a step-3 failure after the migration's ops-file commit leaves a live v2 file until the next snapshot upload (documented at the call site). - sync-ops.json — the hot file, rewritten on every op-bearing sync — had no backup at all, so a torn write wedged split sync until a manual force-upload. It now gets the same backup-before-overwrite + recovery + heal treatment as the single-file format. deleteAllData deletes the split files BEFORE the tombstone and treats source-of-truth deletion failures as errors (success:false) — it previously left every split file behind and reported success. The three backup/recover pairs are collapsed into shared _writeBakFile / _readBakFile helpers (the EQUAL||GREATER_THAN divergence above is exactly the copy-drift failure mode this prevents); .bak file names move into FILE_BASED_SYNC_CONSTANTS as remote-format surface. * fix(sync): show the exact pending-op count in the conflict dialog Compaction can fold still-unsynced ops into the snapshot baseline clock, so the dialog's vector-clock delta could report "0 changes" right next to "N local changes pending" — and the false 0 skipped the secondary USE_REMOTE overwrite confirmation. The dialog now prefers the EXACT pending-op count carried on LocalDataConflictError (new optional ConflictData.localUnsyncedOpsCount): it is precisely "what USE_REMOTE would discard", so both the displayed count and the >= 20-difference confirmation threshold work from a truthful figure; the clock delta remains the fallback when no measured count is supplied. Display/confirmation-only — no clock or op-log semantics touched. Also asserts the explicit-null lastSyncedVectorClock contract at the fresh-client conflict throw sites (test gap from SPAP-7). * chore(lint): enforce tx-handle-only access in op-log transactions The SQLite op-log adapter serializes every entry point through a per-connection FIFO queue; awaiting an adapter method inside a .transaction() callback enqueues behind the transaction's own slot and silently deadlocks all op-log persistence. The port contract documents the precondition and #8849 promised a lint rule — this adds it (no-adapter-in-tx, scoped to src/app/op-log) with RuleTester specs. Matching is rename-proof: it flags access on the SAME receiver the transaction was opened on (plain identifiers and any `this.<field>`), so it does not depend on the field being named `_adapter`; known heuristic gaps (extracted callbacks, aliasing, method indirection) are documented. Also corrects the port doc: IndexedDB serializes only overlapping-scope transactions; SQLite provides the stronger whole-connection exclusion.
260 lines
8.1 KiB
JavaScript
260 lines
8.1 KiB
JavaScript
// @ts-check
|
|
const tseslint = require('typescript-eslint');
|
|
const angular = require('angular-eslint');
|
|
const prettierRecommended = require('eslint-plugin-prettier/recommended');
|
|
const preferArrow = require('eslint-plugin-prefer-arrow');
|
|
const localRules = require('eslint-plugin-local-rules');
|
|
|
|
module.exports = tseslint.config(
|
|
// Global ignores
|
|
{
|
|
ignores: [
|
|
'app-builds/**/*',
|
|
'dist/**',
|
|
'node_modules/**/*',
|
|
'src/app/t.const.ts',
|
|
'src/assets/bundled-plugins/**/*',
|
|
'src/app/config/env.generated.ts',
|
|
'.tmp/**/*',
|
|
'packages/plugin-api/**/*',
|
|
'packages/plugin-dev/**/*',
|
|
'packages/shared-schema/**/*',
|
|
'packages/super-sync-server/**/*',
|
|
'packages/vite-plugin/**/*',
|
|
'packages/*/dist/**/*',
|
|
],
|
|
},
|
|
// TypeScript files
|
|
{
|
|
files: ['**/*.ts'],
|
|
extends: [
|
|
...tseslint.configs.recommended,
|
|
...angular.configs.tsRecommended,
|
|
prettierRecommended,
|
|
],
|
|
processor: angular.processInlineTemplates,
|
|
plugins: {
|
|
'prefer-arrow': preferArrow,
|
|
},
|
|
languageOptions: {
|
|
parserOptions: {
|
|
ecmaVersion: 2020,
|
|
sourceType: 'module',
|
|
},
|
|
},
|
|
rules: {
|
|
// Disabled rules
|
|
'@typescript-eslint/no-explicit-any': 'off',
|
|
'@typescript-eslint/ban-ts-comment': 'off',
|
|
'@typescript-eslint/no-empty-function': 'off',
|
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
|
'@angular-eslint/component-selector': 'off',
|
|
'@angular-eslint/no-input-rename': 'off',
|
|
'@typescript-eslint/no-inferrable-types': 'off',
|
|
'no-underscore-dangle': 'off',
|
|
'arrow-body-style': 'off',
|
|
'@typescript-eslint/member-ordering': 'off',
|
|
'import/order': 'off',
|
|
'arrow-parens': 'off',
|
|
'@typescript-eslint/explicit-member-accessibility': 'off',
|
|
|
|
// Enabled rules
|
|
'prettier/prettier': 'error',
|
|
'@typescript-eslint/no-unused-vars': [
|
|
'error',
|
|
{ args: 'none', caughtErrors: 'none' },
|
|
],
|
|
'@typescript-eslint/explicit-function-return-type': [
|
|
'error',
|
|
{
|
|
allowExpressions: true,
|
|
allowTypedFunctionExpressions: true,
|
|
allowHigherOrderFunctions: true,
|
|
allowDirectConstAssertionInArrowFunctions: true,
|
|
allowConciseArrowFunctionExpressionsStartingWithVoid: true,
|
|
},
|
|
],
|
|
'@typescript-eslint/naming-convention': [
|
|
'error',
|
|
{
|
|
selector: 'default',
|
|
format: ['camelCase', 'snake_case', 'UPPER_CASE', 'PascalCase'],
|
|
leadingUnderscore: 'allowSingleOrDouble',
|
|
trailingUnderscore: 'allow',
|
|
filter: { regex: '(should)|@tags', match: false },
|
|
},
|
|
{
|
|
selector: 'variable',
|
|
format: ['camelCase', 'snake_case', 'UPPER_CASE', 'PascalCase'],
|
|
leadingUnderscore: 'allowSingleOrDouble',
|
|
trailingUnderscore: 'allow',
|
|
},
|
|
{ selector: 'enum', format: ['PascalCase', 'UPPER_CASE'] },
|
|
{ selector: 'typeLike', format: ['PascalCase'] },
|
|
],
|
|
'prefer-const': 'error',
|
|
'@typescript-eslint/no-unused-expressions': 'error',
|
|
'@typescript-eslint/no-empty-object-type': 'error',
|
|
'max-len': [
|
|
'error',
|
|
{
|
|
ignorePattern: '^import \\{.+;$',
|
|
ignoreRegExpLiterals: true,
|
|
ignoreStrings: true,
|
|
ignoreUrls: true,
|
|
code: 150,
|
|
},
|
|
],
|
|
'id-blacklist': 'error',
|
|
// @typescript-eslint/member-delimiter-style removed in v8 - Prettier handles this
|
|
'no-shadow': 'off',
|
|
'@typescript-eslint/no-shadow': 'error',
|
|
'comma-dangle': ['error', 'always-multiline'],
|
|
'no-mixed-operators': 'error',
|
|
'prefer-arrow/prefer-arrow-functions': 'error',
|
|
'@angular-eslint/directive-selector': [
|
|
'error',
|
|
{ type: 'attribute', prefix: '', style: 'camelCase' },
|
|
],
|
|
// @typescript-eslint/ban-types replaced by specific rules in v8
|
|
'@typescript-eslint/no-unsafe-function-type': 'error',
|
|
'@typescript-eslint/no-wrapper-object-types': 'error',
|
|
},
|
|
},
|
|
{
|
|
files: ['packages/sync-core/**/*.ts'],
|
|
rules: {
|
|
'no-restricted-imports': [
|
|
'error',
|
|
{
|
|
paths: [
|
|
{
|
|
name: '@sp/shared-schema',
|
|
message:
|
|
'@sp/sync-core must stay domain-agnostic; shared-schema is SP-specific.',
|
|
},
|
|
],
|
|
patterns: [
|
|
{
|
|
group: [
|
|
'@angular/*',
|
|
'@ngrx/*',
|
|
'@sp/shared-schema/*',
|
|
'../shared-schema/*',
|
|
'../shared-schema/**',
|
|
'../../shared-schema/*',
|
|
'../../shared-schema/**',
|
|
'**/shared-schema/*',
|
|
'**/shared-schema/**',
|
|
'src/app/*',
|
|
'src/app/**',
|
|
'**/src/app/*',
|
|
'**/src/app/**',
|
|
],
|
|
message:
|
|
'@sp/sync-core must not import Angular, NgRx, app code, or SP-specific schema packages.',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
'no-restricted-syntax': [
|
|
'error',
|
|
{
|
|
selector: 'ImportExpression',
|
|
message:
|
|
'@sp/sync-core must not use dynamic imports; they bypass package-boundary checks.',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
files: ['packages/sync-providers/**/*.ts'],
|
|
rules: {
|
|
'no-restricted-imports': [
|
|
'error',
|
|
{
|
|
paths: [
|
|
{
|
|
name: '@sp/shared-schema',
|
|
message: '@sp/sync-providers must not import SP-specific schema packages.',
|
|
},
|
|
],
|
|
patterns: [
|
|
{
|
|
group: [
|
|
'@angular/*',
|
|
'@ngrx/*',
|
|
'@sp/shared-schema/*',
|
|
'@sp/sync-core/*',
|
|
'**/shared-schema/*',
|
|
'**/shared-schema/**',
|
|
'**/sync-core/*',
|
|
'**/sync-core/**',
|
|
'src/app/*',
|
|
'src/app/**',
|
|
'**/src/app/*',
|
|
'**/src/app/**',
|
|
],
|
|
message:
|
|
'@sp/sync-providers must use only public @sp/sync-core exports and must not import Angular, NgRx, app code, or SP-specific schema packages.',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
'no-restricted-syntax': [
|
|
'error',
|
|
{
|
|
selector: 'ImportExpression',
|
|
message:
|
|
'@sp/sync-providers must not use dynamic imports; they bypass package-boundary checks.',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
// NgRx effects files - require hydration guards on selector-based effects
|
|
{
|
|
files: ['**/*.effects.ts'],
|
|
plugins: {
|
|
'local-rules': localRules,
|
|
},
|
|
rules: {
|
|
'local-rules/require-hydration-guard': 'error',
|
|
'local-rules/require-entity-registry': 'warn',
|
|
'local-rules/no-actions-in-effects': 'error',
|
|
'local-rules/no-multi-entity-effect': 'warn',
|
|
},
|
|
},
|
|
// Op-log persistence: inside an adapter.transaction() callback only the tx
|
|
// handle may be used — adapter methods enqueue behind the transaction's own
|
|
// FIFO queue slot on the SQLite backend and deadlock (see
|
|
// SqliteOpLogAdapter._serialize()).
|
|
{
|
|
files: ['src/app/op-log/**/*.ts'],
|
|
plugins: {
|
|
'local-rules': localRules,
|
|
},
|
|
rules: {
|
|
'local-rules/no-adapter-in-tx': 'error',
|
|
},
|
|
},
|
|
// App code must route logging through Log/SyncLog/OpLog/... helpers.
|
|
// Direct console.* calls bypass the exportable log history users attach
|
|
// to bug reports. The Log implementation itself, tests, and benchmarks
|
|
// (which intentionally dump timing numbers to stdout) are exempt.
|
|
{
|
|
files: ['src/app/**/*.ts'],
|
|
ignores: ['src/app/**/*.spec.ts', 'src/app/**/*.benchmark.ts', 'src/app/core/log.ts'],
|
|
rules: {
|
|
'no-console': 'error',
|
|
},
|
|
},
|
|
// HTML files
|
|
{
|
|
files: ['**/*.html'],
|
|
extends: [...angular.configs.templateRecommended, prettierRecommended],
|
|
rules: {
|
|
'@angular-eslint/template/no-negated-async': 'off',
|
|
'prettier/prettier': 'error',
|
|
},
|
|
},
|
|
);
|