@kernhq/module-quire 0.13.0 → 0.14.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.
Files changed (101) hide show
  1. package/dist/contract/models.d.ts +185 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +133 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts +14 -0
  6. package/dist/contract/permissions.d.ts.map +1 -1
  7. package/dist/contract/permissions.js +82 -0
  8. package/dist/contract/permissions.js.map +1 -1
  9. package/dist/contract/properties.d.ts +4 -4
  10. package/dist/contract/router.d.ts +542 -15
  11. package/dist/contract/router.d.ts.map +1 -1
  12. package/dist/contract/router.js +155 -8
  13. package/dist/contract/router.js.map +1 -1
  14. package/dist/server/_impl.d.ts +526 -15
  15. package/dist/server/_impl.d.ts.map +1 -1
  16. package/dist/server/_impl.js +166 -23
  17. package/dist/server/_impl.js.map +1 -1
  18. package/dist/server/export/html.d.ts +67 -0
  19. package/dist/server/export/html.d.ts.map +1 -0
  20. package/dist/server/export/html.js +206 -0
  21. package/dist/server/export/html.js.map +1 -0
  22. package/dist/server/export/markdown.d.ts +51 -0
  23. package/dist/server/export/markdown.d.ts.map +1 -0
  24. package/dist/server/export/markdown.js +312 -0
  25. package/dist/server/export/markdown.js.map +1 -0
  26. package/dist/server/export/pdf.d.ts +20 -0
  27. package/dist/server/export/pdf.d.ts.map +1 -0
  28. package/dist/server/export/pdf.js +91 -0
  29. package/dist/server/export/pdf.js.map +1 -0
  30. package/dist/server/export/zip.d.ts +31 -0
  31. package/dist/server/export/zip.d.ts.map +1 -0
  32. package/dist/server/export/zip.js +158 -0
  33. package/dist/server/export/zip.js.map +1 -0
  34. package/dist/server/import/csv.d.ts +77 -0
  35. package/dist/server/import/csv.d.ts.map +1 -0
  36. package/dist/server/import/csv.js +263 -0
  37. package/dist/server/import/csv.js.map +1 -0
  38. package/dist/server/import/html.d.ts +52 -0
  39. package/dist/server/import/html.d.ts.map +1 -0
  40. package/dist/server/import/html.js +472 -0
  41. package/dist/server/import/html.js.map +1 -0
  42. package/dist/server/import/markdown.d.ts +63 -0
  43. package/dist/server/import/markdown.d.ts.map +1 -0
  44. package/dist/server/import/markdown.js +692 -0
  45. package/dist/server/import/markdown.js.map +1 -0
  46. package/dist/server/import/plan.d.ts +70 -0
  47. package/dist/server/import/plan.d.ts.map +1 -0
  48. package/dist/server/import/plan.js +761 -0
  49. package/dist/server/import/plan.js.map +1 -0
  50. package/dist/server/import/ydoc.d.ts +35 -0
  51. package/dist/server/import/ydoc.d.ts.map +1 -0
  52. package/dist/server/import/ydoc.js +91 -0
  53. package/dist/server/import/ydoc.js.map +1 -0
  54. package/dist/server/import/zip.d.ts +63 -0
  55. package/dist/server/import/zip.d.ts.map +1 -0
  56. package/dist/server/import/zip.js +308 -0
  57. package/dist/server/import/zip.js.map +1 -0
  58. package/dist/server/index.d.ts.map +1 -1
  59. package/dist/server/index.js +3 -1
  60. package/dist/server/index.js.map +1 -1
  61. package/dist/server/schema.d.ts +445 -1
  62. package/dist/server/schema.d.ts.map +1 -1
  63. package/dist/server/schema.js +146 -0
  64. package/dist/server/schema.js.map +1 -1
  65. package/dist/server/services/databases.d.ts +5 -5
  66. package/dist/server/services/export.d.ts +176 -0
  67. package/dist/server/services/export.d.ts.map +1 -0
  68. package/dist/server/services/export.js +822 -0
  69. package/dist/server/services/export.js.map +1 -0
  70. package/dist/server/services/import.d.ts +109 -0
  71. package/dist/server/services/import.d.ts.map +1 -0
  72. package/dist/server/services/import.js +570 -0
  73. package/dist/server/services/import.js.map +1 -0
  74. package/dist/server/services/index.d.ts +28 -1
  75. package/dist/server/services/index.d.ts.map +1 -1
  76. package/dist/server/services/index.js +62 -1
  77. package/dist/server/services/index.js.map +1 -1
  78. package/dist/server/services/publications.d.ts +34 -1
  79. package/dist/server/services/publications.d.ts.map +1 -1
  80. package/dist/server/services/publications.js +62 -3
  81. package/dist/server/services/publications.js.map +1 -1
  82. package/dist/server/services/versions.d.ts +1 -1
  83. package/migrations/0010_transfers.sql +154 -0
  84. package/migrations/meta/_journal.json +7 -0
  85. package/package.json +1 -1
  86. package/src/client/components/ExportDialog.svelte +685 -0
  87. package/src/client/components/ImportDialog.svelte +702 -0
  88. package/src/client/components/ImportReport.svelte +310 -0
  89. package/src/client/components/SidebarSpaces.svelte +79 -0
  90. package/src/client/i18n.ts +614 -0
  91. package/src/client/index.ts +32 -0
  92. package/src/client/mock.ts +318 -0
  93. package/src/client/module.ts +36 -0
  94. package/src/client/pages/PageView.svelte +36 -0
  95. package/src/client/pages/TransfersPage.svelte +570 -0
  96. package/src/client/permissions.ts +11 -0
  97. package/src/client/query.ts +23 -0
  98. package/src/client/transfers.ts +142 -0
  99. package/src/contract/models.ts +152 -0
  100. package/src/contract/permissions.ts +84 -0
  101. package/src/contract/router.ts +170 -7
@@ -0,0 +1,822 @@
1
+ import { KernError, uuidv7 } from '@kernhq/kernel';
2
+ import { and, asc, desc, eq, inArray, isNull, lt } from 'drizzle-orm';
3
+ import { pageDocFromState } from '../document.js';
4
+ import { exportedHtmlDocument, exportLinks } from '../export/html.js';
5
+ import { pageDocToMarkdown } from '../export/markdown.js';
6
+ import { htmlToPdf } from '../export/pdf.js';
7
+ import { safeZipPath, writeZip } from '../export/zip.js';
8
+ import { referencesIn, renderPageDoc } from '../render.js';
9
+ import { exportJobs, pages, pageVersions, spaces } from '../schema.js';
10
+ import { slugifyTitle } from './publications.js';
11
+ /** How long an artefact is kept. Long enough to fetch twice, short enough not to be an archive. */
12
+ export const EXPORT_TTL_DAYS = 7;
13
+ /**
14
+ * How long a job may sit un-finished before it is given up on.
15
+ *
16
+ * **Nothing else can end a job that lost its worker.** `run` writes `failed` from its own `catch`,
17
+ * and the router writes it when the queue refuses the send — both need the process to still be
18
+ * there. A worker killed mid-export (an OOM, a deploy, `docker compose restart core-worker`) leaves
19
+ * the row `running`, and pg-boss's own give-up path ends at pg-boss: `kernel.jobs` registers a
20
+ * handler and nothing else, so there is no dead-letter callback that reaches this table. Measured
21
+ * with a real worker and a SIGKILL: the row was still `running`, `finished_at` null, after the
22
+ * pg-boss job had reached its terminal `failed` state. `exports.list` — which the transfers screen
23
+ * polls every two seconds — reported `running` for ever, and the dialog spun with
24
+ * `aria-busy="true"` for ever.
25
+ *
26
+ * The number is the job's whole budget with room to spare, so nothing legitimate is ever cut off:
27
+ * `quireJobs` gives `export` `expireInSeconds: 900` with `retryLimit: 2`, so three attempts and
28
+ * their backoff are under fifty minutes, and `import` one attempt of an hour. A job still running at
29
+ * two hours has already been abandoned by the queue.
30
+ */
31
+ const ABANDONED_AFTER_MS = 2 * 60 * 60 * 1000;
32
+ /**
33
+ * What a job that lost its worker says.
34
+ *
35
+ * Diagnostic like every other `error` here — the state's own label is what the screen says in the
36
+ * reader's language — and written for the person who has to decide what to do next rather than for
37
+ * the one who wrote the worker.
38
+ */
39
+ const ABANDONED = 'This export stopped before it finished and was given up on after two hours. The worker running ' +
40
+ 'it went away — usually a restart, or a job too big for the memory it had. Nothing was written. ' +
41
+ 'Start it again, a subtree at a time if it was a whole space.';
42
+ /** Neither `done` nor `failed`, and old enough that nothing is still going to happen to it. */
43
+ const isAbandoned = (row) => (row.state === 'queued' || row.state === 'running') &&
44
+ row.createdAt.getTime() < Date.now() - ABANDONED_AFTER_MS;
45
+ /** How long a download link lives. It is minted per request, so it never has to outlive one. */
46
+ const DOWNLOAD_TTL_SEC = 900;
47
+ /** A picture bigger than this is left out rather than allowed to define the size of the archive. */
48
+ const MAX_ATTACHMENT_BYTES = 32 * 1024 * 1024;
49
+ /** The whole artefact, held in memory before it is written. Beyond this the job fails and says so. */
50
+ const MAX_ARTEFACT_BYTES = 256 * 1024 * 1024;
51
+ /** Enough pages that no real space is refused, few enough that one job cannot become an outage. */
52
+ const MAX_PAGES = 5000;
53
+ /** How often the row's counters are written while the job runs. A progress bar, not a write log. */
54
+ const PROGRESS_EVERY = 25;
55
+ /**
56
+ * What a format produces, which is deliberately not a decision the job makes at run time.
57
+ *
58
+ * Markdown and HTML are always a zip, even for a single page with no pictures. A page's attachments
59
+ * live beside it, so the container has to exist as soon as there is one attachment — and a rule that
60
+ * says "a zip, unless" produces two shapes for one request and a filename nobody can predict. The
61
+ * uniform shape is also what makes an export re-importable: a folder of `.md` with its media beside
62
+ * it is exactly the "plain folder of Markdown" the import side reads.
63
+ *
64
+ * A PDF is one document however much went into it, because that is what a PDF is for: a subtree
65
+ * becomes one file with a page break between pages and internal links between them.
66
+ */
67
+ const ARTEFACT = {
68
+ markdown: { extension: 'zip', contentType: 'application/zip' },
69
+ html: { extension: 'zip', contentType: 'application/zip' },
70
+ pdf: { extension: 'pdf', contentType: 'application/pdf' },
71
+ docx: {
72
+ extension: 'docx',
73
+ contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
74
+ },
75
+ };
76
+ /**
77
+ * Why `docx` is declared and refused.
78
+ *
79
+ * `prosemirror-docx` was the intended route and does not fit, for a structural reason rather than
80
+ * for want of effort: it serialises a **ProseMirror `Node`**, and building one needs the schema —
81
+ * which lives in `@kernhq/ui`'s `page-schema.ts`, is assembled out of a dozen Tiptap extensions, and
82
+ * is browser code. A server that has deliberately never loaded Tiptap (see `document.ts`, which
83
+ * reads a page out of Yjs *without* a schema for exactly this reason) has none to give it. Beyond
84
+ * that, the library ships handlers for the basic nodes only, so Quire's callouts, toggles, task
85
+ * lists and page mentions would each need one written by hand against an API that throws on an
86
+ * unknown node.
87
+ *
88
+ * The alternative — emitting OOXML directly — is a zip of XML this file could write, and that is the
89
+ * reason it is not written: nothing here can open the result in Word, so the only thing that could
90
+ * ship is a file that is *probably* valid. A refusal an operator reads is better than a document a
91
+ * customer cannot open, so the format refuses at `start`, immediately, rather than after a job.
92
+ */
93
+ const DOCX_REFUSAL = 'Word export is not available yet. Export as HTML or PDF, both of which Word opens, or as ' +
94
+ 'Markdown to move the pages somewhere else.';
95
+ export const exportArtefactKey = (workspaceId, fileId) => `ws/${workspaceId}/quire/exports/${fileId}`;
96
+ /**
97
+ * The `# Title` line at the top of an exported Markdown file.
98
+ *
99
+ * Exported so the round-trip test can build its fixture from the line the job actually writes rather
100
+ * than from a copy of it — a title is the one thing in an export that has to come back *identical*,
101
+ * because it is the page's name rather than part of its body.
102
+ *
103
+ * The escape set is the same one `export/markdown.ts` uses for inline text — what changes meaning
104
+ * anywhere — plus **one position**: a run of hashes at the end. That is the ATX *closing sequence*,
105
+ * so `Roadmap #` written plainly is a heading whose content is `Roadmap`, per CommonMark, and the
106
+ * page came back under a different name. Escaping the run is the only spelling that survives, and
107
+ * escaping it only there is what keeps `# Sharp C#` — where the hash is ordinary text — readable in
108
+ * every other editor. The reader's half of this is `RE_ATX_CLOSING` in `import/markdown.ts`.
109
+ */
110
+ export const markdownTitleLine = (title) => `# ${title.replace(/([\\`*_[\]<>|~])/g, '\\$1').replace(/(^|[ \t])(#+)([ \t]*)$/, '$1\\$2$3')}`;
111
+ const countsOf = (value) => {
112
+ const raw = (value ?? {});
113
+ const n = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.trunc(v) : 0);
114
+ return { total: n(raw.total), done: n(raw.done), skipped: n(raw.skipped), failed: n(raw.failed) };
115
+ };
116
+ export function toExportJob(row) {
117
+ return {
118
+ id: row.id,
119
+ workspaceId: row.workspaceId,
120
+ requestedBy: row.requestedBy,
121
+ scope: row.scope,
122
+ targetId: row.targetId,
123
+ format: row.format,
124
+ state: row.state,
125
+ fileId: row.fileId,
126
+ error: row.error,
127
+ counts: countsOf(row.counts),
128
+ createdAt: row.createdAt.toISOString(),
129
+ finishedAt: row.finishedAt?.toISOString() ?? null,
130
+ };
131
+ }
132
+ /** The path of one archive folder written from another, so a link between two files resolves. */
133
+ export function relativeFolder(from, to, file) {
134
+ const a = from ? from.split('/') : [];
135
+ const b = to ? to.split('/') : [];
136
+ let shared = 0;
137
+ while (shared < a.length && shared < b.length && a[shared] === b[shared])
138
+ shared++;
139
+ const up = Array.from({ length: a.length - shared }, () => '..');
140
+ const path = [...up, ...b.slice(shared), file].join('/');
141
+ return path.startsWith('.') ? path : `./${path}`;
142
+ }
143
+ /** A file name that is a name and not a path, and that no two pictures in one folder can share. */
144
+ function attachmentName(raw, taken) {
145
+ const cleaned = safeZipPath(raw.split('/').pop() ?? '') || 'attachment';
146
+ const dot = cleaned.lastIndexOf('.');
147
+ const stem = dot > 0 ? cleaned.slice(0, dot) : cleaned;
148
+ const extension = dot > 0 ? cleaned.slice(dot) : '';
149
+ let name = cleaned;
150
+ for (let n = 2; taken.has(name); n++)
151
+ name = `${stem}-${n}${extension}`;
152
+ taken.add(name);
153
+ return name;
154
+ }
155
+ /**
156
+ * Tree order, and a folder per page whose siblings cannot collide.
157
+ *
158
+ * A page whose parent is not in the export is a root of the archive, which is what puts a subtree's
159
+ * own root at the top level. Sibling slugs are suffixed `-2`, `-3` in tree order, the same rule
160
+ * `withPaths` uses for a published site — so two people exporting the same space get the same names.
161
+ */
162
+ export function prepareFolders(selected) {
163
+ const byId = new Map(selected.map((p) => [p.id, p]));
164
+ const children = new Map();
165
+ for (const page of selected) {
166
+ const parent = page.parentId !== null && byId.has(page.parentId) ? page.parentId : null;
167
+ children.set(parent, [...(children.get(parent) ?? []), page]);
168
+ }
169
+ const out = [];
170
+ const seen = new Set();
171
+ const walk = (parent, base, trail) => {
172
+ const taken = new Set();
173
+ for (const page of children.get(parent) ?? []) {
174
+ // `seen` is what stops a cycle — a page that became its own ancestor — from recurring for ever.
175
+ if (seen.has(page.id))
176
+ continue;
177
+ seen.add(page.id);
178
+ const stem = slugifyTitle(page.title || 'Untitled');
179
+ let slug = stem;
180
+ for (let n = 2; taken.has(slug); n++)
181
+ slug = `${stem}-${n}`;
182
+ taken.add(slug);
183
+ const folder = safeZipPath(base ? `${base}/${slug}` : slug) || slug;
184
+ out.push({ ...page, folder, trail });
185
+ walk(page.id, folder, [...trail, page.title || 'Untitled']);
186
+ }
187
+ };
188
+ walk(null, '', []);
189
+ return out;
190
+ }
191
+ export function quireExport(kernel, access) {
192
+ /** The row, or `notFound`. Fenced to the person who asked for it — see `requested_by`'s comment. */
193
+ async function own(tx, workspaceId, jobId, principal) {
194
+ const [row] = await tx
195
+ .select()
196
+ .from(exportJobs)
197
+ .where(and(eq(exportJobs.workspaceId, workspaceId), eq(exportJobs.id, jobId)))
198
+ .limit(1);
199
+ if (!row)
200
+ throw KernError.notFound('Export');
201
+ /*
202
+ * NOT_FOUND rather than FORBIDDEN, for the usual reason: a subtree export flattens pages of
203
+ * different readerships into one artefact, so "that one is not yours" would confirm that
204
+ * somebody else exported something and when. An id another person's job owns names nothing here.
205
+ */
206
+ const mine = principal.instanceAdmin || principal.kind === 'service' || row.requestedBy === principal.userId;
207
+ if (!mine)
208
+ throw KernError.notFound('Export');
209
+ return row;
210
+ }
211
+ /**
212
+ * Tell the one person whose job this is that it moved — and nobody else.
213
+ *
214
+ * Every other entity in this module announces with `kernel.realtime.change`, which publishes to the
215
+ * **workspace** channel; the gateway subscribes every socket to its workspace at `hello`, with no
216
+ * per-message filter, so a `change` there is read by every member. For a page or a label that is
217
+ * correct and is the point. For a transfer it contradicts the rest of the feature: `own()` answers
218
+ * NOT_FOUND rather than FORBIDDEN precisely so that an id belonging to somebody else's export
219
+ * confirms nothing, and `list` returns only the caller's own rows — and then the broadcast handed
220
+ * the whole workspace the job's id and the moment it changed. The payload carried no content, so
221
+ * what leaked was that a named colleague exported something and when, which is the fact the
222
+ * NOT_FOUND was protecting.
223
+ *
224
+ * `toUser` is delivered by the gateway straight to that user's sockets (`kern.rt.user.<id>`, which
225
+ * every socket is joined to at `hello`), and `@kernhq/ui`'s realtime client dispatches on `msg.t`
226
+ * without caring which channel carried it — so the invalidation reaches the screen exactly as
227
+ * before. An instance admin may *read* another person's job through `own()`; they do not get a
228
+ * push about it, which is the same asymmetry as the list.
229
+ */
230
+ async function announce(workspaceId, requestedBy, jobId) {
231
+ await kernel.realtime
232
+ .toUser(requestedBy, {
233
+ t: 'change',
234
+ workspaceId: workspaceId,
235
+ change: { module: 'quire', entity: 'export', id: jobId, op: 'updated' },
236
+ })
237
+ .catch(() => undefined);
238
+ }
239
+ async function patch(workspaceId, requestedBy, jobId, values) {
240
+ await kernel.database.withWorkspace(workspaceId, async (tx) => {
241
+ await tx
242
+ .update(exportJobs)
243
+ .set(values)
244
+ .where(and(eq(exportJobs.workspaceId, workspaceId), eq(exportJobs.id, jobId)));
245
+ });
246
+ await announce(workspaceId, requestedBy, jobId);
247
+ }
248
+ /**
249
+ * Every page in scope that this person may read, in position order, with the version to draw.
250
+ *
251
+ * One flat read of the space rather than a recursive query per page: `access.scopeOf` is a
252
+ * recursive query each time, and a five-hundred-page space would be five hundred of them before a
253
+ * byte is written. Every ancestor of a page is in the same space by construction — `pages.create`
254
+ * and `pages.move` both refuse a parent from another one — so the flat read holds every chain.
255
+ */
256
+ async function select(tx, principal, workspaceId, job) {
257
+ let spaceId;
258
+ let title;
259
+ if (job.scope === 'space') {
260
+ const space = await access.spaceRow(tx, workspaceId, job.targetId);
261
+ spaceId = space.id;
262
+ title = space.name;
263
+ }
264
+ else {
265
+ const page = await access.pageRow(tx, workspaceId, job.targetId);
266
+ spaceId = page.spaceId;
267
+ title = page.title;
268
+ }
269
+ const all = await tx
270
+ .select({
271
+ id: pages.id,
272
+ parentId: pages.parentId,
273
+ title: pages.title,
274
+ archivedAt: pages.archivedAt,
275
+ publishedVersionId: pages.publishedVersionId,
276
+ })
277
+ .from(pages)
278
+ .where(and(eq(pages.workspaceId, workspaceId), eq(pages.spaceId, spaceId), isNull(pages.deletedAt)))
279
+ .orderBy(asc(pages.position));
280
+ const parentOf = new Map(all.map((p) => [p.id, p.parentId]));
281
+ const ancestorsOf = (id) => {
282
+ const chain = [];
283
+ const seen = new Set([id]);
284
+ let at = parentOf.get(id) ?? null;
285
+ while (at !== null && !seen.has(at)) {
286
+ chain.push(at);
287
+ seen.add(at);
288
+ at = parentOf.get(at) ?? null;
289
+ }
290
+ return chain;
291
+ };
292
+ /* Trashed pages are already out of `all`; archived ones are out of the tree on purpose. */
293
+ const live = all.filter((p) => p.archivedAt === null);
294
+ let inScope = live;
295
+ if (job.scope !== 'space') {
296
+ const wanted = new Set([job.targetId]);
297
+ // `live` is ordered by position, which is not tree order, so this runs until it settles.
298
+ for (let changed = true; changed;) {
299
+ changed = false;
300
+ for (const p of live)
301
+ if (!wanted.has(p.id) && p.parentId !== null && wanted.has(p.parentId)) {
302
+ wanted.add(p.id);
303
+ changed = true;
304
+ }
305
+ }
306
+ inScope = live.filter((p) => wanted.has(p.id));
307
+ // `page` takes the one page; `subtree` takes it and everything beneath it.
308
+ if (job.scope === 'page')
309
+ inScope = inScope.filter((p) => p.id === job.targetId);
310
+ }
311
+ if (inScope.length > MAX_PAGES)
312
+ throw KernError.badRequest(`This export covers ${inScope.length} pages, and one job may carry ${MAX_PAGES}. ` +
313
+ 'Export a subtree at a time.');
314
+ const verdicts = await Promise.all(inScope.map(async (p) => ({
315
+ id: p.id,
316
+ allowed: await access.canPage(principal, 'quire.page.view', workspaceId, {
317
+ pageId: p.id,
318
+ spaceId,
319
+ ancestorIds: ancestorsOf(p.id),
320
+ }),
321
+ })));
322
+ const allowed = new Set(verdicts.filter((v) => v.allowed).map((v) => v.id));
323
+ /*
324
+ * A page whose parent was withheld goes with it, because its folder path is built from its
325
+ * ancestors' titles — keeping the child would print the title of the page that was withheld.
326
+ *
327
+ * The walk stops at the edge of the export rather than at the top of the tree, and that is the
328
+ * whole of it: an ancestor outside the scope was never checked, so it is not in `allowed`, and a
329
+ * walk that kept going would find it missing and drop every page. A `page`-scoped export is the
330
+ * case that makes this visible — its one page has a parent that is not in the export at all.
331
+ */
332
+ const inScopeIds = new Set(inScope.map((p) => p.id));
333
+ const kept = inScope.filter((p) => {
334
+ if (!allowed.has(p.id))
335
+ return false;
336
+ let at = p.parentId;
337
+ const seen = new Set([p.id]);
338
+ while (at !== null && inScopeIds.has(at) && !seen.has(at)) {
339
+ if (!allowed.has(at))
340
+ return false;
341
+ seen.add(at);
342
+ at = parentOf.get(at) ?? null;
343
+ }
344
+ return true;
345
+ });
346
+ const states = new Map();
347
+ /*
348
+ * The published version if there is one, the newest stored version otherwise — never the live
349
+ * document. `page_versions.id` is a uuidv7, so `desc(id)` is newest-first without a sort on a
350
+ * timestamp two versions can share.
351
+ */
352
+ const pinned = kept.map((p) => p.publishedVersionId).filter((id) => id !== null);
353
+ if (pinned.length > 0) {
354
+ const rows = await tx
355
+ .select({ pageId: pageVersions.pageId, state: pageVersions.state })
356
+ .from(pageVersions)
357
+ .where(and(eq(pageVersions.workspaceId, workspaceId), inArray(pageVersions.id, pinned)));
358
+ for (const r of rows)
359
+ states.set(r.pageId, r.state);
360
+ }
361
+ for (const p of kept) {
362
+ if (states.has(p.id))
363
+ continue;
364
+ const [newest] = await tx
365
+ .select({ state: pageVersions.state })
366
+ .from(pageVersions)
367
+ .where(and(eq(pageVersions.workspaceId, workspaceId), eq(pageVersions.pageId, p.id)))
368
+ .orderBy(desc(pageVersions.id))
369
+ .limit(1);
370
+ if (newest)
371
+ states.set(p.id, newest.state);
372
+ }
373
+ return {
374
+ title,
375
+ skipped: inScope.length - kept.length,
376
+ pages: kept.map((p) => ({
377
+ id: p.id,
378
+ parentId: p.parentId,
379
+ title: p.title,
380
+ state: states.get(p.id) ?? null,
381
+ })),
382
+ };
383
+ }
384
+ /** The picture with this id, if it belongs to this workspace and is small enough to carry. */
385
+ async function attachment(workspaceId, fileId) {
386
+ try {
387
+ const file = await kernel.call('core.files.get', { id: fileId });
388
+ /*
389
+ * The workspace is compared here rather than trusted. `core.files.get` answers a **service**
390
+ * principal without a membership check — that is what makes it callable from a module at all —
391
+ * so this comparison is the only thing keeping an export inside its tenant. The id comes out
392
+ * of a page document, which is data somebody wrote.
393
+ */
394
+ if (!file || file.workspaceId !== workspaceId || file.status !== 'ready' || !file.key)
395
+ return null;
396
+ if (file.size > MAX_ATTACHMENT_BYTES)
397
+ return null;
398
+ const object = await kernel.storage.get(file.key);
399
+ const chunks = [];
400
+ for await (const chunk of object.body)
401
+ chunks.push(Buffer.from(chunk));
402
+ return { name: file.name, mimeType: file.mimeType, bytes: Buffer.concat(chunks) };
403
+ }
404
+ catch (err) {
405
+ // A deleted file, storage that is not configured, a picture somebody purged: the page is still
406
+ // worth exporting without it, and `renderPageDoc` drops an unresolvable picture by design.
407
+ kernel.log.warn({ err: String(err), fileId }, 'quire: an export could not read an attachment');
408
+ return null;
409
+ }
410
+ }
411
+ /**
412
+ * A zip of one folder per page: `index.md` or `index.html`, with `media/` beside it.
413
+ *
414
+ * Links between two pages that are both in the archive become relative paths, so the export opens
415
+ * and navigates on a laptop with nothing running. A mention of a page that is *not* in the
416
+ * archive — withheld, purged, or in another space — degrades to plain text rather than to a link
417
+ * that goes nowhere, the same rule `renderPageDoc` follows.
418
+ */
419
+ async function buildZip(workspaceId, prepared, format, counts, progress) {
420
+ const file = format === 'markdown' ? 'index.md' : 'index.html';
421
+ const folderOf = new Map(prepared.map((p) => [p.id, p.folder]));
422
+ const entries = [];
423
+ let written = 0;
424
+ for (const page of prepared) {
425
+ try {
426
+ const doc = pageDocFromState(page.state);
427
+ const media = new Map();
428
+ const taken = new Set();
429
+ for (const fileId of doc ? referencesIn(doc).fileIds : []) {
430
+ const found = await attachment(workspaceId, fileId);
431
+ if (!found)
432
+ continue;
433
+ const name = attachmentName(found.name, taken);
434
+ entries.push({ path: `${page.folder}/media/${name}`, data: found.bytes });
435
+ media.set(fileId, `media/${name}`);
436
+ }
437
+ const fileSrc = (id) => media.get(id) ?? null;
438
+ const pageHref = (id) => {
439
+ const to = folderOf.get(id);
440
+ return to === undefined ? null : relativeFolder(page.folder, to, file);
441
+ };
442
+ const title = page.title || 'Untitled';
443
+ let body;
444
+ if (format === 'markdown') {
445
+ body = `${markdownTitleLine(title)}\n\n${pageDocToMarkdown(doc, { fileSrc, pageHref })}`;
446
+ }
447
+ else {
448
+ /*
449
+ * Both of this page's addresses are ones `safeHref` refuses — a relative picture and a
450
+ * relative link to another file — so they go through the renderer as tokens and come back
451
+ * afterwards. See `exportLinks` for why that is the right place to bend rather than
452
+ * `safeHref`.
453
+ */
454
+ const links = exportLinks();
455
+ const html = renderPageDoc(doc, {
456
+ fileSrc: (id) => {
457
+ const path = fileSrc(id);
458
+ return path === null ? null : links.to(path);
459
+ },
460
+ pageHref: (id) => {
461
+ const path = pageHref(id);
462
+ return path === null ? null : links.to(path);
463
+ },
464
+ });
465
+ body = links.resolve(exportedHtmlDocument({
466
+ title,
467
+ pages: [{ id: page.id, title, trail: page.trail, html }],
468
+ }));
469
+ }
470
+ entries.push({ path: `${page.folder}/${file}`, data: Buffer.from(body, 'utf8') });
471
+ counts.done++;
472
+ }
473
+ catch (err) {
474
+ counts.failed++;
475
+ kernel.log.warn({ err: String(err), pageId: page.id }, 'quire: a page could not be exported');
476
+ }
477
+ if (++written % PROGRESS_EVERY === 0)
478
+ await progress();
479
+ }
480
+ return writeZip(entries);
481
+ }
482
+ /**
483
+ * One PDF for the whole export.
484
+ *
485
+ * Every page becomes a section of one document with a page break between them, so a subtree is a
486
+ * booklet rather than a folder of files — and a mention of another exported page becomes an
487
+ * internal link, which is the thing a PDF can do that a folder of PDFs cannot. Pictures are
488
+ * `data:` URIs: Gotenberg's Chromium fetches whatever the document references from inside its own
489
+ * container, so anything else would be a broken picture or a storage URL handed to a third
490
+ * process.
491
+ */
492
+ async function buildPdf(workspaceId, prepared, title, counts, progress) {
493
+ const known = new Set(prepared.map((p) => p.id));
494
+ // One token space for the whole document: the same picture on two pages is inlined once.
495
+ const links = exportLinks();
496
+ const sections = [];
497
+ let written = 0;
498
+ for (const page of prepared) {
499
+ try {
500
+ const doc = pageDocFromState(page.state);
501
+ const media = new Map();
502
+ for (const fileId of doc ? referencesIn(doc).fileIds : []) {
503
+ const found = await attachment(workspaceId, fileId);
504
+ if (!found)
505
+ continue;
506
+ media.set(fileId, `data:${found.mimeType};base64,${found.bytes.toString('base64')}`);
507
+ }
508
+ sections.push({
509
+ id: page.id,
510
+ title: page.title || 'Untitled',
511
+ trail: page.trail,
512
+ html: renderPageDoc(doc, {
513
+ // `data:` is one of the two shapes `safeHref` refuses; `#p-<id>` is a fragment, which it
514
+ // accepts, so only the picture needs a token.
515
+ fileSrc: (id) => {
516
+ const inlined = media.get(id);
517
+ return inlined === undefined ? null : links.to(inlined);
518
+ },
519
+ pageHref: (id) => (known.has(id) ? `#p-${id}` : null),
520
+ }),
521
+ });
522
+ counts.done++;
523
+ }
524
+ catch (err) {
525
+ counts.failed++;
526
+ kernel.log.warn({ err: String(err), pageId: page.id }, 'quire: a page could not be exported');
527
+ }
528
+ if (++written % PROGRESS_EVERY === 0)
529
+ await progress();
530
+ }
531
+ const document = links.resolve(exportedHtmlDocument({ title: title || 'Untitled', pages: sections }));
532
+ return htmlToPdf(document, { title });
533
+ }
534
+ return {
535
+ toExportJob,
536
+ /**
537
+ * Record the request and hand it to a worker.
538
+ *
539
+ * Nothing is rendered here. A whole space is unbounded work and a PDF is a round trip to
540
+ * Chromium, so the request answers with a row somebody can watch rather than a response somebody
541
+ * has to keep a browser open for. The row exists before the job is sent, which is the order that
542
+ * matters: a job whose row is missing fails, and a row whose job never arrived stays `queued`
543
+ * and can be sent again.
544
+ */
545
+ async start(tx, principal, workspaceId, input) {
546
+ if (input.format === 'docx')
547
+ throw KernError.badRequest(DOCX_REFUSAL, { format: 'docx' });
548
+ const userId = principal.userId;
549
+ if (!userId)
550
+ throw new KernError('FORBIDDEN', 'An export belongs to a person, and this caller is a service');
551
+ // Existence before anything is queued: an id that names nothing is a 404 now rather than a job
552
+ // that fails in a minute's time.
553
+ if (input.scope === 'space')
554
+ await access.spaceRow(tx, workspaceId, input.targetId);
555
+ else
556
+ await access.pageRow(tx, workspaceId, input.targetId);
557
+ const [row] = await tx
558
+ .insert(exportJobs)
559
+ .values({
560
+ id: uuidv7(),
561
+ workspaceId,
562
+ requestedBy: userId,
563
+ scope: input.scope,
564
+ targetId: input.targetId,
565
+ format: input.format,
566
+ state: 'queued',
567
+ counts: { total: 0, done: 0, skipped: 0, failed: 0 },
568
+ })
569
+ .returning();
570
+ if (!row)
571
+ throw new KernError('INTERNAL', 'The export could not be recorded');
572
+ return row;
573
+ },
574
+ /**
575
+ * One job, and the one place a *watched* job can give up on itself.
576
+ *
577
+ * The dialog polls this and never the list, so `reap` — which runs beside the list — would never
578
+ * reach the row somebody is actually looking at. One row rather than a workspace-wide update,
579
+ * because this is on a path that runs every second and a half while a job is moving: the write
580
+ * happens only for a row that is already stale, which is never, until it is.
581
+ */
582
+ async get(tx, workspaceId, jobId, principal) {
583
+ const row = await own(tx, workspaceId, jobId, principal);
584
+ if (!isAbandoned(row))
585
+ return row;
586
+ const [failed] = await tx
587
+ .update(exportJobs)
588
+ .set({ state: 'failed', error: ABANDONED, finishedAt: new Date() })
589
+ .where(and(eq(exportJobs.workspaceId, workspaceId), eq(exportJobs.id, jobId)))
590
+ .returning();
591
+ return failed ?? row;
592
+ },
593
+ /** This person's own exports, newest first. Somebody else's are not listed and cannot be. */
594
+ list(tx, workspaceId, principal, limit) {
595
+ const userId = principal.userId;
596
+ if (!userId)
597
+ return Promise.resolve([]);
598
+ return tx
599
+ .select()
600
+ .from(exportJobs)
601
+ .where(and(eq(exportJobs.workspaceId, workspaceId), eq(exportJobs.requestedBy, userId)))
602
+ .orderBy(desc(exportJobs.createdAt))
603
+ .limit(limit);
604
+ },
605
+ /**
606
+ * A short-lived link to the artefact, minted per request and never stored.
607
+ *
608
+ * This is the fence. A subtree export flattens pages of different readerships into one file, so
609
+ * whoever can fetch it can read everything that went into it — which is right for the person the
610
+ * permission check was run as and for nobody else. Minting the URL here rather than writing one
611
+ * into the row is what puts that check at the moment of the fetch instead of an hour earlier.
612
+ */
613
+ async downloadUrl(tx, workspaceId, row) {
614
+ if (row.state !== 'done' || !row.fileId)
615
+ return null;
616
+ const artefact = ARTEFACT[row.format] ?? ARTEFACT.markdown;
617
+ const [target] = row.scope === 'space'
618
+ ? await tx
619
+ .select({ title: spaces.name })
620
+ .from(spaces)
621
+ .where(and(eq(spaces.workspaceId, workspaceId), eq(spaces.id, row.targetId)))
622
+ .limit(1)
623
+ : await tx
624
+ .select({ title: pages.title })
625
+ .from(pages)
626
+ .where(and(eq(pages.workspaceId, workspaceId), eq(pages.id, row.targetId)))
627
+ .limit(1);
628
+ const name = `${slugifyTitle(target?.title || 'quire export')}.${artefact.extension}`;
629
+ try {
630
+ return await kernel.storage.presignGet(exportArtefactKey(workspaceId, row.fileId), {
631
+ expiresIn: DOWNLOAD_TTL_SEC,
632
+ filename: name,
633
+ disposition: 'attachment',
634
+ contentType: artefact.contentType,
635
+ });
636
+ }
637
+ catch (err) {
638
+ /*
639
+ * Signing is arithmetic and fails only when storage is not configured at all — an instance
640
+ * with no S3 credentials. A screen polling this every second must not be answered with a 500
641
+ * because of that: null means "no link", which is what there is.
642
+ */
643
+ kernel.log.warn({ err: String(err), jobId: row.id }, 'quire: an export link could not be signed');
644
+ return null;
645
+ }
646
+ },
647
+ /**
648
+ * Mark a job failed without having run it.
649
+ *
650
+ * There is exactly one caller: the router, when the queue refuses the job it has just recorded.
651
+ * Without it that row sits `queued` for ever and reads as work in progress, which is the worst
652
+ * of the three states to be wrong about.
653
+ */
654
+ async fail(tx, workspaceId, jobId, reason) {
655
+ await tx
656
+ .update(exportJobs)
657
+ .set({ state: 'failed', error: reason.slice(0, 2000), finishedAt: new Date() })
658
+ .where(and(eq(exportJobs.workspaceId, workspaceId), eq(exportJobs.id, jobId)));
659
+ },
660
+ /**
661
+ * Fail this workspace's abandoned jobs, so a dead one stops reading as work in progress.
662
+ *
663
+ * Called from the same two places as `sweep` and for the same reason — see the note there for
664
+ * why neither can be a cron job. It is deliberately a separate method: `sweep` deletes an
665
+ * artefact that has aged out of a job that *finished*, and this ends a job that never will.
666
+ *
667
+ * The screen that is being lied to is the one that fixes it: the transfers list polls
668
+ * `exports.list` every two seconds while anything is running, so an abandoned job is failed by
669
+ * the very poll that would otherwise draw its spinner for ever. `get` does the same for the one
670
+ * row a dialog is watching.
671
+ *
672
+ * Marked, never deleted. The row is the only record that somebody asked for this and did not get
673
+ * it, and it is what `sweep` will remove on its own schedule.
674
+ */
675
+ async reap(tx, workspaceId) {
676
+ const abandoned = await tx
677
+ .update(exportJobs)
678
+ .set({ state: 'failed', error: ABANDONED, finishedAt: new Date() })
679
+ .where(and(eq(exportJobs.workspaceId, workspaceId), inArray(exportJobs.state, ['queued', 'running']), lt(exportJobs.createdAt, new Date(Date.now() - ABANDONED_AFTER_MS))))
680
+ .returning({ id: exportJobs.id });
681
+ return abandoned.length;
682
+ },
683
+ /**
684
+ * Throw away this workspace's expired artefacts.
685
+ *
686
+ * Called from `start` and `list` rather than from a cron, and the reason is worth writing down:
687
+ * a scheduled sweep has to enumerate workspaces, and every tenant table here is under FORCE
688
+ * row-level security keyed on `app.workspace_id`, so a cross-workspace scan has nothing to scan
689
+ * with. Quire keeps no `workspaces` table of its own — the tracker does, which is how its cron
690
+ * jobs manage — and adding one is a migration rather than a service.
691
+ *
692
+ * The limit of doing it this way is real, which is why it is stated: a workspace whose exports
693
+ * nobody looks at again keeps its artefacts until somebody does. They are bounded by what that
694
+ * workspace exported, unreachable without a row, and the row is the only thing that addresses
695
+ * the object — so this is stale storage, not a leak.
696
+ */
697
+ async sweep(tx, workspaceId) {
698
+ const before = new Date(Date.now() - EXPORT_TTL_DAYS * 24 * 60 * 60 * 1000);
699
+ const stale = await tx
700
+ .delete(exportJobs)
701
+ .where(and(eq(exportJobs.workspaceId, workspaceId), lt(exportJobs.createdAt, before)))
702
+ .returning({ fileId: exportJobs.fileId });
703
+ await Promise.all(stale
704
+ .map((r) => r.fileId)
705
+ .filter((id) => id !== null)
706
+ .map((id) => kernel.storage.delete(exportArtefactKey(workspaceId, id)).catch(() => undefined)));
707
+ return stale.length;
708
+ },
709
+ /**
710
+ * The job.
711
+ *
712
+ * Three phases, and the split is not tidiness. The database work happens in short transactions;
713
+ * decoding a space's worth of Yjs, pulling its pictures out of storage and waiting on Chromium
714
+ * happen between them, holding nothing. The artefact is written to storage in one piece, and
715
+ * only then does the row point at it — so there is no moment at which `file_id` names bytes that
716
+ * are still arriving, and a failure anywhere leaves `file_id` null and the row `failed`.
717
+ */
718
+ async run(workspaceId, jobId) {
719
+ /*
720
+ * The claim is **one conditional UPDATE**, and reading the row first was the bug.
721
+ *
722
+ * `select` then `update` is a lost update under READ COMMITTED: two workers both read
723
+ * `queued`, both write `running`, and both run the job. That is not hypothetical here — a
724
+ * pg-boss job that outlives `expireInSeconds` (900 for an export, which `MAX_PAGES = 5000`
725
+ * makes an ordinary large space) is re-dispatched while the first attempt is still going. Both
726
+ * attempts then built the archive and `put` it under a fresh uuid, the row named one of them,
727
+ * and the other became an object in storage that nothing addresses — `sweep` deletes the
728
+ * object named by a row's `file_id`, so it could never reach it. Measured: two concurrent
729
+ * `run`s wrote two objects and orphaned one.
730
+ *
731
+ * `where state = 'queued'` closes it, because Postgres re-evaluates the predicate against the
732
+ * committed row after waiting for the other transaction's lock: the loser matches no row and
733
+ * returns. What that costs is automatic recovery — a row stays `running` until `reap` gives up
734
+ * on it after two hours, rather than being re-run by the next retry. That is the right trade
735
+ * in both directions: a second export attempt leaks an artefact, and a second *import* attempt
736
+ * writes every page in the archive twice.
737
+ */
738
+ const job = await kernel.database.withWorkspace(workspaceId, async (tx) => {
739
+ const [row] = await tx
740
+ .update(exportJobs)
741
+ .set({ state: 'running', error: null })
742
+ .where(and(eq(exportJobs.workspaceId, workspaceId), eq(exportJobs.id, jobId), eq(exportJobs.state, 'queued')))
743
+ .returning();
744
+ return row ?? null;
745
+ });
746
+ if (!job)
747
+ return;
748
+ await announce(workspaceId, job.requestedBy, jobId);
749
+ const counts = { total: 0, done: 0, skipped: 0, failed: 0 };
750
+ const progress = () => patch(workspaceId, job.requestedBy, jobId, { counts });
751
+ try {
752
+ const format = job.format;
753
+ if (format === 'docx')
754
+ throw KernError.badRequest(DOCX_REFUSAL, { format: 'docx' });
755
+ /*
756
+ * The export runs as the person who asked for it, which is the whole permission model here.
757
+ * A job has no principal of its own, so it fetches theirs — and when it cannot, it fails
758
+ * rather than falling back to something more permissive.
759
+ */
760
+ const principal = await kernel
761
+ .call('core.users.principal', { userId: job.requestedBy })
762
+ .catch(() => null);
763
+ if (!principal)
764
+ throw new KernError('INTERNAL', 'The person who asked for this export could not be identified');
765
+ /*
766
+ * Asked again, now, at the scope `exports.start` asked it at — because a job runs minutes
767
+ * after it was queued and a permission can be taken away in between.
768
+ *
769
+ * The per-page `quire.page.view` check below is not a substitute for this one, and the gap
770
+ * between them is exactly the shape of the hole: revoking somebody's *export* permission
771
+ * leaves every page they may still read, so a job queued a moment earlier finished with a
772
+ * complete archive of the space and `exports.get` signed a link to it. That is the one
773
+ * revocation the key exists for — the leaver's last afternoon — and it did not hold.
774
+ * `services/import.ts` re-asks for the same reason; the difference is only that an import
775
+ * writes, so the consequence of missing it was noticed there first.
776
+ */
777
+ await kernel.database.withWorkspace(workspaceId, async (tx) => {
778
+ if (job.scope === 'space') {
779
+ await access.spaceRow(tx, workspaceId, job.targetId);
780
+ await access.requireSpace(principal, 'quire.page.export', workspaceId, job.targetId);
781
+ }
782
+ else {
783
+ await access.pageRow(tx, workspaceId, job.targetId);
784
+ const scope = await access.scopeOf(tx, workspaceId, job.targetId);
785
+ await access.requirePage(principal, 'quire.page.export', workspaceId, scope);
786
+ }
787
+ });
788
+ const selected = await kernel.database.withWorkspace(workspaceId, (tx) => select(tx, principal, workspaceId, job));
789
+ counts.total = selected.pages.length + selected.skipped;
790
+ counts.skipped = selected.skipped;
791
+ await progress();
792
+ const prepared = prepareFolders(selected.pages);
793
+ const bytes = format === 'pdf'
794
+ ? await buildPdf(workspaceId, prepared, selected.title, counts, progress)
795
+ : await buildZip(workspaceId, prepared, format, counts, progress);
796
+ if (bytes.length > MAX_ARTEFACT_BYTES)
797
+ throw KernError.badRequest(`This export came to ${Math.round(bytes.length / 1_048_576)} MB and the limit is ` +
798
+ `${MAX_ARTEFACT_BYTES / 1_048_576} MB. Export a subtree at a time.`);
799
+ const fileId = uuidv7();
800
+ await kernel.storage.put(exportArtefactKey(workspaceId, fileId), bytes, ARTEFACT[format].contentType);
801
+ await patch(workspaceId, job.requestedBy, jobId, {
802
+ state: 'done',
803
+ fileId,
804
+ counts,
805
+ finishedAt: new Date(),
806
+ });
807
+ kernel.log.info({ jobId, workspaceId, format, bytes: bytes.length }, 'quire: export finished');
808
+ }
809
+ catch (err) {
810
+ const message = err instanceof Error ? err.message : String(err);
811
+ await patch(workspaceId, job.requestedBy, jobId, {
812
+ state: 'failed',
813
+ error: message.slice(0, 2000),
814
+ counts,
815
+ finishedAt: new Date(),
816
+ });
817
+ kernel.log.warn({ err: message, jobId, workspaceId }, 'quire: export failed');
818
+ }
819
+ },
820
+ };
821
+ }
822
+ //# sourceMappingURL=export.js.map