@missionmind/sync-api 0.0.0 → 0.1.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,105 @@
1
+ /**
2
+ * Rows deleted from the tables the app pulls by cursor, written by a trigger
3
+ * (`syncTriggersSql`) so that cascades are recorded too. No foreign key to
4
+ * the users table, which is the app's: the trigger writes none for a row
5
+ * deleted with its account.
6
+ */
7
+ export declare const syncTombstones: import("drizzle-orm/pg-core").PgTableWithColumns<{
8
+ name: "sync_tombstones";
9
+ schema: undefined;
10
+ columns: {
11
+ id: import("drizzle-orm/pg-core").PgColumn<{
12
+ name: "id";
13
+ tableName: "sync_tombstones";
14
+ dataType: "number";
15
+ columnType: "PgBigSerial53";
16
+ data: number;
17
+ driverParam: number;
18
+ notNull: true;
19
+ hasDefault: true;
20
+ isPrimaryKey: true;
21
+ isAutoincrement: false;
22
+ hasRuntimeDefault: false;
23
+ enumValues: undefined;
24
+ baseColumn: never;
25
+ identity: undefined;
26
+ generated: undefined;
27
+ }, {}, {}>;
28
+ userId: import("drizzle-orm/pg-core").PgColumn<{
29
+ name: "userId";
30
+ tableName: "sync_tombstones";
31
+ dataType: "string";
32
+ columnType: "PgVarchar";
33
+ data: string;
34
+ driverParam: string;
35
+ notNull: true;
36
+ hasDefault: false;
37
+ isPrimaryKey: false;
38
+ isAutoincrement: false;
39
+ hasRuntimeDefault: false;
40
+ enumValues: [string, ...string[]];
41
+ baseColumn: never;
42
+ identity: undefined;
43
+ generated: undefined;
44
+ }, {}, {
45
+ length: 255;
46
+ }>;
47
+ tableName: import("drizzle-orm/pg-core").PgColumn<{
48
+ name: "tableName";
49
+ tableName: "sync_tombstones";
50
+ dataType: "string";
51
+ columnType: "PgVarchar";
52
+ data: string;
53
+ driverParam: string;
54
+ notNull: true;
55
+ hasDefault: false;
56
+ isPrimaryKey: false;
57
+ isAutoincrement: false;
58
+ hasRuntimeDefault: false;
59
+ enumValues: [string, ...string[]];
60
+ baseColumn: never;
61
+ identity: undefined;
62
+ generated: undefined;
63
+ }, {}, {
64
+ length: 64;
65
+ }>;
66
+ rowId: import("drizzle-orm/pg-core").PgColumn<{
67
+ name: "rowId";
68
+ tableName: "sync_tombstones";
69
+ dataType: "string";
70
+ columnType: "PgVarchar";
71
+ data: string;
72
+ driverParam: string;
73
+ notNull: true;
74
+ hasDefault: false;
75
+ isPrimaryKey: false;
76
+ isAutoincrement: false;
77
+ hasRuntimeDefault: false;
78
+ enumValues: [string, ...string[]];
79
+ baseColumn: never;
80
+ identity: undefined;
81
+ generated: undefined;
82
+ }, {}, {
83
+ length: 255;
84
+ }>;
85
+ deletedAt: import("drizzle-orm/pg-core").PgColumn<{
86
+ name: "deletedAt";
87
+ tableName: "sync_tombstones";
88
+ dataType: "date";
89
+ columnType: "PgTimestamp";
90
+ data: Date;
91
+ driverParam: string;
92
+ notNull: true;
93
+ hasDefault: true;
94
+ isPrimaryKey: false;
95
+ isAutoincrement: false;
96
+ hasRuntimeDefault: false;
97
+ enumValues: undefined;
98
+ baseColumn: never;
99
+ identity: undefined;
100
+ generated: undefined;
101
+ }, {}, {}>;
102
+ };
103
+ dialect: "pg";
104
+ }>;
105
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AASA;;;;;GAKG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiB1B,CAAC"}
package/dist/schema.js ADDED
@@ -0,0 +1,18 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { bigserial, index, pgTable, timestamp, varchar, } from "drizzle-orm/pg-core";
3
+ /**
4
+ * Rows deleted from the tables the app pulls by cursor, written by a trigger
5
+ * (`syncTriggersSql`) so that cascades are recorded too. No foreign key to
6
+ * the users table, which is the app's: the trigger writes none for a row
7
+ * deleted with its account.
8
+ */
9
+ export const syncTombstones = pgTable("sync_tombstones", {
10
+ id: bigserial({ mode: "number" }).primaryKey(),
11
+ userId: varchar({ length: 255 }).notNull(),
12
+ tableName: varchar({ length: 64 }).notNull(),
13
+ rowId: varchar({ length: 255 }).notNull(),
14
+ deletedAt: timestamp({ withTimezone: true }).notNull().default(sql `now()`),
15
+ }, (table) => [
16
+ index("syncTombstones_sync_idx").on(table.userId, table.tableName, table.deletedAt, table.id),
17
+ ]);
18
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAC;AAClC,OAAO,EACL,SAAS,EACT,KAAK,EACL,OAAO,EACP,SAAS,EACT,OAAO,GACR,MAAM,qBAAqB,CAAC;AAE7B;;;;;GAKG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,OAAO,CACnC,iBAAiB,EACjB;IACE,EAAE,EAAE,SAAS,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,UAAU,EAAE;IAC9C,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC,OAAO,EAAE;IAC1C,SAAS,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE;IAC5C,KAAK,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC,OAAO,EAAE;IACzC,SAAS,EAAE,SAAS,CAAC,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,GAAG,CAAA,OAAO,CAAC;CAC3E,EACD,CAAC,KAAK,EAAE,EAAE,CAAC;IACT,KAAK,CAAC,yBAAyB,CAAC,CAAC,EAAE,CACjC,KAAK,CAAC,MAAM,EACZ,KAAK,CAAC,SAAS,EACf,KAAK,CAAC,SAAS,EACf,KAAK,CAAC,EAAE,CACT;CACF,CACF,CAAC"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The SQL that keeps the tables an app pulls by cursor ready for it: each
3
+ * write stamps the row's "updatedAt", and each deletion records a tombstone
4
+ * in `sync_tombstones`. Run it in a migration, after the tables and
5
+ * `sync_tombstones` exist, and again whenever a pulled table is added; it can
6
+ * be run more than once.
7
+ *
8
+ * Inserts are stamped too: a row restored or imported with an old
9
+ * "updatedAt" would otherwise sit behind every cursor.
10
+ *
11
+ * Triggers rather than application code, so that every write counts: foreign
12
+ * keys' ON DELETE SET NULL / CASCADE, scripts, and upserts whose conflict
13
+ * branch Drizzle does not stamp.
14
+ */
15
+ export declare function syncTriggersSql(options: {
16
+ /** The pulled tables, by their SQL names. */
17
+ tables: string[];
18
+ /**
19
+ * The table of users, by its SQL name (Better Auth's is "user"). A row
20
+ * deleted with its user leaves no tombstone: nobody will pull it.
21
+ */
22
+ usersTable: string;
23
+ }): string;
24
+ //# sourceMappingURL=triggers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"triggers.d.ts","sourceRoot":"","sources":["../src/triggers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE;IACvC,6CAA6C;IAC7C,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB;;;OAGG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB,UAiDA"}
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The SQL that keeps the tables an app pulls by cursor ready for it: each
3
+ * write stamps the row's "updatedAt", and each deletion records a tombstone
4
+ * in `sync_tombstones`. Run it in a migration, after the tables and
5
+ * `sync_tombstones` exist, and again whenever a pulled table is added; it can
6
+ * be run more than once.
7
+ *
8
+ * Inserts are stamped too: a row restored or imported with an old
9
+ * "updatedAt" would otherwise sit behind every cursor.
10
+ *
11
+ * Triggers rather than application code, so that every write counts: foreign
12
+ * keys' ON DELETE SET NULL / CASCADE, scripts, and upserts whose conflict
13
+ * branch Drizzle does not stamp.
14
+ */
15
+ export function syncTriggersSql(options) {
16
+ const tables = options.tables.map((table) => quoteLiteral(table)).join(", ");
17
+ const usersTable = quoteIdentifier(options.usersTable);
18
+ return `BEGIN;
19
+
20
+ -- A row inserted again under a deleted id (a restore) must not be deleted
21
+ -- again by its old tombstone on a device pulling both for the first time.
22
+ CREATE OR REPLACE FUNCTION sync_touch_updated_at() RETURNS trigger AS $$
23
+ BEGIN
24
+ NEW."updatedAt" := now();
25
+ IF TG_OP = 'INSERT' THEN
26
+ DELETE FROM sync_tombstones
27
+ WHERE "tableName" = TG_TABLE_NAME AND "rowId" = NEW.id;
28
+ END IF;
29
+ RETURN NEW;
30
+ END;
31
+ $$ LANGUAGE plpgsql;
32
+
33
+ -- Rows removed with their user leave no tombstone: nobody will pull them.
34
+ CREATE OR REPLACE FUNCTION sync_record_tombstone() RETURNS trigger AS $$
35
+ BEGIN
36
+ IF EXISTS (SELECT 1 FROM ${usersTable} WHERE id = OLD."userId") THEN
37
+ INSERT INTO sync_tombstones ("userId", "tableName", "rowId")
38
+ VALUES (OLD."userId", TG_TABLE_NAME, OLD.id);
39
+ END IF;
40
+ RETURN OLD;
41
+ END;
42
+ $$ LANGUAGE plpgsql;
43
+
44
+ DO $$
45
+ DECLARE
46
+ t text;
47
+ BEGIN
48
+ FOREACH t IN ARRAY ARRAY[${tables}]
49
+ LOOP
50
+ EXECUTE format('DROP TRIGGER IF EXISTS sync_touch_updated_at ON %I', t);
51
+ EXECUTE format(
52
+ 'CREATE TRIGGER sync_touch_updated_at BEFORE INSERT OR UPDATE ON %I
53
+ FOR EACH ROW EXECUTE FUNCTION sync_touch_updated_at()', t);
54
+ EXECUTE format('DROP TRIGGER IF EXISTS sync_record_tombstone ON %I', t);
55
+ EXECUTE format(
56
+ 'CREATE TRIGGER sync_record_tombstone AFTER DELETE ON %I
57
+ FOR EACH ROW EXECUTE FUNCTION sync_record_tombstone()', t);
58
+ END LOOP;
59
+ END;
60
+ $$;
61
+
62
+ COMMIT;
63
+ `;
64
+ }
65
+ function quoteLiteral(value) {
66
+ return `'${value.replaceAll("'", "''")}'`;
67
+ }
68
+ function quoteIdentifier(value) {
69
+ return `"${value.replaceAll('"', '""')}"`;
70
+ }
71
+ //# sourceMappingURL=triggers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"triggers.js","sourceRoot":"","sources":["../src/triggers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,eAAe,CAAC,OAQ/B;IACC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC7E,MAAM,UAAU,GAAG,eAAe,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;IACvD,OAAO;;;;;;;;;;;;;;;;;;6BAkBoB,UAAU;;;;;;;;;;;;6BAYV,MAAM;;;;;;;;;;;;;;;CAelC,CAAC;AACF,CAAC;AAED,SAAS,YAAY,CAAC,KAAa;IACjC,OAAO,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC;AAC5C,CAAC;AAED,SAAS,eAAe,CAAC,KAAa;IACpC,OAAO,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC;AAC5C,CAAC"}
package/package.json CHANGED
@@ -1 +1,41 @@
1
- {"name":"@missionmind/sync-api","version":"0.0.0","description":"Placeholder: the first release is 0.1.0","license":"MIT"}
1
+ {
2
+ "name": "@missionmind/sync-api",
3
+ "version": "0.1.0",
4
+ "description": "Server side of an offline-first app: applies the changes a device uploads, and serves its pulls by cursor (rows and tombstones) on Drizzle Postgres.",
5
+ "license": "MIT",
6
+ "author": "MissionMind",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./schema": {
14
+ "types": "./dist/schema.d.ts",
15
+ "default": "./dist/schema.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src"
21
+ ],
22
+ "scripts": {
23
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
24
+ "types:check": "tsc --noEmit",
25
+ "prepack": "node ../../scripts/package-manifest.mjs prepack",
26
+ "postpack": "node ../../scripts/package-manifest.mjs postpack"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "peerDependencies": {
32
+ "drizzle-orm": "^0.45.2",
33
+ "zod": "^4.4.3"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.10.5",
37
+ "drizzle-orm": "0.45.2",
38
+ "typescript": "6.0.3",
39
+ "zod": "4.4.3"
40
+ }
41
+ }
package/src/changes.ts ADDED
@@ -0,0 +1,117 @@
1
+ import z from "zod";
2
+
3
+ /**
4
+ * A change the app queued: a whole row to create or replace, or a deletion.
5
+ * The endpoints take them in batches and answer with the ones they refused.
6
+ */
7
+ export const syncChangeSchema = z.object({
8
+ id: z.string(),
9
+ operation: z.enum(["PUT", "DELETE"]),
10
+ data: z.record(z.string(), z.unknown()).nullable(),
11
+ });
12
+
13
+ export type SyncChange = z.infer<typeof syncChangeSchema>;
14
+
15
+ /** A change the server refused for good; sending it again cannot help. */
16
+ export interface RejectedChange {
17
+ id: string;
18
+ reason: string;
19
+ }
20
+
21
+ export interface TableSync<TInsert> {
22
+ allowedKeys: string[];
23
+ insertSchema: z.ZodType<TInsert>;
24
+ name: string;
25
+ ownerOf: (id: string) => Promise<string | undefined>;
26
+ /** Turns the app's stored values (ISO strings) into the column types. */
27
+ prepare?: (data: Record<string, unknown>) => void;
28
+ remove: (id: string, userId: string) => Promise<unknown>;
29
+ upsert: (id: string, userId: string, data: TInsert) => Promise<unknown>;
30
+ }
31
+
32
+ /**
33
+ * Applies the app's changes to one table. A change the server refuses
34
+ * (invalid data, a row of another user, a broken reference) is skipped and
35
+ * returned, so the app can put the server's version back; the rest of the
36
+ * batch still goes through. Any other failure throws, and the app sends the
37
+ * batch again.
38
+ */
39
+ export async function applyChanges<TInsert>(
40
+ userId: string,
41
+ changes: SyncChange[],
42
+ sync: TableSync<TInsert>
43
+ ): Promise<RejectedChange[]> {
44
+ const rejected: RejectedChange[] = [];
45
+ const reject = (id: string, reason: string) => {
46
+ console.error(`[sync] SYNC_REJECTED ${sync.name} ${id}: ${reason}`);
47
+ rejected.push({ id, reason });
48
+ };
49
+
50
+ for (const change of changes) {
51
+ try {
52
+ if (change.operation === "DELETE") {
53
+ await sync.remove(change.id, userId);
54
+ continue;
55
+ }
56
+
57
+ const data = Object.fromEntries(
58
+ Object.entries(change.data ?? {}).filter(([key]) =>
59
+ sync.allowedKeys.includes(key)
60
+ )
61
+ );
62
+ if (Object.keys(data).length === 0) {
63
+ continue;
64
+ }
65
+ sync.prepare?.(data);
66
+
67
+ const parsed = sync.insertSchema.safeParse(data);
68
+ if (!parsed.success) {
69
+ reject(change.id, `INVALID_DATA: ${describeIssues(parsed.error)}`);
70
+ continue;
71
+ }
72
+ const owner = await sync.ownerOf(change.id);
73
+ if (owner && owner !== userId) {
74
+ reject(change.id, "NOT_OWNED");
75
+ continue;
76
+ }
77
+ await sync.upsert(change.id, userId, parsed.data);
78
+ } catch (error) {
79
+ // A broken reference or a value the column refuses is the change's own
80
+ // fault; anything else (the connection, a timeout) deserves a retry.
81
+ const code = postgresErrorCode(error);
82
+ if (code?.startsWith("23") || code?.startsWith("22")) {
83
+ reject(change.id, `CONSTRAINT: ${code}`);
84
+ continue;
85
+ }
86
+ throw error;
87
+ }
88
+ }
89
+ return rejected;
90
+ }
91
+
92
+ function describeIssues(error: z.ZodError) {
93
+ return error.issues
94
+ .map((issue) => `${issue.path.join(".")} ${issue.message}`)
95
+ .join("; ");
96
+ }
97
+
98
+ const SQLSTATE = /^[0-9A-Z]{5}$/;
99
+
100
+ /** Drizzle wraps the driver's error; the SQLSTATE is on its cause. */
101
+ function postgresErrorCode(error: unknown): string | undefined {
102
+ for (let current = error; current; ) {
103
+ if (
104
+ typeof current === "object" &&
105
+ "code" in current &&
106
+ typeof current.code === "string" &&
107
+ SQLSTATE.test(current.code)
108
+ ) {
109
+ return current.code;
110
+ }
111
+ current =
112
+ typeof current === "object" && "cause" in current
113
+ ? current.cause
114
+ : undefined;
115
+ }
116
+ return;
117
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ /** biome-ignore-all lint/performance/noBarrelFile: the package's entry point */
2
+ export {
3
+ applyChanges,
4
+ type RejectedChange,
5
+ type SyncChange,
6
+ syncChangeSchema,
7
+ type TableSync,
8
+ } from "./changes.js";
9
+ export {
10
+ type ChangesPage,
11
+ createSyncPull,
12
+ type PulledRow,
13
+ type PulledServerTable,
14
+ type SyncDatabase,
15
+ type SyncPullOptions,
16
+ type WholeTableReader,
17
+ } from "./pull.js";
18
+ export { createSyncPushMessage, type SyncPushMessage } from "./push.js";
19
+ export { syncTriggersSql } from "./triggers.js";