docs/sync-and-op-log/operation-rules.md
Last Updated: December 2025 Related: Operation Log Architecture
This document establishes the core rules and principles for designing the Operation Log store and defining new Operations. Adherence to these rules ensures data integrity, synchronization reliability, and system performance.
ops table in the store must be strictly append-only for active operations.syncedAt is set).SUP_OPS, it MUST NOT be modified.SUP_OPS) is the ultimate source of truth for the application state.state_cache and runtime NgRx store are projections derived from the log.state_cache (snapshot).Snapshot + Tail Ops.UPDATE_TASK { id: "A", changes: { title: "New" } }UPDATE_ALL_TASKS { [ ... entire tasks array ... ] }SYNC_IMPORT and BACKUP_IMPORT are allowed to replace large chunks of state but must be treated as special "reset" events.CREATE with an existing ID must be ignored (not merged or updated). If updates are needed, a separate UPDATE operation must follow.DELETE on a missing entity should be a no-op.UPDATE on a missing entity should be queued for retry (see 3.4 Dependency Awareness).Date objects (use timestamp numbers).undefined (use null or omit the key, depending on semantics).vectorClock.OperationLogEffects (or equivalent creator) captures the clock at the moment of creation.schemaVersion.CURRENT_SCHEMA_VERSION from SchemaMigrationService at the time of creation.OpTypes (CRT, UPD, DEL, MOV) rather than a generic CHANGE.REPAIR cycle at the end if needed.OperationLogStore should not contain logic specific to a sync provider (Dropbox, WebDAV).getUnsynced(), markSynced(), markRejected() as generic methods.DependencyQueue until the parent arrives.Status (December 2025): Tombstones are DEFERRED. After comprehensive evaluation, the current event-sourced architecture provides sufficient safeguards without explicit tombstones. See
todo.mdItem 1 for the full evaluation.
SYNC_IMPORT and BACKUP_IMPORT bypass these limits but must be clearly marked as bulk operations and trigger immediate snapshot creation afterward.inject(LOCAL_ACTIONS) instead of inject(Actions).Actions.Example:
@Injectable()
export class MyEffects {
private _actions$ = inject(LOCAL_ACTIONS); // ✅ Correct for side effects
showSnack$ = createEffect(
() =>
this._actions$.pipe(
ofType(completeTask),
tap(() => this.snackService.show('Task completed!')),
),
{ dispatch: false },
);
}
this._actions$.pipe(ofType(...))) over selector-based effects (this._store$.select(...)).LOCAL_ACTIONS filtering.HydrationStateService.isApplyingRemoteOps().ArchiveOperationHandler, NOT by regular effects.ArchiveOperationHandlerEffects routes through ArchiveOperationHandler (via LOCAL_ACTIONS)OperationApplierService calls ArchiveOperationHandler directly after dispatchtagSharedMetaReducer, not in an effect.OperationCaptureService automatically captures all entity changes from a single action.operation-capture.meta-reducer calls OperationCaptureService.enqueue() with the action.entityChanges[] array containing all affected entities.See operation-log.const.ts for all configurable values:
| Constant | Value | Description |
|---|---|---|
COMPACTION_TRIGGER | 500 ops | Operations before automatic compaction |
COMPACTION_RETENTION_MS | 7 days | Synced ops older than this may be deleted |
EMERGENCY_COMPACTION_RETENTION_MS | 1 day | Shorter retention for quota exceeded |
MAX_COMPACTION_FAILURES | 3 | Failures before user notification |
MAX_DOWNLOAD_OPS_IN_MEMORY | 50,000 | Bounds memory during API download |
REMOTE_OP_FILE_RETENTION_MS | 14 days | Server-side operation file retention |
PENDING_OPERATION_EXPIRY_MS | 24 hours | Pending ops older than this are rejected |
When adding a new persistent action:
meta.isPersistent: true to the actionmeta.entityType and meta.opTypeLOCAL_ACTIONSArchiveOperationHandlerACTION_AFFECTED_ENTITIES if multi-entity