@mdfn/server 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/README.md +26 -0
- package/dist/index.cjs +737 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +159 -0
- package/dist/index.d.ts +159 -0
- package/dist/index.js +718 -0
- package/dist/index.js.map +1 -0
- package/package.json +9 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
MDFN_SERVER_SCHEMA_VERSION: () => MDFN_SERVER_SCHEMA_VERSION,
|
|
24
|
+
MDFN_SERVER_VERSION: () => MDFN_SERVER_VERSION,
|
|
25
|
+
MdfnServerError: () => MdfnServerError,
|
|
26
|
+
createMdfnRouter: () => createMdfnRouter,
|
|
27
|
+
createMdfnServer: () => createMdfnServer,
|
|
28
|
+
createMdfnService: () => createMdfnService,
|
|
29
|
+
getSchema: () => getSchema
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(index_exports);
|
|
32
|
+
var import_core = require("@mdfn/core");
|
|
33
|
+
var import_markdown = require("@mdfn/markdown");
|
|
34
|
+
var import_db = require("@superfunctions/db");
|
|
35
|
+
var import_http = require("@superfunctions/http");
|
|
36
|
+
var DOCUMENTS = "mdfnDocuments";
|
|
37
|
+
var VERSIONS = "mdfnVersions";
|
|
38
|
+
var RECEIPTS = "mdfnReceipts";
|
|
39
|
+
var COLLAB_UPDATES = "mdfnCollaborationUpdates";
|
|
40
|
+
var MDFN_SERVER_SCHEMA_VERSION = 2;
|
|
41
|
+
var MdfnServerError = class extends Error {
|
|
42
|
+
constructor(code, status, message = code) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.code = code;
|
|
45
|
+
this.status = status;
|
|
46
|
+
this.name = "MdfnServerError";
|
|
47
|
+
}
|
|
48
|
+
code;
|
|
49
|
+
status;
|
|
50
|
+
};
|
|
51
|
+
function configuredDocumentBytes(config) {
|
|
52
|
+
return config.markdown?.maxBytes ?? 2 * 1024 * 1024;
|
|
53
|
+
}
|
|
54
|
+
function configuredCollaborationUpdateBytes(config) {
|
|
55
|
+
if (config.maxCollaborationUpdateBytes !== void 0) return config.maxCollaborationUpdateBytes;
|
|
56
|
+
const documentBytes = configuredDocumentBytes(config);
|
|
57
|
+
const yjsOverhead = Math.max(64 * 1024, Math.ceil(documentBytes / 16));
|
|
58
|
+
return 4 * Math.ceil((documentBytes + yjsOverhead) / 3);
|
|
59
|
+
}
|
|
60
|
+
function collaborationCursorKey(previousKey) {
|
|
61
|
+
const match = previousKey?.match(/^v2:(\d{20})$/);
|
|
62
|
+
if (previousKey?.startsWith("v2:") && !match) throw new MdfnServerError("MDFN_COLLAB_CURSOR_INVALID", 500);
|
|
63
|
+
const sequence = (match ? BigInt(match[1]) : 0n) + 1n;
|
|
64
|
+
const encoded = sequence.toString().padStart(20, "0");
|
|
65
|
+
if (encoded.length > 20) throw new MdfnServerError("MDFN_COLLAB_CURSOR_EXHAUSTED", 500);
|
|
66
|
+
return `v2:${encoded}`;
|
|
67
|
+
}
|
|
68
|
+
function isUniqueConflict(error) {
|
|
69
|
+
const value = error;
|
|
70
|
+
const cause = value?.cause;
|
|
71
|
+
return /(?:unique|duplicate|23505|p2002)/i.test([value?.code, value?.message, cause?.code, cause?.message].filter(Boolean).join(" "));
|
|
72
|
+
}
|
|
73
|
+
function encodeCollaborationCursor(documentId, key) {
|
|
74
|
+
return encodeURIComponent(JSON.stringify({ documentId, key }));
|
|
75
|
+
}
|
|
76
|
+
function decodeCollaborationCursor(cursor, documentId) {
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(decodeURIComponent(cursor));
|
|
79
|
+
if (parsed.documentId !== documentId || typeof parsed.key !== "string" || !parsed.key) {
|
|
80
|
+
throw new Error("invalid cursor");
|
|
81
|
+
}
|
|
82
|
+
return parsed.key;
|
|
83
|
+
} catch {
|
|
84
|
+
throw new MdfnServerError("MDFN_COLLAB_CURSOR_INVALID", 422);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function getSchema() {
|
|
88
|
+
const date = { type: "date", required: true, dateValueType: "date" };
|
|
89
|
+
return { version: MDFN_SERVER_SCHEMA_VERSION, schemas: [
|
|
90
|
+
{ modelName: DOCUMENTS, fields: { id: { type: "string", required: true }, collaborationGeneration: { type: "string", required: true }, ownerId: { type: "string", required: true }, tenantId: { type: "string" }, title: { type: "string" }, markdown: { type: "string", required: true }, sourceHash: { type: "string", required: true }, schemaHash: { type: "string", required: true }, sidecar: { type: "json" }, version: { type: "number", required: true }, createdAt: date, updatedAt: date }, indexes: [{ name: "idx_mdfn_documents_owner", fields: ["ownerId"] }, { name: "idx_mdfn_documents_tenant", fields: ["tenantId"] }] },
|
|
91
|
+
{ modelName: VERSIONS, fields: { id: { type: "string", required: true }, documentId: { type: "string", required: true }, collaborationGeneration: { type: "string", required: true }, ownerId: { type: "string", required: true }, tenantId: { type: "string" }, title: { type: "string" }, markdown: { type: "string", required: true }, sourceHash: { type: "string", required: true }, schemaHash: { type: "string", required: true }, sidecar: { type: "json" }, version: { type: "number", required: true }, authorId: { type: "string", required: true }, changeSource: { type: "string", required: true }, createdAt: date, updatedAt: date }, indexes: [{ name: "idx_mdfn_versions_document_version", fields: ["documentId", "version"], unique: true }] },
|
|
92
|
+
{ modelName: RECEIPTS, fields: { id: { type: "string", required: true }, documentId: { type: "string", required: true }, idempotencyKey: { type: "string", required: true }, operation: { type: "string", required: true }, payloadHash: { type: "string", required: true }, result: { type: "json", required: true }, createdAt: date }, indexes: [{ name: "idx_mdfn_receipts_key", fields: ["documentId", "idempotencyKey"], unique: true }] },
|
|
93
|
+
{ modelName: COLLAB_UPDATES, fields: { id: { type: "string", required: true }, documentId: { type: "string", required: true, references: { model: DOCUMENTS, field: "id", onDelete: "cascade" } }, documentGeneration: { type: "string", required: true }, authorId: { type: "string", required: true }, update: { type: "string", required: true }, cursorKey: { type: "string", required: true }, createdAt: date }, indexes: [{ name: "idx_mdfn_collab_generation_cursor", fields: ["documentId", "documentGeneration", "cursorKey"], unique: true }] }
|
|
94
|
+
] };
|
|
95
|
+
}
|
|
96
|
+
async function allowed(config, action, principal, document) {
|
|
97
|
+
if (!await config.authorize(action, principal, document)) throw new MdfnServerError("MDFN_FORBIDDEN", 403);
|
|
98
|
+
}
|
|
99
|
+
function whereId(id) {
|
|
100
|
+
return [{ field: "id", operator: "eq", value: id }];
|
|
101
|
+
}
|
|
102
|
+
function createMdfnService(config) {
|
|
103
|
+
const database = (0, import_db.wrapWithSchema)(config.database, getSchema());
|
|
104
|
+
if ((config.durability ?? "required") === "required") {
|
|
105
|
+
if (!database.capabilities.transactions.supported) {
|
|
106
|
+
throw new MdfnServerError("MDFN_TRANSACTIONAL_DATABASE_REQUIRED", 500);
|
|
107
|
+
}
|
|
108
|
+
if (!database.capabilities.schema.constraints) {
|
|
109
|
+
throw new MdfnServerError("MDFN_RELATIONAL_CONSTRAINTS_REQUIRED", 500);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const registry = (0, import_core.resolveExtensions)(config.extensions ?? []);
|
|
113
|
+
const markdownOptions = { ...config.markdown, extensions: registry };
|
|
114
|
+
const createId = config.createId ?? (() => crypto.randomUUID());
|
|
115
|
+
const createCollaborationGeneration = config.createCollaborationGeneration ?? (() => crypto.randomUUID());
|
|
116
|
+
const now = config.now ?? (() => /* @__PURE__ */ new Date());
|
|
117
|
+
const collaborationUpdateBytes = configuredCollaborationUpdateBytes(config);
|
|
118
|
+
const collaborationBatchBytes = Math.max(
|
|
119
|
+
collaborationUpdateBytes,
|
|
120
|
+
config.maxCollaborationBatchBytes ?? collaborationUpdateBytes * 4
|
|
121
|
+
);
|
|
122
|
+
const collaborationBatchUpdates = Math.min(1e3, Math.max(1, config.maxCollaborationBatchUpdates ?? 100));
|
|
123
|
+
const parse = (source, sidecar) => {
|
|
124
|
+
try {
|
|
125
|
+
(0, import_markdown.parseMarkdown)(source, markdownOptions);
|
|
126
|
+
(0, import_core.validateMdfnSidecar)(sidecar, { markdownLength: source.length });
|
|
127
|
+
} catch {
|
|
128
|
+
throw new MdfnServerError("MDFN_DOCUMENT_INVALID", 422);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const loadFrom = async (storage, id) => {
|
|
132
|
+
const result = await storage.findOne({ model: DOCUMENTS, where: whereId(id) });
|
|
133
|
+
if (!result) throw new MdfnServerError("MDFN_DOCUMENT_NOT_FOUND", 404);
|
|
134
|
+
return result;
|
|
135
|
+
};
|
|
136
|
+
const load = (id) => loadFrom(database, id);
|
|
137
|
+
const loadScopedFrom = async (storage, principal, id) => {
|
|
138
|
+
const document = await loadFrom(storage, id);
|
|
139
|
+
const inScope = principal.tenantId ? document.tenantId === principal.tenantId : document.tenantId === void 0 && document.ownerId === principal.id;
|
|
140
|
+
if (!inScope) throw new MdfnServerError("MDFN_DOCUMENT_NOT_FOUND", 404);
|
|
141
|
+
return document;
|
|
142
|
+
};
|
|
143
|
+
const loadScoped = (principal, id) => loadScopedFrom(database, principal, id);
|
|
144
|
+
const revision = (document, authorId, changeSource) => ({ ...document, id: `${document.id}:${document.version}`, documentId: document.id, authorId, changeSource });
|
|
145
|
+
const withStorage = (callback) => database.capabilities.transactions.supported ? database.transaction((transaction) => callback(transaction)) : callback(database);
|
|
146
|
+
const collaborationWriteTails = /* @__PURE__ */ new Map();
|
|
147
|
+
const serializeCollaborationWrite = async (documentId, callback) => {
|
|
148
|
+
const previous = collaborationWriteTails.get(documentId) ?? Promise.resolve();
|
|
149
|
+
let release;
|
|
150
|
+
const gate = new Promise((resolve) => {
|
|
151
|
+
release = resolve;
|
|
152
|
+
});
|
|
153
|
+
const tail = previous.then(() => gate);
|
|
154
|
+
collaborationWriteTails.set(documentId, tail);
|
|
155
|
+
await previous;
|
|
156
|
+
try {
|
|
157
|
+
return await callback();
|
|
158
|
+
} finally {
|
|
159
|
+
release();
|
|
160
|
+
if (collaborationWriteTails.get(documentId) === tail) collaborationWriteTails.delete(documentId);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const withCollaborationWrite = (principal, documentId, action, callback) => serializeCollaborationWrite(documentId, async () => {
|
|
164
|
+
const authorizedDocument = await loadScoped(principal, documentId);
|
|
165
|
+
await allowed(config, action, principal, authorizedDocument);
|
|
166
|
+
const documentGeneration = authorizedDocument.collaborationGeneration;
|
|
167
|
+
let lastConflict;
|
|
168
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
169
|
+
try {
|
|
170
|
+
const result = await withStorage(async (storage) => {
|
|
171
|
+
const storedDocument = await loadScopedFrom(storage, principal, documentId);
|
|
172
|
+
if (storedDocument.collaborationGeneration !== documentGeneration) {
|
|
173
|
+
throw new MdfnServerError("MDFN_DOCUMENT_NOT_FOUND", 404);
|
|
174
|
+
}
|
|
175
|
+
const latest = await storage.findMany({
|
|
176
|
+
model: COLLAB_UPDATES,
|
|
177
|
+
where: [
|
|
178
|
+
{ field: "documentId", operator: "eq", value: documentId },
|
|
179
|
+
{ field: "documentGeneration", operator: "eq", value: documentGeneration }
|
|
180
|
+
],
|
|
181
|
+
select: ["cursorKey"],
|
|
182
|
+
orderBy: [{ field: "cursorKey", direction: "desc" }],
|
|
183
|
+
limit: 1
|
|
184
|
+
});
|
|
185
|
+
return callback(storage, collaborationCursorKey(latest[0]?.cursorKey), documentGeneration);
|
|
186
|
+
});
|
|
187
|
+
let latestDocument;
|
|
188
|
+
try {
|
|
189
|
+
latestDocument = await loadScoped(principal, documentId);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (!(error instanceof MdfnServerError && error.code === "MDFN_DOCUMENT_NOT_FOUND")) throw error;
|
|
192
|
+
}
|
|
193
|
+
if (latestDocument?.collaborationGeneration !== documentGeneration) {
|
|
194
|
+
await database.deleteMany({
|
|
195
|
+
model: COLLAB_UPDATES,
|
|
196
|
+
where: [
|
|
197
|
+
{ field: "documentId", operator: "eq", value: documentId },
|
|
198
|
+
{ field: "documentGeneration", operator: "eq", value: documentGeneration }
|
|
199
|
+
]
|
|
200
|
+
});
|
|
201
|
+
throw new MdfnServerError("MDFN_DOCUMENT_NOT_FOUND", 404);
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (!isUniqueConflict(error)) throw error;
|
|
206
|
+
lastConflict = error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
throw lastConflict;
|
|
210
|
+
});
|
|
211
|
+
let service;
|
|
212
|
+
const editorialActor = (principal) => ({ id: principal.id, createId, now: () => now().toISOString() });
|
|
213
|
+
const maxTitleBytes = config.maxTitleBytes ?? 512;
|
|
214
|
+
if (!Number.isSafeInteger(maxTitleBytes) || maxTitleBytes < 1) {
|
|
215
|
+
throw new MdfnServerError("MDFN_TITLE_LIMIT_INVALID", 500);
|
|
216
|
+
}
|
|
217
|
+
const validateTitle = (title) => {
|
|
218
|
+
if (title !== void 0 && new TextEncoder().encode(title).byteLength > maxTitleBytes) {
|
|
219
|
+
throw new MdfnServerError("MDFN_TITLE_TOO_LARGE", 413);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const canonicalJson = (value) => {
|
|
223
|
+
if (Array.isArray(value)) return value.map(canonicalJson);
|
|
224
|
+
if (value === null || typeof value !== "object") return value;
|
|
225
|
+
return Object.fromEntries(
|
|
226
|
+
Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, entry]) => [key, canonicalJson(entry)])
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
const hashValue = (value) => (0, import_core.hashString)(JSON.stringify(canonicalJson(value)));
|
|
230
|
+
const protectedSidecar = (value) => JSON.stringify(canonicalJson({
|
|
231
|
+
comments: value?.comments ?? [],
|
|
232
|
+
suggestions: value?.suggestions ?? [],
|
|
233
|
+
reviewState: value?.reviewState ?? "draft",
|
|
234
|
+
audit: value?.audit ?? []
|
|
235
|
+
}));
|
|
236
|
+
const coarseChangedRanges = (before, after, offset) => {
|
|
237
|
+
const ranges = [];
|
|
238
|
+
const syncLength = 16;
|
|
239
|
+
const initialLookahead = 2048;
|
|
240
|
+
let remainingSearchWork = 65536;
|
|
241
|
+
let left = 0;
|
|
242
|
+
let right = 0;
|
|
243
|
+
let position = offset;
|
|
244
|
+
while (left < before.length || right < after.length) {
|
|
245
|
+
while (left < before.length && right < after.length && before.charCodeAt(left) === after.charCodeAt(right)) {
|
|
246
|
+
left += 1;
|
|
247
|
+
right += 1;
|
|
248
|
+
position += 1;
|
|
249
|
+
}
|
|
250
|
+
if (left === before.length && right === after.length) break;
|
|
251
|
+
const from = position;
|
|
252
|
+
let match;
|
|
253
|
+
let lookahead = initialLookahead;
|
|
254
|
+
const beforeAnchors = /* @__PURE__ */ new Map();
|
|
255
|
+
const afterAnchors = /* @__PURE__ */ new Map();
|
|
256
|
+
let beforeScannedTo = left - 1;
|
|
257
|
+
let afterScannedTo = right - 1;
|
|
258
|
+
while (!match) {
|
|
259
|
+
const beforeLimit = Math.min(before.length - syncLength, left + lookahead);
|
|
260
|
+
for (let candidate = beforeScannedTo + 1; candidate <= beforeLimit; candidate += 1) {
|
|
261
|
+
if (remainingSearchWork === 0) break;
|
|
262
|
+
remainingSearchWork -= 1;
|
|
263
|
+
const key = before.slice(candidate, candidate + syncLength);
|
|
264
|
+
if (!beforeAnchors.has(key)) beforeAnchors.set(key, candidate);
|
|
265
|
+
const matchingRight = afterAnchors.get(key);
|
|
266
|
+
if (matchingRight !== void 0) {
|
|
267
|
+
const cost = candidate - left + matchingRight - right;
|
|
268
|
+
if (!match || cost < match.cost) match = { left: candidate, right: matchingRight, cost };
|
|
269
|
+
}
|
|
270
|
+
beforeScannedTo = candidate;
|
|
271
|
+
}
|
|
272
|
+
const afterLimit = Math.min(after.length - syncLength, right + lookahead);
|
|
273
|
+
for (let candidate = afterScannedTo + 1; candidate <= afterLimit; candidate += 1) {
|
|
274
|
+
if (remainingSearchWork === 0) break;
|
|
275
|
+
remainingSearchWork -= 1;
|
|
276
|
+
const key = after.slice(candidate, candidate + syncLength);
|
|
277
|
+
if (!afterAnchors.has(key)) afterAnchors.set(key, candidate);
|
|
278
|
+
const matchingLeft = beforeAnchors.get(key);
|
|
279
|
+
if (matchingLeft !== void 0) {
|
|
280
|
+
const cost = matchingLeft - left + candidate - right;
|
|
281
|
+
if (!match || cost < match.cost) match = { left: matchingLeft, right: candidate, cost };
|
|
282
|
+
}
|
|
283
|
+
afterScannedTo = candidate;
|
|
284
|
+
}
|
|
285
|
+
const searchedAllBefore = before.length - left < syncLength || beforeScannedTo >= before.length - syncLength;
|
|
286
|
+
const searchedAllAfter = after.length - right < syncLength || afterScannedTo >= after.length - syncLength;
|
|
287
|
+
if (match || remainingSearchWork === 0 || searchedAllBefore && searchedAllAfter) break;
|
|
288
|
+
lookahead = Math.min(
|
|
289
|
+
Math.max(before.length - left, after.length - right),
|
|
290
|
+
lookahead * 2
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (!match || match.cost === 0) {
|
|
294
|
+
ranges.push({ from, to: from + before.length - left, insertedLength: after.length - right });
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
const removed = match.left - left;
|
|
298
|
+
const insertedLength = match.right - right;
|
|
299
|
+
ranges.push({ from, to: from + removed, insertedLength });
|
|
300
|
+
left = match.left;
|
|
301
|
+
right = match.right;
|
|
302
|
+
position += insertedLength;
|
|
303
|
+
}
|
|
304
|
+
return ranges;
|
|
305
|
+
};
|
|
306
|
+
const changedRanges = (previous, next) => {
|
|
307
|
+
if (previous === next) return [];
|
|
308
|
+
let prefix = 0;
|
|
309
|
+
while (prefix < previous.length && prefix < next.length && previous.charCodeAt(prefix) === next.charCodeAt(prefix)) prefix += 1;
|
|
310
|
+
let previousEnd = previous.length;
|
|
311
|
+
let nextEnd = next.length;
|
|
312
|
+
while (previousEnd > prefix && nextEnd > prefix && previous.charCodeAt(previousEnd - 1) === next.charCodeAt(nextEnd - 1)) {
|
|
313
|
+
previousEnd -= 1;
|
|
314
|
+
nextEnd -= 1;
|
|
315
|
+
}
|
|
316
|
+
const before = previous.slice(prefix, previousEnd);
|
|
317
|
+
const after = next.slice(prefix, nextEnd);
|
|
318
|
+
if (before.length * after.length > 25e4) return coarseChangedRanges(before, after, prefix);
|
|
319
|
+
const width = after.length + 1;
|
|
320
|
+
const lcs = new Uint32Array((before.length + 1) * width);
|
|
321
|
+
for (let left2 = before.length - 1; left2 >= 0; left2 -= 1) {
|
|
322
|
+
for (let right2 = after.length - 1; right2 >= 0; right2 -= 1) {
|
|
323
|
+
const index = left2 * width + right2;
|
|
324
|
+
lcs[index] = before.charCodeAt(left2) === after.charCodeAt(right2) ? lcs[(left2 + 1) * width + right2 + 1] + 1 : Math.max(lcs[(left2 + 1) * width + right2], lcs[index + 1]);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const ranges = [];
|
|
328
|
+
let left = 0;
|
|
329
|
+
let right = 0;
|
|
330
|
+
let position = prefix;
|
|
331
|
+
while (left < before.length || right < after.length) {
|
|
332
|
+
if (left < before.length && right < after.length && before.charCodeAt(left) === after.charCodeAt(right)) {
|
|
333
|
+
left += 1;
|
|
334
|
+
right += 1;
|
|
335
|
+
position += 1;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const from = position;
|
|
339
|
+
let removed = 0;
|
|
340
|
+
let insertedLength = 0;
|
|
341
|
+
while (left < before.length || right < after.length) {
|
|
342
|
+
if (left < before.length && right < after.length && before.charCodeAt(left) === after.charCodeAt(right)) break;
|
|
343
|
+
if (right < after.length && (left === before.length || lcs[left * width + right + 1] >= lcs[(left + 1) * width + right])) {
|
|
344
|
+
right += 1;
|
|
345
|
+
insertedLength += 1;
|
|
346
|
+
} else {
|
|
347
|
+
left += 1;
|
|
348
|
+
removed += 1;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
ranges.push({ from, to: from + removed, insertedLength });
|
|
352
|
+
position += insertedLength;
|
|
353
|
+
}
|
|
354
|
+
return ranges;
|
|
355
|
+
};
|
|
356
|
+
const mapForMarkdownChange = (sidecar, previous, next) => {
|
|
357
|
+
if (!sidecar || previous === next) return sidecar;
|
|
358
|
+
return (0, import_core.mapSidecar)(sidecar, changedRanges(previous, next));
|
|
359
|
+
};
|
|
360
|
+
const replayReceipt = async (storage, documentId, idempotencyKey, operation, payloadHash) => {
|
|
361
|
+
const receipt = await storage.findOne({ model: RECEIPTS, where: [{ field: "documentId", operator: "eq", value: documentId }, { field: "idempotencyKey", operator: "eq", value: idempotencyKey }] });
|
|
362
|
+
if (!receipt) return void 0;
|
|
363
|
+
if (receipt.operation !== operation || receipt.payloadHash !== payloadHash) throw new MdfnServerError("MDFN_IDEMPOTENCY_KEY_REUSED", 409);
|
|
364
|
+
return receipt.result;
|
|
365
|
+
};
|
|
366
|
+
const writeUpdate = async (principal, id, input, options = {}) => {
|
|
367
|
+
const current = await loadScoped(principal, id);
|
|
368
|
+
await allowed(config, options.authorizationAction ?? "update", principal, current);
|
|
369
|
+
const operation = options.idempotencyOperation ?? "document:update";
|
|
370
|
+
const payloadHash = hashValue(options.idempotencyPayload ?? {
|
|
371
|
+
...input,
|
|
372
|
+
idempotencyKey: void 0,
|
|
373
|
+
restoreSnapshot: options.restoreSnapshot
|
|
374
|
+
});
|
|
375
|
+
if (input.idempotencyKey) {
|
|
376
|
+
const replay = await replayReceipt(database, id, input.idempotencyKey, operation, payloadHash);
|
|
377
|
+
if (replay) return replay;
|
|
378
|
+
}
|
|
379
|
+
if (current.version !== input.expectedVersion) throw new MdfnServerError("MDFN_VERSION_CONFLICT", 409);
|
|
380
|
+
const markdown = options.restoreSnapshot?.markdown ?? input.markdown ?? current.markdown;
|
|
381
|
+
if (!options.trustedEditorial && input.sidecar && protectedSidecar(input.sidecar) !== protectedSidecar(current.sidecar)) {
|
|
382
|
+
throw new MdfnServerError("MDFN_EDITORIAL_MUTATION_FORBIDDEN", 403);
|
|
383
|
+
}
|
|
384
|
+
const mappedSidecar = mapForMarkdownChange(current.sidecar, current.markdown, markdown);
|
|
385
|
+
const sidecar = options.restoreSnapshot ? options.restoreSnapshot.sidecar : options.trustedEditorial ? input.sidecar ?? mappedSidecar : input.sidecar ? {
|
|
386
|
+
...input.sidecar,
|
|
387
|
+
comments: mappedSidecar?.comments,
|
|
388
|
+
suggestions: mappedSidecar?.suggestions,
|
|
389
|
+
reviewState: mappedSidecar?.reviewState,
|
|
390
|
+
audit: mappedSidecar?.audit
|
|
391
|
+
} : mappedSidecar;
|
|
392
|
+
parse(markdown, sidecar);
|
|
393
|
+
const title = options.restoreSnapshot ? options.restoreSnapshot.title : input.title ?? current.title;
|
|
394
|
+
validateTitle(title);
|
|
395
|
+
const next = { ...current, title, markdown, sourceHash: (0, import_core.hashString)(markdown), schemaHash: registry.schemaHash, sidecar, version: current.version + 1, updatedAt: now() };
|
|
396
|
+
return withStorage(async (storage) => {
|
|
397
|
+
if (input.idempotencyKey) {
|
|
398
|
+
const replay = await replayReceipt(storage, id, input.idempotencyKey, operation, payloadHash);
|
|
399
|
+
if (replay) return replay;
|
|
400
|
+
}
|
|
401
|
+
const latest = await storage.findOne({ model: DOCUMENTS, where: whereId(id) });
|
|
402
|
+
if (!latest || latest.version !== input.expectedVersion) throw new MdfnServerError("MDFN_VERSION_CONFLICT", 409);
|
|
403
|
+
const updated = await storage.update({ model: DOCUMENTS, where: [{ field: "id", operator: "eq", value: id }, { field: "version", operator: "eq", value: input.expectedVersion }], data: next });
|
|
404
|
+
await storage.create({ model: VERSIONS, data: revision(updated, principal.id, input.changeSource ?? "update") });
|
|
405
|
+
if (input.idempotencyKey) await storage.create({ model: RECEIPTS, data: { id: createId(), documentId: id, idempotencyKey: input.idempotencyKey, operation, payloadHash, result: updated, createdAt: now() } });
|
|
406
|
+
return updated;
|
|
407
|
+
});
|
|
408
|
+
};
|
|
409
|
+
const mutateEditorial = async (principal, id, expectedVersion, authorizationAction, changeSource, idempotencyKey, idempotencyPayload, mutate) => {
|
|
410
|
+
const current = await loadScoped(principal, id);
|
|
411
|
+
await allowed(config, authorizationAction, principal, current);
|
|
412
|
+
if (idempotencyKey) {
|
|
413
|
+
const replay = await replayReceipt(database, id, idempotencyKey, changeSource, hashValue(idempotencyPayload));
|
|
414
|
+
if (replay) return replay;
|
|
415
|
+
}
|
|
416
|
+
if (current.version !== expectedVersion) throw new MdfnServerError("MDFN_VERSION_CONFLICT", 409);
|
|
417
|
+
try {
|
|
418
|
+
const next = mutate(current);
|
|
419
|
+
return writeUpdate(principal, id, { expectedVersion, markdown: next.markdown, sidecar: next.sidecar, changeSource, idempotencyKey }, { trustedEditorial: true, authorizationAction, idempotencyOperation: changeSource, idempotencyPayload });
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if (error instanceof MdfnServerError) throw error;
|
|
422
|
+
throw new MdfnServerError("MDFN_EDITORIAL_INVALID", 422);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
service = {
|
|
426
|
+
async create(principal, input) {
|
|
427
|
+
await allowed(config, "create", principal);
|
|
428
|
+
parse(input.markdown, input.sidecar);
|
|
429
|
+
validateTitle(input.title);
|
|
430
|
+
if (input.id !== void 0 && (typeof input.id !== "string" || input.id.trim().length === 0)) {
|
|
431
|
+
throw new MdfnServerError("MDFN_DOCUMENT_ID_INVALID", 422);
|
|
432
|
+
}
|
|
433
|
+
if (input.sidecar && protectedSidecar(input.sidecar) !== protectedSidecar(void 0)) {
|
|
434
|
+
throw new MdfnServerError("MDFN_EDITORIAL_MUTATION_FORBIDDEN", 403);
|
|
435
|
+
}
|
|
436
|
+
const createdAt = now();
|
|
437
|
+
const collaborationGeneration = createCollaborationGeneration();
|
|
438
|
+
if (typeof collaborationGeneration !== "string" || collaborationGeneration.length === 0) {
|
|
439
|
+
throw new MdfnServerError("MDFN_COLLAB_GENERATION_INVALID", 500);
|
|
440
|
+
}
|
|
441
|
+
const document = { id: input.id ?? createId(), collaborationGeneration, ownerId: principal.id, tenantId: principal.tenantId, title: input.title, markdown: input.markdown, sourceHash: (0, import_core.hashString)(input.markdown), schemaHash: registry.schemaHash, sidecar: input.sidecar, version: 1, createdAt, updatedAt: createdAt };
|
|
442
|
+
return withStorage(async (storage) => {
|
|
443
|
+
const created = await storage.create({ model: DOCUMENTS, data: document });
|
|
444
|
+
await storage.create({ model: VERSIONS, data: revision(created, principal.id, "create") });
|
|
445
|
+
return created;
|
|
446
|
+
});
|
|
447
|
+
},
|
|
448
|
+
async read(principal, id) {
|
|
449
|
+
const document = await loadScoped(principal, id);
|
|
450
|
+
await allowed(config, "read", principal, document);
|
|
451
|
+
return document;
|
|
452
|
+
},
|
|
453
|
+
async list(principal, options = {}) {
|
|
454
|
+
await allowed(config, "read", principal);
|
|
455
|
+
const where = principal.tenantId ? [{ field: "tenantId", operator: "eq", value: principal.tenantId }] : [{ field: "ownerId", operator: "eq", value: principal.id }];
|
|
456
|
+
const offset = Math.max(0, options.offset ?? 0);
|
|
457
|
+
const limit = Math.min(100, Math.max(1, options.limit ?? 50));
|
|
458
|
+
const pageSize = Math.max(25, limit);
|
|
459
|
+
const visible = [];
|
|
460
|
+
let databaseOffset = 0;
|
|
461
|
+
let authorizedOffset = 0;
|
|
462
|
+
while (visible.length < limit) {
|
|
463
|
+
const candidates = await database.findMany({
|
|
464
|
+
model: DOCUMENTS,
|
|
465
|
+
where,
|
|
466
|
+
orderBy: [{ field: "updatedAt", direction: "desc" }, { field: "id", direction: "desc" }],
|
|
467
|
+
limit: pageSize,
|
|
468
|
+
offset: databaseOffset
|
|
469
|
+
});
|
|
470
|
+
databaseOffset += candidates.length;
|
|
471
|
+
for (const document of candidates) {
|
|
472
|
+
if (!principal.tenantId && document.tenantId !== void 0 && document.tenantId !== null) continue;
|
|
473
|
+
if (!await config.authorize("read", principal, document)) continue;
|
|
474
|
+
if (authorizedOffset < offset) {
|
|
475
|
+
authorizedOffset += 1;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
visible.push(document);
|
|
479
|
+
if (visible.length === limit) break;
|
|
480
|
+
}
|
|
481
|
+
if (candidates.length < pageSize) break;
|
|
482
|
+
}
|
|
483
|
+
return visible;
|
|
484
|
+
},
|
|
485
|
+
async update(principal, id, input) {
|
|
486
|
+
return writeUpdate(principal, id, input);
|
|
487
|
+
},
|
|
488
|
+
async delete(principal, id) {
|
|
489
|
+
await serializeCollaborationWrite(id, async () => {
|
|
490
|
+
const current = await loadScoped(principal, id);
|
|
491
|
+
await allowed(config, "delete", principal, current);
|
|
492
|
+
await withStorage(async (storage) => {
|
|
493
|
+
const documentWhere = [{ field: "documentId", operator: "eq", value: id }];
|
|
494
|
+
await storage.deleteMany({ model: COLLAB_UPDATES, where: documentWhere });
|
|
495
|
+
await storage.deleteMany({ model: RECEIPTS, where: documentWhere });
|
|
496
|
+
await storage.deleteMany({ model: VERSIONS, where: documentWhere });
|
|
497
|
+
await storage.delete({ model: DOCUMENTS, where: whereId(id) });
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
},
|
|
501
|
+
async versions(principal, id, options = {}) {
|
|
502
|
+
const document = await loadScoped(principal, id);
|
|
503
|
+
await allowed(config, "history", principal, document);
|
|
504
|
+
const cursor = options.cursor === void 0 ? void 0 : Number(options.cursor);
|
|
505
|
+
const requestedLimit = options.limit ?? 50;
|
|
506
|
+
if (cursor !== void 0 && (!Number.isSafeInteger(cursor) || cursor < 1) || !Number.isSafeInteger(requestedLimit) || requestedLimit < 1) {
|
|
507
|
+
throw new MdfnServerError("MDFN_VERSION_CURSOR_INVALID", 422);
|
|
508
|
+
}
|
|
509
|
+
const limit = Math.min(100, requestedLimit);
|
|
510
|
+
const where = [
|
|
511
|
+
{ field: "documentId", operator: "eq", value: id },
|
|
512
|
+
...cursor === void 0 ? [] : [{ field: "version", operator: "lt", value: cursor }]
|
|
513
|
+
];
|
|
514
|
+
const rows = await database.findMany({
|
|
515
|
+
model: VERSIONS,
|
|
516
|
+
where,
|
|
517
|
+
select: [
|
|
518
|
+
"id",
|
|
519
|
+
"documentId",
|
|
520
|
+
"collaborationGeneration",
|
|
521
|
+
"ownerId",
|
|
522
|
+
"tenantId",
|
|
523
|
+
"title",
|
|
524
|
+
"sourceHash",
|
|
525
|
+
"schemaHash",
|
|
526
|
+
"version",
|
|
527
|
+
"authorId",
|
|
528
|
+
"changeSource",
|
|
529
|
+
"createdAt",
|
|
530
|
+
"updatedAt"
|
|
531
|
+
],
|
|
532
|
+
orderBy: [{ field: "version", direction: "desc" }],
|
|
533
|
+
limit: limit + 1
|
|
534
|
+
});
|
|
535
|
+
const versions = rows.slice(0, limit);
|
|
536
|
+
return {
|
|
537
|
+
versions,
|
|
538
|
+
...rows.length > limit ? { nextCursor: String(versions.at(-1).version) } : {}
|
|
539
|
+
};
|
|
540
|
+
},
|
|
541
|
+
async version(principal, id, version) {
|
|
542
|
+
const document = await loadScoped(principal, id);
|
|
543
|
+
await allowed(config, "history", principal, document);
|
|
544
|
+
const result = await database.findOne({ model: VERSIONS, where: [{ field: "documentId", operator: "eq", value: id }, { field: "version", operator: "eq", value: version }] });
|
|
545
|
+
if (!result) throw new MdfnServerError("MDFN_VERSION_NOT_FOUND", 404);
|
|
546
|
+
return result;
|
|
547
|
+
},
|
|
548
|
+
async restoreVersion(principal, id, input) {
|
|
549
|
+
const current = await loadScoped(principal, id);
|
|
550
|
+
await allowed(config, "history:restore", principal, current);
|
|
551
|
+
const restored = await database.findOne({
|
|
552
|
+
model: VERSIONS,
|
|
553
|
+
where: [
|
|
554
|
+
{ field: "documentId", operator: "eq", value: id },
|
|
555
|
+
{ field: "version", operator: "eq", value: input.version }
|
|
556
|
+
]
|
|
557
|
+
});
|
|
558
|
+
if (!restored) throw new MdfnServerError("MDFN_VERSION_NOT_FOUND", 404);
|
|
559
|
+
return writeUpdate(
|
|
560
|
+
principal,
|
|
561
|
+
id,
|
|
562
|
+
{ expectedVersion: input.expectedVersion, changeSource: `restore:${input.version}`, idempotencyKey: input.idempotencyKey },
|
|
563
|
+
{
|
|
564
|
+
trustedEditorial: true,
|
|
565
|
+
authorizationAction: "history:restore",
|
|
566
|
+
restoreSnapshot: restored,
|
|
567
|
+
idempotencyOperation: "document:restore",
|
|
568
|
+
idempotencyPayload: input
|
|
569
|
+
}
|
|
570
|
+
);
|
|
571
|
+
},
|
|
572
|
+
async createComment(principal, id, input) {
|
|
573
|
+
return mutateEditorial(principal, id, input.expectedVersion, "comment:create", "editorial:comment-created", input.idempotencyKey, input, (document) => (0, import_core.createCommentThread)({ sidecar: document.sidecar, anchor: input.anchor, body: input.body, actor: editorialActor(principal), markdownLength: document.markdown.length }));
|
|
574
|
+
},
|
|
575
|
+
async replyComment(principal, id, threadId, input) {
|
|
576
|
+
return mutateEditorial(principal, id, input.expectedVersion, "comment:reply", "editorial:comment-replied", input.idempotencyKey, { threadId, ...input }, (document) => ({ sidecar: (0, import_core.replyToComment)({ sidecar: document.sidecar ?? {}, threadId, body: input.body, actor: editorialActor(principal) }) }));
|
|
577
|
+
},
|
|
578
|
+
async resolveComment(principal, id, threadId, input) {
|
|
579
|
+
return mutateEditorial(principal, id, input.expectedVersion, "comment:resolve", input.resolved ? "editorial:comment-resolved" : "editorial:comment-reopened", input.idempotencyKey, { threadId, ...input }, (document) => ({ sidecar: (0, import_core.setCommentResolved)({ sidecar: document.sidecar ?? {}, threadId, resolved: input.resolved, actor: editorialActor(principal) }) }));
|
|
580
|
+
},
|
|
581
|
+
async createSuggestion(principal, id, input) {
|
|
582
|
+
return mutateEditorial(principal, id, input.expectedVersion, "suggestion:create", "editorial:suggestion-created", input.idempotencyKey, input, (document) => (0, import_core.createSuggestion)({ sidecar: document.sidecar, anchor: input.anchor, replacement: input.replacement, actor: editorialActor(principal), markdownLength: document.markdown.length }));
|
|
583
|
+
},
|
|
584
|
+
async decideSuggestion(principal, id, suggestionId, input) {
|
|
585
|
+
return mutateEditorial(principal, id, input.expectedVersion, "suggestion:decide", `editorial:suggestion-${input.decision}`, input.idempotencyKey, { suggestionId, ...input }, (document) => {
|
|
586
|
+
const controller = (0, import_core.createEditor)({ markdown: document.markdown, projector: (0, import_markdown.createMarkdownProjector)(markdownOptions), extensions: config.extensions, sidecar: document.sidecar });
|
|
587
|
+
try {
|
|
588
|
+
(0, import_core.decideSuggestion)({ controller, suggestionId, decision: input.decision, actor: editorialActor(principal) });
|
|
589
|
+
const state = controller.getState();
|
|
590
|
+
return { markdown: state.markdown, sidecar: state.sidecar ?? {} };
|
|
591
|
+
} finally {
|
|
592
|
+
controller.destroy();
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
},
|
|
596
|
+
async transitionReview(principal, id, input) {
|
|
597
|
+
return mutateEditorial(principal, id, input.expectedVersion, "review:transition", "editorial:review-transitioned", input.idempotencyKey, input, (document) => ({ sidecar: (0, import_core.transitionReview)({ sidecar: document.sidecar, to: input.state, actor: editorialActor(principal) }) }));
|
|
598
|
+
},
|
|
599
|
+
async appendCollaborationUpdate(principal, id, update) {
|
|
600
|
+
if (new TextEncoder().encode(update).byteLength > collaborationUpdateBytes) throw new MdfnServerError("MDFN_COLLAB_UPDATE_TOO_LARGE", 413);
|
|
601
|
+
const updateId = createId();
|
|
602
|
+
const createdAt = now();
|
|
603
|
+
await withCollaborationWrite(principal, id, "collaborate", async (storage, cursorKey, documentGeneration) => {
|
|
604
|
+
await storage.create({
|
|
605
|
+
model: COLLAB_UPDATES,
|
|
606
|
+
data: { id: updateId, documentId: id, documentGeneration, authorId: principal.id, update, cursorKey, createdAt }
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
return updateId;
|
|
610
|
+
},
|
|
611
|
+
async collaborationUpdates(principal, id, options = {}) {
|
|
612
|
+
const document = await loadScoped(principal, id);
|
|
613
|
+
await allowed(config, "collaborate", principal, document);
|
|
614
|
+
const requestedLimit = options.limit ?? collaborationBatchUpdates;
|
|
615
|
+
if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) {
|
|
616
|
+
throw new MdfnServerError("MDFN_COLLAB_CURSOR_INVALID", 422);
|
|
617
|
+
}
|
|
618
|
+
const limit = Math.min(collaborationBatchUpdates, requestedLimit);
|
|
619
|
+
const cursor = options.cursor === void 0 ? void 0 : decodeCollaborationCursor(options.cursor, id);
|
|
620
|
+
const rows = await database.findMany({
|
|
621
|
+
model: COLLAB_UPDATES,
|
|
622
|
+
where: [
|
|
623
|
+
{ field: "documentId", operator: "eq", value: id },
|
|
624
|
+
{ field: "documentGeneration", operator: "eq", value: document.collaborationGeneration },
|
|
625
|
+
...cursor === void 0 ? [] : [{ field: "cursorKey", operator: "gt", value: cursor }]
|
|
626
|
+
],
|
|
627
|
+
orderBy: [{ field: "cursorKey", direction: "asc" }],
|
|
628
|
+
limit: limit + 1
|
|
629
|
+
});
|
|
630
|
+
const included = [];
|
|
631
|
+
let encodedBytes = 0;
|
|
632
|
+
for (const row of rows.slice(0, limit)) {
|
|
633
|
+
const rowBytes = new TextEncoder().encode(row.update).byteLength;
|
|
634
|
+
if (included.length > 0 && encodedBytes + rowBytes > collaborationBatchBytes) break;
|
|
635
|
+
included.push(row);
|
|
636
|
+
encodedBytes += rowBytes;
|
|
637
|
+
}
|
|
638
|
+
const hasMore = included.length < rows.length;
|
|
639
|
+
return {
|
|
640
|
+
updates: included.map((row) => row.update),
|
|
641
|
+
includedUpdateIds: included.map((row) => row.id),
|
|
642
|
+
...hasMore ? { nextCursor: encodeCollaborationCursor(id, included.at(-1).cursorKey) } : {}
|
|
643
|
+
};
|
|
644
|
+
},
|
|
645
|
+
async compactCollaborationUpdates(principal, id, snapshot, includedUpdateIds) {
|
|
646
|
+
if (new TextEncoder().encode(snapshot).byteLength > collaborationUpdateBytes) throw new MdfnServerError("MDFN_COLLAB_UPDATE_TOO_LARGE", 413);
|
|
647
|
+
if (!Array.isArray(includedUpdateIds) || includedUpdateIds.some((updateId2) => typeof updateId2 !== "string" || !updateId2)) {
|
|
648
|
+
throw new MdfnServerError("MDFN_COLLAB_COMPACTION_INVALID", 422);
|
|
649
|
+
}
|
|
650
|
+
const uniqueUpdateIds = [...new Set(includedUpdateIds)];
|
|
651
|
+
const updateId = createId();
|
|
652
|
+
const createdAt = now();
|
|
653
|
+
await withCollaborationWrite(principal, id, "compact-collaboration", async (storage, cursorKey, documentGeneration) => {
|
|
654
|
+
if (uniqueUpdateIds.length > 0) {
|
|
655
|
+
await storage.deleteMany({ model: COLLAB_UPDATES, where: [{ field: "documentId", operator: "eq", value: id }, { field: "documentGeneration", operator: "eq", value: documentGeneration }, { field: "id", operator: "in", value: uniqueUpdateIds }] });
|
|
656
|
+
}
|
|
657
|
+
await storage.create({ model: COLLAB_UPDATES, data: { id: updateId, documentId: id, documentGeneration, authorId: principal.id, update: snapshot, cursorKey, createdAt } });
|
|
658
|
+
});
|
|
659
|
+
return updateId;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
return service;
|
|
663
|
+
}
|
|
664
|
+
function etag(document) {
|
|
665
|
+
return `"mdfn-${document.version}-${document.sourceHash}"`;
|
|
666
|
+
}
|
|
667
|
+
function response(document, status = 200) {
|
|
668
|
+
return Response.json(document, { status, headers: { ETag: etag(document), "Cache-Control": "no-store" } });
|
|
669
|
+
}
|
|
670
|
+
function idempotent(request, body) {
|
|
671
|
+
return { ...body, idempotencyKey: body.idempotencyKey ?? request.headers.get("Idempotency-Key") ?? void 0 };
|
|
672
|
+
}
|
|
673
|
+
function createMdfnRouter(config, service = createMdfnService(config)) {
|
|
674
|
+
const principal = config.resolvePrincipal ?? (() => {
|
|
675
|
+
throw new MdfnServerError("MDFN_UNAUTHENTICATED", 401);
|
|
676
|
+
});
|
|
677
|
+
const defaultBodyBytes = Math.max(
|
|
678
|
+
configuredDocumentBytes(config) + 4 * 1024 * 1024,
|
|
679
|
+
configuredCollaborationUpdateBytes(config) + 64 * 1024
|
|
680
|
+
);
|
|
681
|
+
return (0, import_http.createRouter)({
|
|
682
|
+
basePath: config.basePath ?? "/api/mdfn",
|
|
683
|
+
maxBodyBytes: config.maxRequestBodyBytes ?? defaultBodyBytes,
|
|
684
|
+
context: async (request) => ({ principal: await principal(request) }),
|
|
685
|
+
onError: (error) => error instanceof MdfnServerError ? Response.json({ error: error.code }, { status: error.status }) : error instanceof import_http.RouterError ? error.toResponse() : Response.json({ error: "MDFN_INTERNAL_ERROR" }, { status: 500 }),
|
|
686
|
+
routes: [
|
|
687
|
+
{ method: "GET", path: "/documents", handler: (_request, context) => service.list(context.principal, { limit: Number(context.query.get("limit") ?? 50), offset: Number(context.query.get("offset") ?? 0) }).then((documents) => Response.json({ documents })) },
|
|
688
|
+
{ method: "POST", path: "/documents", handler: async (_request, context) => response(await service.create(context.principal, await context.json()), 201) },
|
|
689
|
+
{ method: "GET", path: "/documents/:id", handler: async (_request, context) => response(await service.read(context.principal, context.params.id)) },
|
|
690
|
+
{ method: "PATCH", path: "/documents/:id", handler: async (request, context) => response(await service.update(context.principal, context.params.id, idempotent(request, await context.json()))) },
|
|
691
|
+
{ method: "DELETE", path: "/documents/:id", handler: async (_request, context) => {
|
|
692
|
+
await service.delete(context.principal, context.params.id);
|
|
693
|
+
return new Response(null, { status: 204 });
|
|
694
|
+
} },
|
|
695
|
+
{ method: "GET", path: "/documents/:id/versions", handler: async (_request, context) => Response.json(await service.versions(context.principal, context.params.id, { cursor: context.query.get("cursor") ?? void 0, limit: context.query.has("limit") ? Number(context.query.get("limit")) : void 0 })) },
|
|
696
|
+
{ method: "GET", path: "/documents/:id/versions/:version", handler: async (_request, context) => Response.json(await service.version(context.principal, context.params.id, Number(context.params.version))) },
|
|
697
|
+
{ method: "POST", path: "/documents/:id/restore", handler: async (request, context) => response(await service.restoreVersion(context.principal, context.params.id, idempotent(request, await context.json()))) },
|
|
698
|
+
{ method: "GET", path: "/documents/:id/sidecar", handler: async (_request, context) => Response.json((await service.read(context.principal, context.params.id)).sidecar ?? {}) },
|
|
699
|
+
{ method: "PUT", path: "/documents/:id/sidecar", handler: async (request, context) => {
|
|
700
|
+
const body = idempotent(request, await context.json());
|
|
701
|
+
return response(await service.update(context.principal, context.params.id, { ...body, changeSource: "sidecar" }));
|
|
702
|
+
} },
|
|
703
|
+
{ method: "GET", path: "/documents/:id/audit", handler: async (_request, context) => Response.json({ audit: (await service.read(context.principal, context.params.id)).sidecar?.audit ?? [] }) },
|
|
704
|
+
{ method: "POST", path: "/documents/:id/comments", handler: async (request, context) => response(await service.createComment(context.principal, context.params.id, idempotent(request, await context.json()))) },
|
|
705
|
+
{ method: "POST", path: "/documents/:id/comments/:threadId/replies", handler: async (request, context) => response(await service.replyComment(context.principal, context.params.id, context.params.threadId, idempotent(request, await context.json()))) },
|
|
706
|
+
{ method: "PATCH", path: "/documents/:id/comments/:threadId", handler: async (request, context) => response(await service.resolveComment(context.principal, context.params.id, context.params.threadId, idempotent(request, await context.json()))) },
|
|
707
|
+
{ method: "POST", path: "/documents/:id/suggestions", handler: async (request, context) => response(await service.createSuggestion(context.principal, context.params.id, idempotent(request, await context.json()))) },
|
|
708
|
+
{ method: "PATCH", path: "/documents/:id/suggestions/:suggestionId", handler: async (request, context) => response(await service.decideSuggestion(context.principal, context.params.id, context.params.suggestionId, idempotent(request, await context.json()))) },
|
|
709
|
+
{ method: "PUT", path: "/documents/:id/review", handler: async (request, context) => response(await service.transitionReview(context.principal, context.params.id, idempotent(request, await context.json()))) },
|
|
710
|
+
{ method: "GET", path: "/documents/:id/collaboration-updates", handler: async (_request, context) => Response.json(await service.collaborationUpdates(context.principal, context.params.id, { cursor: context.query.get("cursor") ?? void 0, limit: context.query.has("limit") ? Number(context.query.get("limit")) : void 0 })) },
|
|
711
|
+
{ method: "POST", path: "/documents/:id/collaboration-updates", handler: async (_request, context) => {
|
|
712
|
+
const body = await context.json();
|
|
713
|
+
return Response.json({ id: await service.appendCollaborationUpdate(context.principal, context.params.id, body.update) }, { status: 201 });
|
|
714
|
+
} },
|
|
715
|
+
{ method: "PUT", path: "/documents/:id/collaboration-updates/compact", handler: async (_request, context) => {
|
|
716
|
+
const body = await context.json();
|
|
717
|
+
return Response.json({ id: await service.compactCollaborationUpdates(context.principal, context.params.id, body.snapshot, body.includedUpdateIds) });
|
|
718
|
+
} }
|
|
719
|
+
]
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
function createMdfnServer(config) {
|
|
723
|
+
const service = createMdfnService(config);
|
|
724
|
+
return { service, router: createMdfnRouter(config, service), schema: getSchema() };
|
|
725
|
+
}
|
|
726
|
+
var MDFN_SERVER_VERSION = "0.1.0";
|
|
727
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
728
|
+
0 && (module.exports = {
|
|
729
|
+
MDFN_SERVER_SCHEMA_VERSION,
|
|
730
|
+
MDFN_SERVER_VERSION,
|
|
731
|
+
MdfnServerError,
|
|
732
|
+
createMdfnRouter,
|
|
733
|
+
createMdfnServer,
|
|
734
|
+
createMdfnService,
|
|
735
|
+
getSchema
|
|
736
|
+
});
|
|
737
|
+
//# sourceMappingURL=index.cjs.map
|