@agent-native/core 0.154.3 → 0.154.4
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/corpus/README.md +1 -1
- package/corpus/templates/content/actions/_blocks-field-identity.ts +464 -0
- package/corpus/templates/content/actions/_database-utils.ts +11 -3
- package/corpus/templates/content/actions/_property-utils.ts +137 -30
- package/corpus/templates/content/actions/configure-document-property.ts +5 -0
- package/corpus/templates/content/actions/delete-document-property.ts +38 -17
- package/corpus/templates/content/actions/delete-document.ts +3 -0
- package/corpus/templates/content/actions/edit-document.ts +18 -1
- package/corpus/templates/content/actions/export-document.ts +41 -0
- package/corpus/templates/content/actions/remove-database-items.ts +10 -0
- package/corpus/templates/content/actions/set-document-property.ts +62 -2
- package/corpus/templates/content/actions/update-document.ts +24 -0
- package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +99 -11
- package/corpus/templates/content/app/components/editor/blockFieldSaveController.ts +6 -0
- package/corpus/templates/content/app/hooks/use-document-properties.ts +4 -1
- package/corpus/templates/content/app/i18n-data.ts +20 -0
- package/corpus/templates/content/changelog/2026-08-10-database-blocks-fields-now-keep-logical-block-identity-throu.md +6 -0
- package/corpus/templates/content/changelog/2026-08-13-blocks-field-conflicts-now-keep-the-newer-saved-version-visi.md +6 -0
- package/corpus/templates/content/docs/product/capabilities/content.object.block.md +5 -4
- package/corpus/templates/content/docs/product/capabilities/content.object.blocks-field.md +9 -4
- package/corpus/templates/content/docs/product/encyclopedia.md +4 -4
- package/corpus/templates/content/server/db/schema.ts +61 -0
- package/corpus/templates/content/server/plugins/db.ts +55 -0
- package/corpus/templates/content/shared/api.ts +3 -0
- package/corpus/templates/content/shared/blocks-field-identity.ts +554 -0
- package/corpus/templates/content/shared/document-export.ts +33 -1
- package/dist/extensions/url-safety.d.ts +7 -2
- package/dist/extensions/url-safety.js +27 -12
- package/dist/mcp-client/oauth-client.d.ts +16 -1
- package/dist/mcp-client/oauth-client.js +177 -83
- package/dist/mcp-client/remote-store.js +46 -20
- package/dist/oauth-tokens/index.d.ts +2 -1
- package/dist/oauth-tokens/index.js +2 -1
- package/dist/oauth-tokens/lifecycle.d.ts +87 -0
- package/dist/oauth-tokens/lifecycle.js +434 -0
- package/dist/oauth-tokens/migrations.js +10 -0
- package/dist/oauth-tokens/store.d.ts +22 -0
- package/dist/oauth-tokens/store.js +141 -18
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import { docToNfm, nfmToDoc, type PMNode } from "./nfm.js";
|
|
2
|
+
|
|
3
|
+
export const BLOCKS_FIELD_IDENTITY_VERSION = 1;
|
|
4
|
+
export const BLOCK_TOMBSTONE_REVISION_WINDOW = 20;
|
|
5
|
+
export const MAX_BLOCK_TOMBSTONES_PER_FIELD = 500;
|
|
6
|
+
|
|
7
|
+
export type BlocksFieldIdentityStatus = "legacy" | "materialized" | "stale";
|
|
8
|
+
|
|
9
|
+
export interface BlocksFieldBlock {
|
|
10
|
+
id: string;
|
|
11
|
+
parentId: string | null;
|
|
12
|
+
kind: string;
|
|
13
|
+
position: number;
|
|
14
|
+
addressable: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BlocksFieldTombstone {
|
|
18
|
+
id: string;
|
|
19
|
+
kind: string;
|
|
20
|
+
deletedAtRevision: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BlocksFieldIdentity {
|
|
24
|
+
version: typeof BLOCKS_FIELD_IDENTITY_VERSION;
|
|
25
|
+
fieldId: string;
|
|
26
|
+
revision: number;
|
|
27
|
+
contentHash: string;
|
|
28
|
+
identityStatus: BlocksFieldIdentityStatus;
|
|
29
|
+
blocks: BlocksFieldBlock[];
|
|
30
|
+
tombstones: BlocksFieldTombstone[];
|
|
31
|
+
recoveryMode: "editor-undo-with-tombstones";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StoredBlocksFieldBlock extends BlocksFieldBlock {
|
|
35
|
+
contentHash: string;
|
|
36
|
+
markdown: string;
|
|
37
|
+
deletedAtRevision: number | null;
|
|
38
|
+
recoveredAtRevision: number | null;
|
|
39
|
+
state: "live" | "deleted";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface StoredBlocksFieldIdentity {
|
|
43
|
+
fieldId: string;
|
|
44
|
+
revision: number;
|
|
45
|
+
contentHash: string;
|
|
46
|
+
blocks: StoredBlocksFieldBlock[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface BlockSnapshot {
|
|
50
|
+
path: string;
|
|
51
|
+
parentPath: string | null;
|
|
52
|
+
kind: string;
|
|
53
|
+
position: number;
|
|
54
|
+
addressable: boolean;
|
|
55
|
+
contentHash: string;
|
|
56
|
+
markdown: string;
|
|
57
|
+
preferredId: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const BLOCK_NODE_TYPES = new Set([
|
|
61
|
+
"paragraph",
|
|
62
|
+
"heading",
|
|
63
|
+
"horizontalRule",
|
|
64
|
+
"codeBlock",
|
|
65
|
+
"blockquote",
|
|
66
|
+
"bulletList",
|
|
67
|
+
"orderedList",
|
|
68
|
+
"listItem",
|
|
69
|
+
"taskList",
|
|
70
|
+
"taskItem",
|
|
71
|
+
"notionToggle",
|
|
72
|
+
"notionCallout",
|
|
73
|
+
"notionColumns",
|
|
74
|
+
"notionColumn",
|
|
75
|
+
"notionSyncedBlock",
|
|
76
|
+
"table",
|
|
77
|
+
"tableRow",
|
|
78
|
+
"tableHeader",
|
|
79
|
+
"tableCell",
|
|
80
|
+
"image",
|
|
81
|
+
"video",
|
|
82
|
+
"audio",
|
|
83
|
+
"notionBlockAtom",
|
|
84
|
+
"registryBlock",
|
|
85
|
+
"contentReference",
|
|
86
|
+
"localMdxComponent",
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
const NON_ADDRESSABLE_NODE_TYPES = new Set([
|
|
90
|
+
"bulletList",
|
|
91
|
+
"orderedList",
|
|
92
|
+
"taskList",
|
|
93
|
+
"tableRow",
|
|
94
|
+
"tableHeader",
|
|
95
|
+
"tableCell",
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
function hashString(value: string): string {
|
|
99
|
+
let first = 0x811c9dc5;
|
|
100
|
+
let second = 0x9e3779b9;
|
|
101
|
+
for (let index = 0; index < value.length; index++) {
|
|
102
|
+
const code = value.charCodeAt(index);
|
|
103
|
+
first ^= code;
|
|
104
|
+
first = Math.imul(first, 0x01000193);
|
|
105
|
+
second ^= code + index;
|
|
106
|
+
second = Math.imul(second, 0x85ebca6b);
|
|
107
|
+
}
|
|
108
|
+
return `${(first >>> 0).toString(16).padStart(8, "0")}${(second >>> 0)
|
|
109
|
+
.toString(16)
|
|
110
|
+
.padStart(8, "0")}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function blocksFieldId(documentId: string, propertyId: string): string {
|
|
114
|
+
return `blocks_field_${hashString(`${documentId}\0${propertyId}`)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function blocksContentHash(markdown: string): string {
|
|
118
|
+
return `nfm_${hashString(markdown)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function stableNodeValue(value: unknown): unknown {
|
|
122
|
+
if (Array.isArray(value)) return value.map(stableNodeValue);
|
|
123
|
+
if (!value || typeof value !== "object") return value;
|
|
124
|
+
return Object.fromEntries(
|
|
125
|
+
Object.entries(value as Record<string, unknown>)
|
|
126
|
+
.filter(([key]) => key !== "blockId")
|
|
127
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
128
|
+
.map(([key, child]) => [key, stableNodeValue(child)]),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function nodeMarkdown(node: PMNode): string {
|
|
133
|
+
return docToNfm({ type: "doc", content: [node] });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function snapshotMarkdown(markdown: string): BlockSnapshot[] {
|
|
137
|
+
const snapshots: BlockSnapshot[] = [];
|
|
138
|
+
const doc = nfmToDoc(markdown);
|
|
139
|
+
|
|
140
|
+
function visit(nodes: PMNode[] | undefined, parentPath: string | null) {
|
|
141
|
+
let blockPosition = 0;
|
|
142
|
+
for (let index = 0; index < (nodes?.length ?? 0); index++) {
|
|
143
|
+
const node = nodes![index]!;
|
|
144
|
+
if (!BLOCK_NODE_TYPES.has(node.type)) continue;
|
|
145
|
+
const path =
|
|
146
|
+
parentPath === null
|
|
147
|
+
? `${blockPosition}`
|
|
148
|
+
: `${parentPath}.${blockPosition}`;
|
|
149
|
+
const canonical = JSON.stringify(stableNodeValue(node));
|
|
150
|
+
snapshots.push({
|
|
151
|
+
path,
|
|
152
|
+
parentPath,
|
|
153
|
+
kind: node.type,
|
|
154
|
+
position: blockPosition,
|
|
155
|
+
addressable: !NON_ADDRESSABLE_NODE_TYPES.has(node.type),
|
|
156
|
+
contentHash: `block_${hashString(canonical)}`,
|
|
157
|
+
markdown: nodeMarkdown(node),
|
|
158
|
+
preferredId:
|
|
159
|
+
node.type === "registryBlock" &&
|
|
160
|
+
typeof node.attrs?.blockId === "string"
|
|
161
|
+
? node.attrs.blockId
|
|
162
|
+
: null,
|
|
163
|
+
});
|
|
164
|
+
visit(node.content, path);
|
|
165
|
+
blockPosition++;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
visit(doc.content, null);
|
|
170
|
+
return snapshots;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function deterministicBlockId(
|
|
174
|
+
fieldId: string,
|
|
175
|
+
snapshot: Pick<BlockSnapshot, "path" | "kind" | "contentHash">,
|
|
176
|
+
): string {
|
|
177
|
+
return `block_${hashString(
|
|
178
|
+
`${fieldId}\0${snapshot.path}\0${snapshot.kind}\0${snapshot.contentHash}`,
|
|
179
|
+
)}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function fieldScopedBlockId(fieldId: string, candidateId: string): string {
|
|
183
|
+
return `block_${hashString(fieldId)}_${hashString(candidateId)}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function publicIdentity(
|
|
187
|
+
stored: StoredBlocksFieldIdentity,
|
|
188
|
+
identityStatus: BlocksFieldIdentityStatus,
|
|
189
|
+
): BlocksFieldIdentity {
|
|
190
|
+
return {
|
|
191
|
+
version: BLOCKS_FIELD_IDENTITY_VERSION,
|
|
192
|
+
fieldId: stored.fieldId,
|
|
193
|
+
revision: stored.revision,
|
|
194
|
+
contentHash: stored.contentHash,
|
|
195
|
+
identityStatus,
|
|
196
|
+
blocks: stored.blocks
|
|
197
|
+
.filter((block) => block.state === "live")
|
|
198
|
+
.map(({ id, parentId, kind, position, addressable }) => ({
|
|
199
|
+
id,
|
|
200
|
+
parentId,
|
|
201
|
+
kind,
|
|
202
|
+
position,
|
|
203
|
+
addressable,
|
|
204
|
+
})),
|
|
205
|
+
tombstones: stored.blocks
|
|
206
|
+
.filter(
|
|
207
|
+
(
|
|
208
|
+
block,
|
|
209
|
+
): block is StoredBlocksFieldBlock & {
|
|
210
|
+
deletedAtRevision: number;
|
|
211
|
+
} => block.state === "deleted" && block.deletedAtRevision !== null,
|
|
212
|
+
)
|
|
213
|
+
.map(({ id, kind, deletedAtRevision }) => ({
|
|
214
|
+
id,
|
|
215
|
+
kind,
|
|
216
|
+
deletedAtRevision,
|
|
217
|
+
})),
|
|
218
|
+
recoveryMode: "editor-undo-with-tombstones",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function legacyBlocksFieldIdentity(args: {
|
|
223
|
+
documentId: string;
|
|
224
|
+
propertyId: string;
|
|
225
|
+
markdown: string;
|
|
226
|
+
}): BlocksFieldIdentity {
|
|
227
|
+
const fieldId = blocksFieldId(args.documentId, args.propertyId);
|
|
228
|
+
const snapshots = snapshotMarkdown(args.markdown);
|
|
229
|
+
const idByPath = new Map<string, string>();
|
|
230
|
+
const usedIds = new Set<string>();
|
|
231
|
+
const blocks: StoredBlocksFieldBlock[] = snapshots.map((snapshot) => {
|
|
232
|
+
const preferred = snapshot.preferredId
|
|
233
|
+
? fieldScopedBlockId(fieldId, snapshot.preferredId)
|
|
234
|
+
: null;
|
|
235
|
+
const id =
|
|
236
|
+
preferred && !usedIds.has(preferred)
|
|
237
|
+
? preferred
|
|
238
|
+
: deterministicBlockId(fieldId, snapshot);
|
|
239
|
+
usedIds.add(id);
|
|
240
|
+
idByPath.set(snapshot.path, id);
|
|
241
|
+
return {
|
|
242
|
+
id,
|
|
243
|
+
parentId: snapshot.parentPath
|
|
244
|
+
? (idByPath.get(snapshot.parentPath) ?? null)
|
|
245
|
+
: null,
|
|
246
|
+
kind: snapshot.kind,
|
|
247
|
+
position: snapshot.position,
|
|
248
|
+
addressable: snapshot.addressable,
|
|
249
|
+
contentHash: snapshot.contentHash,
|
|
250
|
+
markdown: snapshot.markdown,
|
|
251
|
+
state: "live",
|
|
252
|
+
deletedAtRevision: null,
|
|
253
|
+
recoveredAtRevision: null,
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
return publicIdentity(
|
|
257
|
+
{
|
|
258
|
+
fieldId,
|
|
259
|
+
revision: 0,
|
|
260
|
+
contentHash: blocksContentHash(args.markdown),
|
|
261
|
+
blocks,
|
|
262
|
+
},
|
|
263
|
+
"legacy",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function uniqueIndexByKey<T>(
|
|
268
|
+
values: T[],
|
|
269
|
+
key: (value: T) => string,
|
|
270
|
+
): Map<string, number> {
|
|
271
|
+
const counts = new Map<string, number>();
|
|
272
|
+
const index = new Map<string, number>();
|
|
273
|
+
values.forEach((value, valueIndex) => {
|
|
274
|
+
const valueKey = key(value);
|
|
275
|
+
counts.set(valueKey, (counts.get(valueKey) ?? 0) + 1);
|
|
276
|
+
index.set(valueKey, valueIndex);
|
|
277
|
+
});
|
|
278
|
+
for (const [valueKey, count] of counts) {
|
|
279
|
+
if (count !== 1) index.delete(valueKey);
|
|
280
|
+
}
|
|
281
|
+
return index;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function characterBigrams(value: string): Set<string> {
|
|
285
|
+
const normalized = value.toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
|
286
|
+
if (normalized.length < 2) return new Set(normalized ? [normalized] : []);
|
|
287
|
+
const result = new Set<string>();
|
|
288
|
+
for (let index = 0; index < normalized.length - 1; index++) {
|
|
289
|
+
result.add(normalized.slice(index, index + 2));
|
|
290
|
+
}
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function editSimilarity(left: string, right: string): number {
|
|
295
|
+
const leftBigrams = characterBigrams(left);
|
|
296
|
+
const rightBigrams = characterBigrams(right);
|
|
297
|
+
if (leftBigrams.size === 0 || rightBigrams.size === 0) return 0;
|
|
298
|
+
let shared = 0;
|
|
299
|
+
for (const bigram of leftBigrams) {
|
|
300
|
+
if (rightBigrams.has(bigram)) shared++;
|
|
301
|
+
}
|
|
302
|
+
return (2 * shared) / (leftBigrams.size + rightBigrams.size);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function reconcileBlocksFieldIdentity(args: {
|
|
306
|
+
documentId: string;
|
|
307
|
+
propertyId: string;
|
|
308
|
+
previous: StoredBlocksFieldIdentity;
|
|
309
|
+
markdown: string;
|
|
310
|
+
createId: () => string;
|
|
311
|
+
}): StoredBlocksFieldIdentity {
|
|
312
|
+
const nextRevision = args.previous.revision + 1;
|
|
313
|
+
const snapshots = snapshotMarkdown(args.markdown);
|
|
314
|
+
const previousLive = args.previous.blocks.filter(
|
|
315
|
+
(block) => block.state === "live",
|
|
316
|
+
);
|
|
317
|
+
const previousDeleted = args.previous.blocks.filter(
|
|
318
|
+
(block) => block.state === "deleted",
|
|
319
|
+
);
|
|
320
|
+
const matchedPrevious = new Set<number>();
|
|
321
|
+
const assigned = new Map<number, StoredBlocksFieldBlock>();
|
|
322
|
+
const usedIds = new Set(args.previous.blocks.map((block) => block.id));
|
|
323
|
+
|
|
324
|
+
const previousExact = uniqueIndexByKey(
|
|
325
|
+
previousLive,
|
|
326
|
+
(block) => `${block.kind}\0${block.contentHash}`,
|
|
327
|
+
);
|
|
328
|
+
const nextExact = uniqueIndexByKey(
|
|
329
|
+
snapshots,
|
|
330
|
+
(snapshot) => `${snapshot.kind}\0${snapshot.contentHash}`,
|
|
331
|
+
);
|
|
332
|
+
for (const [key, previousIndex] of previousExact) {
|
|
333
|
+
const nextIndex = nextExact.get(key);
|
|
334
|
+
if (nextIndex === undefined) continue;
|
|
335
|
+
matchedPrevious.add(previousIndex);
|
|
336
|
+
assigned.set(nextIndex, previousLive[previousIndex]!);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Split keeps the leading fragment's ID; merge keeps the receiving block's
|
|
340
|
+
// ID. These are the only cardinality-changing cases where position conveys
|
|
341
|
+
// more identity than text similarity.
|
|
342
|
+
const unmatchedKinds = new Set([
|
|
343
|
+
...previousLive
|
|
344
|
+
.filter((_block, index) => !matchedPrevious.has(index))
|
|
345
|
+
.map((block) => block.kind),
|
|
346
|
+
...snapshots
|
|
347
|
+
.filter((_snapshot, index) => !assigned.has(index))
|
|
348
|
+
.map((snapshot) => snapshot.kind),
|
|
349
|
+
]);
|
|
350
|
+
for (const kind of unmatchedKinds) {
|
|
351
|
+
const previousIndexes = previousLive.flatMap((block, index) =>
|
|
352
|
+
!matchedPrevious.has(index) && block.kind === kind ? [index] : [],
|
|
353
|
+
);
|
|
354
|
+
const nextIndexes = snapshots.flatMap((snapshot, index) =>
|
|
355
|
+
!assigned.has(index) && snapshot.kind === kind ? [index] : [],
|
|
356
|
+
);
|
|
357
|
+
if (previousIndexes.length !== 1 && nextIndexes.length !== 1) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (previousIndexes.length === 0 || nextIndexes.length === 0) continue;
|
|
361
|
+
const samePosition = nextIndexes.find(
|
|
362
|
+
(nextIndex) =>
|
|
363
|
+
snapshots[nextIndex]!.position ===
|
|
364
|
+
previousLive[previousIndexes[0]!]!.position,
|
|
365
|
+
);
|
|
366
|
+
if (samePosition === undefined) continue;
|
|
367
|
+
matchedPrevious.add(previousIndexes[0]!);
|
|
368
|
+
assigned.set(samePosition, previousLive[previousIndexes[0]!]!);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// A reorder and a text edit can happen in one editor transaction. Pair only
|
|
372
|
+
// mutual, sufficiently similar best matches before considering position; an
|
|
373
|
+
// ambiguous match is left for the conservative positional rule below.
|
|
374
|
+
const bestNextForPrevious = new Map<
|
|
375
|
+
number,
|
|
376
|
+
{ index: number; score: number }
|
|
377
|
+
>();
|
|
378
|
+
const bestPreviousForNext = new Map<
|
|
379
|
+
number,
|
|
380
|
+
{ index: number; score: number }
|
|
381
|
+
>();
|
|
382
|
+
for (
|
|
383
|
+
let previousIndex = 0;
|
|
384
|
+
previousIndex < previousLive.length;
|
|
385
|
+
previousIndex++
|
|
386
|
+
) {
|
|
387
|
+
if (matchedPrevious.has(previousIndex)) continue;
|
|
388
|
+
const previous = previousLive[previousIndex]!;
|
|
389
|
+
for (let nextIndex = 0; nextIndex < snapshots.length; nextIndex++) {
|
|
390
|
+
if (assigned.has(nextIndex)) continue;
|
|
391
|
+
const snapshot = snapshots[nextIndex]!;
|
|
392
|
+
if (previous.kind !== snapshot.kind) continue;
|
|
393
|
+
const score = editSimilarity(previous.markdown, snapshot.markdown);
|
|
394
|
+
if (score < 0.45) continue;
|
|
395
|
+
const previousBest = bestNextForPrevious.get(previousIndex);
|
|
396
|
+
if (!previousBest || score > previousBest.score) {
|
|
397
|
+
bestNextForPrevious.set(previousIndex, { index: nextIndex, score });
|
|
398
|
+
}
|
|
399
|
+
const nextBest = bestPreviousForNext.get(nextIndex);
|
|
400
|
+
if (!nextBest || score > nextBest.score) {
|
|
401
|
+
bestPreviousForNext.set(nextIndex, { index: previousIndex, score });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
for (const [previousIndex, nextBest] of bestNextForPrevious) {
|
|
406
|
+
const previousBest = bestPreviousForNext.get(nextBest.index);
|
|
407
|
+
if (previousBest?.index !== previousIndex) continue;
|
|
408
|
+
matchedPrevious.add(previousIndex);
|
|
409
|
+
assigned.set(nextBest.index, previousLive[previousIndex]!);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (let nextIndex = 0; nextIndex < snapshots.length; nextIndex++) {
|
|
413
|
+
if (assigned.has(nextIndex)) continue;
|
|
414
|
+
const snapshot = snapshots[nextIndex]!;
|
|
415
|
+
const samePosition = previousLive.findIndex(
|
|
416
|
+
(block, previousIndex) =>
|
|
417
|
+
!matchedPrevious.has(previousIndex) &&
|
|
418
|
+
block.kind === snapshot.kind &&
|
|
419
|
+
block.position === snapshot.position,
|
|
420
|
+
);
|
|
421
|
+
if (samePosition !== -1) {
|
|
422
|
+
matchedPrevious.add(samePosition);
|
|
423
|
+
assigned.set(nextIndex, previousLive[samePosition]!);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const sameKind = previousLive.findIndex(
|
|
427
|
+
(block, previousIndex) =>
|
|
428
|
+
!matchedPrevious.has(previousIndex) && block.kind === snapshot.kind,
|
|
429
|
+
);
|
|
430
|
+
if (sameKind !== -1) {
|
|
431
|
+
matchedPrevious.add(sameKind);
|
|
432
|
+
assigned.set(nextIndex, previousLive[sameKind]!);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const recoverable = uniqueIndexByKey(
|
|
437
|
+
previousDeleted,
|
|
438
|
+
(block) => `${block.kind}\0${block.contentHash}`,
|
|
439
|
+
);
|
|
440
|
+
const recoveredIds = new Set<string>();
|
|
441
|
+
const idByPath = new Map<string, string>();
|
|
442
|
+
const nextBlocks = snapshots.map((snapshot, nextIndex) => {
|
|
443
|
+
let previous = assigned.get(nextIndex);
|
|
444
|
+
if (!previous) {
|
|
445
|
+
const recoveredIndex = recoverable.get(
|
|
446
|
+
`${snapshot.kind}\0${snapshot.contentHash}`,
|
|
447
|
+
);
|
|
448
|
+
if (recoveredIndex !== undefined) {
|
|
449
|
+
previous = previousDeleted[recoveredIndex];
|
|
450
|
+
if (previous) {
|
|
451
|
+
recoveredIds.add(previous.id);
|
|
452
|
+
recoverable.delete(`${snapshot.kind}\0${snapshot.contentHash}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
let id =
|
|
457
|
+
previous?.id ??
|
|
458
|
+
fieldScopedBlockId(
|
|
459
|
+
args.previous.fieldId,
|
|
460
|
+
snapshot.preferredId ?? args.createId(),
|
|
461
|
+
);
|
|
462
|
+
while (usedIds.has(id) && id !== previous?.id) {
|
|
463
|
+
id = fieldScopedBlockId(args.previous.fieldId, args.createId());
|
|
464
|
+
}
|
|
465
|
+
usedIds.add(id);
|
|
466
|
+
idByPath.set(snapshot.path, id);
|
|
467
|
+
return {
|
|
468
|
+
id,
|
|
469
|
+
parentId: snapshot.parentPath
|
|
470
|
+
? (idByPath.get(snapshot.parentPath) ?? null)
|
|
471
|
+
: null,
|
|
472
|
+
kind: snapshot.kind,
|
|
473
|
+
position: snapshot.position,
|
|
474
|
+
addressable: snapshot.addressable,
|
|
475
|
+
contentHash: snapshot.contentHash,
|
|
476
|
+
markdown: snapshot.markdown,
|
|
477
|
+
state: "live" as const,
|
|
478
|
+
deletedAtRevision: null,
|
|
479
|
+
recoveredAtRevision: recoveredIds.has(id) ? nextRevision : null,
|
|
480
|
+
};
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const deletedNow = previousLive
|
|
484
|
+
.filter((_block, index) => !matchedPrevious.has(index))
|
|
485
|
+
.map((block) => ({
|
|
486
|
+
...block,
|
|
487
|
+
state: "deleted" as const,
|
|
488
|
+
deletedAtRevision: nextRevision,
|
|
489
|
+
recoveredAtRevision: null,
|
|
490
|
+
}));
|
|
491
|
+
const oldestRecoverableRevision =
|
|
492
|
+
nextRevision - BLOCK_TOMBSTONE_REVISION_WINDOW;
|
|
493
|
+
const retainedTombstones = [...previousDeleted, ...deletedNow]
|
|
494
|
+
.filter(
|
|
495
|
+
(block) =>
|
|
496
|
+
!recoveredIds.has(block.id) &&
|
|
497
|
+
block.deletedAtRevision !== null &&
|
|
498
|
+
block.deletedAtRevision > oldestRecoverableRevision,
|
|
499
|
+
)
|
|
500
|
+
.sort(
|
|
501
|
+
(left, right) =>
|
|
502
|
+
(right.deletedAtRevision ?? 0) - (left.deletedAtRevision ?? 0),
|
|
503
|
+
)
|
|
504
|
+
.slice(0, MAX_BLOCK_TOMBSTONES_PER_FIELD);
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
fieldId: args.previous.fieldId,
|
|
508
|
+
revision: nextRevision,
|
|
509
|
+
contentHash: blocksContentHash(args.markdown),
|
|
510
|
+
blocks: [...nextBlocks, ...retainedTombstones],
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function exposeBlocksFieldIdentity(
|
|
515
|
+
stored: StoredBlocksFieldIdentity,
|
|
516
|
+
markdown: string,
|
|
517
|
+
): BlocksFieldIdentity {
|
|
518
|
+
const currentHash = blocksContentHash(markdown);
|
|
519
|
+
if (currentHash === stored.contentHash) {
|
|
520
|
+
return publicIdentity(stored, "materialized");
|
|
521
|
+
}
|
|
522
|
+
let provisionalIndex = 0;
|
|
523
|
+
const reconciled = reconcileBlocksFieldIdentity({
|
|
524
|
+
documentId: "stale-read",
|
|
525
|
+
propertyId: stored.fieldId,
|
|
526
|
+
previous: stored,
|
|
527
|
+
markdown,
|
|
528
|
+
createId: () =>
|
|
529
|
+
`block_${hashString(`${stored.fieldId}\0${markdown}\0${provisionalIndex++}`)}`,
|
|
530
|
+
});
|
|
531
|
+
return publicIdentity({ ...reconciled, revision: stored.revision }, "stale");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export function materializeLegacyBlocksFieldIdentity(args: {
|
|
535
|
+
documentId: string;
|
|
536
|
+
propertyId: string;
|
|
537
|
+
markdown: string;
|
|
538
|
+
}): StoredBlocksFieldIdentity {
|
|
539
|
+
const publicState = legacyBlocksFieldIdentity(args);
|
|
540
|
+
const snapshots = snapshotMarkdown(args.markdown);
|
|
541
|
+
return {
|
|
542
|
+
fieldId: publicState.fieldId,
|
|
543
|
+
revision: 0,
|
|
544
|
+
contentHash: publicState.contentHash,
|
|
545
|
+
blocks: publicState.blocks.map((block, index) => ({
|
|
546
|
+
...block,
|
|
547
|
+
contentHash: snapshots[index]!.contentHash,
|
|
548
|
+
markdown: snapshots[index]!.markdown,
|
|
549
|
+
state: "live",
|
|
550
|
+
deletedAtRevision: null,
|
|
551
|
+
recoveredAtRevision: null,
|
|
552
|
+
})),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BlocksFieldIdentity } from "./blocks-field-identity.js";
|
|
1
2
|
import { matchInlineMathAt } from "./inline-math.js";
|
|
2
3
|
import { KATEX_STYLESHEET_URL, renderMathToHtml } from "./math-rendering.js";
|
|
3
4
|
|
|
@@ -9,6 +10,16 @@ export interface DocumentExportInput {
|
|
|
9
10
|
content?: string | null;
|
|
10
11
|
updatedAt?: string | null;
|
|
11
12
|
format: DocumentExportFormat;
|
|
13
|
+
blocksFields?: BlocksFieldExport[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BlocksFieldExport {
|
|
17
|
+
databaseId: string;
|
|
18
|
+
propertyId: string;
|
|
19
|
+
name: string;
|
|
20
|
+
position: number;
|
|
21
|
+
markdown: string;
|
|
22
|
+
identity: BlocksFieldIdentity;
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
export interface DocumentExportPayload {
|
|
@@ -19,6 +30,15 @@ export interface DocumentExportPayload {
|
|
|
19
30
|
mimeType: string;
|
|
20
31
|
content: string;
|
|
21
32
|
print: boolean;
|
|
33
|
+
blocksFields?: BlocksFieldExport[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function serializeBlocksFieldsManifest(fields: BlocksFieldExport[]): string {
|
|
37
|
+
return JSON.stringify({ version: 1, fields })
|
|
38
|
+
.replace(/</g, "\\u003c")
|
|
39
|
+
.replace(/>/g, "\\u003e")
|
|
40
|
+
.replace(/&/g, "\\u0026")
|
|
41
|
+
.replace(/--/g, "\\u002d\\u002d");
|
|
22
42
|
}
|
|
23
43
|
|
|
24
44
|
const EXTENSION_BY_FORMAT: Record<DocumentExportFormat, string> = {
|
|
@@ -638,7 +658,10 @@ export function buildDocumentExport(
|
|
|
638
658
|
const filename = exportFilename(title, input.format);
|
|
639
659
|
const markdown = markdownWithTitle(title, input.content);
|
|
640
660
|
const isHtmlLike = input.format === "html" || input.format === "pdf";
|
|
641
|
-
const
|
|
661
|
+
const manifest = input.blocksFields?.length
|
|
662
|
+
? serializeBlocksFieldsManifest(input.blocksFields)
|
|
663
|
+
: null;
|
|
664
|
+
let content = isHtmlLike
|
|
642
665
|
? buildHtmlDocument({
|
|
643
666
|
title,
|
|
644
667
|
content: input.content ?? "",
|
|
@@ -646,6 +669,14 @@ export function buildDocumentExport(
|
|
|
646
669
|
print: input.format === "pdf",
|
|
647
670
|
})
|
|
648
671
|
: markdown;
|
|
672
|
+
if (manifest) {
|
|
673
|
+
content = isHtmlLike
|
|
674
|
+
? content.replace(
|
|
675
|
+
"</body>",
|
|
676
|
+
`<script type="application/json" id="agent-native-blocks">${manifest}</script>\n</body>`,
|
|
677
|
+
)
|
|
678
|
+
: `${content.trimEnd()}\n\n<!-- agent-native-blocks:${manifest} -->\n`;
|
|
679
|
+
}
|
|
649
680
|
|
|
650
681
|
return {
|
|
651
682
|
id: input.id,
|
|
@@ -655,5 +686,6 @@ export function buildDocumentExport(
|
|
|
655
686
|
mimeType: MIME_BY_FORMAT[input.format],
|
|
656
687
|
content,
|
|
657
688
|
print: input.format === "pdf",
|
|
689
|
+
...(input.blocksFields?.length ? { blocksFields: input.blocksFields } : {}),
|
|
658
690
|
};
|
|
659
691
|
}
|
|
@@ -5,7 +5,9 @@ export declare function isBlockedExtensionUrl(url: string): boolean;
|
|
|
5
5
|
* "public hostname resolves to a private address" gap before dispatch.
|
|
6
6
|
*/
|
|
7
7
|
export declare function isBlockedExtensionUrlWithDns(url: string): Promise<boolean>;
|
|
8
|
-
export declare function createSsrfSafeDispatcher(allowedPrivateOrigins?: readonly string[]
|
|
8
|
+
export declare function createSsrfSafeDispatcher(allowedPrivateOrigins?: readonly string[], destinationUrl?: string, options?: {
|
|
9
|
+
required?: boolean;
|
|
10
|
+
}): Promise<unknown>;
|
|
9
11
|
/**
|
|
10
12
|
* SSRF-safe `fetch` for any server-side request to a user/agent-supplied URL.
|
|
11
13
|
*
|
|
@@ -14,7 +16,9 @@ export declare function createSsrfSafeDispatcher(allowedPrivateOrigins?: readonl
|
|
|
14
16
|
* 1. Pre-flight DNS-aware private-address check (isBlockedExtensionUrlWithDns)
|
|
15
17
|
* on the initial URL and on every redirect hop.
|
|
16
18
|
* 2. A connect-time dispatcher that re-checks the resolved IP at TCP-connect
|
|
17
|
-
* time (closes the DNS-rebinding TOCTOU) when
|
|
19
|
+
* time (closes the DNS-rebinding TOCTOU) when the runtime supports the
|
|
20
|
+
* Node dispatcher. Edge runtimes retain preflight DNS checks, literal and
|
|
21
|
+
* rebinding-domain rejection, and per-hop redirect validation.
|
|
18
22
|
* 3. Manual redirect handling — a public URL cannot 30x-redirect into the
|
|
19
23
|
* private network because each hop is re-validated before it is followed.
|
|
20
24
|
*
|
|
@@ -33,6 +37,7 @@ export declare function createSsrfSafeDispatcher(allowedPrivateOrigins?: readonl
|
|
|
33
37
|
*/
|
|
34
38
|
export declare function ssrfSafeFetch(url: string, init?: RequestInit, options?: {
|
|
35
39
|
maxRedirects?: number;
|
|
40
|
+
followRedirects?: boolean;
|
|
36
41
|
httpsOnly?: boolean;
|
|
37
42
|
assertUrlAllowed?: (url: string) => void | Promise<void>;
|
|
38
43
|
/**
|