@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/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
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# @missionmind/sync-api
|
|
2
|
+
|
|
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"}
|
package/dist/changes.js
ADDED
|
@@ -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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
package/dist/pull.js.map
ADDED
|
@@ -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
|
package/dist/push.js.map
ADDED
|
@@ -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"}
|