@absolutejs/sync-capacitor 0.6.1 → 0.8.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.
- package/README.md +32 -4
- package/android/src/main/java/js/absolute/sync/AbsoluteBackgroundSyncEngine.java +20 -9
- package/dist/index.d.ts +10 -2
- package/dist/index.js +663 -19
- package/dist/index.js.map +7 -4
- package/ios/Sources/AbsoluteSyncCapacitor/AbsoluteBackgroundSyncEngine.swift +21 -9
- package/package.json +7 -4
|
@@ -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
|
|
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
|
|
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,23 @@ 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
|
|
100
|
+
let conflictPolicy = current["conflictPolicy"] as? [String: Any]
|
|
101
|
+
let conflictStrategy = conflictPolicy?["strategy"] as? String ?? "manual"
|
|
102
|
+
if rejection["kind"] as? String == "conflict" && conflictStrategy == "server-wins" { try execute(db, "DELETE FROM absolute_sync_mutations WHERE namespace=? AND operation_id=?", [config.namespace, sent.id]); continue }
|
|
103
|
+
let conflictAttempts = (current["conflictAttempts"] as? NSNumber)?.intValue ?? 0
|
|
104
|
+
let maxConflictAttempts = (conflictPolicy?["maxAttempts"] as? NSNumber)?.intValue ?? 1
|
|
105
|
+
if rejection["kind"] as? String == "conflict" && conflictStrategy == "client-wins" && conflictAttempts < maxConflictAttempts { current["conflictAttempts"] = conflictAttempts + 1; current["state"] = "pending"; current.removeValue(forKey: "nextAttemptAt"); try updateRecord(db, "absolute_sync_mutations", "operation_id", "mutation", config.namespace, sent.id, current); continue }
|
|
98
106
|
if rejection["kind"] as? String != "retryable" || sent.attempts >= config.maxAttempts { current["state"] = "dead-letter"; current["deadLetteredAt"] = Date().timeIntervalSince1970 * 1000; current.removeValue(forKey: "nextAttemptAt") }
|
|
99
107
|
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)
|
|
108
|
+
try updateRecord(db, "absolute_sync_mutations", "operation_id", "mutation", config.namespace, sent.id, current)
|
|
101
109
|
}
|
|
102
110
|
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
|
|
111
|
+
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 }
|
|
112
|
+
try updateRecord(db, "absolute_sync_collections", "collection_key", "collection", config.namespace, key, try applyPull(current, result)); pulled += 1
|
|
105
113
|
}
|
|
106
114
|
try execute(db, "COMMIT"); return (acknowledged, pulled)
|
|
107
115
|
} catch { try? execute(db, "ROLLBACK"); throw error }
|
|
@@ -123,8 +131,12 @@ enum AbsoluteBackgroundSyncEngine {
|
|
|
123
131
|
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
132
|
|
|
125
133
|
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
|
|
127
|
-
private static func
|
|
134
|
+
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) }
|
|
135
|
+
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) }
|
|
136
|
+
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)!) }
|
|
137
|
+
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)! }
|
|
138
|
+
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) }
|
|
139
|
+
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
140
|
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
141
|
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
142
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@absolutejs/sync-capacitor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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.
|
|
41
|
+
"@absolutejs/sync": ">=2.29.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.
|
|
48
|
+
"@absolutejs/sync": "2.29.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
|
}
|