@missionmind/sync-api 0.0.0-stage → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MissionMind
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,113 @@
1
- # Temporary Holding Version
1
+ # @missionmind/sync-api
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The server side of an offline-first app whose devices keep the user's rows in
4
+ SQLite and sync them: it applies the changes a device uploads, and serves its
5
+ pulls, small tables whole and large ones by cursor. Drizzle on Postgres, with
6
+ any driver and any HTTP or RPC layer. The device side is
7
+ [`@missionmind/sync-front`](https://www.npmjs.com/package/@missionmind/sync-front).
8
+
9
+ ```sh
10
+ npm install @missionmind/sync-api drizzle-orm zod
11
+ ```
12
+
13
+ ## How it works
14
+
15
+ - **Uploads.** A device sends its queued changes in batches, each a whole row
16
+ to create or replace (`PUT`) or a deletion, keyed by the row's id (ids are
17
+ made on the device). `applyChanges` applies them one by one and returns
18
+ those it refuses for good (invalid data, a row of another user, a broken
19
+ reference): the device puts the server's version back, and the rest of the
20
+ batch still goes through. Any other failure throws, and the device sends the
21
+ batch again, so writes must be idempotent.
22
+ - **Pulls.** One request fetches every table: the small ones whole (a
23
+ profile, a short list), the large ones as a page of the rows changed since
24
+ the device's cursor, with the ids deleted since. Cursors are positions in
25
+ two streams per table, rows by (`updatedAt`, `id`) and tombstones by
26
+ (`deletedAt`, `id`), and stay a minute behind "now" so that a transaction
27
+ committing late is never skipped (its rows come again; the device applies
28
+ them idempotently). Tables with more pages are asked again alone.
29
+ - **Triggers** keep the pulled tables ready: every insert or update stamps
30
+ `updatedAt`, every deletion (cascades included) writes a tombstone in
31
+ `sync_tombstones`.
32
+ - **Sync pushes** tell a device that some tables changed on the server (from
33
+ another client), so it pulls them now.
34
+
35
+ ## Exports
36
+
37
+ | Entry | What |
38
+ | --- | --- |
39
+ | `@missionmind/sync-api` | `applyChanges`, `TableSync`, `syncChangeSchema`, `createSyncPull`, `syncTriggersSql`, `createSyncPushMessage` |
40
+ | `@missionmind/sync-api/schema` | the `sync_tombstones` table |
41
+
42
+ ## Tables
43
+
44
+ Every synced table has a string `id` primary key and a `userId`. Tables
45
+ pulled by cursor also need an `updatedAt` timestamp (with time zone) and an
46
+ index on (`userId`, `updatedAt`, `id`). Add `@missionmind/sync-api/schema` to
47
+ `drizzle-kit`'s `schema` beside the app's, then run the triggers' SQL in a
48
+ migration, after the tables exist (and again when a pulled table is added):
49
+
50
+ ```ts
51
+ import { syncTriggersSql } from "@missionmind/sync-api";
52
+
53
+ syncTriggersSql({
54
+ tables: ["notes", "note_items"], // the tables pulled by cursor
55
+ usersTable: "user", // a row deleted with its user leaves no tombstone
56
+ });
57
+ ```
58
+
59
+ ## Uploads
60
+
61
+ ```ts
62
+ import { applyChanges, syncChangeSchema, type TableSync } from "@missionmind/sync-api";
63
+
64
+ const notesSync: TableSync<z.infer<typeof noteSchema>> = {
65
+ name: "note",
66
+ allowedKeys: ["title", "body"], // other keys of the uploaded row are ignored
67
+ insertSchema: noteSchema,
68
+ ownerOf: async (id) => (await db.query.notes.findFirst({ where: eq(notes.id, id) }))?.userId,
69
+ upsert: (id, userId, data) =>
70
+ db.insert(notes).values({ id, userId, ...data }).onConflictDoUpdate({ target: notes.id, set: data }),
71
+ remove: (id, userId) => db.delete(notes).where(and(eq(notes.id, id), eq(notes.userId, userId))),
72
+ };
73
+
74
+ // The endpoint takes { changes: z.array(syncChangeSchema) } and returns { rejected }.
75
+ const rejected = await applyChanges(userId, changes, notesSync);
76
+ ```
77
+
78
+ Tables whose rows reference each other (a note and its items) are uploaded
79
+ in one request, parents first: apply them in that order.
80
+
81
+ ## Pulls
82
+
83
+ ```ts
84
+ import { createSyncPull } from "@missionmind/sync-api";
85
+
86
+ const syncPull = createSyncPull({
87
+ db,
88
+ pulled: { notes, note_items: noteItems }, // by the names the device uses
89
+ whole: { settings: (userId) => db.query.settings.findMany({ where: eq(settings.userId, userId) }) },
90
+ // pageSize (500) and safetyMarginSeconds (60) have defaults.
91
+ });
92
+
93
+ // Inputs: { changes: z.partialRecord(syncPull.pulledTableSchema, z.string().nullable()),
94
+ // whole: z.array(syncPull.wholeTableSchema) }
95
+ const { changes, whole } = await syncPull.pull(userId, input);
96
+ // After a refused upload, the device asks for the server's version of some rows:
97
+ const rows = await syncPull.getRows(userId, "notes", ids);
98
+ ```
99
+
100
+ Rows are returned without their `userId`.
101
+
102
+ ## Sync pushes
103
+
104
+ ```ts
105
+ import { createSyncPushMessage } from "@missionmind/sync-api";
106
+
107
+ // Send it with Expo's push API (https://exp.host/--/api/v2/push/send).
108
+ createSyncPushMessage({ to: expoPushToken, tables: ["notes"], type: "myapp.sync" });
109
+ ```
110
+
111
+ A data-only, high-priority push with `content-available`, kept an hour by the
112
+ push service. The device handles it with `defineSyncPushTask` of
113
+ `@missionmind/sync-front/push`, given the same `type`.
@@ -0,0 +1,38 @@
1
+ import z from "zod";
2
+ /**
3
+ * A change the app queued: a whole row to create or replace, or a deletion.
4
+ * The endpoints take them in batches and answer with the ones they refused.
5
+ */
6
+ export declare const syncChangeSchema: z.ZodObject<{
7
+ id: z.ZodString;
8
+ operation: z.ZodEnum<{
9
+ PUT: "PUT";
10
+ DELETE: "DELETE";
11
+ }>;
12
+ data: z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
13
+ }, z.z.core.$strip>;
14
+ export type SyncChange = z.infer<typeof syncChangeSchema>;
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
+ export interface TableSync<TInsert> {
21
+ allowedKeys: string[];
22
+ insertSchema: z.ZodType<TInsert>;
23
+ name: string;
24
+ ownerOf: (id: string) => Promise<string | undefined>;
25
+ /** Turns the app's stored values (ISO strings) into the column types. */
26
+ prepare?: (data: Record<string, unknown>) => void;
27
+ remove: (id: string, userId: string) => Promise<unknown>;
28
+ upsert: (id: string, userId: string, data: TInsert) => Promise<unknown>;
29
+ }
30
+ /**
31
+ * Applies the app's changes to one table. A change the server refuses
32
+ * (invalid data, a row of another user, a broken reference) is skipped and
33
+ * returned, so the app can put the server's version back; the rest of the
34
+ * batch still goes through. Any other failure throws, and the app sends the
35
+ * batch again.
36
+ */
37
+ export declare function applyChanges<TInsert>(userId: string, changes: SyncChange[], sync: TableSync<TInsert>): Promise<RejectedChange[]>;
38
+ //# sourceMappingURL=changes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"changes.d.ts","sourceRoot":"","sources":["../src/changes.ts"],"names":[],"mappings":"AAAA,OAAO,CAAC,MAAM,KAAK,CAAC;AAEpB;;;GAGG;AACH,eAAO,MAAM,gBAAgB;;;;;;;mBAI3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D,0EAA0E;AAC1E,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,SAAS,CAAC,OAAO;IAChC,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY,EAAE,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACrD,yEAAyE;IACzE,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAClD,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACzD,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACzE;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,OAAO,EACxC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,UAAU,EAAE,EACrB,IAAI,EAAE,SAAS,CAAC,OAAO,CAAC,GACvB,OAAO,CAAC,cAAc,EAAE,CAAC,CA+C3B"}
@@ -0,0 +1,82 @@
1
+ import z from "zod";
2
+ /**
3
+ * A change the app queued: a whole row to create or replace, or a deletion.
4
+ * The endpoints take them in batches and answer with the ones they refused.
5
+ */
6
+ export const syncChangeSchema = z.object({
7
+ id: z.string(),
8
+ operation: z.enum(["PUT", "DELETE"]),
9
+ data: z.record(z.string(), z.unknown()).nullable(),
10
+ });
11
+ /**
12
+ * Applies the app's changes to one table. A change the server refuses
13
+ * (invalid data, a row of another user, a broken reference) is skipped and
14
+ * returned, so the app can put the server's version back; the rest of the
15
+ * batch still goes through. Any other failure throws, and the app sends the
16
+ * batch again.
17
+ */
18
+ export async function applyChanges(userId, changes, sync) {
19
+ const rejected = [];
20
+ const reject = (id, reason) => {
21
+ console.error(`[sync] SYNC_REJECTED ${sync.name} ${id}: ${reason}`);
22
+ rejected.push({ id, reason });
23
+ };
24
+ for (const change of changes) {
25
+ try {
26
+ if (change.operation === "DELETE") {
27
+ await sync.remove(change.id, userId);
28
+ continue;
29
+ }
30
+ const data = Object.fromEntries(Object.entries(change.data ?? {}).filter(([key]) => sync.allowedKeys.includes(key)));
31
+ if (Object.keys(data).length === 0) {
32
+ continue;
33
+ }
34
+ sync.prepare?.(data);
35
+ const parsed = sync.insertSchema.safeParse(data);
36
+ if (!parsed.success) {
37
+ reject(change.id, `INVALID_DATA: ${describeIssues(parsed.error)}`);
38
+ continue;
39
+ }
40
+ const owner = await sync.ownerOf(change.id);
41
+ if (owner && owner !== userId) {
42
+ reject(change.id, "NOT_OWNED");
43
+ continue;
44
+ }
45
+ await sync.upsert(change.id, userId, parsed.data);
46
+ }
47
+ catch (error) {
48
+ // A broken reference or a value the column refuses is the change's own
49
+ // fault; anything else (the connection, a timeout) deserves a retry.
50
+ const code = postgresErrorCode(error);
51
+ if (code?.startsWith("23") || code?.startsWith("22")) {
52
+ reject(change.id, `CONSTRAINT: ${code}`);
53
+ continue;
54
+ }
55
+ throw error;
56
+ }
57
+ }
58
+ return rejected;
59
+ }
60
+ function describeIssues(error) {
61
+ return error.issues
62
+ .map((issue) => `${issue.path.join(".")} ${issue.message}`)
63
+ .join("; ");
64
+ }
65
+ const SQLSTATE = /^[0-9A-Z]{5}$/;
66
+ /** Drizzle wraps the driver's error; the SQLSTATE is on its cause. */
67
+ function postgresErrorCode(error) {
68
+ for (let current = error; current;) {
69
+ if (typeof current === "object" &&
70
+ "code" in current &&
71
+ typeof current.code === "string" &&
72
+ SQLSTATE.test(current.code)) {
73
+ return current.code;
74
+ }
75
+ current =
76
+ typeof current === "object" && "cause" in current
77
+ ? current.cause
78
+ : undefined;
79
+ }
80
+ return;
81
+ }
82
+ //# sourceMappingURL=changes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"changes.js","sourceRoot":"","sources":["../src/changes.ts"],"names":[],"mappings":"AAAA,OAAO,CAAC,MAAM,KAAK,CAAC;AAEpB;;;GAGG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC;IACvC,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;IACd,SAAS,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;IACpC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,QAAQ,EAAE;CACnD,CAAC,CAAC;AAqBH;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,MAAc,EACd,OAAqB,EACrB,IAAwB;IAExB,MAAM,QAAQ,GAAqB,EAAE,CAAC;IACtC,MAAM,MAAM,GAAG,CAAC,EAAU,EAAE,MAAc,EAAE,EAAE;QAC5C,OAAO,CAAC,KAAK,CAAC,wBAAwB,IAAI,CAAC,IAAI,IAAI,EAAE,KAAK,MAAM,EAAE,CAAC,CAAC;QACpE,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;IAChC,CAAC,CAAC;IAEF,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,CAAC;YACH,IAAI,MAAM,CAAC,SAAS,KAAK,QAAQ,EAAE,CAAC;gBAClC,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;gBACrC,SAAS;YACX,CAAC;YAED,MAAM,IAAI,GAAG,MAAM,CAAC,WAAW,CAC7B,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,CACjD,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,GAAG,CAAC,CAC/B,CACF,CAAC;YACF,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBACnC,SAAS;YACX,CAAC;YACD,IAAI,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC;YAErB,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;YACjD,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;gBACpB,MAAM,CAAC,MAAM,CAAC,EAAE,EAAE,iBAAiB,cAAc,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;gBACnE,SAAS;YACX,CAAC;YACD,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;YAC5C,IAAI,KAAK,IAAI,KAAK,KAAK,MAAM,EAAE,CAAC;gBAC9B,MAAM,CAAC,MAAM,CAAC,EAAE,EAAE,WAAW,CAAC,CAAC;gBAC/B,SAAS;YACX,CAAC;YACD,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC;QACpD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,uEAAuE;YACvE,qEAAqE;YACrE,MAAM,IAAI,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC;YACtC,IAAI,IAAI,EAAE,UAAU,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;gBACrD,MAAM,CAAC,MAAM,CAAC,EAAE,EAAE,eAAe,IAAI,EAAE,CAAC,CAAC;gBACzC,SAAS;YACX,CAAC;YACD,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,SAAS,cAAc,CAAC,KAAiB;IACvC,OAAO,KAAK,CAAC,MAAM;SAChB,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;SAC1D,IAAI,CAAC,IAAI,CAAC,CAAC;AAChB,CAAC;AAED,MAAM,QAAQ,GAAG,eAAe,CAAC;AAEjC,sEAAsE;AACtE,SAAS,iBAAiB,CAAC,KAAc;IACvC,KAAK,IAAI,OAAO,GAAG,KAAK,EAAE,OAAO,GAAI,CAAC;QACpC,IACE,OAAO,OAAO,KAAK,QAAQ;YAC3B,MAAM,IAAI,OAAO;YACjB,OAAO,OAAO,CAAC,IAAI,KAAK,QAAQ;YAChC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAC3B,CAAC;YACD,OAAO,OAAO,CAAC,IAAI,CAAC;QACtB,CAAC;QACD,OAAO;YACL,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,IAAI,OAAO;gBAC/C,CAAC,CAAC,OAAO,CAAC,KAAK;gBACf,CAAC,CAAC,SAAS,CAAC;IAClB,CAAC;IACD,OAAO;AACT,CAAC"}
@@ -0,0 +1,6 @@
1
+ /** biome-ignore-all lint/performance/noBarrelFile: the package's entry point */
2
+ export { applyChanges, type RejectedChange, type SyncChange, syncChangeSchema, type TableSync, } from "./changes.js";
3
+ export { type ChangesPage, createSyncPull, type PulledRow, type PulledServerTable, type SyncDatabase, type SyncPullOptions, type WholeTableReader, } from "./pull.js";
4
+ export { createSyncPushMessage, type SyncPushMessage } from "./push.js";
5
+ export { syncTriggersSql } from "./triggers.js";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,OAAO,EACL,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,gBAAgB,EAChB,KAAK,SAAS,GACf,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,KAAK,WAAW,EAChB,cAAc,EACd,KAAK,SAAS,EACd,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,qBAAqB,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AACxE,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ /** biome-ignore-all lint/performance/noBarrelFile: the package's entry point */
2
+ export { applyChanges, syncChangeSchema, } from "./changes.js";
3
+ export { createSyncPull, } from "./pull.js";
4
+ export { createSyncPushMessage } from "./push.js";
5
+ export { syncTriggersSql } from "./triggers.js";
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,OAAO,EACL,YAAY,EAGZ,gBAAgB,GAEjB,MAAM,cAAc,CAAC;AACtB,OAAO,EAEL,cAAc,GAMf,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,qBAAqB,EAAwB,MAAM,WAAW,CAAC;AACxE,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC"}
package/dist/pull.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ import type { PgColumn, PgDatabase, PgTable } from "drizzle-orm/pg-core";
2
+ import z from "zod";
3
+ /** Drizzle on Postgres, whatever the driver. */
4
+ export type SyncDatabase = PgDatabase<any, any, any>;
5
+ /**
6
+ * A table the app pulls by cursor: the user's rows, with a string `id`, a
7
+ * `userId` and an `updatedAt` kept current by the sync triggers
8
+ * (`syncTriggersSql`). Give it a (userId, updatedAt, id) index.
9
+ */
10
+ export type PulledServerTable = PgTable & {
11
+ id: PgColumn;
12
+ updatedAt: PgColumn;
13
+ userId: PgColumn;
14
+ };
15
+ /** A row as a pull sends it: without its `userId`. */
16
+ export type PulledRow<TTable extends PulledServerTable> = Omit<TTable["$inferSelect"], "userId">;
17
+ /** A page of the changes made to a table since a cursor. */
18
+ export interface ChangesPage<TRow> {
19
+ /** Where the next pull of the table starts. */
20
+ cursor: string;
21
+ deletedIds: string[];
22
+ /** Whether another page is ready: ask again with `cursor`. */
23
+ hasMore: boolean;
24
+ rows: TRow[];
25
+ }
26
+ /** The user's rows of a small table, sent whole on every pull. */
27
+ export type WholeTableReader = (userId: string) => Promise<unknown[]>;
28
+ export interface SyncPullOptions<TPulled extends Record<string, PulledServerTable>, TWhole extends Record<string, WholeTableReader>> {
29
+ db: SyncDatabase;
30
+ /** Rows per table and page. Defaults to 500. */
31
+ pageSize?: number;
32
+ /** Tables pulled by cursor, under the names the app asks for them by. */
33
+ pulled: TPulled;
34
+ /**
35
+ * How far behind "now" a cursor stays, in seconds. Defaults to 60: longer
36
+ * than any transaction that writes the pulled tables.
37
+ */
38
+ safetyMarginSeconds?: number;
39
+ /** Small tables the app fetches whole on every pull, with their readers. */
40
+ whole: TWhole;
41
+ }
42
+ /**
43
+ * What the app's sync fetches: the small tables whole, and the others a page
44
+ * at a time from a cursor, with the ids deleted since (the tombstones the
45
+ * sync triggers write).
46
+ */
47
+ export declare function createSyncPull<TPulled extends Record<string, PulledServerTable>, TWhole extends Record<string, WholeTableReader>>(options: SyncPullOptions<TPulled, TWhole>): {
48
+ getRows: <TName extends Extract<keyof TPulled, string>>(userId: string, tableName: TName, ids: string[]) => Promise<PulledRow<TPulled[TName]>[]>;
49
+ pull: (userId: string, request: {
50
+ changes: Partial<Record<Extract<keyof TPulled, string>, string | null>>;
51
+ whole: Extract<keyof TWhole, string>[];
52
+ }) => Promise<{
53
+ changes: { [TName in Extract<keyof TPulled, string>]?: ChangesPage<PulledRow<TPulled[TName]>> | undefined; };
54
+ whole: { [TName_1 in Extract<keyof TWhole, string>]?: Awaited<ReturnType<TWhole[TName_1]>> | undefined; };
55
+ }>;
56
+ pulledTableSchema: z.ZodEnum<{ [k_1 in Extract<keyof TPulled, string>]: k_1; } extends infer T ? { [k in keyof T]: T[k]; } : never>;
57
+ wholeTableSchema: z.ZodEnum<{ [k_3 in Extract<keyof TWhole, string>]: k_3; } extends infer T_1 ? { [k_2 in keyof T_1]: T_1[k_2]; } : never>;
58
+ };
59
+ //# sourceMappingURL=pull.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pull.d.ts","sourceRoot":"","sources":["../src/pull.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AACzE,OAAO,CAAC,MAAM,KAAK,CAAC;AAGpB,gDAAgD;AAEhD,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC;AAErD;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,GAAG,OAAO,GAAG;IACxC,EAAE,EAAE,QAAQ,CAAC;IACb,SAAS,EAAE,QAAQ,CAAC;IACpB,MAAM,EAAE,QAAQ,CAAC;CAClB,CAAC;AAEF,sDAAsD;AACtD,MAAM,MAAM,SAAS,CAAC,MAAM,SAAS,iBAAiB,IAAI,IAAI,CAC5D,MAAM,CAAC,cAAc,CAAC,EACtB,QAAQ,CACT,CAAC;AAEF,4DAA4D;AAC5D,MAAM,WAAW,WAAW,CAAC,IAAI;IAC/B,+CAA+C;IAC/C,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,8DAA8D;IAC9D,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,IAAI,EAAE,CAAC;CACd;AAED,kEAAkE;AAClE,MAAM,MAAM,gBAAgB,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;AAEtE,MAAM,WAAW,eAAe,CAC9B,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,EACjD,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAE/C,EAAE,EAAE,YAAY,CAAC;IACjB,gDAAgD;IAChD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yEAAyE;IACzE,MAAM,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAC;CACf;AAyCD;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,EACjD,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,EAC/C,OAAO,EAAE,eAAe,CAAC,OAAO,EAAE,MAAM,CAAC;cAyBlB,KAAK,iDAClB,MAAM,aACH,KAAK,OACX,MAAM,EAAE,KACZ,OAAO,CAAC,SAAS,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;mBA6G7B,MAAM,WACL;QACP,OAAO,EAAE,OAAO,CAAC,MAAM,iCAAa,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC;QACpD,KAAK,EAAE,+BAAW,CAAC;KACpB;oBAUE,KAAK;;;;;EAgCX"}
package/dist/pull.js ADDED
@@ -0,0 +1,149 @@
1
+ import { and, asc, eq, getTableColumns, inArray, sql, } from "drizzle-orm";
2
+ import z from "zod";
3
+ import { syncTombstones } from "./schema.js";
4
+ /**
5
+ * Position in the two streams of a table: rows ordered by (updatedAt, id),
6
+ * tombstones by (deletedAt, id). Timestamps are kept as text with their
7
+ * microseconds, which JavaScript dates would drop and stall the cursor.
8
+ */
9
+ const cursorSchema = z.object({
10
+ rows: z.tuple([z.string(), z.string()]).nullable(),
11
+ tombstones: z.tuple([z.string(), z.number()]).nullable(),
12
+ });
13
+ function decodeCursor(cursor) {
14
+ if (!cursor) {
15
+ return { rows: null, tombstones: null };
16
+ }
17
+ const parsed = cursorSchema.safeParse(JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")));
18
+ if (!parsed.success) {
19
+ throw new Error("SYNC_ERROR.INVALID_CURSOR");
20
+ }
21
+ return parsed.data;
22
+ }
23
+ function encodeCursor(cursor) {
24
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
25
+ }
26
+ /** Fixed-width UTC text: compares like the timestamp it encodes. */
27
+ const cursorTime = (value) => sql `to_char((${value}) at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
28
+ /** The rows of a raw query, whether the driver returns them alone or in `rows`. */
29
+ function rowsOf(result) {
30
+ return (Array.isArray(result) ? result : result.rows);
31
+ }
32
+ /**
33
+ * What the app's sync fetches: the small tables whole, and the others a page
34
+ * at a time from a cursor, with the ids deleted since (the tombstones the
35
+ * sync triggers write).
36
+ */
37
+ export function createSyncPull(options) {
38
+ const { db, pulled } = options;
39
+ const pageSize = options.pageSize ?? 500;
40
+ /**
41
+ * A row is stamped with its transaction's start time but only visible once
42
+ * committed, so a transaction still running when a page is read may commit
43
+ * rows older than that page's newest one. Staying behind re-sends the last
44
+ * minute of changes on every pull (the app applies them idempotently)
45
+ * instead of missing those rows.
46
+ */
47
+ const safetyMargin = sql.raw(`interval '${Math.floor(options.safetyMarginSeconds ?? 60)} seconds'`);
48
+ const pulledTableSchema = z.enum(Object.keys(pulled));
49
+ const wholeTableSchema = z.enum(Object.keys(options.whole));
50
+ /** The user's rows among `ids`, as `pull` returns them. */
51
+ async function getRows(userId, tableName, ids) {
52
+ if (ids.length === 0) {
53
+ return [];
54
+ }
55
+ const table = pulled[tableName];
56
+ const rows = (await db
57
+ .select()
58
+ .from(table)
59
+ .where(and(eq(table.userId, userId), inArray(table.id, ids))));
60
+ return rows.map(({ userId: _user, ...row }) => row);
61
+ }
62
+ /** One page of the changes to `tableName` since `cursor`. */
63
+ async function pullChanges(userId, tableName, cursor, safeUntil) {
64
+ const table = pulled[tableName];
65
+ const position = decodeCursor(cursor);
66
+ const rows = (await db
67
+ .select({
68
+ ...getTableColumns(table),
69
+ cursorAt: cursorTime(table.updatedAt),
70
+ isRecent: sql `${table.updatedAt} > ${safeUntil}::timestamptz`,
71
+ })
72
+ .from(table)
73
+ .where(and(eq(table.userId, userId), position.rows
74
+ ? sql `(${table.updatedAt}, ${table.id}) > (${position.rows[0]}::timestamptz, ${position.rows[1]})`
75
+ : undefined))
76
+ .orderBy(asc(table.updatedAt), asc(table.id))
77
+ .limit(pageSize));
78
+ const tombstones = await db
79
+ .select({
80
+ id: syncTombstones.id,
81
+ rowId: syncTombstones.rowId,
82
+ cursorAt: cursorTime(syncTombstones.deletedAt),
83
+ isRecent: sql `${syncTombstones.deletedAt} > ${safeUntil}::timestamptz`,
84
+ })
85
+ .from(syncTombstones)
86
+ .where(and(eq(syncTombstones.userId, userId), eq(syncTombstones.tableName, tableName), position.tombstones
87
+ ? sql `(${syncTombstones.deletedAt}, ${syncTombstones.id}) > (${position.tombstones[0]}::timestamptz, ${position.tombstones[1]})`
88
+ : undefined))
89
+ .orderBy(asc(syncTombstones.deletedAt), asc(syncTombstones.id))
90
+ .limit(pageSize);
91
+ const rowsStream = advance(rows.map((row) => ({
92
+ position: [row.cursorAt, row.id],
93
+ isRecent: row.isRecent,
94
+ })), position.rows, [safeUntil, ""], pageSize);
95
+ const tombstonesStream = advance(tombstones.map((row) => ({
96
+ position: [row.cursorAt, row.id],
97
+ isRecent: row.isRecent,
98
+ })), position.tombstones, [safeUntil, 0], pageSize);
99
+ return {
100
+ rows: rows.map(({ cursorAt: _at, isRecent: _recent, userId: _user, ...row }) => row),
101
+ deletedIds: tombstones.map((tombstone) => tombstone.rowId),
102
+ cursor: encodeCursor({
103
+ rows: rowsStream.position,
104
+ tombstones: tombstonesStream.position,
105
+ }),
106
+ hasMore: rowsStream.hasMore || tombstonesStream.hasMore,
107
+ };
108
+ }
109
+ /**
110
+ * Everything a sync fetches, in one request: the `whole` tables in full,
111
+ * and a page of the changes to each table of `changes` since its cursor (a
112
+ * null cursor starts from the beginning). The app calls again, for the
113
+ * tables whose page `hasMore`, with the returned cursors.
114
+ */
115
+ async function pull(userId, request) {
116
+ // One reference time for every table of the request.
117
+ const [{ safeUntil }] = rowsOf(await db.execute(sql `select ${cursorTime(sql `now() - ${safetyMargin}`)} as "safeUntil"`));
118
+ const changes = {};
119
+ for (const [tableName, cursor] of Object.entries(request.changes)) {
120
+ changes[tableName] = await pullChanges(userId, tableName, cursor, safeUntil);
121
+ }
122
+ const requested = new Set(request.whole);
123
+ const whole = {};
124
+ for (const [tableName, read] of Object.entries(options.whole)) {
125
+ if (requested.has(tableName)) {
126
+ whole[tableName] = (await read(userId));
127
+ }
128
+ }
129
+ return { changes, whole };
130
+ }
131
+ return { getRows, pull, pulledTableSchema, wholeTableSchema };
132
+ }
133
+ /**
134
+ * Where a stream resumes: after its last item, unless that item is inside the
135
+ * safety margin, in which case at the margin (so the recent items come again).
136
+ */
137
+ function advance(items, previous, margin, pageSize) {
138
+ const last = items.at(-1);
139
+ if (!last) {
140
+ return { position: previous, hasMore: false };
141
+ }
142
+ if (last.isRecent) {
143
+ // Never move backwards past where a previous pull already got to.
144
+ const isAheadOfPrevious = !previous || margin[0] > previous[0];
145
+ return { position: isAheadOfPrevious ? margin : previous, hasMore: false };
146
+ }
147
+ return { position: last.position, hasMore: items.length === pageSize };
148
+ }
149
+ //# sourceMappingURL=pull.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pull.js","sourceRoot":"","sources":["../src/pull.ts"],"names":[],"mappings":"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"}
package/dist/push.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A sync push: a headless background notification (data only, no title or
3
+ * body, `content-available` on iOS) telling the user's device that these
4
+ * tables changed on the server, so it pulls them now rather than at its next
5
+ * sync. `@missionmind/sync-front/push` handles it on the device, with the same
6
+ * `type`.
7
+ */
8
+ export interface SyncPushMessage {
9
+ _contentAvailable: true;
10
+ data: {
11
+ tables: string[];
12
+ type: string;
13
+ };
14
+ priority: "high";
15
+ to: string;
16
+ ttl: number;
17
+ }
18
+ /** The Expo push message of a sync push, for Expo's push API. */
19
+ export declare function createSyncPushMessage(options: {
20
+ tables: string[];
21
+ /** The Expo push token of the device. */
22
+ to: string;
23
+ /** Tells a sync push from the app's other notifications, e.g. "myapp.sync". */
24
+ type: string;
25
+ /**
26
+ * Seconds the push service keeps trying to deliver it. Defaults to an hour:
27
+ * a sync asked for long ago is no longer worth waking the app for.
28
+ */
29
+ ttl?: number;
30
+ }): SyncPushMessage;
31
+ //# sourceMappingURL=push.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push.d.ts","sourceRoot":"","sources":["../src/push.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,iBAAiB,EAAE,IAAI,CAAC;IACxB,IAAI,EAAE;QAAE,MAAM,EAAE,MAAM,EAAE,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;CACb;AAED,iEAAiE;AACjE,wBAAgB,qBAAqB,CAAC,OAAO,EAAE;IAC7C,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,yCAAyC;IACzC,EAAE,EAAE,MAAM,CAAC;IACX,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,GAAG,eAAe,CAQlB"}
package/dist/push.js ADDED
@@ -0,0 +1,11 @@
1
+ /** The Expo push message of a sync push, for Expo's push API. */
2
+ export function createSyncPushMessage(options) {
3
+ return {
4
+ to: options.to,
5
+ data: { type: options.type, tables: options.tables },
6
+ _contentAvailable: true,
7
+ priority: "high",
8
+ ttl: options.ttl ?? 60 * 60,
9
+ };
10
+ }
11
+ //# sourceMappingURL=push.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push.js","sourceRoot":"","sources":["../src/push.ts"],"names":[],"mappings":"AAeA,iEAAiE;AACjE,MAAM,UAAU,qBAAqB,CAAC,OAWrC;IACC,OAAO;QACL,EAAE,EAAE,OAAO,CAAC,EAAE;QACd,IAAI,EAAE,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE;QACpD,iBAAiB,EAAE,IAAI;QACvB,QAAQ,EAAE,MAAM;QAChB,GAAG,EAAE,OAAO,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE;KAC5B,CAAC;AACJ,CAAC"}