/** * Bare React Native `StorageAdapter` backed entirely by * `react-native-fs`. Every value the SDK persists is a file under an * SDK-owned subdirectory of the app's document directory by default; * callers may override the root with the `rootDir` arg. * * Writes route through a per-operation scratch sibling that is moved * over the destination on success. The fresh-write case is atomic; * the overwrite case is best-effort because `react-native-fs.moveFile` * does not expose an atomic replace-and-rename primitive, so the * adapter falls back to delete-then-move and a reader may briefly * observe `null` for the destination during that window. * * @example * ```ts * import { BareRNStorageAdapter } from '@keewano/react-native-sdk'; * * const storage = new BareRNStorageAdapter(); * await storage.writeFile({ path: 'batches/1.kwub', bytes }); * ``` */ import type { BareRNStorageAdapterArgs } from './types/nativeStorageAdapter'; import { type DeleteFileArgs, type FileSizeArgs, type ListFilesArgs, type ReadFileArgs, type StorageAdapter, type WriteFileArgs } from '@keewano/core'; declare class BareRNStorageAdapter implements StorageAdapter { /** * Per-`rootDir` coordinator registry. Two adapter instances pointed * at the same sandbox root share the same serialization queue so * path-level mutation safety follows the on-disk location, not the * wrapper instance. * * KNOWN LIMITATION: keys are EXACT paths, so two concurrent * mutations on ancestor/descendant pairs (e.g. write `a/b` while * delete `a` is in flight) do not serialize. The dir-vs-file guards * + `ensureParentDir` defense usually catch the resulting state * mismatch, but a true fix is prefix-aware locking inside * `MutationCoordinator`. Coarsening this to `rootDir` would * serialize ALL writes which is too expensive for the dispatcher's * batch flush path; defer to a dedicated MutationCoordinator * upgrade rather than the broad stopgap. */ private static readonly sharedMutations; private readonly rootDir; private readonly rnfs; private readonly mutations; /** * Construct an adapter bound to the host's React Native runtime * modules. The native bindings are loaded lazily so a host that * imports the adapter without instantiating it pays no startup cost. * * @param args - Optional sandbox root override and a DI hook for * `react-native-fs`. Pass a mock in tests. */ constructor(args?: BareRNStorageAdapterArgs); /** * Bytes are base64-encoded (react-native-fs's `writeFile` is * string-only) and routed through a per-operation scratch sibling * moved into place via `moveFile`. See the class-level JSDoc for * the overwrite atomicity caveat. * * Scratch sibling names embed a per-operation id (`__kwtmp__.` * and `__kwbak__.`) so a real user file living at * `${fullPath}.tmp` or `${fullPath}.bak` cannot collide with our * scratch state: serialization protects only the destination path, * not arbitrary suffixes a caller might already be using. * * @param args - Adapter-relative path and binary contents. */ writeFile({ path, bytes }: WriteFileArgs): Promise; /** * Full write pipeline: prepare parent, stage to `.tmp`, swap the * existing destination aside to `.bak`, commit the tmp into place * (restoring from backup if the final move fails), and clean up. */ private doWriteFile; /** * Rename the existing destination file (if any) aside to `backupPath` * so a failed move of the tmp file can be undone. Returns `true` * when a backup was made. Throws when the destination resolves to a * directory. */ private moveExistingToBackup; /** * Confirm the entry that landed at `backupPath` after the rename is * still an explicit file. If the stat fails OR reports a directory * (file-to-directory swap after the last guard), best-effort restore * the backup to `fullPath` so a failed overwrite is never * destructive, then surface the original failure / contract * violation. Counterpart of the Expo `verifyBackupOrRestore` helper. */ private verifyBackupOrRestore; /** * Move the tmp file into the final destination. On failure, restore * the backup so the overwrite is non-destructive. On success, clear * the backup best-effort. */ private commitTmpOrRestore; /** * Best-effort unlink used for tmp / backup cleanup so a cleanup * error never masks the original write failure. The targets are * always our own per-operation scratch siblings (suffix * `.__kwtmp__.` / `.__kwbak__.`), so a directory cannot * realistically land there - no defensive isDirectory check is * needed. */ private bestEffortUnlink; /** * The read is attempted first, then `exists` confirms whether a * thrown error means "absent" or a genuine I/O failure. Avoids the * check-then-read race window opened by the `unlink` + `moveFile` * pair in `writeFile`. * * The recovery wrap is scoped to `rnfs.readFile` only: a * `base64ToBytes` decode failure on legitimately-read bytes must * surface as itself, never as `null`, even if the path happens to * vanish between the successful read and a later stat. * * @param args - Adapter-relative path. * @returns File contents, or `null` when the path does not exist. */ readFile({ path }: ReadFileArgs): Promise; /** * `react-native-fs.unlink` throws on a missing path, so the adapter * pre-checks `exists` and returns silently when the destination is * already absent. A `stat` guard rejects directory targets up-front * because `unlink` removes directories recursively, which would * violate the file-only contract and risk wiping an entire subtree * from a mistaken caller. * * Each filesystem call is wrapped in its own narrow race-recovery * try/catch so a concurrent removal mid-flight resolves silently per * the idempotent contract. Contract-violation throws (the explicit * `deleteFile: path is a directory` errors) live OUTSIDE these * try/catch blocks so a directory target never gets swallowed as a * silent success - even if the directory disappears between the * stat that classified it and the recovery recheck below. * * @param args - Adapter-relative path. * @throws Error when the path exists and resolves to a directory. */ deleteFile({ path }: DeleteFileArgs): Promise; /** Inner delete pipeline run by `deleteFile` under the mutation chain. */ private doDeleteFile; /** * Three-state existence probe: `present` / `absent` / `unknown`. * `unknown` is returned when the native `exists` call itself throws * (transient I/O, permission flap, bridge serialization). Post- * mutation recovery branches MUST distinguish `absent` from * `unknown` so a thrown probe is not silently conflated with a * confirmed-missing entry: if a rename actually landed and the * follow-up probe throws, treating it as `absent` leaves data * orphaned at the scratch path. */ private probePresence; /** * Stat `path`. If `stat` throws but the three-state probe confirms * the path is gone, return `null` so the caller can treat it as * "vanished". Otherwise rethrow the original stat error so a real * I/O failure (or an ambiguous probe throw) surfaces. Using * `probePresence` instead of a 2-state silent probe makes a * thrown follow-up `exists` count as ambiguous rather than as a * confirmed-missing entry - which would otherwise let an orphan at * the scratch path silently pass as a completed delete. */ private statOrTreatVanished; /** * Move `fullPath` to a per-operation trash sibling, verify the moved * entry is still an explicit file, and only then unlink the trash. * If anything later fails (verify stat throw, contract-violation * throw, or unlink throw) AFTER the move-to-trash succeeded, the * helper best-effort restores the file to its original path before * rethrowing so a failed delete is never silently destructive. * Counterpart of the Expo `unlinkViaTrash` helper. */ /** * Classify a post-rename throw using the three-state probe pattern. * Returns: * - `'landed'`: probe confirms the destination is present - the * rename actually succeeded despite the throw; caller continues * as if the rename returned cleanly. * - `'sourceGone'`: destination is confirmed missing AND source is * confirmed missing - caller may treat as a benign concurrent * removal (silent no-op). * Throws `originalError` when the source is still present (standard * rethrow path) OR when the destination probe was ambiguous and the * source is confirmed gone (orphan risk - fail loudly). */ private classifyPostRenameThrow; /** * `unlink` may complete on the OS layer and only THEN reject. If * BOTH the trash entry AND the source path are confirmed gone, the * delete intent is satisfied even though the call threw. */ private unlinkLanded; private unlinkViaTrash; /** * After a filesystem call throws, re-check existence so a confirmed * missing path resolves silently per the idempotent contract. Throws * `originalError` when the path still exists or when the recovery * `exists` call itself throws. Returning normally signals "missing", * leaving the caller to issue its own `return` / `return null`. */ private recoverMissingOrRethrow; /** * `rnfs.exists` reports `true` for both files and directories, and * `rnfs.readDir` throws on a non-directory entry, so a `stat` is * needed to distinguish the two cases. A non-directory path is * treated the same as a missing directory. * * @param args - Adapter-relative directory and optional glob filter. * @returns Basenames of matching entries, or an empty array when * the directory does not exist, is not a directory, or no entries * match. */ listFiles({ dir, pattern }: ListFilesArgs): Promise; /** * `react-native-fs.stat` returns size as a number on iOS and a * numeric string on Android, so the value is normalized via * `Number(...)` (strict whole-string parse) when needed. Directories * are rejected explicitly and any non-integer or negative parsed * size is treated as a hard failure so callers never receive `NaN` * or a silently-truncated value like `Number.parseInt('123abc')`. * * The recovery wrap is scoped to `rnfs.stat` only: contract-violation * throws (directory target, invalid size) live OUTSIDE the catch so * a concurrent removal cannot swallow them as a silent `null`. * * @param args - Adapter-relative path. * @returns Byte length, or `null` when the path does not exist. * @throws Error when the path is a directory or when `stat.size` * cannot be parsed as a non-negative integer. */ fileSize({ path }: FileSizeArgs): Promise; } export { BareRNStorageAdapter };