@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/src/pull.ts ADDED
@@ -0,0 +1,319 @@
1
+ import {
2
+ and,
3
+ asc,
4
+ eq,
5
+ getTableColumns,
6
+ inArray,
7
+ type SQL,
8
+ sql,
9
+ } from "drizzle-orm";
10
+ import type { PgColumn, PgDatabase, PgTable } from "drizzle-orm/pg-core";
11
+ import z from "zod";
12
+ import { syncTombstones } from "./schema.js";
13
+
14
+ /** Drizzle on Postgres, whatever the driver. */
15
+ // biome-ignore lint/suspicious/noExplicitAny: any driver and schema
16
+ export type SyncDatabase = PgDatabase<any, any, any>;
17
+
18
+ /**
19
+ * A table the app pulls by cursor: the user's rows, with a string `id`, a
20
+ * `userId` and an `updatedAt` kept current by the sync triggers
21
+ * (`syncTriggersSql`). Give it a (userId, updatedAt, id) index.
22
+ */
23
+ export type PulledServerTable = PgTable & {
24
+ id: PgColumn;
25
+ updatedAt: PgColumn;
26
+ userId: PgColumn;
27
+ };
28
+
29
+ /** A row as a pull sends it: without its `userId`. */
30
+ export type PulledRow<TTable extends PulledServerTable> = Omit<
31
+ TTable["$inferSelect"],
32
+ "userId"
33
+ >;
34
+
35
+ /** A page of the changes made to a table since a cursor. */
36
+ export interface ChangesPage<TRow> {
37
+ /** Where the next pull of the table starts. */
38
+ cursor: string;
39
+ deletedIds: string[];
40
+ /** Whether another page is ready: ask again with `cursor`. */
41
+ hasMore: boolean;
42
+ rows: TRow[];
43
+ }
44
+
45
+ /** The user's rows of a small table, sent whole on every pull. */
46
+ export type WholeTableReader = (userId: string) => Promise<unknown[]>;
47
+
48
+ export interface SyncPullOptions<
49
+ TPulled extends Record<string, PulledServerTable>,
50
+ TWhole extends Record<string, WholeTableReader>,
51
+ > {
52
+ db: SyncDatabase;
53
+ /** Rows per table and page. Defaults to 500. */
54
+ pageSize?: number;
55
+ /** Tables pulled by cursor, under the names the app asks for them by. */
56
+ pulled: TPulled;
57
+ /**
58
+ * How far behind "now" a cursor stays, in seconds. Defaults to 60: longer
59
+ * than any transaction that writes the pulled tables.
60
+ */
61
+ safetyMarginSeconds?: number;
62
+ /** Small tables the app fetches whole on every pull, with their readers. */
63
+ whole: TWhole;
64
+ }
65
+
66
+ /**
67
+ * Position in the two streams of a table: rows ordered by (updatedAt, id),
68
+ * tombstones by (deletedAt, id). Timestamps are kept as text with their
69
+ * microseconds, which JavaScript dates would drop and stall the cursor.
70
+ */
71
+ const cursorSchema = z.object({
72
+ rows: z.tuple([z.string(), z.string()]).nullable(),
73
+ tombstones: z.tuple([z.string(), z.number()]).nullable(),
74
+ });
75
+ type Cursor = z.infer<typeof cursorSchema>;
76
+
77
+ function decodeCursor(cursor: string | null): Cursor {
78
+ if (!cursor) {
79
+ return { rows: null, tombstones: null };
80
+ }
81
+ const parsed = cursorSchema.safeParse(
82
+ JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"))
83
+ );
84
+ if (!parsed.success) {
85
+ throw new Error("SYNC_ERROR.INVALID_CURSOR");
86
+ }
87
+ return parsed.data;
88
+ }
89
+
90
+ function encodeCursor(cursor: Cursor) {
91
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
92
+ }
93
+
94
+ /** Fixed-width UTC text: compares like the timestamp it encodes. */
95
+ const cursorTime = (value: SQL | SQL.Aliased | unknown) =>
96
+ sql<string>`to_char((${value}) at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
97
+
98
+ /** The rows of a raw query, whether the driver returns them alone or in `rows`. */
99
+ function rowsOf<T>(result: unknown): T[] {
100
+ return (
101
+ Array.isArray(result) ? result : (result as { rows: T[] }).rows
102
+ ) as T[];
103
+ }
104
+
105
+ /**
106
+ * What the app's sync fetches: the small tables whole, and the others a page
107
+ * at a time from a cursor, with the ids deleted since (the tombstones the
108
+ * sync triggers write).
109
+ */
110
+ export function createSyncPull<
111
+ TPulled extends Record<string, PulledServerTable>,
112
+ TWhole extends Record<string, WholeTableReader>,
113
+ >(options: SyncPullOptions<TPulled, TWhole>) {
114
+ type PulledName = Extract<keyof TPulled, string>;
115
+ type WholeName = Extract<keyof TWhole, string>;
116
+
117
+ const { db, pulled } = options;
118
+ const pageSize = options.pageSize ?? 500;
119
+ /**
120
+ * A row is stamped with its transaction's start time but only visible once
121
+ * committed, so a transaction still running when a page is read may commit
122
+ * rows older than that page's newest one. Staying behind re-sends the last
123
+ * minute of changes on every pull (the app applies them idempotently)
124
+ * instead of missing those rows.
125
+ */
126
+ const safetyMargin = sql.raw(
127
+ `interval '${Math.floor(options.safetyMarginSeconds ?? 60)} seconds'`
128
+ );
129
+
130
+ const pulledTableSchema = z.enum(
131
+ Object.keys(pulled) as [PulledName, ...PulledName[]]
132
+ );
133
+ const wholeTableSchema = z.enum(
134
+ Object.keys(options.whole) as [WholeName, ...WholeName[]]
135
+ );
136
+
137
+ /** The user's rows among `ids`, as `pull` returns them. */
138
+ async function getRows<TName extends PulledName>(
139
+ userId: string,
140
+ tableName: TName,
141
+ ids: string[]
142
+ ): Promise<PulledRow<TPulled[TName]>[]> {
143
+ if (ids.length === 0) {
144
+ return [];
145
+ }
146
+ const table: PulledServerTable = pulled[tableName];
147
+ const rows = (await db
148
+ .select()
149
+ .from(table)
150
+ .where(and(eq(table.userId, userId), inArray(table.id, ids)))) as Record<
151
+ string,
152
+ unknown
153
+ >[];
154
+ return rows.map(
155
+ ({ userId: _user, ...row }) => row as PulledRow<TPulled[TName]>
156
+ );
157
+ }
158
+
159
+ /** One page of the changes to `tableName` since `cursor`. */
160
+ async function pullChanges<TName extends PulledName>(
161
+ userId: string,
162
+ tableName: TName,
163
+ cursor: string | null,
164
+ safeUntil: string
165
+ ): Promise<ChangesPage<PulledRow<TPulled[TName]>>> {
166
+ const table: PulledServerTable = pulled[tableName];
167
+ const position = decodeCursor(cursor);
168
+
169
+ const rows = (await db
170
+ .select({
171
+ ...getTableColumns(table),
172
+ cursorAt: cursorTime(table.updatedAt),
173
+ isRecent: sql<boolean>`${table.updatedAt} > ${safeUntil}::timestamptz`,
174
+ })
175
+ .from(table)
176
+ .where(
177
+ and(
178
+ eq(table.userId, userId),
179
+ position.rows
180
+ ? sql`(${table.updatedAt}, ${table.id}) > (${position.rows[0]}::timestamptz, ${position.rows[1]})`
181
+ : undefined
182
+ )
183
+ )
184
+ .orderBy(asc(table.updatedAt), asc(table.id))
185
+ .limit(pageSize)) as (Record<string, unknown> & {
186
+ cursorAt: string;
187
+ id: string;
188
+ isRecent: boolean;
189
+ })[];
190
+
191
+ const tombstones = await db
192
+ .select({
193
+ id: syncTombstones.id,
194
+ rowId: syncTombstones.rowId,
195
+ cursorAt: cursorTime(syncTombstones.deletedAt),
196
+ isRecent: sql<boolean>`${syncTombstones.deletedAt} > ${safeUntil}::timestamptz`,
197
+ })
198
+ .from(syncTombstones)
199
+ .where(
200
+ and(
201
+ eq(syncTombstones.userId, userId),
202
+ eq(syncTombstones.tableName, tableName),
203
+ position.tombstones
204
+ ? sql`(${syncTombstones.deletedAt}, ${syncTombstones.id}) > (${position.tombstones[0]}::timestamptz, ${position.tombstones[1]})`
205
+ : undefined
206
+ )
207
+ )
208
+ .orderBy(asc(syncTombstones.deletedAt), asc(syncTombstones.id))
209
+ .limit(pageSize);
210
+
211
+ const rowsStream = advance(
212
+ rows.map((row) => ({
213
+ position: [row.cursorAt, row.id] as [string, string],
214
+ isRecent: row.isRecent,
215
+ })),
216
+ position.rows,
217
+ [safeUntil, ""],
218
+ pageSize
219
+ );
220
+ const tombstonesStream = advance(
221
+ tombstones.map((row) => ({
222
+ position: [row.cursorAt, row.id] as [string, number],
223
+ isRecent: row.isRecent,
224
+ })),
225
+ position.tombstones,
226
+ [safeUntil, 0],
227
+ pageSize
228
+ );
229
+
230
+ return {
231
+ rows: rows.map(
232
+ ({ cursorAt: _at, isRecent: _recent, userId: _user, ...row }) =>
233
+ row as PulledRow<TPulled[TName]>
234
+ ),
235
+ deletedIds: tombstones.map((tombstone) => tombstone.rowId),
236
+ cursor: encodeCursor({
237
+ rows: rowsStream.position,
238
+ tombstones: tombstonesStream.position,
239
+ }),
240
+ hasMore: rowsStream.hasMore || tombstonesStream.hasMore,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Everything a sync fetches, in one request: the `whole` tables in full,
246
+ * and a page of the changes to each table of `changes` since its cursor (a
247
+ * null cursor starts from the beginning). The app calls again, for the
248
+ * tables whose page `hasMore`, with the returned cursors.
249
+ */
250
+ async function pull(
251
+ userId: string,
252
+ request: {
253
+ changes: Partial<Record<PulledName, string | null>>;
254
+ whole: WholeName[];
255
+ }
256
+ ) {
257
+ // One reference time for every table of the request.
258
+ const [{ safeUntil }] = rowsOf<{ safeUntil: string }>(
259
+ await db.execute(
260
+ sql`select ${cursorTime(sql`now() - ${safetyMargin}`)} as "safeUntil"`
261
+ )
262
+ );
263
+
264
+ const changes: {
265
+ [TName in PulledName]?: ChangesPage<PulledRow<TPulled[TName]>>;
266
+ } = {};
267
+ for (const [tableName, cursor] of Object.entries(request.changes) as [
268
+ PulledName,
269
+ string | null,
270
+ ][]) {
271
+ changes[tableName] = await pullChanges(
272
+ userId,
273
+ tableName,
274
+ cursor,
275
+ safeUntil
276
+ );
277
+ }
278
+
279
+ const requested = new Set<string>(request.whole);
280
+ const whole: {
281
+ [TName in WholeName]?: Awaited<ReturnType<TWhole[TName]>>;
282
+ } = {};
283
+ for (const [tableName, read] of Object.entries(options.whole) as [
284
+ WholeName,
285
+ WholeTableReader,
286
+ ][]) {
287
+ if (requested.has(tableName)) {
288
+ whole[tableName] = (await read(userId)) as Awaited<
289
+ ReturnType<TWhole[typeof tableName]>
290
+ >;
291
+ }
292
+ }
293
+ return { changes, whole };
294
+ }
295
+
296
+ return { getRows, pull, pulledTableSchema, wholeTableSchema };
297
+ }
298
+
299
+ /**
300
+ * Where a stream resumes: after its last item, unless that item is inside the
301
+ * safety margin, in which case at the margin (so the recent items come again).
302
+ */
303
+ function advance<T extends [string, string | number]>(
304
+ items: { position: T; isRecent: boolean }[],
305
+ previous: T | null,
306
+ margin: T,
307
+ pageSize: number
308
+ ): { position: T | null; hasMore: boolean } {
309
+ const last = items.at(-1);
310
+ if (!last) {
311
+ return { position: previous, hasMore: false };
312
+ }
313
+ if (last.isRecent) {
314
+ // Never move backwards past where a previous pull already got to.
315
+ const isAheadOfPrevious = !previous || margin[0] > previous[0];
316
+ return { position: isAheadOfPrevious ? margin : previous, hasMore: false };
317
+ }
318
+ return { position: last.position, hasMore: items.length === pageSize };
319
+ }
package/src/push.ts ADDED
@@ -0,0 +1,36 @@
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: { tables: string[]; type: string };
11
+ priority: "high";
12
+ to: string;
13
+ ttl: number;
14
+ }
15
+
16
+ /** The Expo push message of a sync push, for Expo's push API. */
17
+ export function createSyncPushMessage(options: {
18
+ tables: string[];
19
+ /** The Expo push token of the device. */
20
+ to: string;
21
+ /** Tells a sync push from the app's other notifications, e.g. "myapp.sync". */
22
+ type: string;
23
+ /**
24
+ * Seconds the push service keeps trying to deliver it. Defaults to an hour:
25
+ * a sync asked for long ago is no longer worth waking the app for.
26
+ */
27
+ ttl?: number;
28
+ }): SyncPushMessage {
29
+ return {
30
+ to: options.to,
31
+ data: { type: options.type, tables: options.tables },
32
+ _contentAvailable: true,
33
+ priority: "high",
34
+ ttl: options.ttl ?? 60 * 60,
35
+ };
36
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { sql } from "drizzle-orm";
2
+ import {
3
+ bigserial,
4
+ index,
5
+ pgTable,
6
+ timestamp,
7
+ varchar,
8
+ } from "drizzle-orm/pg-core";
9
+
10
+ /**
11
+ * Rows deleted from the tables the app pulls by cursor, written by a trigger
12
+ * (`syncTriggersSql`) so that cascades are recorded too. No foreign key to
13
+ * the users table, which is the app's: the trigger writes none for a row
14
+ * deleted with its account.
15
+ */
16
+ export const syncTombstones = pgTable(
17
+ "sync_tombstones",
18
+ {
19
+ id: bigserial({ mode: "number" }).primaryKey(),
20
+ userId: varchar({ length: 255 }).notNull(),
21
+ tableName: varchar({ length: 64 }).notNull(),
22
+ rowId: varchar({ length: 255 }).notNull(),
23
+ deletedAt: timestamp({ withTimezone: true }).notNull().default(sql`now()`),
24
+ },
25
+ (table) => [
26
+ index("syncTombstones_sync_idx").on(
27
+ table.userId,
28
+ table.tableName,
29
+ table.deletedAt,
30
+ table.id
31
+ ),
32
+ ]
33
+ );
@@ -0,0 +1,80 @@
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
+ /** 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
+ }) {
24
+ const tables = options.tables.map((table) => quoteLiteral(table)).join(", ");
25
+ const usersTable = quoteIdentifier(options.usersTable);
26
+ return `BEGIN;
27
+
28
+ -- A row inserted again under a deleted id (a restore) must not be deleted
29
+ -- again by its old tombstone on a device pulling both for the first time.
30
+ CREATE OR REPLACE FUNCTION sync_touch_updated_at() RETURNS trigger AS $$
31
+ BEGIN
32
+ NEW."updatedAt" := now();
33
+ IF TG_OP = 'INSERT' THEN
34
+ DELETE FROM sync_tombstones
35
+ WHERE "tableName" = TG_TABLE_NAME AND "rowId" = NEW.id;
36
+ END IF;
37
+ RETURN NEW;
38
+ END;
39
+ $$ LANGUAGE plpgsql;
40
+
41
+ -- Rows removed with their user leave no tombstone: nobody will pull them.
42
+ CREATE OR REPLACE FUNCTION sync_record_tombstone() RETURNS trigger AS $$
43
+ BEGIN
44
+ IF EXISTS (SELECT 1 FROM ${usersTable} WHERE id = OLD."userId") THEN
45
+ INSERT INTO sync_tombstones ("userId", "tableName", "rowId")
46
+ VALUES (OLD."userId", TG_TABLE_NAME, OLD.id);
47
+ END IF;
48
+ RETURN OLD;
49
+ END;
50
+ $$ LANGUAGE plpgsql;
51
+
52
+ DO $$
53
+ DECLARE
54
+ t text;
55
+ BEGIN
56
+ FOREACH t IN ARRAY ARRAY[${tables}]
57
+ LOOP
58
+ EXECUTE format('DROP TRIGGER IF EXISTS sync_touch_updated_at ON %I', t);
59
+ EXECUTE format(
60
+ 'CREATE TRIGGER sync_touch_updated_at BEFORE INSERT OR UPDATE ON %I
61
+ FOR EACH ROW EXECUTE FUNCTION sync_touch_updated_at()', t);
62
+ EXECUTE format('DROP TRIGGER IF EXISTS sync_record_tombstone ON %I', t);
63
+ EXECUTE format(
64
+ 'CREATE TRIGGER sync_record_tombstone AFTER DELETE ON %I
65
+ FOR EACH ROW EXECUTE FUNCTION sync_record_tombstone()', t);
66
+ END LOOP;
67
+ END;
68
+ $$;
69
+
70
+ COMMIT;
71
+ `;
72
+ }
73
+
74
+ function quoteLiteral(value: string) {
75
+ return `'${value.replaceAll("'", "''")}'`;
76
+ }
77
+
78
+ function quoteIdentifier(value: string) {
79
+ return `"${value.replaceAll('"', '""')}"`;
80
+ }