@absolutejs/sync-capacitor 0.2.0 → 0.3.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.
@@ -0,0 +1,13 @@
1
+ Pod::Spec.new do |s|
2
+ s.name = 'AbsoluteSyncCapacitor'
3
+ s.version = '0.3.0'
4
+ s.summary = 'Durable foreground and background Sync for AbsoluteJS Capacitor apps.'
5
+ s.license = 'Apache-2.0'
6
+ s.homepage = 'https://github.com/absolutejs/sync-adapters'
7
+ s.author = 'Alex Kahn'
8
+ s.source = { :git => 'https://github.com/absolutejs/sync-adapters.git', :tag => s.version.to_s }
9
+ s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
10
+ s.ios.deployment_target = '15.0'
11
+ s.dependency 'Capacitor'
12
+ s.dependency 'AbsoluteDevicesCapacitor'
13
+ end
package/Package.swift ADDED
@@ -0,0 +1,27 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "AbsoluteSyncCapacitor",
6
+ platforms: [.iOS(.v15)],
7
+ products: [.library(name: "AbsoluteSyncCapacitor", targets: ["AbsoluteSyncCapacitor"])],
8
+ dependencies: [
9
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0"),
10
+ // Both plugins are direct app dependencies. Resolving the sibling npm
11
+ // package keeps SwiftPM on the same installed devices-capacitor build
12
+ // that Capacitor discovered, instead of treating a monorepo subfolder
13
+ // as a nonexistent root Swift package.
14
+ .package(name: "AbsoluteDevicesCapacitor", path: "../devices-capacitor")
15
+ ],
16
+ targets: [
17
+ .target(
18
+ name: "AbsoluteSyncCapacitor",
19
+ dependencies: [
20
+ .product(name: "Capacitor", package: "capacitor-swift-pm"),
21
+ .product(name: "AbsoluteDevicesCapacitor", package: "AbsoluteDevicesCapacitor")
22
+ ],
23
+ path: "ios/Sources/AbsoluteSyncCapacitor",
24
+ linkerSettings: [.linkedLibrary("sqlite3")]
25
+ )
26
+ ]
27
+ )
package/README.md CHANGED
@@ -42,6 +42,48 @@ issuer, public client ID, and subject. Signing out locks that partition by
42
42
  removing it from the active runtime; it does not silently destroy offline data.
43
43
  Signing back in as the same verified principal unlocks the same partition.
44
44
 
45
+ ## Managed native background Sync
46
+
47
+ AbsoluteJS also configures `AbsoluteBackgroundSync` when the application uses
48
+ both `@absolutejs/auth` and `@absolutejs/sync`. The native worker is deliberately
49
+ finite: Android WorkManager or iOS `BGProcessingTask` wakes it, it refreshes a
50
+ short-lived access token, pushes a bounded durable outbox batch, pulls the
51
+ foreground client's persisted collection descriptors, commits the response to
52
+ the same SQLite database, and exits. Foreground/resume Sync remains the
53
+ correctness path because neither operating system guarantees when background
54
+ work will run.
55
+
56
+ The worker does not run application JavaScript and does not expose Capacitor
57
+ APIs to a background WebView. Its credential and network boundary is fixed:
58
+
59
+ - the OAuth refresh token is read from the shared native Keychain/Keystore
60
+ vault and sent only to the issuer-advertised HTTPS token endpoint;
61
+ - the resulting bearer token, mutation arguments, and collection parameters
62
+ are sent only to the configured same-origin AbsoluteJS endpoint;
63
+ - redirects fail closed, and response bodies are bounded before parsing;
64
+ - a rotated refresh token is written back to the vault, while returned Sync
65
+ data is written only to the principal's SQLite partition.
66
+
67
+ Direct Capacitor applications can configure the worker after resolving a
68
+ verified principal:
69
+
70
+ ```ts
71
+ import { configureCapacitorBackgroundSync } from '@absolutejs/sync-capacitor';
72
+
73
+ await configureCapacitorBackgroundSync({
74
+ issuer: 'https://app.example.com',
75
+ clientId: 'mobile-public-client',
76
+ endpoint: 'https://app.example.com/__absolute/sync/background',
77
+ namespace: authenticatedPrincipalNamespace
78
+ });
79
+ ```
80
+
81
+ Call `AbsoluteBackgroundSync.clear()` on sign-out. On iOS, register
82
+ `AbsoluteBackgroundSyncPlugin.registerBackgroundTask()` during application
83
+ launch and list `<bundle-id>.absolutejs.background-sync` in
84
+ `BGTaskSchedulerPermittedIdentifiers`; the AbsoluteJS mobile CLI owns those
85
+ generated regions. Android scheduling is registered by the plugin.
86
+
45
87
  ## Platform notes
46
88
 
47
89
  - Native Android and iOS use SQLCipher through
@@ -51,6 +93,10 @@ Signing back in as the same verified principal unlocks the same partition.
51
93
  `@absolutejs/sync/client`; they do not need the plugin's WASM/web component.
52
94
  - The adapter serializes transactions so an app cannot overlap two explicit
53
95
  transactions on the same Capacitor connection.
96
+ - The native iOS worker currently targets the SQLite plugin's default Documents
97
+ location. Applications that override `CapacitorSQLite.iosDatabaseLocation`
98
+ must keep foreground and background database locations aligned before
99
+ enabling managed background Sync.
54
100
 
55
101
  ## License
56
102
 
@@ -0,0 +1,30 @@
1
+ ext {
2
+ junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
3
+ }
4
+
5
+ buildscript {
6
+ repositories { google(); mavenCentral() }
7
+ dependencies { classpath 'com.android.tools.build:gradle:8.7.2' }
8
+ }
9
+
10
+ apply plugin: 'com.android.library'
11
+
12
+ android {
13
+ namespace "js.absolute.sync"
14
+ compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
15
+ defaultConfig {
16
+ minSdk project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
17
+ targetSdk project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
18
+ versionCode 1
19
+ versionName "0.3.0"
20
+ }
21
+ }
22
+
23
+ repositories { google(); mavenCentral() }
24
+
25
+ dependencies {
26
+ implementation project(':capacitor-android')
27
+ implementation project(':absolutejs-devices-capacitor')
28
+ implementation 'androidx.work:work-runtime:2.10.1'
29
+ testImplementation "junit:junit:$junitVersion"
30
+ }
@@ -0,0 +1,3 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.INTERNET" />
3
+ </manifest>
@@ -0,0 +1,61 @@
1
+ package js.absolute.sync;
2
+
3
+ import android.content.Context;
4
+ import android.content.SharedPreferences;
5
+ import java.net.URI;
6
+ import org.json.JSONException;
7
+ import org.json.JSONObject;
8
+
9
+ final class AbsoluteBackgroundSyncConfig {
10
+ static final String PREFERENCES = "absolutejs-background-sync";
11
+ static final String CONFIG = "config";
12
+ static final String WORK_NAME = "absolutejs-background-sync";
13
+ static final String REFRESH_KEY = "absolutejs.auth.oidc.refresh";
14
+ final String endpoint, issuer, clientId, namespace, databaseName;
15
+ final int intervalMinutes, maxMutations, maxPulls, maxAttempts;
16
+
17
+ AbsoluteBackgroundSyncConfig(JSONObject value) throws Exception {
18
+ endpoint = requireUrl(value, "endpoint", false);
19
+ issuer = requireUrl(value, "issuer", true);
20
+ if (!sameOrigin(new URI(endpoint), new URI(issuer))) throw new IllegalArgumentException("endpoint must use the Auth issuer origin.");
21
+ clientId = requireText(value, "clientId");
22
+ namespace = requireText(value, "namespace");
23
+ databaseName = value.optString("databaseName", "absolutejs-sync-local-v1");
24
+ if (!databaseName.matches("[A-Za-z0-9._-]{1,128}")) throw new IllegalArgumentException("databaseName is invalid.");
25
+ intervalMinutes = bounded(value.optInt("intervalMinutes", 15), 15, 1440, "intervalMinutes");
26
+ maxMutations = bounded(value.optInt("maxMutations", 50), 0, 100, "maxMutations");
27
+ maxPulls = bounded(value.optInt("maxPulls", 50), 0, 100, "maxPulls");
28
+ maxAttempts = bounded(value.optInt("maxAttempts", 5), 1, 100, "maxAttempts");
29
+ }
30
+ private static boolean sameOrigin(URI left, URI right) {
31
+ int leftPort = left.getPort() == -1 ? ("https".equals(left.getScheme()) ? 443 : 80) : left.getPort();
32
+ int rightPort = right.getPort() == -1 ? ("https".equals(right.getScheme()) ? 443 : 80) : right.getPort();
33
+ return left.getScheme().equals(right.getScheme()) && left.getHost().equalsIgnoreCase(right.getHost()) && leftPort == rightPort;
34
+ }
35
+ private static int bounded(int value, int min, int max, String name) {
36
+ if (value < min || value > max) throw new IllegalArgumentException(name + " is out of range.");
37
+ return value;
38
+ }
39
+ private static String requireText(JSONObject value, String name) throws JSONException {
40
+ String text = value.getString(name).trim();
41
+ if (text.isEmpty() || text.length() > 2048) throw new IllegalArgumentException(name + " is invalid.");
42
+ return text;
43
+ }
44
+ private static String requireUrl(JSONObject value, String name, boolean originOnly) throws Exception {
45
+ URI uri = new URI(requireText(value, name));
46
+ boolean loopback = "http".equals(uri.getScheme()) && ("localhost".equals(uri.getHost()) || "127.0.0.1".equals(uri.getHost()) || "::1".equals(uri.getHost()));
47
+ if (!"https".equals(uri.getScheme()) && !loopback) throw new IllegalArgumentException(name + " must use HTTPS.");
48
+ if (uri.getUserInfo() != null || uri.getFragment() != null) throw new IllegalArgumentException(name + " is invalid.");
49
+ if (originOnly && ((uri.getPath() != null && !uri.getPath().isEmpty() && !"/".equals(uri.getPath())) || uri.getQuery() != null)) throw new IllegalArgumentException(name + " must be an origin.");
50
+ return uri.toString();
51
+ }
52
+ JSONObject json() throws JSONException {
53
+ return new JSONObject().put("endpoint", endpoint).put("issuer", issuer).put("clientId", clientId).put("namespace", namespace).put("databaseName", databaseName).put("intervalMinutes", intervalMinutes).put("maxMutations", maxMutations).put("maxPulls", maxPulls).put("maxAttempts", maxAttempts);
54
+ }
55
+ static AbsoluteBackgroundSyncConfig load(Context context) throws Exception {
56
+ String encoded = preferences(context).getString(CONFIG, null);
57
+ return encoded == null ? null : new AbsoluteBackgroundSyncConfig(new JSONObject(encoded));
58
+ }
59
+ void save(Context context) throws JSONException { preferences(context).edit().putString(CONFIG, json().toString()).apply(); }
60
+ static SharedPreferences preferences(Context context) { return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE); }
61
+ }
@@ -0,0 +1,95 @@
1
+ package js.absolute.sync;
2
+
3
+ import android.content.ContentValues;
4
+ import android.content.Context;
5
+ import android.database.Cursor;
6
+ import android.database.sqlite.SQLiteDatabase;
7
+ import com.getcapacitor.JSObject;
8
+ import java.io.ByteArrayOutputStream;
9
+ import java.io.InputStream;
10
+ import java.io.OutputStream;
11
+ import java.net.HttpURLConnection;
12
+ import java.net.URI;
13
+ import java.net.URL;
14
+ import java.net.URLEncoder;
15
+ import java.nio.charset.StandardCharsets;
16
+ import java.util.HashMap;
17
+ import java.util.LinkedHashMap;
18
+ import java.util.Map;
19
+ import js.absolute.devices.AbsoluteSecureStorageVault;
20
+ import org.json.JSONArray;
21
+ import org.json.JSONObject;
22
+
23
+ final class AbsoluteBackgroundSyncEngine {
24
+ static final class NoSession extends Exception {}
25
+ private static final int MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
26
+ private static final String RUNNING="running", LAST_RUN="lastRunAt", LAST_ERROR="lastError", LAST_ACK="lastAcknowledged", LAST_PULL="lastPulled";
27
+ private static final class Mutation { final String id; final int attempts; Mutation(String i,int a){id=i;attempts=a;} }
28
+ private static final class Batch { final JSONObject request=new JSONObject(); final Map<String,Mutation> mutations=new LinkedHashMap<>(); final Map<String,JSONObject> pulls=new LinkedHashMap<>(); }
29
+
30
+ static JSObject status(Context context){
31
+ android.content.SharedPreferences p=AbsoluteBackgroundSyncConfig.preferences(context); JSObject v=new JSObject();
32
+ v.put("configured",p.contains(AbsoluteBackgroundSyncConfig.CONFIG)); v.put(RUNNING,p.getBoolean(RUNNING,false));
33
+ if(p.contains(LAST_RUN))v.put(LAST_RUN,p.getLong(LAST_RUN,0)); if(p.contains(LAST_ERROR))v.put(LAST_ERROR,p.getString(LAST_ERROR,null));
34
+ if(p.contains(LAST_ACK))v.put(LAST_ACK,p.getInt(LAST_ACK,0)); if(p.contains(LAST_PULL))v.put(LAST_PULL,p.getInt(LAST_PULL,0)); return v;
35
+ }
36
+ static void recordError(Context c,Exception e){AbsoluteBackgroundSyncConfig.preferences(c).edit().putBoolean(RUNNING,false).putString(LAST_ERROR,e.getMessage()==null?e.getClass().getSimpleName():e.getMessage()).apply();}
37
+ static void run(Context context,AbsoluteBackgroundSyncConfig config)throws Exception{
38
+ AbsoluteBackgroundSyncConfig.preferences(context).edit().putBoolean(RUNNING,true).remove(LAST_ERROR).apply();
39
+ try{
40
+ String access=refresh(context,config); SQLiteDatabase db=SQLiteDatabase.openDatabase(context.getDatabasePath(config.databaseName+"SQLite.db").getPath(),null,SQLiteDatabase.OPEN_READWRITE);
41
+ try{db.execSQL("PRAGMA busy_timeout=5000"); Batch batch=prepare(db,config); JSONObject response=request(config.endpoint,"POST",batch.request.toString().getBytes(StandardCharsets.UTF_8),"application/json","Bearer "+access,config.issuer); int[] counts=settle(db,config,batch,response);
42
+ AbsoluteBackgroundSyncConfig.preferences(context).edit().putBoolean(RUNNING,false).putLong(LAST_RUN,System.currentTimeMillis()).putInt(LAST_ACK,counts[0]).putInt(LAST_PULL,counts[1]).remove(LAST_ERROR).apply();
43
+ }finally{db.close();}
44
+ }catch(NoSession e){AbsoluteBackgroundSyncConfig.preferences(context).edit().putBoolean(RUNNING,false).remove(LAST_ERROR).apply();throw e;}
45
+ catch(Exception e){recordError(context,e);throw e;}
46
+ }
47
+ private static String refresh(Context context,AbsoluteBackgroundSyncConfig config)throws Exception{
48
+ String lease=AbsoluteSecureStorageVault.acquireLease(AbsoluteBackgroundSyncConfig.REFRESH_KEY,120000);if(lease==null)throw new IllegalStateException("Credential refresh is already running.");
49
+ try{
50
+ String refresh=AbsoluteSecureStorageVault.get(context,AbsoluteBackgroundSyncConfig.REFRESH_KEY);if(refresh==null)throw new NoSession();
51
+ JSONObject discovery=request(config.issuer.replaceAll("/$","")+"/.well-known/openid-configuration","GET",null,null,null,config.issuer,false); String tokenEndpoint=discovery.getString("token_endpoint");
52
+ String body=form("client_id",config.clientId)+"&"+form("grant_type","refresh_token")+"&"+form("refresh_token",refresh)+"&"+form("resource",config.issuer);
53
+ JSONObject tokens=request(tokenEndpoint,"POST",body.getBytes(StandardCharsets.UTF_8),"application/x-www-form-urlencoded",null,config.issuer,true);
54
+ if(!AbsoluteSecureStorageVault.setIfLease(context,AbsoluteBackgroundSyncConfig.REFRESH_KEY,tokens.getString("refresh_token"),lease))throw new NoSession();return tokens.getString("access_token");
55
+ }finally{AbsoluteSecureStorageVault.releaseLease(AbsoluteBackgroundSyncConfig.REFRESH_KEY,lease);}
56
+ }
57
+ private static String form(String n,String v)throws Exception{return URLEncoder.encode(n,"UTF-8")+"="+URLEncoder.encode(v,"UTF-8");}
58
+
59
+ private static Batch prepare(SQLiteDatabase db,AbsoluteBackgroundSyncConfig config)throws Exception{
60
+ Batch b=new Batch(); JSONArray mutations=new JSONArray(),pulls=new JSONArray(); long now=System.currentTimeMillis(); db.beginTransaction();
61
+ try{
62
+ try(Cursor c=db.rawQuery("SELECT operation_id,record_json FROM absolute_sync_mutations WHERE namespace=? ORDER BY created_at,operation_id",new String[]{config.namespace})){
63
+ while(c.moveToNext()&&mutations.length()<config.maxMutations){String id=c.getString(0);JSONObject r=new JSONObject(c.getString(1));if("dead-letter".equals(r.optString("state"))||r.optLong("nextAttemptAt",0)>now)continue;int attempts=r.optInt("attempts",0)+1;r.put("attempts",attempts).remove("nextAttemptAt");updateMutation(db,config.namespace,id,r);JSONObject m=new JSONObject().put("operationId",id).put("name",r.getString("name"));if(r.has("args"))m.put("args",r.get("args"));mutations.put(m);b.mutations.put(id,new Mutation(id,attempts));}
64
+ }
65
+ try(Cursor c=db.rawQuery("SELECT collection_key,record_json FROM absolute_sync_collections WHERE namespace=? ORDER BY collection_key",new String[]{config.namespace})){
66
+ while(c.moveToNext()&&pulls.length()<config.maxPulls){String key=c.getString(0);JSONObject r=new JSONObject(c.getString(1));String collection=r.optString("collection");if(collection.isEmpty())continue;JSONObject p=new JSONObject().put("id",key).put("collection",collection);if(r.has("params"))p.put("params",r.get("params"));if("id".equals(r.optString("headlessKey"))){if(r.has("cursor"))p.put("since",r.get("cursor"));else if(r.optInt("version",0)>0)p.put("since",r.getInt("version"));}pulls.put(p);b.pulls.put(key,r);}
67
+ }
68
+ db.setTransactionSuccessful();
69
+ }finally{db.endTransaction();}
70
+ b.request.put("version",1).put("mutations",mutations).put("pulls",pulls);return b;
71
+ }
72
+ private static int[] settle(SQLiteDatabase db,AbsoluteBackgroundSyncConfig config,Batch b,JSONObject response)throws Exception{
73
+ if(response.getInt("version")!=1)throw new IllegalStateException("Unsupported Headless Sync response.");Map<String,JSONObject> mutationResults=index(response.getJSONArray("mutations"),"operationId"),pullResults=index(response.getJSONArray("pulls"),"id");int ack=0,pulled=0;db.beginTransaction();
74
+ try{
75
+ for(Mutation sent:b.mutations.values()){JSONObject current=record(db,"absolute_sync_mutations","operation_id",config.namespace,sent.id);if(current==null||current.optInt("attempts",-1)!=sent.attempts)continue;JSONObject outcome=mutationResults.get(sent.id);if(outcome!=null&&"ack".equals(outcome.optString("status"))){db.delete("absolute_sync_mutations","namespace=? AND operation_id=?",new String[]{config.namespace,sent.id});ack++;continue;}JSONObject rejection=outcome==null?new JSONObject().put("kind","retryable").put("message","Missing mutation response."):outcome.getJSONObject("rejection");current.put("lastError",rejection.optString("message","Mutation rejected.")).put("rejection",rejection);if(!"retryable".equals(rejection.optString("kind"))||sent.attempts>=config.maxAttempts)current.put("state","dead-letter").put("deadLetteredAt",System.currentTimeMillis()).remove("nextAttemptAt");else current.put("state","pending").put("nextAttemptAt",System.currentTimeMillis()+Math.max(0,rejection.optLong("retryAfterMs",Math.min(30000,500L<<Math.min(20,sent.attempts-1)))));updateMutation(db,config.namespace,sent.id,current);}
76
+ for(Map.Entry<String,JSONObject> requested:b.pulls.entrySet()){JSONObject result=pullResults.get(requested.getKey());if(result==null||"error".equals(result.optString("type")))continue;JSONObject current=record(db,"absolute_sync_collections","collection_key",config.namespace,requested.getKey());if(!same(current,requested.getValue()))continue;ContentValues values=new ContentValues();values.put("record_json",applyPull(current,result).toString());db.update("absolute_sync_collections",values,"namespace=? AND collection_key=?",new String[]{config.namespace,requested.getKey()});pulled++;}
77
+ db.setTransactionSuccessful();
78
+ }finally{db.endTransaction();}return new int[]{ack,pulled};
79
+ }
80
+ private static JSONObject applyPull(JSONObject current,JSONObject result)throws Exception{JSONObject next=new JSONObject(current.toString());if("snapshot".equals(result.getString("type")))next.put("rows",result.getJSONArray("rows"));else{Map<String,JSONObject> rows=new LinkedHashMap<>();JSONArray existing=current.getJSONArray("rows");for(int i=0;i<existing.length();i++){JSONObject row=existing.getJSONObject(i);rows.put(key(row),row);}remove(rows,result.getJSONArray("removed"));set(rows,result.getJSONArray("changed"));set(rows,result.getJSONArray("added"));next.put("rows",new JSONArray(rows.values()));}return next.put("version",result.getInt("version")).put("cursor",result.getString("cursor"));}
81
+ private static String key(JSONObject row)throws Exception{Object id=row.get("id");if(!(id instanceof String)&&!(id instanceof Number))throw new IllegalStateException("Background Sync row requires id.");return id.getClass().getName()+":"+id;}
82
+ private static void set(Map<String,JSONObject> rows,JSONArray values)throws Exception{for(int i=0;i<values.length();i++){JSONObject row=values.getJSONObject(i);rows.put(key(row),row);}}
83
+ private static void remove(Map<String,JSONObject> rows,JSONArray values)throws Exception{for(int i=0;i<values.length();i++)rows.remove(key(values.getJSONObject(i)));}
84
+ private static boolean same(JSONObject a,JSONObject b){return a!=null&&a.optInt("version")==b.optInt("version")&&a.optString("cursor").equals(b.optString("cursor"));}
85
+ private static Map<String,JSONObject> index(JSONArray values,String field)throws Exception{Map<String,JSONObject> result=new HashMap<>();for(int i=0;i<values.length();i++){JSONObject value=values.getJSONObject(i);result.put(value.getString(field),value);}return result;}
86
+ private static JSONObject record(SQLiteDatabase db,String table,String field,String namespace,String id)throws Exception{try(Cursor c=db.rawQuery("SELECT record_json FROM "+table+" WHERE namespace=? AND "+field+"=? LIMIT 1",new String[]{namespace,id})){return c.moveToFirst()?new JSONObject(c.getString(0)):null;}}
87
+ private static void updateMutation(SQLiteDatabase db,String namespace,String id,JSONObject record){ContentValues values=new ContentValues();values.put("record_json",record.toString());db.update("absolute_sync_mutations",values,"namespace=? AND operation_id=?",new String[]{namespace,id});}
88
+
89
+ private static JSONObject request(String address,String method,byte[] body,String type,String auth,String trustedOrigin)throws Exception{return request(address,method,body,type,auth,trustedOrigin,false);}
90
+ private static JSONObject request(String address,String method,byte[] body,String type,String auth,String trustedOrigin,boolean advertisedTokenEndpoint)throws Exception{
91
+ if(body!=null&&body.length>MAX_RESPONSE_BYTES)throw new IllegalStateException("Background request is too large.");URI target=new URI(address);if(advertisedTokenEndpoint){boolean loopback="http".equals(target.getScheme())&&("localhost".equalsIgnoreCase(target.getHost())||"127.0.0.1".equals(target.getHost())||"::1".equals(target.getHost()));if((!"https".equals(target.getScheme())&&!loopback)||target.getUserInfo()!=null||target.getFragment()!=null)throw new SecurityException("OIDC advertised an unsafe token endpoint.");}else if(!sameOrigin(target,new URI(trustedOrigin)))throw new SecurityException("Background request left the Auth issuer origin.");HttpURLConnection c=(HttpURLConnection)new URL(address).openConnection();c.setConnectTimeout(10000);c.setReadTimeout(20000);c.setRequestMethod(method);c.setRequestProperty("Accept","application/json");c.setInstanceFollowRedirects(false);if(auth!=null)c.setRequestProperty("Authorization",auth);if(body!=null){c.setDoOutput(true);c.setFixedLengthStreamingMode(body.length);c.setRequestProperty("Content-Type",type);try(OutputStream output=c.getOutputStream()){output.write(body);}}int status=c.getResponseCode();InputStream input=status>=200&&status<300?c.getInputStream():c.getErrorStream();byte[] bytes=read(input);c.disconnect();if(status<200||status>=300)throw new IllegalStateException("Background request failed with HTTP "+status+".");return new JSONObject(new String(bytes,StandardCharsets.UTF_8));
92
+ }
93
+ private static boolean sameOrigin(URI a,URI b){int ap=a.getPort()<0?("https".equals(a.getScheme())?443:80):a.getPort(),bp=b.getPort()<0?("https".equals(b.getScheme())?443:80):b.getPort();return a.getScheme().equals(b.getScheme())&&a.getHost().equalsIgnoreCase(b.getHost())&&ap==bp;}
94
+ private static byte[] read(InputStream input)throws Exception{if(input==null)return new byte[0];ByteArrayOutputStream out=new ByteArrayOutputStream();byte[] buffer=new byte[8192];int count,total=0;while((count=input.read(buffer))!=-1){total+=count;if(total>MAX_RESPONSE_BYTES)throw new IllegalStateException("Background response is too large.");out.write(buffer,0,count);}input.close();return out.toByteArray();}
95
+ }
@@ -0,0 +1,49 @@
1
+ package js.absolute.sync;
2
+
3
+ import androidx.work.Constraints;
4
+ import androidx.work.ExistingPeriodicWorkPolicy;
5
+ import androidx.work.NetworkType;
6
+ import androidx.work.PeriodicWorkRequest;
7
+ import androidx.work.WorkManager;
8
+ import com.getcapacitor.Plugin;
9
+ import com.getcapacitor.PluginCall;
10
+ import com.getcapacitor.PluginMethod;
11
+ import com.getcapacitor.annotation.CapacitorPlugin;
12
+ import java.util.concurrent.TimeUnit;
13
+
14
+ @CapacitorPlugin(name = "AbsoluteBackgroundSync")
15
+ public class AbsoluteBackgroundSyncPlugin extends Plugin {
16
+ private Constraints networkConstraint() { return new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(); }
17
+ private void schedule(AbsoluteBackgroundSyncConfig config) {
18
+ PeriodicWorkRequest request = new PeriodicWorkRequest.Builder(AbsoluteBackgroundSyncWorker.class, config.intervalMinutes, TimeUnit.MINUTES).setConstraints(networkConstraint()).build();
19
+ WorkManager.getInstance(getContext()).enqueueUniquePeriodicWork(AbsoluteBackgroundSyncConfig.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request);
20
+ }
21
+ @PluginMethod public void configure(PluginCall call) {
22
+ try {
23
+ AbsoluteBackgroundSyncConfig config = new AbsoluteBackgroundSyncConfig(call.getData());
24
+ config.save(getContext());
25
+ schedule(config);
26
+ call.resolve();
27
+ } catch (Exception error) { call.reject("Invalid background Sync configuration.", "INVALID_ARGUMENT", error); }
28
+ }
29
+ @PluginMethod public void clear(PluginCall call) {
30
+ WorkManager.getInstance(getContext()).cancelUniqueWork(AbsoluteBackgroundSyncConfig.WORK_NAME);
31
+ AbsoluteBackgroundSyncConfig.preferences(getContext()).edit().clear().apply();
32
+ call.resolve();
33
+ }
34
+ @PluginMethod public void runNow(PluginCall call) {
35
+ new Thread(() -> {
36
+ try {
37
+ AbsoluteBackgroundSyncConfig config = AbsoluteBackgroundSyncConfig.load(getContext());
38
+ if (config != null) AbsoluteBackgroundSyncEngine.run(getContext(), config);
39
+ call.resolve(AbsoluteBackgroundSyncEngine.status(getContext()));
40
+ } catch (AbsoluteBackgroundSyncEngine.NoSession error) {
41
+ call.resolve(AbsoluteBackgroundSyncEngine.status(getContext()));
42
+ } catch (Exception error) {
43
+ AbsoluteBackgroundSyncEngine.recordError(getContext(), error);
44
+ call.reject("Background Sync failed.", "SYNC_FAILURE", error);
45
+ }
46
+ }, "absolutejs-background-sync-manual").start();
47
+ }
48
+ @PluginMethod public void status(PluginCall call) { call.resolve(AbsoluteBackgroundSyncEngine.status(getContext())); }
49
+ }
@@ -0,0 +1,23 @@
1
+ package js.absolute.sync;
2
+
3
+ import android.content.Context;
4
+ import androidx.annotation.NonNull;
5
+ import androidx.work.Worker;
6
+ import androidx.work.WorkerParameters;
7
+
8
+ public final class AbsoluteBackgroundSyncWorker extends Worker {
9
+ public AbsoluteBackgroundSyncWorker(@NonNull Context context, @NonNull WorkerParameters parameters) { super(context, parameters); }
10
+ @NonNull @Override public Result doWork() {
11
+ try {
12
+ AbsoluteBackgroundSyncConfig config = AbsoluteBackgroundSyncConfig.load(getApplicationContext());
13
+ if (config == null) return Result.success();
14
+ AbsoluteBackgroundSyncEngine.run(getApplicationContext(), config);
15
+ return Result.success();
16
+ } catch (AbsoluteBackgroundSyncEngine.NoSession error) {
17
+ return Result.success();
18
+ } catch (Exception error) {
19
+ AbsoluteBackgroundSyncEngine.recordError(getApplicationContext(), error);
20
+ return Result.retry();
21
+ }
22
+ }
23
+ }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,34 @@ import { type SQLiteDBConnection } from '@capacitor-community/sqlite';
2
2
  import type { SyncLocalStore } from '@absolutejs/sync/client';
3
3
  import type { DeviceLifecycleCapability, DeviceNetworkCapability, DeviceSubscription } from '@absolutejs/devices';
4
4
  import type { SyncClient } from '@absolutejs/sync/client';
5
+ export type CapacitorBackgroundSyncConfig = {
6
+ endpoint: string;
7
+ issuer: string;
8
+ clientId: string;
9
+ namespace: string;
10
+ databaseName?: string;
11
+ intervalMinutes?: number;
12
+ maxMutations?: number;
13
+ maxPulls?: number;
14
+ maxAttempts?: number;
15
+ };
16
+ export type CapacitorBackgroundSyncStatus = {
17
+ configured: boolean;
18
+ running: boolean;
19
+ lastRunAt?: number;
20
+ lastError?: string;
21
+ lastAcknowledged?: number;
22
+ lastPulled?: number;
23
+ };
24
+ export type AbsoluteBackgroundSyncPlugin = {
25
+ configure(options: CapacitorBackgroundSyncConfig): Promise<void>;
26
+ clear(): Promise<void>;
27
+ runNow(): Promise<CapacitorBackgroundSyncStatus>;
28
+ status(): Promise<CapacitorBackgroundSyncStatus>;
29
+ };
30
+ export declare const AbsoluteBackgroundSync: AbsoluteBackgroundSyncPlugin;
31
+ /** Configure the managed scheduler. Auth credentials remain in the native vault. */
32
+ export declare const configureCapacitorBackgroundSync: (options: CapacitorBackgroundSyncConfig, plugin?: AbsoluteBackgroundSyncPlugin) => Promise<void>;
5
33
  export type CapacitorSyncSqliteConnection = Pick<SQLiteDBConnection, 'beginTransaction' | 'commitTransaction' | 'execute' | 'open' | 'query' | 'rollbackTransaction' | 'run'>;
6
34
  export type CapacitorSyncSqliteFactory = () => CapacitorSyncSqliteConnection | Promise<CapacitorSyncSqliteConnection>;
7
35
  export type CapacitorSyncLocalStoreOptions = {
package/dist/index.js CHANGED
@@ -3,6 +3,9 @@ import {
3
3
  CapacitorSQLite,
4
4
  SQLiteConnection
5
5
  } from "@capacitor-community/sqlite";
6
+ import { registerPlugin } from "@capacitor/core";
7
+ var AbsoluteBackgroundSync = registerPlugin("AbsoluteBackgroundSync");
8
+ var configureCapacitorBackgroundSync = (options, plugin = AbsoluteBackgroundSync) => plugin.configure(options);
6
9
  var SCHEMA = `
7
10
  CREATE TABLE IF NOT EXISTS absolute_sync_metadata (
8
11
  namespace TEXT PRIMARY KEY NOT NULL,
@@ -194,8 +197,10 @@ var installCapacitorSyncLifecycle = async ({
194
197
  };
195
198
  export {
196
199
  installCapacitorSyncLifecycle,
197
- createCapacitorSyncLocalStore
200
+ createCapacitorSyncLocalStore,
201
+ configureCapacitorBackgroundSync,
202
+ AbsoluteBackgroundSync
198
203
  };
199
204
 
200
- //# debugId=9D6EB7498C1CD6F664756E2164756E21
205
+ //# debugId=1D548F4E3CBABDF264756E2164756E21
201
206
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
4
  "sourcesContent": [
5
- "import {\n\tCapacitorSQLite,\n\tSQLiteConnection,\n\ttype SQLiteDBConnection\n} from '@capacitor-community/sqlite';\nimport type {\n\tLocalCollectionRecord,\n\tLocalMutationRecord,\n\tSyncLocalStore,\n\tSyncLocalStoreMode,\n\tSyncLocalTransaction\n} from '@absolutejs/sync/client';\nimport type {\n\tDeviceLifecycleCapability,\n\tDeviceNetworkCapability,\n\tDeviceSubscription\n} from '@absolutejs/devices';\nimport type { SyncClient } from '@absolutejs/sync/client';\n\nexport type CapacitorSyncSqliteConnection = Pick<\n\tSQLiteDBConnection,\n\t| 'beginTransaction'\n\t| 'commitTransaction'\n\t| 'execute'\n\t| 'open'\n\t| 'query'\n\t| 'rollbackTransaction'\n\t| 'run'\n>;\n\nexport type CapacitorSyncSqliteFactory = () =>\n\t| CapacitorSyncSqliteConnection\n\t| Promise<CapacitorSyncSqliteConnection>;\n\nexport type CapacitorSyncLocalStoreOptions = {\n\t/** Defaults to `absolutejs-sync-local-v1`. */\n\tdatabaseName?: string;\n\t/** Injection seam for tests and custom SQLCipher provisioning. */\n\tconnection?: CapacitorSyncSqliteFactory;\n};\n\nconst SCHEMA = `\nCREATE TABLE IF NOT EXISTS absolute_sync_metadata (\n namespace TEXT PRIMARY KEY NOT NULL,\n installation_id TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS absolute_sync_collections (\n namespace TEXT NOT NULL,\n collection_key TEXT NOT NULL,\n record_json TEXT NOT NULL,\n PRIMARY KEY (namespace, collection_key)\n);\nCREATE TABLE IF NOT EXISTS absolute_sync_mutations (\n namespace TEXT NOT NULL,\n operation_id TEXT NOT NULL,\n created_at INTEGER NOT NULL,\n record_json TEXT NOT NULL,\n PRIMARY KEY (namespace, operation_id)\n);\nCREATE INDEX IF NOT EXISTS absolute_sync_mutations_order\n ON absolute_sync_mutations (namespace, created_at, operation_id);\n`;\n\ntype SqliteRow = Record<string, unknown>;\n\nconst firstString = (rows: SqliteRow[] | undefined, field: string) => {\n\tconst value = rows?.[0]?.[field];\n\treturn typeof value === 'string' ? value : undefined;\n};\n\nconst parseRecord = <T>(value: unknown, label: string): T | undefined => {\n\tif (value === undefined) return undefined;\n\tif (typeof value !== 'string')\n\t\tthrow new Error(\n\t\t\t`Capacitor Sync SQLite returned invalid ${label} JSON.`\n\t\t);\n\ttry {\n\t\treturn JSON.parse(value) as T;\n\t} catch (cause) {\n\t\tthrow new Error(\n\t\t\t`Capacitor Sync SQLite could not parse ${label} JSON.`,\n\t\t\t{\n\t\t\t\tcause\n\t\t\t}\n\t\t);\n\t}\n};\n\nconst defaultConnection = async (\n\tdatabaseName: string\n): Promise<CapacitorSyncSqliteConnection> => {\n\tconst sqlite = new SQLiteConnection(CapacitorSQLite);\n\tconst existing = await sqlite.isConnection(databaseName, false);\n\tconst connection = existing.result\n\t\t? await sqlite.retrieveConnection(databaseName, false)\n\t\t: await sqlite.createConnection(\n\t\t\t\tdatabaseName,\n\t\t\t\tfalse,\n\t\t\t\t'no-encryption',\n\t\t\t\t1,\n\t\t\t\tfalse\n\t\t\t);\n\tawait connection.open();\n\treturn connection;\n};\n\nconst requireNamespace = (namespace: string) => {\n\tif (namespace.length === 0)\n\t\tthrow new Error('Sync local-store namespace must not be empty');\n};\n\n/**\n * Native SQLite implementation of Sync's principal-partitioned atomic cache\n * and mutation outbox. All calls are serialized because a Capacitor database\n * connection owns one explicit transaction at a time.\n */\nexport const createCapacitorSyncLocalStore = ({\n\tdatabaseName = 'absolutejs-sync-local-v1',\n\tconnection: createConnection = () => defaultConnection(databaseName)\n}: CapacitorSyncLocalStoreOptions = {}): SyncLocalStore => {\n\tif (databaseName.length === 0)\n\t\tthrow new TypeError('Capacitor Sync databaseName cannot be empty.');\n\tlet connectionPromise: Promise<CapacitorSyncSqliteConnection> | undefined;\n\tconst connection = () => {\n\t\tconnectionPromise ??= Promise.resolve(createConnection()).then(\n\t\t\tasync (database) => {\n\t\t\t\tawait database.execute(SCHEMA);\n\t\t\t\treturn database;\n\t\t\t}\n\t\t);\n\t\treturn connectionPromise;\n\t};\n\tlet tail = Promise.resolve();\n\tconst locked = async <R>(run: () => Promise<R>): Promise<R> => {\n\t\tlet release!: () => void;\n\t\tconst previous = tail;\n\t\ttail = new Promise<void>((resolve) => {\n\t\t\trelease = resolve;\n\t\t});\n\t\tawait previous;\n\t\ttry {\n\t\t\treturn await run();\n\t\t} finally {\n\t\t\trelease();\n\t\t}\n\t};\n\n\tconst transaction = async <R>(\n\t\tnamespace: string,\n\t\tmode: SyncLocalStoreMode,\n\t\trun: (tx: SyncLocalTransaction) => Promise<R>\n\t): Promise<R> => {\n\t\trequireNamespace(namespace);\n\t\treturn locked(async () => {\n\t\t\tconst database = await connection();\n\t\t\tawait database.beginTransaction();\n\t\t\tconst writable = () => {\n\t\t\t\tif (mode !== 'readwrite')\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'Cannot write in a readonly Sync local transaction'\n\t\t\t\t\t);\n\t\t\t};\n\t\t\tconst tx: SyncLocalTransaction = {\n\t\t\t\tgetInstallationId: async () =>\n\t\t\t\t\tfirstString(\n\t\t\t\t\t\t(\n\t\t\t\t\t\t\tawait database.query(\n\t\t\t\t\t\t\t\t'SELECT installation_id FROM absolute_sync_metadata WHERE namespace = ? LIMIT 1',\n\t\t\t\t\t\t\t\t[namespace]\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t).values,\n\t\t\t\t\t\t'installation_id'\n\t\t\t\t\t),\n\t\t\t\tsetInstallationId: async (installationId) => {\n\t\t\t\t\twritable();\n\t\t\t\t\tif (installationId.length === 0)\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t'Sync installation id must not be empty'\n\t\t\t\t\t\t);\n\t\t\t\t\tawait database.run(\n\t\t\t\t\t\t'INSERT INTO absolute_sync_metadata (namespace, installation_id) VALUES (?, ?) ON CONFLICT(namespace) DO UPDATE SET installation_id = excluded.installation_id',\n\t\t\t\t\t\t[namespace, installationId],\n\t\t\t\t\t\tfalse\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t\tgetCollection: async <T>(key: string) =>\n\t\t\t\t\tparseRecord<LocalCollectionRecord<T>>(\n\t\t\t\t\t\t(\n\t\t\t\t\t\t\tawait database.query(\n\t\t\t\t\t\t\t\t'SELECT record_json FROM absolute_sync_collections WHERE namespace = ? AND collection_key = ? LIMIT 1',\n\t\t\t\t\t\t\t\t[namespace, key]\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t).values?.[0]?.record_json,\n\t\t\t\t\t\t'collection'\n\t\t\t\t\t),\n\t\t\t\tputCollection: async (key, record) => {\n\t\t\t\t\twritable();\n\t\t\t\t\tawait database.run(\n\t\t\t\t\t\t'INSERT INTO absolute_sync_collections (namespace, collection_key, record_json) VALUES (?, ?, ?) ON CONFLICT(namespace, collection_key) DO UPDATE SET record_json = excluded.record_json',\n\t\t\t\t\t\t[namespace, key, JSON.stringify(record)],\n\t\t\t\t\t\tfalse\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t\tdeleteCollection: async (key) => {\n\t\t\t\t\twritable();\n\t\t\t\t\tawait database.run(\n\t\t\t\t\t\t'DELETE FROM absolute_sync_collections WHERE namespace = ? AND collection_key = ?',\n\t\t\t\t\t\t[namespace, key],\n\t\t\t\t\t\tfalse\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t\tlistMutations: async () => {\n\t\t\t\t\tconst rows = (\n\t\t\t\t\t\tawait database.query(\n\t\t\t\t\t\t\t'SELECT record_json FROM absolute_sync_mutations WHERE namespace = ? ORDER BY created_at ASC, operation_id ASC',\n\t\t\t\t\t\t\t[namespace]\n\t\t\t\t\t\t)\n\t\t\t\t\t).values;\n\t\t\t\t\treturn (rows ?? [])\n\t\t\t\t\t\t.map((row) =>\n\t\t\t\t\t\t\tparseRecord<LocalMutationRecord>(\n\t\t\t\t\t\t\t\trow.record_json,\n\t\t\t\t\t\t\t\t'mutation'\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.filter(\n\t\t\t\t\t\t\t(record): record is LocalMutationRecord =>\n\t\t\t\t\t\t\t\trecord !== undefined\n\t\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t\tgetMutation: async (operationId) =>\n\t\t\t\t\tparseRecord<LocalMutationRecord>(\n\t\t\t\t\t\t(\n\t\t\t\t\t\t\tawait database.query(\n\t\t\t\t\t\t\t\t'SELECT record_json FROM absolute_sync_mutations WHERE namespace = ? AND operation_id = ? LIMIT 1',\n\t\t\t\t\t\t\t\t[namespace, operationId]\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t).values?.[0]?.record_json,\n\t\t\t\t\t\t'mutation'\n\t\t\t\t\t),\n\t\t\t\tputMutation: async (record) => {\n\t\t\t\t\twritable();\n\t\t\t\t\tawait database.run(\n\t\t\t\t\t\t'INSERT INTO absolute_sync_mutations (namespace, operation_id, created_at, record_json) VALUES (?, ?, ?, ?) ON CONFLICT(namespace, operation_id) DO UPDATE SET created_at = excluded.created_at, record_json = excluded.record_json',\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\tnamespace,\n\t\t\t\t\t\t\trecord.operationId,\n\t\t\t\t\t\t\trecord.createdAt,\n\t\t\t\t\t\t\tJSON.stringify(record)\n\t\t\t\t\t\t],\n\t\t\t\t\t\tfalse\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t\tdeleteMutation: async (operationId) => {\n\t\t\t\t\twritable();\n\t\t\t\t\tawait database.run(\n\t\t\t\t\t\t'DELETE FROM absolute_sync_mutations WHERE namespace = ? AND operation_id = ?',\n\t\t\t\t\t\t[namespace, operationId],\n\t\t\t\t\t\tfalse\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t};\n\t\t\ttry {\n\t\t\t\tconst result = await run(tx);\n\t\t\t\tawait database.commitTransaction();\n\t\t\t\treturn result;\n\t\t\t} catch (error) {\n\t\t\t\ttry {\n\t\t\t\t\tawait database.rollbackTransaction();\n\t\t\t\t} catch (rollbackError) {\n\t\t\t\t\tthrow new AggregateError(\n\t\t\t\t\t\t[error, rollbackError],\n\t\t\t\t\t\t'Capacitor Sync SQLite transaction and rollback both failed'\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t});\n\t};\n\n\treturn {\n\t\ttransaction,\n\t\tdeleteNamespace: async (namespace) => {\n\t\t\trequireNamespace(namespace);\n\t\t\tawait locked(async () => {\n\t\t\t\tconst database = await connection();\n\t\t\t\tawait database.beginTransaction();\n\t\t\t\ttry {\n\t\t\t\t\tfor (const table of [\n\t\t\t\t\t\t'absolute_sync_metadata',\n\t\t\t\t\t\t'absolute_sync_collections',\n\t\t\t\t\t\t'absolute_sync_mutations'\n\t\t\t\t\t])\n\t\t\t\t\t\tawait database.run(\n\t\t\t\t\t\t\t`DELETE FROM ${table} WHERE namespace = ?`,\n\t\t\t\t\t\t\t[namespace],\n\t\t\t\t\t\t\tfalse\n\t\t\t\t\t\t);\n\t\t\t\t\tawait database.commitTransaction();\n\t\t\t\t} catch (error) {\n\t\t\t\t\tawait database.rollbackTransaction();\n\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t};\n};\n\nexport type CapacitorSyncLifecycleOptions = {\n\tclient: Pick<SyncClient, 'reconnect'> & Partial<Pick<SyncClient, 'flush'>>;\n\tlifecycle: DeviceLifecycleCapability;\n\tnetwork: DeviceNetworkCapability;\n\t/** Finite outbox budget after a wake-up. Defaults to 10 seconds. */\n\tflushTimeoutMs?: number;\n\t/** Lifecycle flush failures are observable without becoming unhandled. */\n\tonError?: (error: unknown) => void;\n};\n\n/** Refreshes the socket/ticket and runs a bounded outbox flush after wake-up. */\nexport const installCapacitorSyncLifecycle = async ({\n\tclient,\n\tlifecycle,\n\tnetwork,\n\tflushTimeoutMs = 10_000,\n\tonError\n}: CapacitorSyncLifecycleOptions): Promise<DeviceSubscription> => {\n\tif (!Number.isFinite(flushTimeoutMs) || flushTimeoutMs < 0)\n\t\tthrow new TypeError(\n\t\t\t'Capacitor Sync flushTimeoutMs must be a non-negative number.'\n\t\t);\n\tconst wake = () => {\n\t\tclient.reconnect();\n\t\tvoid client\n\t\t\t.flush?.({ timeoutMs: flushTimeoutMs })\n\t\t\t.catch((error) => onError?.(error));\n\t};\n\tconst removers = await Promise.all([\n\t\tlifecycle.onResume?.(wake) ?? (() => undefined),\n\t\tnetwork.onChange((status) => {\n\t\t\tif (status.connected) wake();\n\t\t})\n\t]);\n\tlet active = true;\n\treturn async () => {\n\t\tif (!active) return;\n\t\tactive = false;\n\t\tawait Promise.all(removers.map((remove) => remove()));\n\t};\n};\n"
5
+ "import {\n\tCapacitorSQLite,\n\tSQLiteConnection,\n\ttype SQLiteDBConnection\n} from '@capacitor-community/sqlite';\nimport type {\n\tLocalCollectionRecord,\n\tLocalMutationRecord,\n\tSyncLocalStore,\n\tSyncLocalStoreMode,\n\tSyncLocalTransaction\n} from '@absolutejs/sync/client';\nimport type {\n\tDeviceLifecycleCapability,\n\tDeviceNetworkCapability,\n\tDeviceSubscription\n} from '@absolutejs/devices';\nimport type { SyncClient } from '@absolutejs/sync/client';\nimport { registerPlugin } from '@capacitor/core';\n\nexport type CapacitorBackgroundSyncConfig = {\n\tendpoint: string;\n\tissuer: string;\n\tclientId: string;\n\tnamespace: string;\n\tdatabaseName?: string;\n\tintervalMinutes?: number;\n\tmaxMutations?: number;\n\tmaxPulls?: number;\n\tmaxAttempts?: number;\n};\n\nexport type CapacitorBackgroundSyncStatus = {\n\tconfigured: boolean;\n\trunning: boolean;\n\tlastRunAt?: number;\n\tlastError?: string;\n\tlastAcknowledged?: number;\n\tlastPulled?: number;\n};\n\nexport type AbsoluteBackgroundSyncPlugin = {\n\tconfigure(options: CapacitorBackgroundSyncConfig): Promise<void>;\n\tclear(): Promise<void>;\n\trunNow(): Promise<CapacitorBackgroundSyncStatus>;\n\tstatus(): Promise<CapacitorBackgroundSyncStatus>;\n};\n\nexport const AbsoluteBackgroundSync =\n\tregisterPlugin<AbsoluteBackgroundSyncPlugin>('AbsoluteBackgroundSync');\n\n/** Configure the managed scheduler. Auth credentials remain in the native vault. */\nexport const configureCapacitorBackgroundSync = (\n\toptions: CapacitorBackgroundSyncConfig,\n\tplugin: AbsoluteBackgroundSyncPlugin = AbsoluteBackgroundSync\n) => plugin.configure(options);\n\nexport type CapacitorSyncSqliteConnection = Pick<\n\tSQLiteDBConnection,\n\t| 'beginTransaction'\n\t| 'commitTransaction'\n\t| 'execute'\n\t| 'open'\n\t| 'query'\n\t| 'rollbackTransaction'\n\t| 'run'\n>;\n\nexport type CapacitorSyncSqliteFactory = () =>\n\t| CapacitorSyncSqliteConnection\n\t| Promise<CapacitorSyncSqliteConnection>;\n\nexport type CapacitorSyncLocalStoreOptions = {\n\t/** Defaults to `absolutejs-sync-local-v1`. */\n\tdatabaseName?: string;\n\t/** Injection seam for tests and custom SQLCipher provisioning. */\n\tconnection?: CapacitorSyncSqliteFactory;\n};\n\nconst SCHEMA = `\nCREATE TABLE IF NOT EXISTS absolute_sync_metadata (\n namespace TEXT PRIMARY KEY NOT NULL,\n installation_id TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS absolute_sync_collections (\n namespace TEXT NOT NULL,\n collection_key TEXT NOT NULL,\n record_json TEXT NOT NULL,\n PRIMARY KEY (namespace, collection_key)\n);\nCREATE TABLE IF NOT EXISTS absolute_sync_mutations (\n namespace TEXT NOT NULL,\n operation_id TEXT NOT NULL,\n created_at INTEGER NOT NULL,\n record_json TEXT NOT NULL,\n PRIMARY KEY (namespace, operation_id)\n);\nCREATE INDEX IF NOT EXISTS absolute_sync_mutations_order\n ON absolute_sync_mutations (namespace, created_at, operation_id);\n`;\n\ntype SqliteRow = Record<string, unknown>;\n\nconst firstString = (rows: SqliteRow[] | undefined, field: string) => {\n\tconst value = rows?.[0]?.[field];\n\treturn typeof value === 'string' ? value : undefined;\n};\n\nconst parseRecord = <T>(value: unknown, label: string): T | undefined => {\n\tif (value === undefined) return undefined;\n\tif (typeof value !== 'string')\n\t\tthrow new Error(\n\t\t\t`Capacitor Sync SQLite returned invalid ${label} JSON.`\n\t\t);\n\ttry {\n\t\treturn JSON.parse(value) as T;\n\t} catch (cause) {\n\t\tthrow new Error(\n\t\t\t`Capacitor Sync SQLite could not parse ${label} JSON.`,\n\t\t\t{\n\t\t\t\tcause\n\t\t\t}\n\t\t);\n\t}\n};\n\nconst defaultConnection = async (\n\tdatabaseName: string\n): Promise<CapacitorSyncSqliteConnection> => {\n\tconst sqlite = new SQLiteConnection(CapacitorSQLite);\n\tconst existing = await sqlite.isConnection(databaseName, false);\n\tconst connection = existing.result\n\t\t? await sqlite.retrieveConnection(databaseName, false)\n\t\t: await sqlite.createConnection(\n\t\t\t\tdatabaseName,\n\t\t\t\tfalse,\n\t\t\t\t'no-encryption',\n\t\t\t\t1,\n\t\t\t\tfalse\n\t\t\t);\n\tawait connection.open();\n\treturn connection;\n};\n\nconst requireNamespace = (namespace: string) => {\n\tif (namespace.length === 0)\n\t\tthrow new Error('Sync local-store namespace must not be empty');\n};\n\n/**\n * Native SQLite implementation of Sync's principal-partitioned atomic cache\n * and mutation outbox. All calls are serialized because a Capacitor database\n * connection owns one explicit transaction at a time.\n */\nexport const createCapacitorSyncLocalStore = ({\n\tdatabaseName = 'absolutejs-sync-local-v1',\n\tconnection: createConnection = () => defaultConnection(databaseName)\n}: CapacitorSyncLocalStoreOptions = {}): SyncLocalStore => {\n\tif (databaseName.length === 0)\n\t\tthrow new TypeError('Capacitor Sync databaseName cannot be empty.');\n\tlet connectionPromise: Promise<CapacitorSyncSqliteConnection> | undefined;\n\tconst connection = () => {\n\t\tconnectionPromise ??= Promise.resolve(createConnection()).then(\n\t\t\tasync (database) => {\n\t\t\t\tawait database.execute(SCHEMA);\n\t\t\t\treturn database;\n\t\t\t}\n\t\t);\n\t\treturn connectionPromise;\n\t};\n\tlet tail = Promise.resolve();\n\tconst locked = async <R>(run: () => Promise<R>): Promise<R> => {\n\t\tlet release!: () => void;\n\t\tconst previous = tail;\n\t\ttail = new Promise<void>((resolve) => {\n\t\t\trelease = resolve;\n\t\t});\n\t\tawait previous;\n\t\ttry {\n\t\t\treturn await run();\n\t\t} finally {\n\t\t\trelease();\n\t\t}\n\t};\n\n\tconst transaction = async <R>(\n\t\tnamespace: string,\n\t\tmode: SyncLocalStoreMode,\n\t\trun: (tx: SyncLocalTransaction) => Promise<R>\n\t): Promise<R> => {\n\t\trequireNamespace(namespace);\n\t\treturn locked(async () => {\n\t\t\tconst database = await connection();\n\t\t\tawait database.beginTransaction();\n\t\t\tconst writable = () => {\n\t\t\t\tif (mode !== 'readwrite')\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'Cannot write in a readonly Sync local transaction'\n\t\t\t\t\t);\n\t\t\t};\n\t\t\tconst tx: SyncLocalTransaction = {\n\t\t\t\tgetInstallationId: async () =>\n\t\t\t\t\tfirstString(\n\t\t\t\t\t\t(\n\t\t\t\t\t\t\tawait database.query(\n\t\t\t\t\t\t\t\t'SELECT installation_id FROM absolute_sync_metadata WHERE namespace = ? LIMIT 1',\n\t\t\t\t\t\t\t\t[namespace]\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t).values,\n\t\t\t\t\t\t'installation_id'\n\t\t\t\t\t),\n\t\t\t\tsetInstallationId: async (installationId) => {\n\t\t\t\t\twritable();\n\t\t\t\t\tif (installationId.length === 0)\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t'Sync installation id must not be empty'\n\t\t\t\t\t\t);\n\t\t\t\t\tawait database.run(\n\t\t\t\t\t\t'INSERT INTO absolute_sync_metadata (namespace, installation_id) VALUES (?, ?) ON CONFLICT(namespace) DO UPDATE SET installation_id = excluded.installation_id',\n\t\t\t\t\t\t[namespace, installationId],\n\t\t\t\t\t\tfalse\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t\tgetCollection: async <T>(key: string) =>\n\t\t\t\t\tparseRecord<LocalCollectionRecord<T>>(\n\t\t\t\t\t\t(\n\t\t\t\t\t\t\tawait database.query(\n\t\t\t\t\t\t\t\t'SELECT record_json FROM absolute_sync_collections WHERE namespace = ? AND collection_key = ? LIMIT 1',\n\t\t\t\t\t\t\t\t[namespace, key]\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t).values?.[0]?.record_json,\n\t\t\t\t\t\t'collection'\n\t\t\t\t\t),\n\t\t\t\tputCollection: async (key, record) => {\n\t\t\t\t\twritable();\n\t\t\t\t\tawait database.run(\n\t\t\t\t\t\t'INSERT INTO absolute_sync_collections (namespace, collection_key, record_json) VALUES (?, ?, ?) ON CONFLICT(namespace, collection_key) DO UPDATE SET record_json = excluded.record_json',\n\t\t\t\t\t\t[namespace, key, JSON.stringify(record)],\n\t\t\t\t\t\tfalse\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t\tdeleteCollection: async (key) => {\n\t\t\t\t\twritable();\n\t\t\t\t\tawait database.run(\n\t\t\t\t\t\t'DELETE FROM absolute_sync_collections WHERE namespace = ? AND collection_key = ?',\n\t\t\t\t\t\t[namespace, key],\n\t\t\t\t\t\tfalse\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t\tlistMutations: async () => {\n\t\t\t\t\tconst rows = (\n\t\t\t\t\t\tawait database.query(\n\t\t\t\t\t\t\t'SELECT record_json FROM absolute_sync_mutations WHERE namespace = ? ORDER BY created_at ASC, operation_id ASC',\n\t\t\t\t\t\t\t[namespace]\n\t\t\t\t\t\t)\n\t\t\t\t\t).values;\n\t\t\t\t\treturn (rows ?? [])\n\t\t\t\t\t\t.map((row) =>\n\t\t\t\t\t\t\tparseRecord<LocalMutationRecord>(\n\t\t\t\t\t\t\t\trow.record_json,\n\t\t\t\t\t\t\t\t'mutation'\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.filter(\n\t\t\t\t\t\t\t(record): record is LocalMutationRecord =>\n\t\t\t\t\t\t\t\trecord !== undefined\n\t\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t\tgetMutation: async (operationId) =>\n\t\t\t\t\tparseRecord<LocalMutationRecord>(\n\t\t\t\t\t\t(\n\t\t\t\t\t\t\tawait database.query(\n\t\t\t\t\t\t\t\t'SELECT record_json FROM absolute_sync_mutations WHERE namespace = ? AND operation_id = ? LIMIT 1',\n\t\t\t\t\t\t\t\t[namespace, operationId]\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t).values?.[0]?.record_json,\n\t\t\t\t\t\t'mutation'\n\t\t\t\t\t),\n\t\t\t\tputMutation: async (record) => {\n\t\t\t\t\twritable();\n\t\t\t\t\tawait database.run(\n\t\t\t\t\t\t'INSERT INTO absolute_sync_mutations (namespace, operation_id, created_at, record_json) VALUES (?, ?, ?, ?) ON CONFLICT(namespace, operation_id) DO UPDATE SET created_at = excluded.created_at, record_json = excluded.record_json',\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\tnamespace,\n\t\t\t\t\t\t\trecord.operationId,\n\t\t\t\t\t\t\trecord.createdAt,\n\t\t\t\t\t\t\tJSON.stringify(record)\n\t\t\t\t\t\t],\n\t\t\t\t\t\tfalse\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t\tdeleteMutation: async (operationId) => {\n\t\t\t\t\twritable();\n\t\t\t\t\tawait database.run(\n\t\t\t\t\t\t'DELETE FROM absolute_sync_mutations WHERE namespace = ? AND operation_id = ?',\n\t\t\t\t\t\t[namespace, operationId],\n\t\t\t\t\t\tfalse\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t};\n\t\t\ttry {\n\t\t\t\tconst result = await run(tx);\n\t\t\t\tawait database.commitTransaction();\n\t\t\t\treturn result;\n\t\t\t} catch (error) {\n\t\t\t\ttry {\n\t\t\t\t\tawait database.rollbackTransaction();\n\t\t\t\t} catch (rollbackError) {\n\t\t\t\t\tthrow new AggregateError(\n\t\t\t\t\t\t[error, rollbackError],\n\t\t\t\t\t\t'Capacitor Sync SQLite transaction and rollback both failed'\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t});\n\t};\n\n\treturn {\n\t\ttransaction,\n\t\tdeleteNamespace: async (namespace) => {\n\t\t\trequireNamespace(namespace);\n\t\t\tawait locked(async () => {\n\t\t\t\tconst database = await connection();\n\t\t\t\tawait database.beginTransaction();\n\t\t\t\ttry {\n\t\t\t\t\tfor (const table of [\n\t\t\t\t\t\t'absolute_sync_metadata',\n\t\t\t\t\t\t'absolute_sync_collections',\n\t\t\t\t\t\t'absolute_sync_mutations'\n\t\t\t\t\t])\n\t\t\t\t\t\tawait database.run(\n\t\t\t\t\t\t\t`DELETE FROM ${table} WHERE namespace = ?`,\n\t\t\t\t\t\t\t[namespace],\n\t\t\t\t\t\t\tfalse\n\t\t\t\t\t\t);\n\t\t\t\t\tawait database.commitTransaction();\n\t\t\t\t} catch (error) {\n\t\t\t\t\tawait database.rollbackTransaction();\n\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t};\n};\n\nexport type CapacitorSyncLifecycleOptions = {\n\tclient: Pick<SyncClient, 'reconnect'> & Partial<Pick<SyncClient, 'flush'>>;\n\tlifecycle: DeviceLifecycleCapability;\n\tnetwork: DeviceNetworkCapability;\n\t/** Finite outbox budget after a wake-up. Defaults to 10 seconds. */\n\tflushTimeoutMs?: number;\n\t/** Lifecycle flush failures are observable without becoming unhandled. */\n\tonError?: (error: unknown) => void;\n};\n\n/** Refreshes the socket/ticket and runs a bounded outbox flush after wake-up. */\nexport const installCapacitorSyncLifecycle = async ({\n\tclient,\n\tlifecycle,\n\tnetwork,\n\tflushTimeoutMs = 10_000,\n\tonError\n}: CapacitorSyncLifecycleOptions): Promise<DeviceSubscription> => {\n\tif (!Number.isFinite(flushTimeoutMs) || flushTimeoutMs < 0)\n\t\tthrow new TypeError(\n\t\t\t'Capacitor Sync flushTimeoutMs must be a non-negative number.'\n\t\t);\n\tconst wake = () => {\n\t\tclient.reconnect();\n\t\tvoid client\n\t\t\t.flush?.({ timeoutMs: flushTimeoutMs })\n\t\t\t.catch((error) => onError?.(error));\n\t};\n\tconst removers = await Promise.all([\n\t\tlifecycle.onResume?.(wake) ?? (() => undefined),\n\t\tnetwork.onChange((status) => {\n\t\t\tif (status.connected) wake();\n\t\t})\n\t]);\n\tlet active = true;\n\treturn async () => {\n\t\tif (!active) return;\n\t\tactive = false;\n\t\tawait Promise.all(removers.map((remove) => remove()));\n\t};\n};\n"
6
6
  ],
7
- "mappings": "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",
8
- "debugId": "9D6EB7498C1CD6F664756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "1D548F4E3CBABDF264756E2164756E21",
9
9
  "names": []
10
10
  }
@@ -0,0 +1,143 @@
1
+ import AbsoluteDevicesCapacitor
2
+ import Foundation
3
+ import SQLite3
4
+
5
+ private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
6
+
7
+ private final class NoRedirect: NSObject, URLSessionTaskDelegate {
8
+ func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
9
+ }
10
+
11
+ private struct NativeMutation { let id: String; let attempts: Int }
12
+ private struct NativeBatch { let body: [String: Any]; let mutations: [String: NativeMutation]; let pulls: [String: [String: Any]] }
13
+
14
+ enum AbsoluteBackgroundSyncEngine {
15
+ private static let refreshKey = "absolutejs.auth.oidc.refresh"
16
+ private static let maxResponse = 5 * 1024 * 1024
17
+
18
+ static func record(_ error: Error) {
19
+ let defaults = AbsoluteBackgroundSyncPlugin.defaults
20
+ defaults.set(false, forKey: "absolutejs.background-sync.running")
21
+ defaults.set(error.localizedDescription, forKey: "absolutejs.background-sync.lastError")
22
+ }
23
+ static func run() async throws {
24
+ guard let config = try AbsoluteBackgroundSyncPlugin.configuration() else { return }
25
+ let defaults = AbsoluteBackgroundSyncPlugin.defaults
26
+ defaults.set(true, forKey: "absolutejs.background-sync.running"); defaults.removeObject(forKey: "absolutejs.background-sync.lastError")
27
+ let access = try await refreshAccess(config)
28
+ let documents = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
29
+ var handle: OpaquePointer?
30
+ guard sqlite3_open_v2(documents.appendingPathComponent("\(config.databaseName)SQLite.db").path, &handle, SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK, let db = handle else { throw SyncNativeError.invalidResponse("Unable to open Sync database.") }
31
+ defer { sqlite3_close(db) }
32
+ sqlite3_busy_timeout(db, 5_000)
33
+ let batch = try prepare(db, config)
34
+ let response = try await jsonRequest(config.endpoint, method: "POST", body: batch.body, bearer: access, issuer: config.issuer)
35
+ let counts = try settle(db, config, batch, response)
36
+ defaults.set(false, forKey: "absolutejs.background-sync.running"); defaults.set(Date().timeIntervalSince1970 * 1000, forKey: "absolutejs.background-sync.lastRunAt")
37
+ defaults.set(counts.0, forKey: "absolutejs.background-sync.lastAcknowledged"); defaults.set(counts.1, forKey: "absolutejs.background-sync.lastPulled")
38
+ }
39
+
40
+ private static func refreshAccess(_ config: AbsoluteBackgroundSyncConfiguration) async throws -> String {
41
+ guard let lease = AbsoluteSecureStorageVault.acquireLease(refreshKey, ttlMilliseconds: 120_000) else { throw SyncNativeError.invalidResponse("Credential refresh is already running.") }
42
+ defer { AbsoluteSecureStorageVault.releaseLease(refreshKey, leaseId: lease) }
43
+ guard let refresh = try AbsoluteSecureStorageVault.get(refreshKey) else {
44
+ AbsoluteBackgroundSyncPlugin.defaults.set(false, forKey: "absolutejs.background-sync.running")
45
+ throw SyncNativeError.noSession
46
+ }
47
+ let discoveryURL = config.issuer.appendingPathComponent(".well-known/openid-configuration")
48
+ let discovery = try await jsonRequest(discoveryURL, method: "GET", body: nil, bearer: nil, issuer: config.issuer)
49
+ guard let endpointValue = discovery["token_endpoint"] as? String, let endpoint = URL(string: endpointValue) else { throw SyncNativeError.invalidResponse("OIDC discovery has no token endpoint.") }
50
+ let form = ["client_id": config.clientId, "grant_type": "refresh_token", "refresh_token": refresh, "resource": config.issuer.absoluteString]
51
+ .map { key, value in "\(encode(key))=\(encode(value))" }.joined(separator: "&").data(using: .utf8)!
52
+ let tokens = try await jsonRequest(endpoint, method: "POST", rawBody: form, contentType: "application/x-www-form-urlencoded", bearer: nil, issuer: config.issuer, advertisedTokenEndpoint: true)
53
+ guard let replacement = tokens["refresh_token"] as? String, !replacement.isEmpty,
54
+ let access = tokens["access_token"] as? String else { throw SyncNativeError.invalidResponse("OIDC token response is malformed.") }
55
+ guard try AbsoluteSecureStorageVault.setIfLease(refreshKey, value: replacement, leaseId: lease) else {
56
+ AbsoluteBackgroundSyncPlugin.defaults.set(false, forKey: "absolutejs.background-sync.running")
57
+ throw SyncNativeError.noSession
58
+ }
59
+ return access
60
+ }
61
+ private static func encode(_ value: String) -> String { value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "" }
62
+
63
+ private static func prepare(_ db: OpaquePointer, _ config: AbsoluteBackgroundSyncConfiguration) throws -> NativeBatch {
64
+ try execute(db, "BEGIN IMMEDIATE")
65
+ do {
66
+ var mutations: [[String: Any]] = [], mutationMap: [String: NativeMutation] = [:]
67
+ 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
69
+ if record["state"] as? String == "dead-letter" || (record["nextAttemptAt"] as? NSNumber)?.doubleValue ?? 0 > now { continue }
70
+ 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)
72
+ guard let name = record["name"] as? String else { throw SyncNativeError.invalidResponse("Local mutation has no name.") }
73
+ var mutation: [String: Any] = ["operationId": id, "name": name]; if let args = record["args"] { mutation["args"] = args }
74
+ mutations.append(mutation); mutationMap[id] = NativeMutation(id: id, attempts: attempts)
75
+ }
76
+ var pulls: [[String: Any]] = [], pullMap: [String: [String: Any]] = [:]
77
+ 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 }
79
+ var pull: [String: Any] = ["id": key, "collection": collection]; if let params = record["params"] { pull["params"] = params }
80
+ 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
+ pulls.append(pull); pullMap[key] = record
82
+ }
83
+ try execute(db, "COMMIT")
84
+ return NativeBatch(body: ["version": 1, "mutations": mutations, "pulls": pulls], mutations: mutationMap, pulls: pullMap)
85
+ } catch { try? execute(db, "ROLLBACK"); throw error }
86
+ }
87
+
88
+ private static func settle(_ db: OpaquePointer, _ config: AbsoluteBackgroundSyncConfiguration, _ batch: NativeBatch, _ response: [String: Any]) throws -> (Int, Int) {
89
+ guard (response["version"] as? NSNumber)?.intValue == 1 else { throw SyncNativeError.invalidResponse("Unsupported Headless Sync response.") }
90
+ let mutationResults = index(response["mutations"], "operationId"), pullResults = index(response["pulls"], "id")
91
+ var acknowledged = 0, pulled = 0; try execute(db, "BEGIN IMMEDIATE")
92
+ do {
93
+ 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 }
95
+ 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
+ let rejection = mutationResults[sent.id]?["rejection"] as? [String: Any] ?? ["kind": "retryable", "message": "Missing mutation response."]
97
+ current["lastError"] = rejection["message"]; current["rejection"] = rejection
98
+ if rejection["kind"] as? String != "retryable" || sent.attempts >= config.maxAttempts { current["state"] = "dead-letter"; current["deadLetteredAt"] = Date().timeIntervalSince1970 * 1000; current.removeValue(forKey: "nextAttemptAt") }
99
+ 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)
101
+ }
102
+ 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
+ }
106
+ try execute(db, "COMMIT"); return (acknowledged, pulled)
107
+ } catch { try? execute(db, "ROLLBACK"); throw error }
108
+ }
109
+
110
+ private static func applyPull(_ current: [String: Any], _ result: [String: Any]) throws -> [String: Any] {
111
+ var next = current
112
+ if result["type"] as? String == "snapshot" { next["rows"] = result["rows"] }
113
+ else {
114
+ var keyed: [String: [String: Any]] = [:]; for row in current["rows"] as? [[String: Any]] ?? [] { keyed[try key(row)] = row }
115
+ for row in result["removed"] as? [[String: Any]] ?? [] { keyed.removeValue(forKey: try key(row)) }
116
+ for row in (result["changed"] as? [[String: Any]] ?? []) + (result["added"] as? [[String: Any]] ?? []) { keyed[try key(row)] = row }
117
+ next["rows"] = Array(keyed.values)
118
+ }
119
+ next["version"] = result["version"]; next["cursor"] = result["cursor"]; return next
120
+ }
121
+ private static func key(_ row: [String: Any]) throws -> String { guard let id = row["id"], id is String || id is NSNumber else { throw SyncNativeError.invalidResponse("Background Sync row requires id.") }; return "\(type(of: id)):\(id)" }
122
+ private static func same(_ a: [String: Any], _ b: [String: Any]) -> Bool { ((a["version"] as? NSNumber)?.intValue ?? 0) == ((b["version"] as? NSNumber)?.intValue ?? 0) && (a["cursor"] as? String ?? "") == (b["cursor"] as? String ?? "") }
123
+ 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
+
125
+ 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 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
+ 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
+
131
+ private static func jsonRequest(_ url: URL, method: String, body: [String: Any]?, bearer: String?, issuer: URL) async throws -> [String: Any] { try await jsonRequest(url, method: method, rawBody: body.map{try! JSONSerialization.data(withJSONObject:$0)}, contentType: body == nil ? nil : "application/json", bearer: bearer, issuer: issuer) }
132
+ private static func jsonRequest(_ url: URL, method: String, rawBody: Data?, contentType: String?, bearer: String?, issuer: URL, advertisedTokenEndpoint: Bool = false) async throws -> [String: Any] {
133
+ guard (rawBody?.count ?? 0) <= maxResponse else { throw SyncNativeError.invalidResponse("Background request is too large.") }
134
+ if advertisedTokenEndpoint {
135
+ let loopback = url.scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(url.host?.lowercased() ?? "")
136
+ guard (url.scheme == "https" || loopback), url.user == nil, url.password == nil, url.fragment == nil else { throw SyncNativeError.invalidResponse("OIDC advertised an unsafe token endpoint.") }
137
+ } else {
138
+ guard AbsoluteBackgroundSyncConfiguration.origin(url)==AbsoluteBackgroundSyncConfiguration.origin(issuer) else { throw SyncNativeError.invalidResponse("Background request left the Auth issuer origin.") }
139
+ }
140
+ var request=URLRequest(url:url);request.httpMethod=method;request.httpBody=rawBody;request.timeoutInterval=20;request.setValue("application/json",forHTTPHeaderField:"Accept");if let contentType{request.setValue(contentType,forHTTPHeaderField:"Content-Type")};if let bearer{request.setValue("Bearer \(bearer)",forHTTPHeaderField:"Authorization")}
141
+ let session=URLSession(configuration:.ephemeral,delegate:NoRedirect(),delegateQueue:nil);let(data,response)=try await session.data(for:request);guard data.count<=maxResponse,let http=response as? HTTPURLResponse,(200..<300).contains(http.statusCode),let value=try JSONSerialization.jsonObject(with:data)as?[String:Any]else{throw SyncNativeError.invalidResponse("Background request failed.")};return value
142
+ }
143
+ }
@@ -0,0 +1,98 @@
1
+ import BackgroundTasks
2
+ import Capacitor
3
+ import Foundation
4
+
5
+ private let taskSuffix = ".absolutejs.background-sync"
6
+
7
+ struct AbsoluteBackgroundSyncConfiguration: Codable {
8
+ let endpoint: URL
9
+ let issuer: URL
10
+ let clientId: String
11
+ let namespace: String
12
+ let databaseName: String
13
+ let intervalMinutes: Int
14
+ let maxMutations: Int
15
+ let maxPulls: Int
16
+ let maxAttempts: Int
17
+
18
+ init(call: CAPPluginCall) throws {
19
+ guard let endpointValue = call.getString("endpoint"), let endpoint = URL(string: endpointValue),
20
+ let issuerValue = call.getString("issuer"), let issuer = URL(string: issuerValue),
21
+ let clientId = call.getString("clientId"), !clientId.isEmpty,
22
+ let namespace = call.getString("namespace"), !namespace.isEmpty else { throw SyncNativeError.invalidConfiguration }
23
+ guard Self.origin(endpoint) == Self.origin(issuer), endpoint.user == nil, endpoint.password == nil,
24
+ issuer.path.isEmpty || issuer.path == "/", issuer.query == nil, issuer.fragment == nil else { throw SyncNativeError.invalidConfiguration }
25
+ let loopback = issuer.scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(issuer.host?.lowercased() ?? "")
26
+ guard issuer.scheme == "https" || loopback else { throw SyncNativeError.invalidConfiguration }
27
+ let databaseName = call.getString("databaseName") ?? "absolutejs-sync-local-v1"
28
+ guard databaseName.range(of: "^[A-Za-z0-9._-]{1,128}$", options: .regularExpression) != nil else { throw SyncNativeError.invalidConfiguration }
29
+ let interval = call.getInt("intervalMinutes") ?? 15
30
+ let mutationLimit = call.getInt("maxMutations") ?? 50
31
+ let pullLimit = call.getInt("maxPulls") ?? 50
32
+ let attemptLimit = call.getInt("maxAttempts") ?? 5
33
+ guard interval >= 15 && interval <= 1440, mutationLimit >= 0 && mutationLimit <= 100, pullLimit >= 0 && pullLimit <= 100, attemptLimit >= 1 && attemptLimit <= 100 else { throw SyncNativeError.invalidConfiguration }
34
+ self.endpoint = endpoint; self.issuer = issuer; self.clientId = clientId; self.namespace = namespace
35
+ self.databaseName = databaseName; intervalMinutes = interval; maxMutations = mutationLimit; maxPulls = pullLimit; maxAttempts = attemptLimit
36
+ }
37
+
38
+ static func origin(_ url: URL) -> String {
39
+ let port = url.port ?? (url.scheme == "https" ? 443 : 80)
40
+ return "\(url.scheme ?? "")://\(url.host?.lowercased() ?? ""):\(port)"
41
+ }
42
+ }
43
+
44
+ enum SyncNativeError: Error { case invalidConfiguration, noSession, invalidResponse(String) }
45
+
46
+ @objc(AbsoluteBackgroundSyncPlugin)
47
+ public class AbsoluteBackgroundSyncPlugin: CAPPlugin, CAPBridgedPlugin {
48
+ public let identifier = "AbsoluteBackgroundSyncPlugin"
49
+ public let jsName = "AbsoluteBackgroundSync"
50
+ public let pluginMethods: [CAPPluginMethod] = [
51
+ CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
52
+ CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise),
53
+ CAPPluginMethod(name: "runNow", returnType: CAPPluginReturnPromise),
54
+ CAPPluginMethod(name: "status", returnType: CAPPluginReturnPromise),
55
+ ]
56
+ static var taskIdentifier: String { "\(Bundle.main.bundleIdentifier ?? "absolutejs.app")\(taskSuffix)" }
57
+ static let defaults = UserDefaults.standard
58
+ static let configKey = "absolutejs.background-sync.config"
59
+
60
+ /** AbsoluteJS inserts this call in AppDelegate before launch completes. */
61
+ @objc public static func registerBackgroundTask() {
62
+ BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in
63
+ guard let processing = task as? BGProcessingTask else { task.setTaskCompleted(success: false); return }
64
+ let work = Task {
65
+ do { try await AbsoluteBackgroundSyncEngine.run(); schedule(); processing.setTaskCompleted(success: true) }
66
+ catch SyncNativeError.noSession { processing.setTaskCompleted(success: true) }
67
+ catch { AbsoluteBackgroundSyncEngine.record(error); schedule(); processing.setTaskCompleted(success: false) }
68
+ }
69
+ processing.expirationHandler = { work.cancel() }
70
+ }
71
+ }
72
+
73
+ static func configuration() throws -> AbsoluteBackgroundSyncConfiguration? {
74
+ guard let data = defaults.data(forKey: configKey) else { return nil }
75
+ return try JSONDecoder().decode(AbsoluteBackgroundSyncConfiguration.self, from: data)
76
+ }
77
+ static func schedule() {
78
+ guard let config = try? configuration() else { return }
79
+ let request = BGProcessingTaskRequest(identifier: taskIdentifier)
80
+ request.requiresNetworkConnectivity = true
81
+ request.earliestBeginDate = Date(timeIntervalSinceNow: TimeInterval(config.intervalMinutes * 60))
82
+ try? BGTaskScheduler.shared.submit(request)
83
+ }
84
+ static func statusValue() -> [String: Any] {
85
+ var value: [String: Any] = ["configured": defaults.data(forKey: configKey) != nil, "running": defaults.bool(forKey: "absolutejs.background-sync.running")]
86
+ for key in ["lastRunAt", "lastAcknowledged", "lastPulled"] where defaults.object(forKey: "absolutejs.background-sync.\(key)") != nil { value[key] = defaults.object(forKey: "absolutejs.background-sync.\(key)")! }
87
+ if let error = defaults.string(forKey: "absolutejs.background-sync.lastError") { value["lastError"] = error }
88
+ return value
89
+ }
90
+
91
+ @objc func configure(_ call: CAPPluginCall) {
92
+ do { let config = try AbsoluteBackgroundSyncConfiguration(call: call); Self.defaults.set(try JSONEncoder().encode(config), forKey: Self.configKey); Self.schedule(); call.resolve() }
93
+ catch { call.reject("Invalid background Sync configuration.", "INVALID_ARGUMENT", error) }
94
+ }
95
+ @objc func clear(_ call: CAPPluginCall) { BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier); Self.defaults.removeObject(forKey: Self.configKey); call.resolve() }
96
+ @objc func runNow(_ call: CAPPluginCall) { Task { do { try await AbsoluteBackgroundSyncEngine.run(); call.resolve(Self.statusValue()) } catch SyncNativeError.noSession { Self.defaults.set(false, forKey: "absolutejs.background-sync.running"); call.resolve(Self.statusValue()) } catch { AbsoluteBackgroundSyncEngine.record(error); call.reject("Background Sync failed.", "SYNC_FAILURE", error) } } }
97
+ @objc func status(_ call: CAPPluginCall) { call.resolve(Self.statusValue()) }
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/sync-capacitor",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Transactional Capacitor SQLite local store and native lifecycle adapter for @absolutejs/sync",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,20 +29,24 @@
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-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 @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}\"",
36
36
  "release": "bun run format && bun run typecheck && bun test && bun run build && npm publish"
37
37
  },
38
38
  "peerDependencies": {
39
- "@absolutejs/devices": ">=0.0.2 <0.1.0",
40
- "@absolutejs/sync": ">=2.22.0 <3",
39
+ "@absolutejs/devices": ">=0.0.3 <0.1.0",
40
+ "@absolutejs/devices-capacitor": ">=0.1.3 <0.2.0",
41
+ "@absolutejs/sync": ">=2.23.0 <3",
42
+ "@capacitor/core": ">=8 <9",
41
43
  "@capacitor-community/sqlite": ">=8.1.1 <9"
42
44
  },
43
45
  "devDependencies": {
44
- "@absolutejs/devices": "0.0.2",
45
- "@absolutejs/sync": "2.22.0",
46
+ "@absolutejs/devices": "0.0.3",
47
+ "@absolutejs/devices-capacitor": "0.1.3",
48
+ "@absolutejs/sync": "2.23.0",
49
+ "@capacitor/core": "8.5.0",
46
50
  "@capacitor-community/sqlite": "8.1.1",
47
51
  "@types/bun": "1.3.14",
48
52
  "prettier": "3.5.3",
@@ -56,8 +60,21 @@
56
60
  }
57
61
  },
58
62
  "files": [
63
+ "android/src/main/",
64
+ "android/build.gradle",
59
65
  "dist",
66
+ "ios/Sources/",
67
+ "Package.swift",
68
+ "AbsoluteSyncCapacitor.podspec",
60
69
  "LICENSE",
61
70
  "README.md"
62
- ]
71
+ ],
72
+ "capacitor": {
73
+ "android": {
74
+ "src": "android"
75
+ },
76
+ "ios": {
77
+ "src": "ios"
78
+ }
79
+ }
63
80
  }