@absolutejs/sync-capacitor 0.6.1 → 0.7.0

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.
@@ -1,4 +1,5 @@
1
1
  import AbsoluteDevicesCapacitor
2
+ import CryptoKit
2
3
  import Foundation
3
4
  import SQLite3
4
5
 
@@ -13,6 +14,7 @@ private struct NativeBatch { let body: [String: Any]; let mutations: [String: Na
13
14
 
14
15
  enum AbsoluteBackgroundSyncEngine {
15
16
  private static let refreshKey = "absolutejs.auth.oidc.refresh"
17
+ private static let dataKeyName = "absolutejs.sync.data-key.v1"
16
18
  private static let maxResponse = 5 * 1024 * 1024
17
19
 
18
20
  static func record(_ error: Error) {
@@ -65,17 +67,17 @@ enum AbsoluteBackgroundSyncEngine {
65
67
  do {
66
68
  var mutations: [[String: Any]] = [], mutationMap: [String: NativeMutation] = [:]
67
69
  for row in try rows(db, "SELECT operation_id,record_json FROM absolute_sync_mutations WHERE namespace=? ORDER BY created_at,operation_id", [config.namespace]) where mutations.count < config.maxMutations {
68
- let id = row[0], record = try object(row[1]); let now = Date().timeIntervalSince1970 * 1000
70
+ let id = row[0], record = try decodeRecord(row[1], kind: "mutation", namespace: config.namespace); let now = Date().timeIntervalSince1970 * 1000
69
71
  if record["state"] as? String == "dead-letter" || (record["nextAttemptAt"] as? NSNumber)?.doubleValue ?? 0 > now { continue }
70
72
  let attempts = ((record["attempts"] as? NSNumber)?.intValue ?? 0) + 1; var next = record; next["attempts"] = attempts; next.removeValue(forKey: "nextAttemptAt")
71
- try updateRecord(db, "absolute_sync_mutations", "operation_id", config.namespace, id, next)
73
+ try updateRecord(db, "absolute_sync_mutations", "operation_id", "mutation", config.namespace, id, next)
72
74
  guard let name = record["name"] as? String else { throw SyncNativeError.invalidResponse("Local mutation has no name.") }
73
75
  var mutation: [String: Any] = ["operationId": id, "name": name]; if let args = record["args"] { mutation["args"] = args }
74
76
  mutations.append(mutation); mutationMap[id] = NativeMutation(id: id, attempts: attempts)
75
77
  }
76
78
  var pulls: [[String: Any]] = [], pullMap: [String: [String: Any]] = [:]
77
79
  for row in try rows(db, "SELECT collection_key,record_json FROM absolute_sync_collections WHERE namespace=? ORDER BY collection_key", [config.namespace]) where pulls.count < config.maxPulls {
78
- let key = row[0], record = try object(row[1]); guard let collection = record["collection"] as? String, !collection.isEmpty else { continue }
80
+ let key = row[0], record = try decodeRecord(row[1], kind: "collection", namespace: config.namespace); guard let collection = record["collection"] as? String, !collection.isEmpty else { continue }
79
81
  var pull: [String: Any] = ["id": key, "collection": collection]; if let params = record["params"] { pull["params"] = params }
80
82
  if record["headlessKey"] as? String == "id" { if let cursor = record["cursor"] { pull["since"] = cursor } else if ((record["version"] as? NSNumber)?.intValue ?? 0) > 0 { pull["since"] = record["version"] } }
81
83
  pulls.append(pull); pullMap[key] = record
@@ -91,17 +93,17 @@ enum AbsoluteBackgroundSyncEngine {
91
93
  var acknowledged = 0, pulled = 0; try execute(db, "BEGIN IMMEDIATE")
92
94
  do {
93
95
  for sent in batch.mutations.values {
94
- guard var current = try record(db, "absolute_sync_mutations", "operation_id", config.namespace, sent.id), (current["attempts"] as? NSNumber)?.intValue == sent.attempts else { continue }
96
+ guard var current = try record(db, "absolute_sync_mutations", "operation_id", "mutation", config.namespace, sent.id), (current["attempts"] as? NSNumber)?.intValue == sent.attempts else { continue }
95
97
  if mutationResults[sent.id]?["status"] as? String == "ack" { try execute(db, "DELETE FROM absolute_sync_mutations WHERE namespace=? AND operation_id=?", [config.namespace, sent.id]); acknowledged += 1; continue }
96
98
  let rejection = mutationResults[sent.id]?["rejection"] as? [String: Any] ?? ["kind": "retryable", "message": "Missing mutation response."]
97
99
  current["lastError"] = rejection["message"]; current["rejection"] = rejection
98
100
  if rejection["kind"] as? String != "retryable" || sent.attempts >= config.maxAttempts { current["state"] = "dead-letter"; current["deadLetteredAt"] = Date().timeIntervalSince1970 * 1000; current.removeValue(forKey: "nextAttemptAt") }
99
101
  else { let hint = (rejection["retryAfterMs"] as? NSNumber)?.doubleValue ?? min(30_000, 500 * pow(2, Double(max(0, sent.attempts - 1)))); current["state"] = "pending"; current["nextAttemptAt"] = Date().timeIntervalSince1970 * 1000 + max(0, hint) }
100
- try updateRecord(db, "absolute_sync_mutations", "operation_id", config.namespace, sent.id, current)
102
+ try updateRecord(db, "absolute_sync_mutations", "operation_id", "mutation", config.namespace, sent.id, current)
101
103
  }
102
104
  for (key, baseline) in batch.pulls {
103
- guard let result = pullResults[key], result["type"] as? String != "error", let current = try record(db, "absolute_sync_collections", "collection_key", config.namespace, key), same(current, baseline) else { continue }
104
- try updateRecord(db, "absolute_sync_collections", "collection_key", config.namespace, key, try applyPull(current, result)); pulled += 1
105
+ guard let result = pullResults[key], result["type"] as? String != "error", let current = try record(db, "absolute_sync_collections", "collection_key", "collection", config.namespace, key), same(current, baseline) else { continue }
106
+ try updateRecord(db, "absolute_sync_collections", "collection_key", "collection", config.namespace, key, try applyPull(current, result)); pulled += 1
105
107
  }
106
108
  try execute(db, "COMMIT"); return (acknowledged, pulled)
107
109
  } catch { try? execute(db, "ROLLBACK"); throw error }
@@ -123,8 +125,12 @@ enum AbsoluteBackgroundSyncEngine {
123
125
  private static func index(_ value: Any?, _ field: String) -> [String: [String: Any]] { var result: [String: [String: Any]] = [:]; for item in value as? [[String: Any]] ?? [] { if let key = item[field] as? String { result[key] = item } }; return result }
124
126
 
125
127
  private static func object(_ json: String) throws -> [String: Any] { guard let value = try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] else { throw SyncNativeError.invalidResponse("Invalid local Sync record.") }; return value }
126
- private static func record(_ db: OpaquePointer, _ table: String, _ field: String, _ namespace: String, _ id: String) throws -> [String: Any]? { guard let value = try rows(db, "SELECT record_json FROM \(table) WHERE namespace=? AND \(field)=? LIMIT 1", [namespace,id]).first?.first else { return nil }; return try object(value) }
127
- private static func updateRecord(_ db: OpaquePointer, _ table: String, _ field: String, _ namespace: String, _ id: String, _ value: [String: Any]) throws { let json = String(data: try JSONSerialization.data(withJSONObject: value), encoding: .utf8)!; try execute(db, "UPDATE \(table) SET record_json=? WHERE namespace=? AND \(field)=?", [json,namespace,id]) }
128
+ private static func dataKey() throws -> SymmetricKey? { guard let value = try AbsoluteSecureStorageVault.get(dataKeyName) else { return nil }; guard let bytes = Data(base64Encoded: value), bytes.count == 32 else { throw SyncNativeError.invalidResponse("Protected Sync data key is invalid.") }; return SymmetricKey(data: bytes) }
129
+ private static func aad(_ kind: String, _ namespace: String, _ name: String) -> Data { Data("absolute-sync-v1\u{0}\(kind)\u{0}\(namespace)\u{0}\(name)".utf8) }
130
+ private static func decodeRecord(_ stored: String, kind: String, namespace: String) throws -> [String: Any] { let outer = try object(stored); guard let envelope = outer["__absoluteSyncProtected"] as? [String: Any] else { return outer }; guard envelope["protector"] as? String == "aes-256-gcm-v1", let name = envelope["name"] as? String, let encoded = envelope["value"] as? String, let packed = Data(base64Encoded: encoded), packed.count >= 28, let key = try dataKey() else { throw SyncNativeError.invalidResponse("Protected Sync data key is unavailable.") }; let nonce = try AES.GCM.Nonce(data: packed.prefix(12)), ciphertextAndTag = packed.dropFirst(12), box = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertextAndTag.dropLast(16), tag: ciphertextAndTag.suffix(16)); return try object(String(data: try AES.GCM.open(box, using: key, authenticating: aad(kind, namespace, name)), encoding: .utf8)!) }
131
+ private static func encodeRecord(_ value: [String: Any], kind: String, namespace: String, name: String) throws -> String { let plain = try JSONSerialization.data(withJSONObject: value); guard let key = try dataKey() else { return String(data: plain, encoding: .utf8)! }; let sealed = try AES.GCM.seal(plain, using: key, authenticating: aad(kind, namespace, name)); var packed = Data(sealed.nonce); packed.append(sealed.ciphertext); packed.append(sealed.tag); let envelope: [String: Any] = ["__absoluteSyncProtected": ["name": name, "protector": "aes-256-gcm-v1", "value": packed.base64EncodedString()]]; return String(data: try JSONSerialization.data(withJSONObject: envelope), encoding: .utf8)! }
132
+ private static func record(_ db: OpaquePointer, _ table: String, _ field: String, _ kind: String, _ namespace: String, _ id: String) throws -> [String: Any]? { guard let value = try rows(db, "SELECT record_json FROM \(table) WHERE namespace=? AND \(field)=? LIMIT 1", [namespace,id]).first?.first else { return nil }; return try decodeRecord(value, kind: kind, namespace: namespace) }
133
+ private static func updateRecord(_ db: OpaquePointer, _ table: String, _ field: String, _ kind: String, _ namespace: String, _ id: String, _ value: [String: Any]) throws { let name = (value[kind == "mutation" ? "name" : "collection"] as? String) ?? id; let json = try encodeRecord(value, kind: kind, namespace: namespace, name: name); try execute(db, "UPDATE \(table) SET record_json=? WHERE namespace=? AND \(field)=?", [json,namespace,id]) }
128
134
  private static func rows(_ db: OpaquePointer, _ sql: String, _ bindings: [String]) throws -> [[String]] { var statement: OpaquePointer?; guard sqlite3_prepare_v2(db,sql,-1,&statement,nil)==SQLITE_OK else { throw SyncNativeError.invalidResponse("SQLite prepare failed.") }; defer{sqlite3_finalize(statement)}; for (i,value) in bindings.enumerated(){sqlite3_bind_text(statement,Int32(i+1),(value as NSString).utf8String,-1,SQLITE_TRANSIENT)}; var output:[[String]]=[]; while sqlite3_step(statement)==SQLITE_ROW { output.append((0..<sqlite3_column_count(statement)).map{String(cString:sqlite3_column_text(statement,$0))}) }; return output }
129
135
  private static func execute(_ db: OpaquePointer, _ sql: String, _ bindings: [String] = []) throws { var statement: OpaquePointer?; guard sqlite3_prepare_v2(db,sql,-1,&statement,nil)==SQLITE_OK else { throw SyncNativeError.invalidResponse("SQLite prepare failed.") }; defer{sqlite3_finalize(statement)}; for(i,value)in bindings.enumerated(){sqlite3_bind_text(statement,Int32(i+1),(value as NSString).utf8String,-1,SQLITE_TRANSIENT)}; guard sqlite3_step(statement)==SQLITE_DONE else { throw SyncNativeError.invalidResponse("SQLite write failed.") } }
130
136
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/sync-capacitor",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Transactional Capacitor SQLite local store and native lifecycle adapter for @absolutejs/sync",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,7 +29,7 @@
29
29
  "mobile"
30
30
  ],
31
31
  "scripts": {
32
- "build": "rm -rf dist && bun build src/index.ts --outdir dist --sourcemap --target=browser --external @absolutejs/sync --external @absolutejs/devices --external @capacitor/core --external @capacitor-community/sqlite && tsc --project tsconfig.build.json",
32
+ "build": "rm -rf dist && bun build src/index.ts --outdir dist --sourcemap --target=browser --external @absolutejs/sync --external @absolutejs/devices --external @absolutejs/devices-capacitor --external @capacitor/core --external @capacitor-community/sqlite && tsc --project tsconfig.build.json",
33
33
  "test": "bun test",
34
34
  "typecheck": "tsc --noEmit",
35
35
  "format": "prettier --write \"./**/*.{ts,json,md}\"",
@@ -38,14 +38,14 @@
38
38
  "peerDependencies": {
39
39
  "@absolutejs/devices": ">=0.0.3 <0.1.0",
40
40
  "@absolutejs/devices-capacitor": ">=0.1.3 <0.2.0",
41
- "@absolutejs/sync": ">=2.26.1 <3",
41
+ "@absolutejs/sync": ">=2.27.0 <3",
42
42
  "@capacitor/core": ">=8 <9",
43
43
  "@capacitor-community/sqlite": ">=8.1.1 <9"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@absolutejs/devices": "0.0.3",
47
47
  "@absolutejs/devices-capacitor": "0.1.3",
48
- "@absolutejs/sync": "2.26.1",
48
+ "@absolutejs/sync": "2.27.0",
49
49
  "@capacitor/core": "8.5.0",
50
50
  "@capacitor-community/sqlite": "8.1.1",
51
51
  "@types/bun": "1.3.14",
@@ -76,5 +76,8 @@
76
76
  "ios": {
77
77
  "src": "ios"
78
78
  }
79
+ },
80
+ "dependencies": {
81
+ "@noble/ciphers": "^2.3.0"
79
82
  }
80
83
  }