@drakkar.software/starfish-client 3.0.0-alpha.2 → 3.0.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_crypto_helpers.d.ts +4 -0
- package/dist/bindings/zustand.js +8 -3
- package/dist/bindings/zustand.js.map +2 -2
- package/dist/cap-mint.d.ts +20 -0
- package/dist/cap-mint.js +12 -0
- package/dist/cap-mint.js.map +7 -0
- package/dist/directory.d.ts +9 -0
- package/dist/directory.js +24 -0
- package/dist/directory.js.map +7 -0
- package/dist/identity.d.ts +4 -82
- package/dist/identity.js +2 -354
- package/dist/identity.js.map +4 -4
- package/dist/index.js +8 -3
- package/dist/index.js.map +2 -2
- package/dist/keyring.d.ts +6 -0
- package/dist/keyring.js +26 -0
- package/dist/keyring.js.map +7 -0
- package/dist/pairing.d.ts +6 -0
- package/dist/pairing.js +26 -0
- package/dist/pairing.js.map +7 -0
- package/dist/recipients.d.ts +6 -0
- package/dist/recipients.js +16 -0
- package/dist/recipients.js.map +7 -0
- package/dist/types.d.ts +9 -1
- package/package.json +2 -2
- package/dist/append.d.ts +0 -50
- package/dist/background-sync.js +0 -29
- package/dist/bindings/broadcast.d.ts +0 -19
- package/dist/bindings/broadcast.js +0 -65
- package/dist/bindings/react.d.ts +0 -12
- package/dist/bindings/react.js +0 -25
- package/dist/bindings/suspense.js +0 -49
- package/dist/client.js +0 -112
- package/dist/config.js +0 -18
- package/dist/crypto.js +0 -49
- package/dist/debounced-sync.js +0 -120
- package/dist/dedup.js +0 -35
- package/dist/entitlements.js +0 -41
- package/dist/export.js +0 -115
- package/dist/group-crypto.d.ts +0 -111
- package/dist/group-crypto.js +0 -205
- package/dist/group-crypto.js.map +0 -7
- package/dist/history.js +0 -61
- package/dist/logger.js +0 -80
- package/dist/migrate.js +0 -38
- package/dist/mobile-lifecycle.js +0 -55
- package/dist/multi-store.js +0 -92
- package/dist/polling.js +0 -52
- package/dist/resolvers.js +0 -223
- package/dist/service-worker.js +0 -55
- package/dist/storage/indexeddb.js +0 -59
- package/dist/sync.js +0 -127
- package/dist/types.js +0 -18
- package/dist/validate.js +0 -28
package/dist/group-crypto.js.map
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../src/group-crypto.ts", "../src/crypto.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Group encryption utilities for Starfish.\n *\n * Enables multiple users to share a common encrypted collection without sharing\n * a passphrase. Each member holds their own credentials; a Group Encryption Key\n * (GEK) is distributed per-member using X25519 ECDH key agreement.\n *\n * Typical flow:\n * 1. Each user calls `deriveCredentials(passphrase)` \u2014 now includes groupPublicKey / groupPrivateKey.\n * 2. Admin calls `createGroupKeyring(...)` to create a keyring document.\n * 3. Members call `createGroupEncryptor(keyringData, myIdentity, myPrivateKey)` to get an Encryptor.\n * 4. The Encryptor is passed to SyncManager via the `encryptor` option.\n */\n\nimport { x25519 } from \"@noble/curves/ed25519.js\"\nimport { getCrypto, getBase64, IV_BYTES, deriveKey } from \"@drakkar.software/starfish-protocol\"\nimport type { Encryptor } from \"./crypto.js\"\nimport { createEncryptor } from \"./crypto.js\"\n\n// \u2500\u2500 Internal helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction bytesToHex(bytes: Uint8Array): string {\n return Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\")).join(\"\")\n}\n\nfunction hexToBytes(hex: string): Uint8Array {\n const bytes = new Uint8Array(hex.length / 2)\n for (let i = 0; i < bytes.length; i++) {\n bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)\n }\n return bytes\n}\n\nconst ALGO = \"AES-GCM\"\nconst GROUP_WRAP_SALT = \"starfish-group-wrap\"\nconst GROUP_WRAP_INFO = \"starfish-group-wrap\"\nconst GROUP_ECDH_DOMAIN = \"starfish-group-ecdh\"\nconst GROUP_DATA_INFO = \"starfish-group\"\nconst GEK_BYTES = 32\n\n// \u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** An ECDH key pair used for group encryption. Hex-encoded for easy serialization. */\nexport interface GroupKeyPair {\n /** Hex-encoded X25519 private key (32 bytes). Keep secret \u2014 never store on server. */\n privateKey: string\n /** Hex-encoded X25519 public key (32 bytes). Safe to publish. */\n publicKey: string\n}\n\n/** One epoch's wrapped keys: each member's GEK encrypted to their public key. */\nexport interface EpochKeyring {\n /** The admin's hex-encoded X25519 public key (used for ECDH by members). */\n adminPublicKey: string\n /** Map from member identity (userId) \u2192 base64(IV || AES-GCM(GEK)) */\n wrappedKeys: Record<string, string>\n}\n\n/** The full keyring document stored in a Starfish collection. Push this with any SyncManager. */\nexport interface GroupKeyring {\n /** The epoch number currently used for new encryptions. */\n currentEpoch: number\n /** All epochs. Members unwrap the GEK for whichever epoch a document was encrypted with. */\n epochs: Record<string, EpochKeyring>\n}\n\n// \u2500\u2500 Key derivation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Derives a deterministic X25519 key pair from a passphrase + userId.\n *\n * The derivation uses SHA-256 with a fixed domain separator so it is distinct\n * from the auth token and encryption key derivations. Same passphrase + userId\n * always produces the same key pair on any device (stateless).\n */\nexport async function deriveGroupKeyPair(passphrase: string, userId: string): Promise<GroupKeyPair> {\n const c = getCrypto()\n const enc = new TextEncoder()\n const input = enc.encode(`${passphrase}:${userId}:${GROUP_ECDH_DOMAIN}`)\n const hash = await c.subtle.digest(\"SHA-256\", input)\n const privateKeyBytes = new Uint8Array(hash)\n const publicKeyBytes = x25519.getPublicKey(privateKeyBytes)\n return { privateKey: bytesToHex(privateKeyBytes), publicKey: bytesToHex(publicKeyBytes) }\n}\n\n// \u2500\u2500 GEK generation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Generates a random 256-bit Group Encryption Key as a hex string. */\nexport function generateGroupKey(): string {\n const c = getCrypto()\n return bytesToHex(c.getRandomValues(new Uint8Array(GEK_BYTES)))\n}\n\n// \u2500\u2500 Key wrapping / unwrapping \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Wraps a GEK for a specific member using ECDH key agreement.\n *\n * The wrapper (admin) and member each have an X25519 key pair. ECDH between\n * `wrapperPrivateKey` and `memberPublicKey` produces a shared secret, which is\n * used to derive an AES-256-GCM key that encrypts the GEK.\n *\n * @returns base64(IV || AES-GCM-ciphertext)\n */\nexport async function wrapGroupKey(\n gek: string,\n memberPublicKey: string,\n wrapperPrivateKey: string,\n): Promise<string> {\n const sharedSecret = x25519.getSharedSecret(hexToBytes(wrapperPrivateKey), hexToBytes(memberPublicKey))\n const wrappingKey = await deriveKey(bytesToHex(sharedSecret), GROUP_WRAP_SALT, GROUP_WRAP_INFO)\n\n const c = getCrypto()\n const b64 = getBase64()\n const iv = c.getRandomValues(new Uint8Array(IV_BYTES))\n const encrypted = await c.subtle.encrypt({ name: ALGO, iv }, wrappingKey, hexToBytes(gek).buffer as ArrayBuffer)\n\n const combined = new Uint8Array(IV_BYTES + encrypted.byteLength)\n combined.set(iv)\n combined.set(new Uint8Array(encrypted), IV_BYTES)\n return b64.encode(combined)\n}\n\n/**\n * Unwraps a GEK using the member's own private key and the admin's public key.\n *\n * ECDH between `memberPrivateKey` and `adminPublicKey` yields the same shared\n * secret as the wrapping step, so the same AES key is derived and the GEK is\n * recovered.\n *\n * @returns GEK as a hex string\n */\nexport async function unwrapGroupKey(\n wrapped: string,\n memberPrivateKey: string,\n adminPublicKey: string,\n): Promise<string> {\n const sharedSecret = x25519.getSharedSecret(hexToBytes(memberPrivateKey), hexToBytes(adminPublicKey))\n const wrappingKey = await deriveKey(bytesToHex(sharedSecret), GROUP_WRAP_SALT, GROUP_WRAP_INFO)\n\n const b64 = getBase64()\n const c = getCrypto()\n const combined = b64.decode(wrapped)\n const iv = combined.slice(0, IV_BYTES)\n const ciphertext = combined.slice(IV_BYTES)\n try {\n const decrypted = await c.subtle.decrypt({ name: ALGO, iv }, wrappingKey, ciphertext)\n return bytesToHex(new Uint8Array(decrypted))\n } catch {\n throw new Error(\"Failed to unwrap group key: decryption failed (wrong keys or corrupted data)\")\n }\n}\n\n// \u2500\u2500 Keyring management \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Creates a new group keyring document with epoch 1.\n *\n * @param adminKeyPair The admin's key pair (from `deriveGroupKeyPair` or `deriveCredentials`)\n * @param members Map from member identity (userId) \u2192 hex public key\n * @param gek Optional GEK to use; generated randomly if omitted\n * @returns The keyring document and the raw GEK (admin keeps the GEK to add future members)\n */\nexport async function createGroupKeyring(\n adminKeyPair: GroupKeyPair,\n members: Record<string, string>,\n gek?: string,\n): Promise<{ keyring: GroupKeyring; gek: string }> {\n const resolvedGek = gek ?? generateGroupKey()\n const wrappedKeys: Record<string, string> = {}\n for (const [memberId, memberPublicKey] of Object.entries(members)) {\n wrappedKeys[memberId] = await wrapGroupKey(resolvedGek, memberPublicKey, adminKeyPair.privateKey)\n }\n const keyring: GroupKeyring = {\n currentEpoch: 1,\n epochs: {\n \"1\": { adminPublicKey: adminKeyPair.publicKey, wrappedKeys },\n },\n }\n return { keyring, gek: resolvedGek }\n}\n\n/**\n * Adds a new member to the current epoch of an existing keyring.\n *\n * The admin supplies the current GEK (returned by `createGroupKeyring` or\n * `rotateGroupKey`) and their key pair to wrap it for the new member.\n * This does NOT rotate the GEK \u2014 the new member can read all existing\n * documents encrypted with the current epoch key.\n *\n * Only the admin (whose `publicKey` matches `epochKeyring.adminPublicKey`) can\n * add members, because all wrapped entries must use the same ECDH key pair.\n */\nexport async function addGroupMember(\n keyring: GroupKeyring,\n adminKeyPair: GroupKeyPair,\n currentGek: string,\n newMemberId: string,\n newMemberPublicKey: string,\n): Promise<GroupKeyring> {\n const epochKey = String(keyring.currentEpoch)\n const epochKeyring = keyring.epochs[epochKey]\n if (!epochKeyring) throw new Error(`Epoch ${keyring.currentEpoch} not found in keyring`)\n if (epochKeyring.adminPublicKey !== adminKeyPair.publicKey) {\n throw new Error(`Provided key pair does not match the admin public key stored in epoch ${keyring.currentEpoch}`)\n }\n\n const wrapped = await wrapGroupKey(currentGek, newMemberPublicKey, adminKeyPair.privateKey)\n\n return {\n ...keyring,\n epochs: {\n ...keyring.epochs,\n [epochKey]: {\n ...epochKeyring,\n wrappedKeys: { ...epochKeyring.wrappedKeys, [newMemberId]: wrapped },\n },\n },\n }\n}\n\n/**\n * Rotates the group key, creating a new epoch.\n *\n * Used when removing a member. The removed member retains their old epoch key\n * (and can still read old documents), but cannot read new documents.\n *\n * @param remainingMembers Map from identity \u2192 hex public key for members who keep access\n */\nexport async function rotateGroupKey(\n keyring: GroupKeyring,\n adminKeyPair: GroupKeyPair,\n remainingMembers: Record<string, string>,\n newGek?: string,\n): Promise<{ keyring: GroupKeyring; gek: string }> {\n const epochKey = String(keyring.currentEpoch)\n const epochKeyring = keyring.epochs[epochKey]\n if (epochKeyring && epochKeyring.adminPublicKey !== adminKeyPair.publicKey) {\n throw new Error(\n `Provided key pair does not match the admin public key stored in epoch ${keyring.currentEpoch}`,\n )\n }\n const resolvedGek = newGek ?? generateGroupKey()\n const newEpoch = keyring.currentEpoch + 1\n const wrappedKeys: Record<string, string> = {}\n for (const [memberId, memberPublicKey] of Object.entries(remainingMembers)) {\n wrappedKeys[memberId] = await wrapGroupKey(resolvedGek, memberPublicKey, adminKeyPair.privateKey)\n }\n const newKeyring: GroupKeyring = {\n currentEpoch: newEpoch,\n epochs: {\n ...keyring.epochs,\n [String(newEpoch)]: { adminPublicKey: adminKeyPair.publicKey, wrappedKeys },\n },\n }\n return { keyring: newKeyring, gek: resolvedGek }\n}\n\n// \u2500\u2500 Encryptor factory \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Creates an Encryptor that can decrypt any epoch and encrypts with the current epoch.\n *\n * Wire format: `{ _encrypted: \"base64(IV || ciphertext)\", _epoch: N }`\n *\n * @param keyring The keyring document fetched from Starfish\n * @param myIdentity The caller's userId (to locate their wrapped key in each epoch)\n * @param myPrivateKey The caller's hex-encoded X25519 private key\n */\nexport async function createGroupEncryptor(\n keyring: GroupKeyring,\n myIdentity: string,\n myPrivateKey: string,\n): Promise<Encryptor> {\n // Unwrap GEK for each epoch we have a key for\n const epochEncryptors = new Map<number, Encryptor>()\n for (const [epochStr, epochKeyring] of Object.entries(keyring.epochs)) {\n const epoch = parseInt(epochStr, 10)\n const wrapped = epochKeyring.wrappedKeys[myIdentity]\n if (!wrapped) continue\n const gek = await unwrapGroupKey(wrapped, myPrivateKey, epochKeyring.adminPublicKey)\n epochEncryptors.set(epoch, createEncryptor(gek, `epoch-${epoch}`, GROUP_DATA_INFO))\n }\n\n const currentEpoch = keyring.currentEpoch\n const currentEncryptor = epochEncryptors.get(currentEpoch)\n if (!currentEncryptor) {\n throw new Error(\n `No wrapped key found for identity \"${myIdentity}\" in epoch ${currentEpoch}. ` +\n `Ensure the admin has added this member to the keyring.`,\n )\n }\n\n return {\n async encrypt(data: Record<string, unknown>): Promise<Record<string, unknown>> {\n const encrypted = await currentEncryptor.encrypt(data)\n return { ...encrypted, _epoch: currentEpoch }\n },\n\n async decrypt(wrapper: Record<string, unknown>): Promise<Record<string, unknown>> {\n const epoch = typeof wrapper._epoch === \"number\" ? wrapper._epoch : currentEpoch\n const encryptor = epochEncryptors.get(epoch)\n if (!encryptor) {\n throw new Error(\n `No key available for epoch ${epoch}. ` +\n `This document was encrypted in a different epoch. ` +\n `Ensure your keyring is up to date.`,\n )\n }\n return encryptor.decrypt(wrapper)\n },\n }\n}\n", "import { getCrypto, getBase64, IV_BYTES, ENCRYPTED_KEY, deriveKey } from \"@drakkar.software/starfish-protocol\"\n\nconst ALGO = \"AES-GCM\"\n\nexport { ENCRYPTED_KEY }\n\n/** Encrypt/decrypt interface for client-side E2E encryption. */\nexport interface Encryptor {\n encrypt(data: Record<string, unknown>): Promise<Record<string, unknown>>\n decrypt(wrapper: Record<string, unknown>): Promise<Record<string, unknown>>\n}\n\n/**\n * Creates an Encryptor that uses AES-256-GCM with HKDF-derived keys.\n */\nexport function createEncryptor(secret: string, salt: string, info: string = \"starfish-e2e\"): Encryptor {\n if (!secret) throw new Error(\"encryptionSecret must not be empty\")\n if (!salt) throw new Error(\"encryptionSalt must not be empty\")\n const keyPromise = deriveKey(secret, salt, info)\n\n return {\n async encrypt(data: Record<string, unknown>): Promise<Record<string, unknown>> {\n const key = await keyPromise\n const c = getCrypto()\n const b64 = getBase64()\n const plaintext = new TextEncoder().encode(JSON.stringify(data))\n const iv = c.getRandomValues(new Uint8Array(IV_BYTES))\n const ciphertext = await c.subtle.encrypt({ name: ALGO, iv }, key, plaintext)\n\n const combined = new Uint8Array(iv.length + ciphertext.byteLength)\n combined.set(iv)\n combined.set(new Uint8Array(ciphertext), iv.length)\n\n return { [ENCRYPTED_KEY]: b64.encode(combined) }\n },\n\n async decrypt(wrapper: Record<string, unknown>): Promise<Record<string, unknown>> {\n const encoded = wrapper[ENCRYPTED_KEY]\n if (typeof encoded !== \"string\") {\n throw new Error(\"Expected encrypted data but received unencrypted document\")\n }\n\n const key = await keyPromise\n const c = getCrypto()\n const b64 = getBase64()\n const combined = b64.decode(encoded)\n if (combined.length < IV_BYTES) {\n throw new Error(\"Encrypted data is too short\")\n }\n const iv = combined.slice(0, IV_BYTES)\n const ciphertext = combined.slice(IV_BYTES)\n try {\n const plaintext = await c.subtle.decrypt({ name: ALGO, iv }, key, ciphertext)\n return JSON.parse(new TextDecoder().decode(plaintext))\n } catch (err) {\n throw new Error(\"Decryption failed: data may be tampered or key is incorrect\", { cause: err })\n }\n },\n }\n}\n"],
|
|
5
|
-
"mappings": ";AAcA,SAAS,cAAc;AACvB,SAAS,aAAAA,YAAW,aAAAC,YAAW,YAAAC,WAAU,aAAAC,kBAAiB;;;ACf1D,SAAS,WAAW,WAAW,UAAU,eAAe,iBAAiB;AAEzE,IAAM,OAAO;AAaN,SAAS,gBAAgB,QAAgB,MAAc,OAAe,gBAA2B;AACtG,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,oCAAoC;AACjE,MAAI,CAAC,KAAM,OAAM,IAAI,MAAM,kCAAkC;AAC7D,QAAM,aAAa,UAAU,QAAQ,MAAM,IAAI;AAE/C,SAAO;AAAA,IACL,MAAM,QAAQ,MAAiE;AAC7E,YAAM,MAAM,MAAM;AAClB,YAAM,IAAI,UAAU;AACpB,YAAM,MAAM,UAAU;AACtB,YAAM,YAAY,IAAI,YAAY,EAAE,OAAO,KAAK,UAAU,IAAI,CAAC;AAC/D,YAAM,KAAK,EAAE,gBAAgB,IAAI,WAAW,QAAQ,CAAC;AACrD,YAAM,aAAa,MAAM,EAAE,OAAO,QAAQ,EAAE,MAAM,MAAM,GAAG,GAAG,KAAK,SAAS;AAE5E,YAAM,WAAW,IAAI,WAAW,GAAG,SAAS,WAAW,UAAU;AACjE,eAAS,IAAI,EAAE;AACf,eAAS,IAAI,IAAI,WAAW,UAAU,GAAG,GAAG,MAAM;AAElD,aAAO,EAAE,CAAC,aAAa,GAAG,IAAI,OAAO,QAAQ,EAAE;AAAA,IACjD;AAAA,IAEA,MAAM,QAAQ,SAAoE;AAChF,YAAM,UAAU,QAAQ,aAAa;AACrC,UAAI,OAAO,YAAY,UAAU;AAC/B,cAAM,IAAI,MAAM,2DAA2D;AAAA,MAC7E;AAEA,YAAM,MAAM,MAAM;AAClB,YAAM,IAAI,UAAU;AACpB,YAAM,MAAM,UAAU;AACtB,YAAM,WAAW,IAAI,OAAO,OAAO;AACnC,UAAI,SAAS,SAAS,UAAU;AAC9B,cAAM,IAAI,MAAM,6BAA6B;AAAA,MAC/C;AACA,YAAM,KAAK,SAAS,MAAM,GAAG,QAAQ;AACrC,YAAM,aAAa,SAAS,MAAM,QAAQ;AAC1C,UAAI;AACF,cAAM,YAAY,MAAM,EAAE,OAAO,QAAQ,EAAE,MAAM,MAAM,GAAG,GAAG,KAAK,UAAU;AAC5E,eAAO,KAAK,MAAM,IAAI,YAAY,EAAE,OAAO,SAAS,CAAC;AAAA,MACvD,SAAS,KAAK;AACZ,cAAM,IAAI,MAAM,+DAA+D,EAAE,OAAO,IAAI,CAAC;AAAA,MAC/F;AAAA,IACF;AAAA,EACF;AACF;;;ADtCA,SAAS,WAAW,OAA2B;AAC7C,SAAO,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAAE,KAAK,EAAE;AAC1E;AAEA,SAAS,WAAW,KAAyB;AAC3C,QAAM,QAAQ,IAAI,WAAW,IAAI,SAAS,CAAC;AAC3C,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACrC,UAAM,CAAC,IAAI,SAAS,IAAI,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE;AAAA,EACrD;AACA,SAAO;AACT;AAEA,IAAMC,QAAO;AACb,IAAM,kBAAkB;AACxB,IAAM,kBAAkB;AACxB,IAAM,oBAAoB;AAC1B,IAAM,kBAAkB;AACxB,IAAM,YAAY;AAqClB,eAAsB,mBAAmB,YAAoB,QAAuC;AAClG,QAAM,IAAIC,WAAU;AACpB,QAAM,MAAM,IAAI,YAAY;AAC5B,QAAM,QAAQ,IAAI,OAAO,GAAG,UAAU,IAAI,MAAM,IAAI,iBAAiB,EAAE;AACvE,QAAM,OAAO,MAAM,EAAE,OAAO,OAAO,WAAW,KAAK;AACnD,QAAM,kBAAkB,IAAI,WAAW,IAAI;AAC3C,QAAM,iBAAiB,OAAO,aAAa,eAAe;AAC1D,SAAO,EAAE,YAAY,WAAW,eAAe,GAAG,WAAW,WAAW,cAAc,EAAE;AAC1F;AAKO,SAAS,mBAA2B;AACzC,QAAM,IAAIA,WAAU;AACpB,SAAO,WAAW,EAAE,gBAAgB,IAAI,WAAW,SAAS,CAAC,CAAC;AAChE;AAaA,eAAsB,aACpB,KACA,iBACA,mBACiB;AACjB,QAAM,eAAe,OAAO,gBAAgB,WAAW,iBAAiB,GAAG,WAAW,eAAe,CAAC;AACtG,QAAM,cAAc,MAAMC,WAAU,WAAW,YAAY,GAAG,iBAAiB,eAAe;AAE9F,QAAM,IAAID,WAAU;AACpB,QAAM,MAAME,WAAU;AACtB,QAAM,KAAK,EAAE,gBAAgB,IAAI,WAAWC,SAAQ,CAAC;AACrD,QAAM,YAAY,MAAM,EAAE,OAAO,QAAQ,EAAE,MAAMJ,OAAM,GAAG,GAAG,aAAa,WAAW,GAAG,EAAE,MAAqB;AAE/G,QAAM,WAAW,IAAI,WAAWI,YAAW,UAAU,UAAU;AAC/D,WAAS,IAAI,EAAE;AACf,WAAS,IAAI,IAAI,WAAW,SAAS,GAAGA,SAAQ;AAChD,SAAO,IAAI,OAAO,QAAQ;AAC5B;AAWA,eAAsB,eACpB,SACA,kBACA,gBACiB;AACjB,QAAM,eAAe,OAAO,gBAAgB,WAAW,gBAAgB,GAAG,WAAW,cAAc,CAAC;AACpG,QAAM,cAAc,MAAMF,WAAU,WAAW,YAAY,GAAG,iBAAiB,eAAe;AAE9F,QAAM,MAAMC,WAAU;AACtB,QAAM,IAAIF,WAAU;AACpB,QAAM,WAAW,IAAI,OAAO,OAAO;AACnC,QAAM,KAAK,SAAS,MAAM,GAAGG,SAAQ;AACrC,QAAM,aAAa,SAAS,MAAMA,SAAQ;AAC1C,MAAI;AACF,UAAM,YAAY,MAAM,EAAE,OAAO,QAAQ,EAAE,MAAMJ,OAAM,GAAG,GAAG,aAAa,UAAU;AACpF,WAAO,WAAW,IAAI,WAAW,SAAS,CAAC;AAAA,EAC7C,QAAQ;AACN,UAAM,IAAI,MAAM,8EAA8E;AAAA,EAChG;AACF;AAYA,eAAsB,mBACpB,cACA,SACA,KACiD;AACjD,QAAM,cAAc,OAAO,iBAAiB;AAC5C,QAAM,cAAsC,CAAC;AAC7C,aAAW,CAAC,UAAU,eAAe,KAAK,OAAO,QAAQ,OAAO,GAAG;AACjE,gBAAY,QAAQ,IAAI,MAAM,aAAa,aAAa,iBAAiB,aAAa,UAAU;AAAA,EAClG;AACA,QAAM,UAAwB;AAAA,IAC5B,cAAc;AAAA,IACd,QAAQ;AAAA,MACN,KAAK,EAAE,gBAAgB,aAAa,WAAW,YAAY;AAAA,IAC7D;AAAA,EACF;AACA,SAAO,EAAE,SAAS,KAAK,YAAY;AACrC;AAaA,eAAsB,eACpB,SACA,cACA,YACA,aACA,oBACuB;AACvB,QAAM,WAAW,OAAO,QAAQ,YAAY;AAC5C,QAAM,eAAe,QAAQ,OAAO,QAAQ;AAC5C,MAAI,CAAC,aAAc,OAAM,IAAI,MAAM,SAAS,QAAQ,YAAY,uBAAuB;AACvF,MAAI,aAAa,mBAAmB,aAAa,WAAW;AAC1D,UAAM,IAAI,MAAM,yEAAyE,QAAQ,YAAY,EAAE;AAAA,EACjH;AAEA,QAAM,UAAU,MAAM,aAAa,YAAY,oBAAoB,aAAa,UAAU;AAE1F,SAAO;AAAA,IACL,GAAG;AAAA,IACH,QAAQ;AAAA,MACN,GAAG,QAAQ;AAAA,MACX,CAAC,QAAQ,GAAG;AAAA,QACV,GAAG;AAAA,QACH,aAAa,EAAE,GAAG,aAAa,aAAa,CAAC,WAAW,GAAG,QAAQ;AAAA,MACrE;AAAA,IACF;AAAA,EACF;AACF;AAUA,eAAsB,eACpB,SACA,cACA,kBACA,QACiD;AACjD,QAAM,WAAW,OAAO,QAAQ,YAAY;AAC5C,QAAM,eAAe,QAAQ,OAAO,QAAQ;AAC5C,MAAI,gBAAgB,aAAa,mBAAmB,aAAa,WAAW;AAC1E,UAAM,IAAI;AAAA,MACR,yEAAyE,QAAQ,YAAY;AAAA,IAC/F;AAAA,EACF;AACA,QAAM,cAAc,UAAU,iBAAiB;AAC/C,QAAM,WAAW,QAAQ,eAAe;AACxC,QAAM,cAAsC,CAAC;AAC7C,aAAW,CAAC,UAAU,eAAe,KAAK,OAAO,QAAQ,gBAAgB,GAAG;AAC1E,gBAAY,QAAQ,IAAI,MAAM,aAAa,aAAa,iBAAiB,aAAa,UAAU;AAAA,EAClG;AACA,QAAM,aAA2B;AAAA,IAC/B,cAAc;AAAA,IACd,QAAQ;AAAA,MACN,GAAG,QAAQ;AAAA,MACX,CAAC,OAAO,QAAQ,CAAC,GAAG,EAAE,gBAAgB,aAAa,WAAW,YAAY;AAAA,IAC5E;AAAA,EACF;AACA,SAAO,EAAE,SAAS,YAAY,KAAK,YAAY;AACjD;AAaA,eAAsB,qBACpB,SACA,YACA,cACoB;AAEpB,QAAM,kBAAkB,oBAAI,IAAuB;AACnD,aAAW,CAAC,UAAU,YAAY,KAAK,OAAO,QAAQ,QAAQ,MAAM,GAAG;AACrE,UAAM,QAAQ,SAAS,UAAU,EAAE;AACnC,UAAM,UAAU,aAAa,YAAY,UAAU;AACnD,QAAI,CAAC,QAAS;AACd,UAAM,MAAM,MAAM,eAAe,SAAS,cAAc,aAAa,cAAc;AACnF,oBAAgB,IAAI,OAAO,gBAAgB,KAAK,SAAS,KAAK,IAAI,eAAe,CAAC;AAAA,EACpF;AAEA,QAAM,eAAe,QAAQ;AAC7B,QAAM,mBAAmB,gBAAgB,IAAI,YAAY;AACzD,MAAI,CAAC,kBAAkB;AACrB,UAAM,IAAI;AAAA,MACR,sCAAsC,UAAU,cAAc,YAAY;AAAA,IAE5E;AAAA,EACF;AAEA,SAAO;AAAA,IACL,MAAM,QAAQ,MAAiE;AAC7E,YAAM,YAAY,MAAM,iBAAiB,QAAQ,IAAI;AACrD,aAAO,EAAE,GAAG,WAAW,QAAQ,aAAa;AAAA,IAC9C;AAAA,IAEA,MAAM,QAAQ,SAAoE;AAChF,YAAM,QAAQ,OAAO,QAAQ,WAAW,WAAW,QAAQ,SAAS;AACpE,YAAM,YAAY,gBAAgB,IAAI,KAAK;AAC3C,UAAI,CAAC,WAAW;AACd,cAAM,IAAI;AAAA,UACR,8BAA8B,KAAK;AAAA,QAGrC;AAAA,MACF;AACA,aAAO,UAAU,QAAQ,OAAO;AAAA,IAClC;AAAA,EACF;AACF;",
|
|
6
|
-
"names": ["getCrypto", "getBase64", "IV_BYTES", "deriveKey", "ALGO", "getCrypto", "deriveKey", "getBase64", "IV_BYTES"]
|
|
7
|
-
}
|
package/dist/history.js
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
export class SnapshotHistory {
|
|
2
|
-
snapshots = [];
|
|
3
|
-
maxSnapshots;
|
|
4
|
-
storageKey;
|
|
5
|
-
constructor(options) {
|
|
6
|
-
this.maxSnapshots = options?.maxSnapshots ?? 20;
|
|
7
|
-
this.storageKey = options?.storageKey;
|
|
8
|
-
if (this.storageKey) {
|
|
9
|
-
try {
|
|
10
|
-
const raw = localStorage.getItem(this.storageKey);
|
|
11
|
-
if (raw) {
|
|
12
|
-
const parsed = JSON.parse(raw);
|
|
13
|
-
if (Array.isArray(parsed))
|
|
14
|
-
this.snapshots = parsed;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
catch { /* corrupted or unavailable — start fresh */ }
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
/** Take a labeled snapshot of the given data. */
|
|
21
|
-
take(label, data) {
|
|
22
|
-
this.snapshots.push({
|
|
23
|
-
timestamp: Date.now(),
|
|
24
|
-
label,
|
|
25
|
-
data: JSON.stringify(data),
|
|
26
|
-
});
|
|
27
|
-
if (this.snapshots.length > this.maxSnapshots) {
|
|
28
|
-
this.snapshots = this.snapshots.slice(-this.maxSnapshots);
|
|
29
|
-
}
|
|
30
|
-
this.persist();
|
|
31
|
-
}
|
|
32
|
-
/** Restore data from a snapshot at the given index. Returns undefined if index is invalid or data is corrupt. */
|
|
33
|
-
restore(index) {
|
|
34
|
-
const snapshot = this.snapshots[index];
|
|
35
|
-
if (!snapshot)
|
|
36
|
-
return undefined;
|
|
37
|
-
try {
|
|
38
|
-
return JSON.parse(snapshot.data);
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
/** List available snapshots (metadata only, no data payload). */
|
|
45
|
-
list() {
|
|
46
|
-
return this.snapshots.map(({ timestamp, label }) => ({ timestamp, label }));
|
|
47
|
-
}
|
|
48
|
-
/** Clear all snapshots. */
|
|
49
|
-
clear() {
|
|
50
|
-
this.snapshots = [];
|
|
51
|
-
this.persist();
|
|
52
|
-
}
|
|
53
|
-
persist() {
|
|
54
|
-
if (!this.storageKey)
|
|
55
|
-
return;
|
|
56
|
-
try {
|
|
57
|
-
localStorage.setItem(this.storageKey, JSON.stringify(this.snapshots));
|
|
58
|
-
}
|
|
59
|
-
catch { /* quota exceeded — skip silently */ }
|
|
60
|
-
}
|
|
61
|
-
}
|
package/dist/logger.js
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
/** Console-based sync logger with structured output. */
|
|
2
|
-
export const consoleSyncLogger = {
|
|
3
|
-
pullStart: (s) => console.log(`[starfish:${s}] pull started`),
|
|
4
|
-
pullSuccess: (s, ms, m) => {
|
|
5
|
-
let msg = `[starfish:${s}] pull OK (${ms}ms)`;
|
|
6
|
-
if (m?.bytesTransferred)
|
|
7
|
-
msg += ` ${m.bytesTransferred}B`;
|
|
8
|
-
if (m?.cacheHit)
|
|
9
|
-
msg += ` (cache hit)`;
|
|
10
|
-
console.log(msg);
|
|
11
|
-
},
|
|
12
|
-
pullError: (s, err) => console.error(`[starfish:${s}] pull failed: ${err}`),
|
|
13
|
-
pushStart: (s) => console.log(`[starfish:${s}] push started`),
|
|
14
|
-
pushSuccess: (s, ms, m) => {
|
|
15
|
-
let msg = `[starfish:${s}] push OK (${ms}ms)`;
|
|
16
|
-
if (m?.bytesTransferred)
|
|
17
|
-
msg += ` ${m.bytesTransferred}B`;
|
|
18
|
-
console.log(msg);
|
|
19
|
-
},
|
|
20
|
-
pushError: (s, err) => console.error(`[starfish:${s}] push failed: ${err}`),
|
|
21
|
-
conflict: (s, n) => console.warn(`[starfish:${s}] conflict (attempt ${n})`),
|
|
22
|
-
};
|
|
23
|
-
/** Silent sync logger (no output). */
|
|
24
|
-
export const noopSyncLogger = {
|
|
25
|
-
pullStart: () => { },
|
|
26
|
-
pullSuccess: () => { },
|
|
27
|
-
pullError: () => { },
|
|
28
|
-
pushStart: () => { },
|
|
29
|
-
pushSuccess: () => { },
|
|
30
|
-
pushError: () => { },
|
|
31
|
-
conflict: () => { },
|
|
32
|
-
};
|
|
33
|
-
/** Create a metrics collector that accumulates sync statistics. */
|
|
34
|
-
export function createMetricsCollector() {
|
|
35
|
-
const stores = new Map();
|
|
36
|
-
function ensureStore(name) {
|
|
37
|
-
let s = stores.get(name);
|
|
38
|
-
if (!s) {
|
|
39
|
-
s = { totalPulls: 0, totalPushes: 0, totalDurationMs: 0, totalBytes: 0, totalConflicts: 0 };
|
|
40
|
-
stores.set(name, s);
|
|
41
|
-
}
|
|
42
|
-
return s;
|
|
43
|
-
}
|
|
44
|
-
return {
|
|
45
|
-
recordPull(name, durationMs, metrics) {
|
|
46
|
-
const s = ensureStore(name);
|
|
47
|
-
s.totalPulls++;
|
|
48
|
-
s.totalDurationMs += durationMs;
|
|
49
|
-
if (metrics?.bytesTransferred)
|
|
50
|
-
s.totalBytes += metrics.bytesTransferred;
|
|
51
|
-
},
|
|
52
|
-
recordPush(name, durationMs, metrics) {
|
|
53
|
-
const s = ensureStore(name);
|
|
54
|
-
s.totalPushes++;
|
|
55
|
-
s.totalDurationMs += durationMs;
|
|
56
|
-
if (metrics?.bytesTransferred)
|
|
57
|
-
s.totalBytes += metrics.bytesTransferred;
|
|
58
|
-
},
|
|
59
|
-
recordConflict(name) {
|
|
60
|
-
ensureStore(name).totalConflicts++;
|
|
61
|
-
},
|
|
62
|
-
getSummary() {
|
|
63
|
-
const result = {};
|
|
64
|
-
for (const [name, s] of stores) {
|
|
65
|
-
const totalOps = s.totalPulls + s.totalPushes;
|
|
66
|
-
result[name] = {
|
|
67
|
-
totalPulls: s.totalPulls,
|
|
68
|
-
totalPushes: s.totalPushes,
|
|
69
|
-
avgDurationMs: totalOps > 0 ? Math.round(s.totalDurationMs / totalOps) : 0,
|
|
70
|
-
totalBytes: s.totalBytes,
|
|
71
|
-
totalConflicts: s.totalConflicts,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
return result;
|
|
75
|
-
},
|
|
76
|
-
reset() {
|
|
77
|
-
stores.clear();
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
}
|
package/dist/migrate.js
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Creates a migration runner that upgrades documents to the current schema version.
|
|
3
|
-
*
|
|
4
|
-
* Given a document with `_schemaVersion`, applies each migration in sequence
|
|
5
|
-
* until the document reaches `currentVersion`. Throws if the document version
|
|
6
|
-
* is ahead of the app (forward compatibility guard).
|
|
7
|
-
*/
|
|
8
|
-
export function createMigrator(config) {
|
|
9
|
-
// Eagerly validate the migration chain
|
|
10
|
-
for (let v = 1; v < config.currentVersion; v++) {
|
|
11
|
-
if (!config.migrations[v]) {
|
|
12
|
-
throw new Error(`Missing migration for version ${v} -> ${v + 1}`);
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
return (data) => {
|
|
16
|
-
const version = typeof data._schemaVersion === "number" ? data._schemaVersion : 1;
|
|
17
|
-
if (version > config.currentVersion) {
|
|
18
|
-
throw new Error(`Document schema version ${version} is newer than app version ${config.currentVersion}. Update the app.`);
|
|
19
|
-
}
|
|
20
|
-
if (version === config.currentVersion)
|
|
21
|
-
return data;
|
|
22
|
-
let result = { ...data };
|
|
23
|
-
for (let v = version; v < config.currentVersion; v++) {
|
|
24
|
-
const fn = config.migrations[v];
|
|
25
|
-
if (!fn) {
|
|
26
|
-
throw new Error(`Missing migration for version ${v} -> ${v + 1}`);
|
|
27
|
-
}
|
|
28
|
-
try {
|
|
29
|
-
result = fn(result);
|
|
30
|
-
}
|
|
31
|
-
catch (err) {
|
|
32
|
-
throw new Error(`Migration from version ${v} to ${v + 1} failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
result._schemaVersion = config.currentVersion;
|
|
36
|
-
return result;
|
|
37
|
-
};
|
|
38
|
-
}
|
package/dist/mobile-lifecycle.js
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
// ── Implementation ────────────────────────────────────────────────────────────
|
|
2
|
-
/**
|
|
3
|
-
* Wires React Native app lifecycle events to a Starfish store.
|
|
4
|
-
*
|
|
5
|
-
* - **Background**: flushes pending changes before the OS suspends the app.
|
|
6
|
-
* - **Foreground**: pulls remote changes when the user returns to the app.
|
|
7
|
-
* - **NetInfo**: forwards connectivity changes to `store.getState().setOnline()`.
|
|
8
|
-
*
|
|
9
|
-
* Uses dependency injection so no `react-native` or `netinfo` imports are needed
|
|
10
|
-
* in this package. Pass the modules directly:
|
|
11
|
-
*
|
|
12
|
-
* ```ts
|
|
13
|
-
* import { AppState } from "react-native"
|
|
14
|
-
* import NetInfo from "@react-native-community/netinfo"
|
|
15
|
-
* import { createMobileLifecycle } from "@drakkar.software/starfish-client"
|
|
16
|
-
*
|
|
17
|
-
* // Call once, after the store is created:
|
|
18
|
-
* const cleanup = createMobileLifecycle(
|
|
19
|
-
* store,
|
|
20
|
-
* { appState: AppState, netInfo: NetInfo },
|
|
21
|
-
* )
|
|
22
|
-
*
|
|
23
|
-
* // In a React component (e.g. root layout):
|
|
24
|
-
* useEffect(() => cleanup, [])
|
|
25
|
-
* ```
|
|
26
|
-
*
|
|
27
|
-
* @returns A cleanup function that removes all event listeners.
|
|
28
|
-
*/
|
|
29
|
-
export function createMobileLifecycle(store, deps, options = {}) {
|
|
30
|
-
const { pullOnForeground = true, flushOnBackground = true } = options;
|
|
31
|
-
const appSub = deps.appState.addEventListener("change", (appState) => {
|
|
32
|
-
if (appState === "background" && flushOnBackground) {
|
|
33
|
-
if (store.getState().dirty) {
|
|
34
|
-
store.getState().flush().catch(() => { });
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
else if (appState === "active" && pullOnForeground) {
|
|
38
|
-
const { online, syncing } = store.getState();
|
|
39
|
-
if (online && !syncing) {
|
|
40
|
-
store.getState().pull().catch(() => { });
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
// "inactive" (iOS transition) and other states are intentionally ignored
|
|
44
|
-
});
|
|
45
|
-
let netUnsub = null;
|
|
46
|
-
if (deps.netInfo) {
|
|
47
|
-
netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
|
|
48
|
-
store.getState().setOnline(!!isConnected);
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
return () => {
|
|
52
|
-
appSub.remove();
|
|
53
|
-
netUnsub?.();
|
|
54
|
-
};
|
|
55
|
-
}
|
package/dist/multi-store.js
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
2
|
-
// ── Implementation ────────────────────────────────────────────────────────────
|
|
3
|
-
/**
|
|
4
|
-
* Creates a multi-store sync coordinator.
|
|
5
|
-
*
|
|
6
|
-
* Collects multiple application stores into a single Starfish sync document,
|
|
7
|
-
* with versioned schema migrations for backward compatibility.
|
|
8
|
-
*
|
|
9
|
-
* ```ts
|
|
10
|
-
* const multiSync = createMultiStoreSync({
|
|
11
|
-
* slices: {
|
|
12
|
-
* tasks: {
|
|
13
|
-
* serialize: () => taskStore.getState().tasks,
|
|
14
|
-
* restore: (tasks) => taskStore.setState({ tasks }),
|
|
15
|
-
* },
|
|
16
|
-
* settings: {
|
|
17
|
-
* serialize: () => settingsStore.getState().settings,
|
|
18
|
-
* restore: (settings) => settingsStore.setState({ settings }),
|
|
19
|
-
* },
|
|
20
|
-
* },
|
|
21
|
-
* version: 2,
|
|
22
|
-
* migrations: {
|
|
23
|
-
* // data from version 1 → upgrade to version 2
|
|
24
|
-
* 1: (data) => ({ ...data, settings: { ...(data.settings as object), darkMode: false } }),
|
|
25
|
-
* },
|
|
26
|
-
* })
|
|
27
|
-
*
|
|
28
|
-
* // Push:
|
|
29
|
-
* starfishStore.getState().set(() => multiSync.serialize())
|
|
30
|
-
*
|
|
31
|
-
* // Restore on pull (pass as onRemoteUpdate to createStarfishStore):
|
|
32
|
-
* createStarfishStore({
|
|
33
|
-
* name: "app",
|
|
34
|
-
* syncManager,
|
|
35
|
-
* onRemoteUpdate: (doc) => multiSync.restore(doc as BackupDocument),
|
|
36
|
-
* })
|
|
37
|
-
* ```
|
|
38
|
-
*/
|
|
39
|
-
export function createMultiStoreSync(options) {
|
|
40
|
-
const { slices, version, migrations = {} } = options;
|
|
41
|
-
// Validate migration chain at construction time (fail fast)
|
|
42
|
-
for (const fromVersion of Object.keys(migrations)) {
|
|
43
|
-
const v = Number(fromVersion);
|
|
44
|
-
if (isNaN(v) || v < 1) {
|
|
45
|
-
throw new Error(`Migration key must be a positive integer, got: "${fromVersion}"`);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
function serialize() {
|
|
49
|
-
const data = {};
|
|
50
|
-
for (const key of Object.keys(slices)) {
|
|
51
|
-
data[key] = slices[key].serialize();
|
|
52
|
-
}
|
|
53
|
-
return { version, timestamp: Date.now(), data };
|
|
54
|
-
}
|
|
55
|
-
function restore(doc) {
|
|
56
|
-
if (typeof doc !== "object" || doc === null) {
|
|
57
|
-
throw new Error("restore: expected a BackupDocument object");
|
|
58
|
-
}
|
|
59
|
-
const docVersion = doc.version ?? 1;
|
|
60
|
-
if (typeof docVersion !== "number" || !Number.isInteger(docVersion) || docVersion < 1) {
|
|
61
|
-
throw new Error(`restore: invalid document version: ${String(doc.version)}`);
|
|
62
|
-
}
|
|
63
|
-
if (docVersion > version) {
|
|
64
|
-
throw new Error(`restore: document version ${docVersion} is newer than current version ${version}. ` +
|
|
65
|
-
`Update the app to restore this backup.`);
|
|
66
|
-
}
|
|
67
|
-
// Run migrations sequentially from docVersion up to current version
|
|
68
|
-
let data = typeof doc.data === "object" && doc.data !== null
|
|
69
|
-
? { ...doc.data }
|
|
70
|
-
: {};
|
|
71
|
-
for (let v = docVersion; v < version; v++) {
|
|
72
|
-
const migration = migrations[v];
|
|
73
|
-
if (!migration)
|
|
74
|
-
continue;
|
|
75
|
-
try {
|
|
76
|
-
data = migration(data);
|
|
77
|
-
}
|
|
78
|
-
catch (err) {
|
|
79
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
80
|
-
throw new Error(`restore: migration from version ${v} to ${v + 1} failed: ${msg}`);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
// Restore each slice
|
|
84
|
-
for (const key of Object.keys(slices)) {
|
|
85
|
-
const sliceData = data[key];
|
|
86
|
-
if (sliceData !== undefined) {
|
|
87
|
-
slices[key].restore(sliceData);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return { serialize, restore, version };
|
|
92
|
-
}
|
package/dist/polling.js
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
const DEFAULT_INTERVALS = {
|
|
2
|
-
"slow-2g": 120_000,
|
|
3
|
-
"2g": 60_000,
|
|
4
|
-
"3g": 30_000,
|
|
5
|
-
"4g": 10_000,
|
|
6
|
-
};
|
|
7
|
-
const DEFAULT_FALLBACK_MS = 15_000;
|
|
8
|
-
/**
|
|
9
|
-
* Start periodic pulling at a fixed interval.
|
|
10
|
-
* Skips pulls when offline or already syncing.
|
|
11
|
-
* Returns a cleanup function that stops polling.
|
|
12
|
-
*/
|
|
13
|
-
export function startPolling(pullFn, getState, intervalMs = 30_000) {
|
|
14
|
-
const timer = setInterval(() => {
|
|
15
|
-
const { online, syncing } = getState();
|
|
16
|
-
if (online && !syncing)
|
|
17
|
-
pullFn().catch(() => { });
|
|
18
|
-
}, intervalMs);
|
|
19
|
-
return () => clearInterval(timer);
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Start polling with adaptive intervals based on network quality.
|
|
23
|
-
* Uses the Network Information API (`navigator.connection.effectiveType`) when available.
|
|
24
|
-
* Returns controls to pause, resume, or stop polling.
|
|
25
|
-
*/
|
|
26
|
-
export function startAdaptivePolling(pullFn, getState, options) {
|
|
27
|
-
let intervalMs;
|
|
28
|
-
if (options?.intervalMs != null) {
|
|
29
|
-
intervalMs = options.intervalMs;
|
|
30
|
-
}
|
|
31
|
-
else {
|
|
32
|
-
const intervals = options?.intervals ?? DEFAULT_INTERVALS;
|
|
33
|
-
let effectiveType;
|
|
34
|
-
if (typeof navigator !== "undefined" && "connection" in navigator) {
|
|
35
|
-
effectiveType = navigator.connection.effectiveType;
|
|
36
|
-
}
|
|
37
|
-
intervalMs = (effectiveType != null ? intervals[effectiveType] : undefined) ?? DEFAULT_FALLBACK_MS;
|
|
38
|
-
}
|
|
39
|
-
let paused = false;
|
|
40
|
-
const timer = setInterval(() => {
|
|
41
|
-
if (paused)
|
|
42
|
-
return;
|
|
43
|
-
const { online, syncing } = getState();
|
|
44
|
-
if (online && !syncing)
|
|
45
|
-
pullFn().catch(() => { });
|
|
46
|
-
}, intervalMs);
|
|
47
|
-
return {
|
|
48
|
-
pause: () => { paused = true; },
|
|
49
|
-
resume: () => { paused = false; },
|
|
50
|
-
stop: () => clearInterval(timer),
|
|
51
|
-
};
|
|
52
|
-
}
|
package/dist/resolvers.js
DELETED
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
/** Shallow structural comparison of two values. Handles objects, arrays, and primitives. */
|
|
2
|
-
function shallowEqual(a, b) {
|
|
3
|
-
if (a === b)
|
|
4
|
-
return true;
|
|
5
|
-
if (a == null || b == null)
|
|
6
|
-
return a === b;
|
|
7
|
-
if (typeof a !== typeof b)
|
|
8
|
-
return false;
|
|
9
|
-
if (typeof a !== "object")
|
|
10
|
-
return false;
|
|
11
|
-
if (Array.isArray(a) !== Array.isArray(b))
|
|
12
|
-
return false;
|
|
13
|
-
if (Array.isArray(a) && Array.isArray(b)) {
|
|
14
|
-
if (a.length !== b.length)
|
|
15
|
-
return false;
|
|
16
|
-
return a.every((v, i) => shallowEqual(v, b[i]));
|
|
17
|
-
}
|
|
18
|
-
const aObj = a;
|
|
19
|
-
const bObj = b;
|
|
20
|
-
const aKeys = Object.keys(aObj);
|
|
21
|
-
const bKeys = Object.keys(bObj);
|
|
22
|
-
if (aKeys.length !== bKeys.length)
|
|
23
|
-
return false;
|
|
24
|
-
return aKeys.every((k) => shallowEqual(aObj[k], bObj[k]));
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Wrap a standard ConflictResolver to also return metadata about which fields conflicted.
|
|
28
|
-
* Compares local and remote keys to detect differing fields.
|
|
29
|
-
*/
|
|
30
|
-
export function withConflictMeta(resolver) {
|
|
31
|
-
return (local, remote) => {
|
|
32
|
-
const conflictedFields = [];
|
|
33
|
-
const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]);
|
|
34
|
-
for (const key of allKeys) {
|
|
35
|
-
const lv = local[key];
|
|
36
|
-
const rv = remote[key];
|
|
37
|
-
if (!shallowEqual(lv, rv)) {
|
|
38
|
-
conflictedFields.push(key);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
const data = resolver(local, remote);
|
|
42
|
-
// Determine how it was resolved using structural comparison
|
|
43
|
-
let resolvedBy = "merged";
|
|
44
|
-
if (shallowEqual(data, local))
|
|
45
|
-
resolvedBy = "local";
|
|
46
|
-
else if (shallowEqual(data, remote))
|
|
47
|
-
resolvedBy = "remote";
|
|
48
|
-
return {
|
|
49
|
-
data,
|
|
50
|
-
meta: {
|
|
51
|
-
conflictedFields,
|
|
52
|
-
resolvedBy,
|
|
53
|
-
timestamp: Date.now(),
|
|
54
|
-
},
|
|
55
|
-
};
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
/** Compare two timestamp values. Handles both numeric (epoch) and string (ISO-8601) timestamps. */
|
|
59
|
-
function compareTimestamps(a, b) {
|
|
60
|
-
if (typeof a === "number" && typeof b === "number")
|
|
61
|
-
return a >= b;
|
|
62
|
-
return String(a ?? "") >= String(b ?? "");
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Creates a conflict resolver that merges arrays by ID with per-item
|
|
66
|
-
* timestamp comparison, and uses document-level timestamp for scalars.
|
|
67
|
-
*
|
|
68
|
-
* For arrays: builds a union of both sets keyed by `idKey`. When both
|
|
69
|
-
* sides have the same item, the one with the newer `timestampKey` wins.
|
|
70
|
-
* For scalars: the document with the newer `documentTimestampKey` wins.
|
|
71
|
-
*
|
|
72
|
-
* @example
|
|
73
|
-
* ```ts
|
|
74
|
-
* const merge = createUnionMerge()
|
|
75
|
-
* const sync = new SyncManager({ ..., onConflict: merge })
|
|
76
|
-
* ```
|
|
77
|
-
*/
|
|
78
|
-
export function createUnionMerge(options) {
|
|
79
|
-
const idKey = options?.idKey ?? "id";
|
|
80
|
-
const tsKey = options?.timestampKey ?? "updatedAt";
|
|
81
|
-
const docTsKey = options?.documentTimestampKey ?? "timestamp";
|
|
82
|
-
return (local, remote) => {
|
|
83
|
-
const result = {};
|
|
84
|
-
const localNewer = compareTimestamps(local[docTsKey], remote[docTsKey]);
|
|
85
|
-
const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]);
|
|
86
|
-
for (const key of allKeys) {
|
|
87
|
-
const lv = local[key];
|
|
88
|
-
const rv = remote[key];
|
|
89
|
-
// Both sides have arrays — attempt ID-based union
|
|
90
|
-
if (Array.isArray(lv) && Array.isArray(rv)) {
|
|
91
|
-
const map = new Map();
|
|
92
|
-
// Seed with remote items
|
|
93
|
-
for (const item of rv) {
|
|
94
|
-
if (item && typeof item === "object" && idKey in item) {
|
|
95
|
-
map.set(item[idKey], item);
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
map.set(Symbol(), item);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
// Overlay local items (per-item timestamp wins)
|
|
102
|
-
for (const item of lv) {
|
|
103
|
-
if (item && typeof item === "object" && idKey in item) {
|
|
104
|
-
const localItem = item;
|
|
105
|
-
const id = localItem[idKey];
|
|
106
|
-
const remoteItem = map.get(id);
|
|
107
|
-
if (!remoteItem) {
|
|
108
|
-
map.set(id, localItem);
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
if (compareTimestamps(localItem[tsKey], remoteItem[tsKey])) {
|
|
112
|
-
map.set(id, localItem);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
else {
|
|
117
|
-
map.set(Symbol(), item);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
result[key] = [...map.values()];
|
|
121
|
-
}
|
|
122
|
-
else if (lv !== undefined && rv !== undefined) {
|
|
123
|
-
// Scalar: document-level timestamp wins
|
|
124
|
-
result[key] = localNewer ? lv : rv;
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
// Only one side has the key
|
|
128
|
-
result[key] = lv ?? rv;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return result;
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Creates a conflict resolver that handles soft-deleted items (tombstones).
|
|
136
|
-
* Extends union merge with tombstone awareness: if an item exists on one side
|
|
137
|
-
* with a `deletedAtKey` set, that deletion is respected even if the other side
|
|
138
|
-
* still has the item alive — as long as the deletion timestamp is newer.
|
|
139
|
-
*/
|
|
140
|
-
export function createSoftDeleteResolver(options) {
|
|
141
|
-
const idKey = options?.idKey ?? "id";
|
|
142
|
-
const tsKey = options?.timestampKey ?? "updatedAt";
|
|
143
|
-
const deletedAtKey = options?.deletedAtKey ?? "_deletedAt";
|
|
144
|
-
const baseMerge = createUnionMerge(options);
|
|
145
|
-
return (local, remote) => {
|
|
146
|
-
const merged = baseMerge(local, remote);
|
|
147
|
-
// Build a tombstone map from both sides: id → deletedAt timestamp
|
|
148
|
-
const tombstones = new Map();
|
|
149
|
-
for (const source of [local, remote]) {
|
|
150
|
-
for (const key of Object.keys(source)) {
|
|
151
|
-
const arr = source[key];
|
|
152
|
-
if (!Array.isArray(arr))
|
|
153
|
-
continue;
|
|
154
|
-
for (const item of arr) {
|
|
155
|
-
if (item && typeof item === "object" && idKey in item && deletedAtKey in item) {
|
|
156
|
-
const rec = item;
|
|
157
|
-
const id = rec[idKey];
|
|
158
|
-
const deletedAt = rec[deletedAtKey];
|
|
159
|
-
if (typeof deletedAt === "number" || typeof deletedAt === "string") {
|
|
160
|
-
const existing = tombstones.get(id);
|
|
161
|
-
if (existing == null || compareTimestamps(deletedAt, existing))
|
|
162
|
-
tombstones.set(id, deletedAt);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
// For merged arrays, ensure tombstoned items stay deleted
|
|
169
|
-
// (don't resurrect an item if its tombstone is newer than its updatedAt)
|
|
170
|
-
for (const key of Object.keys(merged)) {
|
|
171
|
-
const value = merged[key];
|
|
172
|
-
if (!Array.isArray(value))
|
|
173
|
-
continue;
|
|
174
|
-
merged[key] = value.filter((item) => {
|
|
175
|
-
if (!item || typeof item !== "object" || !(idKey in item))
|
|
176
|
-
return true;
|
|
177
|
-
const rec = item;
|
|
178
|
-
const id = rec[idKey];
|
|
179
|
-
const deletedAt = tombstones.get(id);
|
|
180
|
-
if (deletedAt == null)
|
|
181
|
-
return true;
|
|
182
|
-
// Keep the item if it has a deletedAt (it's the tombstone itself)
|
|
183
|
-
if (rec[deletedAtKey] != null)
|
|
184
|
-
return true;
|
|
185
|
-
// Filter out alive items that have a newer tombstone
|
|
186
|
-
return compareTimestamps(rec[tsKey], deletedAt) && rec[tsKey] !== deletedAt;
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
return merged;
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* Simple resolver: the document with the newer timestamp wins entirely.
|
|
194
|
-
* No per-field or per-item merging.
|
|
195
|
-
*/
|
|
196
|
-
export function timestampWinner(timestampKey = "timestamp") {
|
|
197
|
-
return (local, remote) => {
|
|
198
|
-
return compareTimestamps(local[timestampKey], remote[timestampKey])
|
|
199
|
-
? local
|
|
200
|
-
: remote;
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
/**
|
|
204
|
-
* Remove expired tombstones from an array of items.
|
|
205
|
-
* Items with a `deletedAtKey` older than `ttlMs` are pruned.
|
|
206
|
-
*
|
|
207
|
-
* @param items - Array of items, some with a deletedAt timestamp
|
|
208
|
-
* @param ttlMs - Time-to-live in ms for tombstones (default: 30 days)
|
|
209
|
-
* @param deletedAtKey - Key marking deletion timestamp (default: "_deletedAt")
|
|
210
|
-
*/
|
|
211
|
-
export function pruneTombstones(items, ttlMs = 30 * 24 * 60 * 60 * 1000, deletedAtKey = "_deletedAt") {
|
|
212
|
-
const cutoff = Date.now() - ttlMs;
|
|
213
|
-
return items.filter((item) => {
|
|
214
|
-
const deletedAt = item[deletedAtKey];
|
|
215
|
-
if (deletedAt == null)
|
|
216
|
-
return true;
|
|
217
|
-
if (typeof deletedAt === "number")
|
|
218
|
-
return deletedAt > cutoff;
|
|
219
|
-
if (typeof deletedAt === "string")
|
|
220
|
-
return new Date(deletedAt).getTime() > cutoff;
|
|
221
|
-
return false;
|
|
222
|
-
});
|
|
223
|
-
}
|