@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.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/dist/changes.d.ts +38 -0
- package/dist/changes.d.ts.map +1 -0
- package/dist/changes.js +82 -0
- package/dist/changes.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/pull.d.ts +59 -0
- package/dist/pull.d.ts.map +1 -0
- package/dist/pull.js +149 -0
- package/dist/pull.js.map +1 -0
- package/dist/push.d.ts +31 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +11 -0
- package/dist/push.js.map +1 -0
- package/dist/schema.d.ts +105 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +18 -0
- package/dist/schema.js.map +1 -0
- package/dist/triggers.d.ts +24 -0
- package/dist/triggers.d.ts.map +1 -0
- package/dist/triggers.js +71 -0
- package/dist/triggers.js.map +1 -0
- package/package.json +41 -1
- package/src/changes.ts +117 -0
- package/src/index.ts +19 -0
- package/src/pull.ts +319 -0
- package/src/push.ts +36 -0
- package/src/schema.ts +33 -0
- package/src/triggers.ts +80 -0
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
|
+
);
|
package/src/triggers.ts
ADDED
|
@@ -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
|
+
}
|