@rific/sync 0.1.2

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 ADDED
@@ -0,0 +1,117 @@
1
+ # @rific/sync
2
+
3
+ A generic SQLite sync engine for Expo. Handles schema management, upsert/delete, and cursor-based sync state — so you can focus on fetching data, not wiring up tables.
4
+
5
+ Works with `expo-sqlite`'s `SQLiteDatabase` out of the box.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ npm install @rific/sync
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import * as SQLite from 'expo-sqlite'
17
+ import { SyncEngine } from '@rific/sync'
18
+
19
+ const db = await SQLite.openDatabaseAsync('my.db')
20
+
21
+ const sync = new SyncEngine(db)
22
+
23
+ sync.register({
24
+ name: 'products',
25
+ schema: {
26
+ id: 'TEXT',
27
+ name: 'TEXT',
28
+ price: 'REAL',
29
+ stock: 'INTEGER',
30
+ },
31
+ primaryKey: 'id',
32
+ })
33
+
34
+ // Creates _sync_state and all registered tables (safe to call on every launch)
35
+ await sync.init()
36
+ ```
37
+
38
+ ### Syncing records
39
+
40
+ ```ts
41
+ // Fetch from your API, using the last cursor to get only new changes
42
+ const cursor = await sync.getCursor('products')
43
+ const { records, nextCursor } = await fetchProducts({ since: cursor })
44
+
45
+ for (const record of records) {
46
+ if (record.deleted) {
47
+ await sync.remove('products', record.id)
48
+ } else {
49
+ await sync.push('products', record)
50
+ }
51
+ }
52
+
53
+ await sync.setCursor('products', nextCursor)
54
+ ```
55
+
56
+ ### Transforming records
57
+
58
+ Use `transform` to reshape API responses before they hit SQLite:
59
+
60
+ ```ts
61
+ sync.register({
62
+ name: 'orders',
63
+ schema: {
64
+ id: 'TEXT',
65
+ customer_id: 'TEXT',
66
+ total: 'REAL',
67
+ placed_at: 'TEXT',
68
+ },
69
+ transform: (record: ApiOrder) => ({
70
+ id: record.id,
71
+ customer_id: record.customerId,
72
+ total: record.total,
73
+ placed_at: record.placedAt,
74
+ }),
75
+ })
76
+ ```
77
+
78
+ ## API
79
+
80
+ ### `new SyncEngine(db)`
81
+
82
+ Creates a sync engine. `db` must satisfy the `Database` interface (automatically satisfied by `expo-sqlite`'s `SQLiteDatabase`).
83
+
84
+ ### `.register(config)`
85
+
86
+ Registers a channel (table). Returns `this` for chaining.
87
+
88
+ | Field | Type | Description |
89
+ |---|---|---|
90
+ | `name` | `string` | Table name in SQLite |
91
+ | `schema` | `Record<string, ColumnType>` | Column definitions |
92
+ | `primaryKey` | `string` | Primary key column. Defaults to `'id'` |
93
+ | `transform` | `(record: TRecord) => TRow \| Promise<TRow>` | Optional transform before write |
94
+
95
+ ### `.init()`
96
+
97
+ Creates `_sync_state` and all registered tables if they don't exist. Safe to call on every app launch.
98
+
99
+ ### `.push(name, record)`
100
+
101
+ Upserts a record into the named channel's table.
102
+
103
+ ### `.remove(name, id)`
104
+
105
+ Deletes a record by primary key from the named channel's table.
106
+
107
+ ### `.getCursor(name)`
108
+
109
+ Returns the last saved cursor for a channel, or `null` if none exists.
110
+
111
+ ### `.setCursor(name, cursor)`
112
+
113
+ Saves a cursor for a channel along with a `syncedAt` timestamp.
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,33 @@
1
+ type SQLiteValue = string | number | null;
2
+ type ColumnType = 'TEXT' | 'INTEGER' | 'REAL' | 'BLOB';
3
+ /** Minimal database interface — satisfied by expo-sqlite's SQLiteDatabase out of the box. */
4
+ interface Database {
5
+ execAsync(source: string): Promise<void>;
6
+ runAsync(source: string, params: SQLiteValue[]): Promise<unknown>;
7
+ getFirstAsync<T>(source: string, params: SQLiteValue[]): Promise<T | null>;
8
+ }
9
+ interface ChannelConfig<TRecord = unknown, TRow extends Record<string, SQLiteValue> = Record<string, SQLiteValue>> {
10
+ /** Table name in SQLite. */
11
+ name: string;
12
+ /** Column definitions: key = column name, value = SQLite type. */
13
+ schema: Record<string, ColumnType>;
14
+ /** Column used as the primary key. Defaults to 'id'. */
15
+ primaryKey?: string;
16
+ /** Optional transform applied to each record before it is written to SQLite. */
17
+ transform?: (record: TRecord) => TRow | Promise<TRow>;
18
+ }
19
+
20
+ declare class SyncEngine {
21
+ private readonly db;
22
+ private readonly channels;
23
+ constructor(db: Database);
24
+ register<TRecord, TRow extends Record<string, SQLiteValue>>(config: ChannelConfig<TRecord, TRow>): this;
25
+ init(): Promise<void>;
26
+ push<TRecord>(name: string, record: TRecord): Promise<void>;
27
+ remove(name: string, id: SQLiteValue): Promise<void>;
28
+ getCursor(name: string): Promise<string | null>;
29
+ setCursor(name: string, cursor: string): Promise<void>;
30
+ private requireChannel;
31
+ }
32
+
33
+ export { type ChannelConfig, type ColumnType, type Database, type SQLiteValue, SyncEngine };
@@ -0,0 +1,33 @@
1
+ type SQLiteValue = string | number | null;
2
+ type ColumnType = 'TEXT' | 'INTEGER' | 'REAL' | 'BLOB';
3
+ /** Minimal database interface — satisfied by expo-sqlite's SQLiteDatabase out of the box. */
4
+ interface Database {
5
+ execAsync(source: string): Promise<void>;
6
+ runAsync(source: string, params: SQLiteValue[]): Promise<unknown>;
7
+ getFirstAsync<T>(source: string, params: SQLiteValue[]): Promise<T | null>;
8
+ }
9
+ interface ChannelConfig<TRecord = unknown, TRow extends Record<string, SQLiteValue> = Record<string, SQLiteValue>> {
10
+ /** Table name in SQLite. */
11
+ name: string;
12
+ /** Column definitions: key = column name, value = SQLite type. */
13
+ schema: Record<string, ColumnType>;
14
+ /** Column used as the primary key. Defaults to 'id'. */
15
+ primaryKey?: string;
16
+ /** Optional transform applied to each record before it is written to SQLite. */
17
+ transform?: (record: TRecord) => TRow | Promise<TRow>;
18
+ }
19
+
20
+ declare class SyncEngine {
21
+ private readonly db;
22
+ private readonly channels;
23
+ constructor(db: Database);
24
+ register<TRecord, TRow extends Record<string, SQLiteValue>>(config: ChannelConfig<TRecord, TRow>): this;
25
+ init(): Promise<void>;
26
+ push<TRecord>(name: string, record: TRecord): Promise<void>;
27
+ remove(name: string, id: SQLiteValue): Promise<void>;
28
+ getCursor(name: string): Promise<string | null>;
29
+ setCursor(name: string, cursor: string): Promise<void>;
30
+ private requireChannel;
31
+ }
32
+
33
+ export { type ChannelConfig, type ColumnType, type Database, type SQLiteValue, SyncEngine };
package/dist/index.js ADDED
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ SyncEngine: () => SyncEngine
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/SyncEngine.ts
28
+ var SyncEngine = class {
29
+ constructor(db) {
30
+ this.channels = /* @__PURE__ */ new Map();
31
+ this.db = db;
32
+ }
33
+ register(config) {
34
+ this.channels.set(config.name, config);
35
+ return this;
36
+ }
37
+ async init() {
38
+ await this.db.execAsync(`CREATE TABLE IF NOT EXISTS _sync_state (channel TEXT PRIMARY KEY, cursor TEXT, syncedAt TEXT)`);
39
+ for (const [, channel] of this.channels) {
40
+ const pk = channel.primaryKey ?? "id";
41
+ const cols = Object.entries(channel.schema).map(([col, type]) => `${col} ${type}${col === pk ? " PRIMARY KEY" : ""}`).join(", ");
42
+ await this.db.execAsync(`CREATE TABLE IF NOT EXISTS ${channel.name} (${cols})`);
43
+ }
44
+ }
45
+ async push(name, record) {
46
+ const channel = this.requireChannel(name);
47
+ const row = channel.transform ? await channel.transform(record) : record;
48
+ const cols = Object.keys(row);
49
+ const values = cols.map((k) => row[k]);
50
+ await this.db.runAsync(`INSERT OR REPLACE INTO ${name} (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")})`, values);
51
+ }
52
+ async remove(name, id) {
53
+ const channel = this.requireChannel(name);
54
+ const pk = channel.primaryKey ?? "id";
55
+ await this.db.runAsync(`DELETE FROM ${name} WHERE ${pk} = ?`, [id]);
56
+ }
57
+ async getCursor(name) {
58
+ const row = await this.db.getFirstAsync(`SELECT cursor FROM _sync_state WHERE channel = ?`, [name]);
59
+ return row?.cursor ?? null;
60
+ }
61
+ async setCursor(name, cursor) {
62
+ await this.db.runAsync(`INSERT OR REPLACE INTO _sync_state (channel, cursor, syncedAt) VALUES (?, ?, ?)`, [name, cursor, (/* @__PURE__ */ new Date()).toISOString()]);
63
+ }
64
+ requireChannel(name) {
65
+ const channel = this.channels.get(name);
66
+ if (!channel) throw new Error(`[expo-sync] Unknown channel: "${name}"`);
67
+ return channel;
68
+ }
69
+ };
70
+ // Annotate the CommonJS export names for ESM import in node:
71
+ 0 && (module.exports = {
72
+ SyncEngine
73
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,46 @@
1
+ // src/SyncEngine.ts
2
+ var SyncEngine = class {
3
+ constructor(db) {
4
+ this.channels = /* @__PURE__ */ new Map();
5
+ this.db = db;
6
+ }
7
+ register(config) {
8
+ this.channels.set(config.name, config);
9
+ return this;
10
+ }
11
+ async init() {
12
+ await this.db.execAsync(`CREATE TABLE IF NOT EXISTS _sync_state (channel TEXT PRIMARY KEY, cursor TEXT, syncedAt TEXT)`);
13
+ for (const [, channel] of this.channels) {
14
+ const pk = channel.primaryKey ?? "id";
15
+ const cols = Object.entries(channel.schema).map(([col, type]) => `${col} ${type}${col === pk ? " PRIMARY KEY" : ""}`).join(", ");
16
+ await this.db.execAsync(`CREATE TABLE IF NOT EXISTS ${channel.name} (${cols})`);
17
+ }
18
+ }
19
+ async push(name, record) {
20
+ const channel = this.requireChannel(name);
21
+ const row = channel.transform ? await channel.transform(record) : record;
22
+ const cols = Object.keys(row);
23
+ const values = cols.map((k) => row[k]);
24
+ await this.db.runAsync(`INSERT OR REPLACE INTO ${name} (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")})`, values);
25
+ }
26
+ async remove(name, id) {
27
+ const channel = this.requireChannel(name);
28
+ const pk = channel.primaryKey ?? "id";
29
+ await this.db.runAsync(`DELETE FROM ${name} WHERE ${pk} = ?`, [id]);
30
+ }
31
+ async getCursor(name) {
32
+ const row = await this.db.getFirstAsync(`SELECT cursor FROM _sync_state WHERE channel = ?`, [name]);
33
+ return row?.cursor ?? null;
34
+ }
35
+ async setCursor(name, cursor) {
36
+ await this.db.runAsync(`INSERT OR REPLACE INTO _sync_state (channel, cursor, syncedAt) VALUES (?, ?, ?)`, [name, cursor, (/* @__PURE__ */ new Date()).toISOString()]);
37
+ }
38
+ requireChannel(name) {
39
+ const channel = this.channels.get(name);
40
+ if (!channel) throw new Error(`[expo-sync] Unknown channel: "${name}"`);
41
+ return channel;
42
+ }
43
+ };
44
+ export {
45
+ SyncEngine
46
+ };
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@rific/sync",
3
+ "version": "0.1.2",
4
+ "description": "Generic SQLite sync engine for Expo — schema management, upsert/delete, and cursor state persistence",
5
+ "keywords": [
6
+ "expo",
7
+ "sqlite",
8
+ "sync",
9
+ "offline-first",
10
+ "expo-sqlite"
11
+ ],
12
+ "homepage": "https://github.com/jayrdeaton/Expo-Sync#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/jayrdeaton/Expo-Sync/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/jayrdeaton/Expo-Sync.git"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Jay Deaton",
22
+ "sideEffects": false,
23
+ "type": "commonjs",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.mjs",
28
+ "require": "./dist/index.js"
29
+ }
30
+ },
31
+ "main": "dist/index.js",
32
+ "module": "dist/index.mjs",
33
+ "types": "dist/index.d.ts",
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
39
+ "fix": "eslint --fix",
40
+ "lint": "eslint",
41
+ "prepublishOnly": "npm run build",
42
+ "release": "git push --follow-tags",
43
+ "release:major": "npm version major && git push --follow-tags",
44
+ "release:minor": "npm version minor && git push --follow-tags",
45
+ "release:patch": "npm version patch && git push --follow-tags",
46
+ "test": "jest",
47
+ "test:watch": "jest --watchAll",
48
+ "typecheck": "tsc --noEmit",
49
+ "preversion": "npm run lint && npm test"
50
+ },
51
+ "devDependencies": {
52
+ "@types/jest": "^30.0.0",
53
+ "@typescript-eslint/parser": "^8.59.3",
54
+ "eslint": "^9.39.4",
55
+ "eslint-config-prettier": "^10.1.8",
56
+ "eslint-plugin-package-json": "^1.0.0",
57
+ "eslint-plugin-prettier": "^5.5.5",
58
+ "eslint-plugin-simple-import-sort": "^13.0.0",
59
+ "jest": "^30.4.2",
60
+ "prettier": "^3.8.3",
61
+ "ts-jest": "^29.4.9",
62
+ "ts-node": "^10.9.2",
63
+ "tsup": "^8.0.0",
64
+ "typescript": "^5.9.3",
65
+ "typescript-eslint": "^8.59.3"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public"
69
+ }
70
+ }