@gryt/core 0.28.0 → 0.30.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.
@@ -34,6 +34,12 @@ export interface ImportDeps {
34
34
  newId: () => string;
35
35
  /** Downloads and uploads running together. A phone holding each file in memory wants fewer. */
36
36
  transfersAtOnce?: number;
37
+ /** Send a big import's text as one sealed upload. Only for apps that read `import_bundle`. */
38
+ bundles?: boolean;
39
+ /** Whether this device's history already holds an entry, so a second run skips it. */
40
+ alreadyHas?: (entryId: string) => Promise<boolean>;
41
+ /** The server's file size limit, so a file over it is never sealed and sent to be refused. */
42
+ maxFileBytes?: number | null;
37
43
  }
38
44
  export interface ImportProgress {
39
45
  messages: {
@@ -61,5 +67,5 @@ export interface ImportControl {
61
67
  /** For a 429's Retry-After header. Only the seconds form: anything else gets the default wait. */
62
68
  export declare function retryAfterMs(header: string | null): number;
63
69
  export declare const importId: (discordId: string) => string;
64
- /** Messages without files first, then every file downloaded to the store, then the messages that carry files. */
70
+ /** Text first, then 20 messages at a time: their files go straight through, then those messages go. */
65
71
  export declare function runDiscordImport(messages: DiscordPackageMessage[], deps: ImportDeps, control: ImportControl, report: (p: ImportProgress) => void): Promise<ImportProgress>;
@@ -1,8 +1,10 @@
1
- import { importBatches } from "../mls/content.js";
1
+ import { encodeImportBundle, importBatches } from "../mls/content.js";
2
2
  const TRANSFERS_AT_ONCE = 16;
3
3
  /** `mls:send` allows about one send every two seconds sustained (RL_SEND in the server). */
4
4
  const SEND_GAP_MS = 2_000;
5
5
  const FILE_BATCH = 20;
6
+ /* A batch holds up to 200, and one send is the point where a bundle starts saving time. */
7
+ const BUNDLE_ABOVE = 200;
6
8
  const DOWNLOAD_RETRIES = 5;
7
9
  /* A server allows 30 uploads a minute with a ban after, so a big import waits many times over. */
8
10
  const UPLOAD_RETRIES = 200;
@@ -22,12 +24,16 @@ function nameOf(url) {
22
24
  return "file";
23
25
  }
24
26
  }
25
- /** Messages without files first, then every file downloaded to the store, then the messages that carry files. */
27
+ /** Text first, then 20 messages at a time: their files go straight through, then those messages go. */
26
28
  export async function runDiscordImport(messages, deps, control, report) {
27
- const withFiles = messages.filter((m) => m.attachments.length > 0);
29
+ const fresh = [];
30
+ for (const m of messages)
31
+ if (!(await deps.alreadyHas?.(importId(m.discordId))))
32
+ fresh.push(m);
33
+ const withFiles = fresh.filter((m) => m.attachments.length > 0);
28
34
  const urls = [...new Set(withFiles.flatMap((m) => m.attachments))];
29
35
  const p = {
30
- messages: { done: 0, total: messages.length },
36
+ messages: { done: messages.length - fresh.length, total: messages.length },
31
37
  downloaded: { done: 0, total: urls.length },
32
38
  sent: { done: 0, total: urls.length },
33
39
  missing: 0,
@@ -41,6 +47,32 @@ export async function runDiscordImport(messages, deps, control, report) {
41
47
  while (control.paused() && !control.stopped())
42
48
  await deps.sleep(500);
43
49
  };
50
+ let uploadClearAt = 0;
51
+ const uploadFile = async (file) => {
52
+ for (let attempt = 0; attempt <= UPLOAD_RETRIES && !control.stopped(); attempt++) {
53
+ const wait = uploadClearAt - Date.now();
54
+ if (wait > 0)
55
+ await deps.sleep(wait);
56
+ const got = await deps.upload(file);
57
+ if (!("retryAfterMs" in got))
58
+ return got;
59
+ uploadClearAt = Math.max(uploadClearAt, Date.now() + Math.min(got.retryAfterMs, MAX_RATE_LIMIT_WAIT_MS));
60
+ }
61
+ if (control.stopped())
62
+ return null;
63
+ throw new Error("The server kept refusing uploads.");
64
+ };
65
+ /* False when the bundle can't go up, so the batches carry it. */
66
+ const sendBundle = async (entries) => {
67
+ await hold();
68
+ const up = await uploadFile({ name: "discord-import.json", type: "application/json", bytes: encodeImportBundle(entries) });
69
+ if (!up || "tooLarge" in up)
70
+ return false;
71
+ await deps.send({ type: "import_bundle", id: deps.newId(), source: "discord", attachments: { [up.id]: up.key } });
72
+ p.messages.done += entries.length;
73
+ tick();
74
+ return true;
75
+ };
44
76
  const sendAll = async (entries) => {
45
77
  for (const batch of importBatches(entries, deps.newId)) {
46
78
  await hold();
@@ -52,7 +84,9 @@ export async function runDiscordImport(messages, deps, control, report) {
52
84
  await deps.sleep(SEND_GAP_MS);
53
85
  }
54
86
  };
55
- await sendAll(messages.filter((m) => m.attachments.length === 0).map((m) => ({ id: importId(m.discordId), sentAt: m.sentAt, text: m.text })));
87
+ const textOnly = fresh.filter((m) => m.attachments.length === 0).map((m) => ({ id: importId(m.discordId), sentAt: m.sentAt, text: m.text }));
88
+ if (!(deps.bundles && textOnly.length > BUNDLE_ABOVE && !control.stopped() && (await sendBundle(textOnly))))
89
+ await sendAll(textOnly);
56
90
  const missing = new Set();
57
91
  let clearAt = 0;
58
92
  const downloadFile = async (url) => {
@@ -68,89 +102,85 @@ export async function runDiscordImport(messages, deps, control, report) {
68
102
  }
69
103
  return null;
70
104
  };
71
- const eachAtOnce = async (run) => {
72
- let next = 0;
73
- await Promise.all(Array.from({ length: deps.transfersAtOnce ?? TRANSFERS_AT_ONCE }, async () => {
74
- while (!control.stopped()) {
75
- await hold();
76
- if (next >= urls.length)
77
- break;
78
- await run(urls[next++]);
79
- }
80
- }));
81
- };
82
- await eachAtOnce(async (url) => {
83
- if (!(await deps.store.has(url))) {
84
- const file = await downloadFile(url);
85
- if (file) {
105
+ const uploaded = new Map();
106
+ const tooLarge = new Set();
107
+ const finished = new Set();
108
+ /* The file is only kept if it can't go up now, so a stop or a failure never downloads it twice. */
109
+ const transfer = async (url) => {
110
+ if (finished.has(url))
111
+ return;
112
+ finished.add(url);
113
+ let file = await deps.store.take(url);
114
+ if (!file) {
115
+ file = await downloadFile(url);
116
+ if (file)
86
117
  p.bytes += file.bytes.length;
87
- await deps.store.put(url, file);
88
- }
89
- else {
118
+ else if (!control.stopped()) {
90
119
  missing.add(url);
91
120
  p.missing = missing.size;
92
121
  }
93
122
  }
123
+ if (control.stopped()) {
124
+ if (file)
125
+ await deps.store.put(url, file);
126
+ return;
127
+ }
94
128
  p.downloaded.done++;
95
129
  tick();
96
- });
97
- const uploaded = new Map();
98
- const tooLarge = new Set();
99
- let uploadClearAt = 0;
100
- const uploadFile = async (file) => {
101
- for (let attempt = 0; attempt <= UPLOAD_RETRIES && !control.stopped(); attempt++) {
102
- const wait = uploadClearAt - Date.now();
103
- if (wait > 0)
104
- await deps.sleep(wait);
105
- const got = await deps.upload(file);
106
- if (!("retryAfterMs" in got))
107
- return got;
108
- uploadClearAt = Math.max(uploadClearAt, Date.now() + Math.min(got.retryAfterMs, MAX_RATE_LIMIT_WAIT_MS));
109
- }
110
- if (control.stopped())
111
- return null;
112
- throw new Error("The server kept refusing uploads.");
113
- };
114
- await eachAtOnce(async (url) => {
115
- const stored = missing.has(url) ? null : await deps.store.take(url);
116
- if (stored) {
117
- const up = await uploadFile({ name: nameOf(url), ...stored });
118
- if (up && "tooLarge" in up) {
130
+ if (file) {
131
+ let up;
132
+ try {
133
+ up = deps.maxFileBytes && file.bytes.length > deps.maxFileBytes ? { tooLarge: true } : await uploadFile({ name: nameOf(url), ...file });
134
+ }
135
+ catch (e) {
136
+ await deps.store.put(url, file);
137
+ throw e;
138
+ }
139
+ if (!up) {
140
+ await deps.store.put(url, file);
141
+ return;
142
+ }
143
+ if ("tooLarge" in up) {
119
144
  tooLarge.add(url);
120
145
  p.tooLarge = tooLarge.size;
121
146
  }
122
- else if (up) {
147
+ else
123
148
  uploaded.set(url, up);
124
- }
125
- if (up)
126
- await deps.store.drop(url);
149
+ await deps.store.drop(url);
127
150
  }
128
151
  p.sent.done++;
129
152
  tick();
130
- });
131
- let pending = [];
132
- for (const m of withFiles) {
153
+ };
154
+ const atOnce = deps.transfersAtOnce ?? TRANSFERS_AT_ONCE;
155
+ for (let i = 0; i < withFiles.length && !control.stopped(); i += FILE_BATCH) {
156
+ const chunk = withFiles.slice(i, i + FILE_BATCH);
157
+ const todo = [...new Set(chunk.flatMap((m) => m.attachments))];
158
+ let next = 0;
159
+ await Promise.all(Array.from({ length: atOnce }, async () => {
160
+ while (!control.stopped()) {
161
+ await hold();
162
+ if (next >= todo.length)
163
+ break;
164
+ await transfer(todo[next++]);
165
+ }
166
+ }));
133
167
  if (control.stopped())
134
168
  break;
135
- const attachments = {};
136
- for (const url of m.attachments) {
137
- const up = uploaded.get(url);
138
- if (up)
139
- attachments[up.id] = up.key;
140
- }
141
- const big = m.attachments.filter((u) => tooLarge.has(u)).length;
142
- const lost = m.attachments.length - Object.keys(attachments).length - big;
143
- const lostNote = lost ? `${lost === 1 ? "A file" : `${lost} files`} from Discord couldn’t be downloaded.` : "";
144
- const bigNote = big ? `${big === 1 ? "A file" : `${big} files`} from Discord ${big === 1 ? "was" : "were"} too big for this server.` : "";
145
- const text = [m.text, lostNote, bigNote].filter(Boolean).join("\n");
146
- pending.push({ id: importId(m.discordId), sentAt: m.sentAt, text, ...(Object.keys(attachments).length ? { attachments } : {}) });
147
- if (pending.length >= FILE_BATCH) {
148
- await sendAll(pending);
149
- pending = [];
150
- }
169
+ await sendAll(chunk.map((m) => {
170
+ const attachments = {};
171
+ for (const url of m.attachments) {
172
+ const up = uploaded.get(url);
173
+ if (up)
174
+ attachments[up.id] = up.key;
175
+ }
176
+ const big = m.attachments.filter((u) => tooLarge.has(u)).length;
177
+ const lost = m.attachments.length - Object.keys(attachments).length - big;
178
+ const lostNote = lost ? `${lost === 1 ? "A file" : `${lost} files`} from Discord couldn’t be downloaded.` : "";
179
+ const bigNote = big ? `${big === 1 ? "A file" : `${big} files`} from Discord ${big === 1 ? "was" : "were"} too big for this server.` : "";
180
+ const text = [m.text, lostNote, bigNote].filter(Boolean).join("\n");
181
+ return { id: importId(m.discordId), sentAt: m.sentAt, text, ...(Object.keys(attachments).length ? { attachments } : {}) };
182
+ }));
151
183
  }
152
- if (pending.length)
153
- await sendAll(pending);
154
184
  tick();
155
185
  return p;
156
186
  }
package/dist/index.d.ts CHANGED
@@ -13,7 +13,7 @@ export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_
13
13
  export { createMlsDmDriver, MlsDriverError, type MlsDriverErrorCode } from "./mls/dmDriver.js";
14
14
  export { mlsPinsFromPeerPins } from "./mls/pins.js";
15
15
  export { reencryptedId, reencryptMine, type ReencryptCandidate, type ReencryptDeps } from "./mls/reencrypt.js";
16
- export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, type MlsDmContent, type MlsHistoryEntry, type MlsHistoryReaction, type MlsImportEntry, readMlsDmContent, } from "./mls/content.js";
16
+ export { decodeMlsDmContent, encodeImportBundle, readImportBundle, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, type MlsDmContent, type MlsHistoryEntry, type MlsHistoryReaction, type MlsImportEntry, readMlsDmContent, } from "./mls/content.js";
17
17
  export { applyMlsReaction, mlsReactionAction, type MlsReaction } from "./mls/reactions.js";
18
18
  export { type DiscordPackageDm, type DiscordPackageMessage, isDiscordPackage, linksExpireAt, listDiscordDms, type PackageFiles, readDiscordDm, } from "./import/discordPackage.js";
19
19
  export { inflateRaw, openZipBytes } from "./import/unzip.js";
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_
15
15
  export { createMlsDmDriver, MlsDriverError } from "./mls/dmDriver.js";
16
16
  export { mlsPinsFromPeerPins } from "./mls/pins.js";
17
17
  export { reencryptedId, reencryptMine } from "./mls/reencrypt.js";
18
- export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, readMlsDmContent, } from "./mls/content.js";
18
+ export { decodeMlsDmContent, encodeImportBundle, readImportBundle, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, readMlsDmContent, } from "./mls/content.js";
19
19
  export { applyMlsReaction, mlsReactionAction } from "./mls/reactions.js";
20
20
  export { isDiscordPackage, linksExpireAt, listDiscordDms, readDiscordDm, } from "./import/discordPackage.js";
21
21
  export { inflateRaw, openZipBytes } from "./import/unzip.js";
@@ -30,6 +30,13 @@ export type MlsDmContent = {
30
30
  id: string;
31
31
  source: "discord";
32
32
  entries: MlsImportEntry[];
33
+ } | {
34
+ /** An import too big for messages: its entries sit in one sealed upload, read with `readImportBundle`. */
35
+ type: "import_bundle";
36
+ id: string;
37
+ source: "discord";
38
+ /** Upload id to its key. Exactly one. */
39
+ attachments: Record<string, SealedAttachmentKey>;
33
40
  } | {
34
41
  type: "history";
35
42
  /** For the batch itself, so a repeat is the same message. */
@@ -82,6 +89,9 @@ export declare function acceptImport(content: Extract<MlsDmContent, {
82
89
  senderId: string;
83
90
  at: number;
84
91
  }): MlsHistoryEntry[];
92
+ export declare function encodeImportBundle(entries: MlsImportEntry[]): Uint8Array;
93
+ /** A bundle's entries, or null for junk. A peer wrote it, so each entry is checked like a message's. */
94
+ export declare function readImportBundle(bytes: Uint8Array): MlsImportEntry[] | null;
85
95
  /** Null for anything this version can't read: a newer version, a new type, or junk. */
86
96
  export declare function decodeMlsDmContent(bytes: Uint8Array): MlsDmContent | null;
87
97
  /**
@@ -84,7 +84,10 @@ function readAttachments(v) {
84
84
  /** The same bytes the phone writes: `v` first, then the content's fields in this order. */
85
85
  export function encodeMlsDmContent(content) {
86
86
  let body;
87
- if (content.type === "history") {
87
+ if (content.type === "import_bundle") {
88
+ body = { type: content.type, id: content.id, source: content.source, attachments: content.attachments };
89
+ }
90
+ else if (content.type === "history") {
88
91
  body = { type: content.type, id: content.id, entries: content.entries.map(historyBody) };
89
92
  }
90
93
  else if (content.type === "import") {
@@ -213,6 +216,17 @@ function readImportEntry(v) {
213
216
  }
214
217
  return out;
215
218
  }
219
+ export function encodeImportBundle(entries) {
220
+ return new TextEncoder().encode(JSON.stringify({ v: MLS_DM_CONTENT_VERSION, entries: entries.map(importBody) }));
221
+ }
222
+ /** A bundle's entries, or null for junk. A peer wrote it, so each entry is checked like a message's. */
223
+ export function readImportBundle(bytes) {
224
+ const raw = parse(bytes);
225
+ if (!isObject(raw) || raw.v !== MLS_DM_CONTENT_VERSION || !Array.isArray(raw.entries))
226
+ return null;
227
+ const entries = raw.entries.map(readImportEntry);
228
+ return entries.every((e) => e !== null) ? entries : null;
229
+ }
216
230
  /** Null for anything this version can't read: a newer version, a new type, or junk. */
217
231
  export function decodeMlsDmContent(bytes) {
218
232
  return fromRaw(parse(bytes));
@@ -225,7 +239,7 @@ export function readMlsDmContent(bytes) {
225
239
  const raw = parse(bytes);
226
240
  return fromRaw(raw) ?? (isNewer(raw) ? "newer" : null);
227
241
  }
228
- const KNOWN_TYPES = new Set(["message", "edit", "delete", "reaction", "history", "import"]);
242
+ const KNOWN_TYPES = new Set(["message", "edit", "delete", "reaction", "history", "import", "import_bundle"]);
229
243
  const TYPE_NAME = /^[a-z][a-z0-9_]{0,31}$/;
230
244
  /** A later version, or a type this one doesn't know that names its message the way ours do. */
231
245
  function isNewer(raw) {
@@ -259,6 +273,10 @@ function fromRaw(raw) {
259
273
  const entries = raw.entries.map(readImportEntry);
260
274
  return entries.every((e) => e !== null) ? { type, id, source: raw.source, entries } : null;
261
275
  }
276
+ if (type === "import_bundle") {
277
+ const attachments = raw.source === "discord" ? readAttachments(raw.attachments) : null;
278
+ return attachments && Object.keys(attachments).length === 1 ? { type, id, source: "discord", attachments } : null;
279
+ }
262
280
  if (type === "message" && isText(text)) {
263
281
  const out = { type, id, text };
264
282
  if (raw.replyTo !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Gryt's shared application logic: one implementation of the things the desktop and mobile apps both do.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {