edytor 6.0.0 → 7.0.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 +3 -3
- package/dist/cloudflare/DocumentRoom.d.ts +77 -2
- package/dist/cloudflare/DocumentRoom.js +37 -1
- package/dist/cloudflare/index.d.ts +1 -1
- package/dist/cloudflare/index.js +1 -1
- package/dist/cloudflare/room/access.d.ts +6 -1
- package/dist/cloudflare/room/access.js +30 -4
- package/dist/cloudflare/room/admission.d.ts +11 -0
- package/dist/cloudflare/room/admission.js +43 -10
- package/dist/cloudflare/room/context.d.ts +11 -2
- package/dist/cloudflare/room/context.js +17 -6
- package/dist/cloudflare/room/forged.d.ts +1 -1
- package/dist/cloudflare/room/forged.js +11 -9
- package/dist/cloudflare/room/frames.d.ts +2 -0
- package/dist/cloudflare/room/frames.js +2 -0
- package/dist/cloudflare/room/presence.js +1 -4
- package/dist/cloudflare/room/reseed.d.ts +93 -0
- package/dist/cloudflare/room/reseed.js +500 -0
- package/dist/cloudflare/room/scheduler.d.ts +1 -1
- package/dist/cloudflare/room/scheduler.js +9 -1
- package/dist/cloudflare/room/shared.d.ts +10 -0
- package/dist/cloudflare/room/shared.js +10 -0
- package/dist/cloudflare/room/storage.d.ts +7 -0
- package/dist/cloudflare/room/storage.js +33 -1
- package/dist/components/Edytor.svelte +18 -3
- package/dist/components/Edytor.svelte.d.ts +10 -1
- package/dist/components/EdytorRoot.svelte +17 -0
- package/dist/components/EdytorRoot.svelte.d.ts +4 -0
- package/dist/components/Suggestion.svelte +1 -1
- package/dist/crdt/attribution/block.d.ts +3 -0
- package/dist/crdt/attribution/block.js +3 -2
- package/dist/crdt/doc/suggest.d.ts +6 -2
- package/dist/crdt/doc/suggest.js +6 -5
- package/dist/crdt/edytor-doc.d.ts +1 -1
- package/dist/crdt/edytor-doc.js +1 -1
- package/dist/crdt/engine-room.d.ts +4 -0
- package/dist/crdt/engine-room.js +6 -0
- package/dist/crdt/index.d.ts +2 -1
- package/dist/crdt/protocol.d.ts +2 -2
- package/dist/crdt/protocol.js +2 -2
- package/dist/crdt/protocols/auth.d.ts +8 -1
- package/dist/crdt/protocols/auth.js +14 -1
- package/dist/crdt/providers/index.d.ts +1 -0
- package/dist/crdt/providers/index.js +22 -3
- package/dist/crdt/providers/room.d.ts +15 -0
- package/dist/crdt/providers/room.js +15 -0
- package/dist/crdt/providers/websocket.d.ts +4 -2
- package/dist/crdt/providers/websocket.js +36 -9
- package/dist/crdt/reseed.d.ts +127 -0
- package/dist/crdt/reseed.js +533 -0
- package/dist/crdt/restore.d.ts +13 -1
- package/dist/crdt/restore.js +8 -3
- package/dist/crdt/schema.d.ts +6 -0
- package/dist/crdt/schema.js +6 -0
- package/dist/crdt/structs.d.ts +8 -11
- package/dist/crdt/structs.js +25 -44
- package/dist/crdt/text/index/build.d.ts +3 -9
- package/dist/crdt/text/index/build.js +87 -181
- package/dist/crdt/vendor/yjs/UPSTREAM.md +101 -5
- package/dist/crdt/vendor/yjs/dts/index.d.ts +1 -1
- package/dist/crdt/vendor/yjs/dts/utils/Transaction.d.ts +10 -10
- package/dist/crdt/vendor/yjs/dts/utils/encoding.d.ts +1 -0
- package/dist/crdt/vendor/yjs/src/index.js +1 -1
- package/dist/crdt/vendor/yjs/src/structs/Item.js +24 -13
- package/dist/crdt/vendor/yjs/src/utils/Transaction.js +12 -10
- package/dist/crdt/vendor/yjs/src/utils/encoding.js +42 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/selection/selection.svelte.d.ts +1 -1
- package/dist/selection/selection.svelte.js +87 -106
- package/dist/session/commands.js +49 -51
- package/dist/surface/attributes.d.ts +3 -3
- package/dist/surface/attributes.js +2 -2
- package/dist/surface/observer.svelte.d.ts +7 -7
- package/dist/surface/observer.svelte.js +213 -309
- package/package.json +1 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { EngineDoc } from './engine-api.js';
|
|
2
|
+
import { type BlockId } from './placement/model.js';
|
|
3
|
+
import { type DataPatch } from './data.js';
|
|
4
|
+
import { restoreDocument, type RestoreFacade } from './restore.js';
|
|
5
|
+
import type { ModelView } from './placement/model.js';
|
|
6
|
+
import { type SuggestContext } from './doc/suggest.js';
|
|
7
|
+
import type { JSONDoc } from '../utils/json.js';
|
|
8
|
+
/** What the rebase reads and writes through: a facade over a document (structurally). */
|
|
9
|
+
export type ReseedFacade<Plan = unknown> = RestoreFacade<Plan> & {
|
|
10
|
+
order(): readonly BlockId[];
|
|
11
|
+
displayLength(id: BlockId): number;
|
|
12
|
+
toJSON(options?: {
|
|
13
|
+
suggestions?: boolean;
|
|
14
|
+
}): JSONDoc;
|
|
15
|
+
/** The facade's reads beyond its operations: the index's view, the base view (`doc/suggest.ts` builds over it too). */
|
|
16
|
+
part: {
|
|
17
|
+
view(): ModelView;
|
|
18
|
+
base?: SuggestContext['base'];
|
|
19
|
+
};
|
|
20
|
+
prepare: RestoreFacade<Plan>['prepare'] & {
|
|
21
|
+
patchData(target: {
|
|
22
|
+
block: BlockId;
|
|
23
|
+
atom: string;
|
|
24
|
+
}, ops: DataPatch[]): Plan;
|
|
25
|
+
formatRange(id: BlockId, offset: number, length: number, marks: Record<string, unknown>): Plan;
|
|
26
|
+
splitBlock(id: BlockId, offset: number, newId: BlockId): Plan;
|
|
27
|
+
mergeBlocks(from: BlockId, into: BlockId): Plan;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
/** A document and the facade over it. */
|
|
31
|
+
export type Replica = {
|
|
32
|
+
doc: EngineDoc;
|
|
33
|
+
facade: ReseedFacade;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* A document without its pending suggestions (`sugg.json.base`), its JSON
|
|
37
|
+
* and each unit's identity read from one view: what a cut is made from.
|
|
38
|
+
*/
|
|
39
|
+
export type Base = {
|
|
40
|
+
json: JSONDoc;
|
|
41
|
+
ids: Map<BlockId, string[]>;
|
|
42
|
+
};
|
|
43
|
+
export declare const baseOf: (facade: ReseedFacade) => Base;
|
|
44
|
+
/** What the room keeps of a re-seed (`room.reseed.cut`). */
|
|
45
|
+
export type Cut = {
|
|
46
|
+
/** The old document's state vector at the cut. */
|
|
47
|
+
sv: Uint8Array;
|
|
48
|
+
/** The old document's deletes at the cut (an update without structs). */
|
|
49
|
+
ds: Uint8Array;
|
|
50
|
+
/** The cut map (`encodeRuns`). */
|
|
51
|
+
map: Uint8Array;
|
|
52
|
+
};
|
|
53
|
+
/** A cut as one value (`Cut` → bytes and back), for the room's storage. */
|
|
54
|
+
export declare const encodeCut: (cut: Cut) => Uint8Array<ArrayBuffer>;
|
|
55
|
+
export declare const decodeCut: (bytes: Uint8Array) => Cut;
|
|
56
|
+
/**
|
|
57
|
+
* The new epoch's document (`room.reseed.cut`): `seed` builds it from the
|
|
58
|
+
* old one's base (`baseOf`: the caller's semantics and actor), then its
|
|
59
|
+
* blocks take the old ones' attribution, and it takes the old one's actor
|
|
60
|
+
* profiles (`u/<actorId>`: names and colors). Reads `old`, writes only the
|
|
61
|
+
* new document.
|
|
62
|
+
*/
|
|
63
|
+
export declare const seedFrom: (old: Replica, base: Base, seed: (json: JSONDoc) => Replica) => Replica;
|
|
64
|
+
/**
|
|
65
|
+
* The cut between `old` and `fresh`, the new epoch seeded from its base
|
|
66
|
+
* ({@link seedFrom}): each seed unit stands for the base's unit at its
|
|
67
|
+
* place.
|
|
68
|
+
*/
|
|
69
|
+
export declare const cutBetween: (old: Replica, base: Base, fresh: Replica) => Cut;
|
|
70
|
+
/** Cut `old` (`room.reseed.cut`): the new epoch's document and the cut ({@link seedFrom}, {@link cutBetween}). */
|
|
71
|
+
export declare const cutDocument: (old: Replica, seed: (json: JSONDoc) => Replica) => {
|
|
72
|
+
document: Replica;
|
|
73
|
+
cut: Cut;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Make `doc` show `json`, where `ids` names each unit of each of its blocks
|
|
77
|
+
* (an identity `doc` holds is that unit, kept where it can be; any other
|
|
78
|
+
* is new text). Splits and merges first: each block of `doc` is cut where
|
|
79
|
+
* its units go to another block of `json` (or where another block's text
|
|
80
|
+
* comes between them), then each block of `json` is assembled from its
|
|
81
|
+
* pieces in order (its first piece takes its id, the others are merged
|
|
82
|
+
* into it; a unit `json` no longer shows stays in the piece before it).
|
|
83
|
+
* Then `restoreDocument` writes the rest, its content by identity, and
|
|
84
|
+
* keeps the pending suggestions. An identity `doc` does not hold (`new:`,
|
|
85
|
+
* `~`, an old unit it never had) names no unit of it: a unit this restore
|
|
86
|
+
* wrote and `ids` names nowhere else stands for it, so it is written once
|
|
87
|
+
* (a `new:` one, the caller's own new unit, any such unit). Runs inside the
|
|
88
|
+
* caller's transaction.
|
|
89
|
+
*/
|
|
90
|
+
export declare const restoreByIdentity: (doc: EngineDoc, facade: ReseedFacade, json: JSONDoc, ids: ReadonlyMap<BlockId, readonly string[]>) => import("./restore.js").RestoreReport;
|
|
91
|
+
/** What a rebase did: nothing to merge, or the restore's report of its write. */
|
|
92
|
+
export type RebaseReport = {
|
|
93
|
+
status: 'noop';
|
|
94
|
+
} | ({
|
|
95
|
+
status: 'applied';
|
|
96
|
+
} & ReturnType<typeof restoreDocument>);
|
|
97
|
+
/**
|
|
98
|
+
* What one user's rebases from one cut merged so far (`room.reseed.rebase`):
|
|
99
|
+
* the old document's state they covered (a state vector, and the deletes
|
|
100
|
+
* as an update without structs), so a second upload of the same edits
|
|
101
|
+
* (another tab's) merges nothing twice, and the merged maps: `maps[i]`,
|
|
102
|
+
* written while the `i`th cut's epoch was current, reads each unit those
|
|
103
|
+
* rebases wrote as the old unit it stands for, so an edit that builds on
|
|
104
|
+
* them still finds it. Starts as the cut's own state, with no map.
|
|
105
|
+
*/
|
|
106
|
+
export type Merged = {
|
|
107
|
+
sv: Uint8Array;
|
|
108
|
+
ds: Uint8Array;
|
|
109
|
+
maps: Uint8Array[];
|
|
110
|
+
};
|
|
111
|
+
export declare const encodeMerged: (merged: Merged) => Uint8Array<ArrayBuffer>;
|
|
112
|
+
export declare const decodeMerged: (bytes: Uint8Array) => Merged;
|
|
113
|
+
/** Whether `upload` holds anything `merged` does not cover: what a rebase merges, if anything. */
|
|
114
|
+
export declare const hasEdits: (upload: Uint8Array, merged: Merged) => boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Merge a client's unsaved edits, `upload` (its old document, whole),
|
|
117
|
+
* into `target`, the current document: `cuts` are the re-seeds since the
|
|
118
|
+
* client's epoch, oldest first (each one's map reads the next epoch's seed
|
|
119
|
+
* units as the previous epoch's), `merged` what its user's earlier rebases
|
|
120
|
+
* from the oldest cut merged (default: the cut's own state), `scratch`
|
|
121
|
+
* builds a document from an update. Writes `target` inside the caller's
|
|
122
|
+
* transaction; answers the report and `merged` grown by this rebase.
|
|
123
|
+
*/
|
|
124
|
+
export declare const rebaseOnto: (target: Replica, cuts: readonly Cut[], upload: Uint8Array, scratch: (update: Uint8Array) => Replica, merged?: Merged) => {
|
|
125
|
+
report: RebaseReport;
|
|
126
|
+
merged: Merged;
|
|
127
|
+
};
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A document's re-seed (`room.reseed.*` in `docs/editor-delete-contract.md`):
|
|
3
|
+
* the room rebuilds a document that grew near its quota from its visible
|
|
4
|
+
* content, with fresh identities, and keeps a cut so that an offline
|
|
5
|
+
* client's unsaved edits made against the old document still merge.
|
|
6
|
+
* Worker-safe; the room (`cloudflare/room/reseed.ts`) owns the storage,
|
|
7
|
+
* the trigger and the protocol, this module the cut and the merge.
|
|
8
|
+
*
|
|
9
|
+
* The cut (`cutDocument`): the new epoch's document, seeded from the old
|
|
10
|
+
* one without its pending suggestions (`baseOf`: same block ids, kinds,
|
|
11
|
+
* data, content, marks, block attribution), and what the room keeps of
|
|
12
|
+
* the old one: its state vector and its deletes (which part of an
|
|
13
|
+
* uploaded old document the room had) and the cut map (each seed
|
|
14
|
+
* character's old identity, read from the same view as the seed).
|
|
15
|
+
*
|
|
16
|
+
* The rebase (`rebaseOnto`), from a client's old document, against what
|
|
17
|
+
* its user's earlier rebases from the same cut merged (`Merged`, the cut's
|
|
18
|
+
* own state at first):
|
|
19
|
+
*
|
|
20
|
+
* 1. its base, the part already merged (`Y.clipUpdate`, fork patch YP34),
|
|
21
|
+
* into a scratch document, the shadow, without its pending suggestions
|
|
22
|
+
* (the cut dropped them); its own edits, the rest (`Y.diffUpdate`);
|
|
23
|
+
* 2. the current document written onto the shadow by identity
|
|
24
|
+
* (`restoreByIdentity`): a seed character is its old character (the
|
|
25
|
+
* cut map), a character an earlier rebase wrote the one it stood for
|
|
26
|
+
* (the merged map), so the shadow keeps it and the client's edits still
|
|
27
|
+
* find it; the new document's splits and merges are written as splits
|
|
28
|
+
* and merges;
|
|
29
|
+
* 3. the client's edits merged into the shadow by the CRDT, its pending
|
|
30
|
+
* suggestions dropped (`room.reseed.suggestions`);
|
|
31
|
+
* 4. the shadow written onto the current document by identity, in the
|
|
32
|
+
* caller's transaction; each character it writes is added to the
|
|
33
|
+
* merged map.
|
|
34
|
+
*
|
|
35
|
+
* Exact for text; when both sides restructured the same blocks the CRDT's
|
|
36
|
+
* outcome can depend on the old document's structural history, which the
|
|
37
|
+
* cut does not keep (`room.reseed.residue`).
|
|
38
|
+
*/
|
|
39
|
+
import { Y } from './engine.js';
|
|
40
|
+
import { clipUpdate, diffUpdate } from './engine-room.js';
|
|
41
|
+
import { asYDoc } from './structs.js';
|
|
42
|
+
import * as encoding from 'lib0-v14/encoding';
|
|
43
|
+
import * as decoding from 'lib0-v14/decoding';
|
|
44
|
+
import { blockNodeOf, view as modelView } from './placement/model.js';
|
|
45
|
+
import { bindBlockAttribution, blockAttributionOf, CONTRIBUTOR_PREFIX, CREATED_KEY } from './attribution/block.js';
|
|
46
|
+
import { ATTRIBUTION_ROOT, LAST_CHANGED_ATTR } from './schema.js';
|
|
47
|
+
import { ACTOR_PREFIX } from './attribution/attribution.js';
|
|
48
|
+
import { common } from './data.js';
|
|
49
|
+
import { canonKey } from './text/model.js';
|
|
50
|
+
import { restoreDocument, runsOf, unitsOf } from './restore.js';
|
|
51
|
+
import { displayOf, isRowMark, openRangeCursor } from './text/model.js';
|
|
52
|
+
import { suggestionsOf } from './doc/suggest.js';
|
|
53
|
+
const width = (u) => (u.item.kind === 'text' ? u.item.text.length : 1);
|
|
54
|
+
const sum = (units) => units.reduce((n, u) => n + width(u), 0);
|
|
55
|
+
/**
|
|
56
|
+
* Each unit's identity in block `id` (its character's or atom's item: the
|
|
57
|
+
* engine id of its first code unit), in display order, as `unitsOf` lists
|
|
58
|
+
* the block's units: one walk over the pieces the block displays.
|
|
59
|
+
*/
|
|
60
|
+
const idsOf = (facade, id) => {
|
|
61
|
+
const { blocks, own } = facade.part.view();
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const seg of displayOf(id, blocks, own, undefined, true) ?? []) {
|
|
64
|
+
for (const piece of openRangeCursor(seg.text).read(seg.i0, seg.i1)) {
|
|
65
|
+
if (piece.deleted || piece.len === 0)
|
|
66
|
+
continue;
|
|
67
|
+
const { client, clock } = piece.id;
|
|
68
|
+
const c = piece.content;
|
|
69
|
+
if (typeof c.str === 'string') {
|
|
70
|
+
let k = piece.offset;
|
|
71
|
+
for (const ch of c.str.slice(piece.offset, piece.offset + piece.len)) {
|
|
72
|
+
out.push(`${client}:${clock + k}`);
|
|
73
|
+
k += ch.length;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (typeof c.getContent === 'function') {
|
|
77
|
+
const all = c.getContent();
|
|
78
|
+
for (let k = piece.offset; k < piece.offset + piece.len; k++)
|
|
79
|
+
if (!isRowMark(all[k]))
|
|
80
|
+
out.push(`${client}:${clock + k}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
};
|
|
86
|
+
/** Every displayed block's unit identities. */
|
|
87
|
+
const identities = (facade) => new Map(facade.order().map((id) => [id, idsOf(facade, id)]));
|
|
88
|
+
/** A unit's identity and what it is (its character, or its atom's id and kind): a wrong identity never keeps another unit. */
|
|
89
|
+
const keyed = (ids, units) => ids.map((id, i) => {
|
|
90
|
+
const item = units[i]?.item;
|
|
91
|
+
return `${id}|${item?.kind === 'text' ? item.text : `@${item?.id}|${item?.type}`}`;
|
|
92
|
+
});
|
|
93
|
+
export const baseOf = (facade) => facade.part.base?.((other) => ({
|
|
94
|
+
json: other.toJSON({ suggestions: true }),
|
|
95
|
+
ids: identities(other)
|
|
96
|
+
})) ?? { json: facade.toJSON(), ids: identities(facade) };
|
|
97
|
+
/** Reject every pending suggestion of `facade`'s document (`room.reseed.suggestions`). */
|
|
98
|
+
const dropSuggestions = (facade) => {
|
|
99
|
+
const all = suggestionsOf(facade);
|
|
100
|
+
const pending = all.changes();
|
|
101
|
+
if (pending.length > 0)
|
|
102
|
+
all.run(all.decide(pending, false));
|
|
103
|
+
};
|
|
104
|
+
const encodeRuns = (runs) => {
|
|
105
|
+
const e = encoding.createEncoder();
|
|
106
|
+
encoding.writeVarUint(e, runs.length);
|
|
107
|
+
for (const r of runs)
|
|
108
|
+
for (const n of [r.c, r.k, r.len, r.oc, r.ok])
|
|
109
|
+
encoding.writeVarUint(e, n);
|
|
110
|
+
return encoding.toUint8Array(e);
|
|
111
|
+
};
|
|
112
|
+
const decodeRuns = (bytes) => {
|
|
113
|
+
const d = decoding.createDecoder(bytes);
|
|
114
|
+
return Array.from({ length: decoding.readVarUint(d) }, () => {
|
|
115
|
+
const [c, k, len, oc, ok] = Array.from({ length: 5 }, () => decoding.readVarUint(d));
|
|
116
|
+
return { c: c, k: k, len: len, oc: oc, ok: ok };
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
/** Add `id` (`client:clock`) standing for `was` to `runs`, joined to the last run when it continues it. */
|
|
120
|
+
const addRun = (runs, id, was) => {
|
|
121
|
+
const [c, k] = id.split(':').map(Number);
|
|
122
|
+
const [oc, ok] = was.split(':').map(Number);
|
|
123
|
+
const last = runs.at(-1);
|
|
124
|
+
if (last &&
|
|
125
|
+
last.c === c &&
|
|
126
|
+
last.k + last.len === k &&
|
|
127
|
+
last.oc === oc &&
|
|
128
|
+
last.ok + last.len === ok)
|
|
129
|
+
last.len++;
|
|
130
|
+
else
|
|
131
|
+
runs.push({ c, k, len: 1, oc, ok });
|
|
132
|
+
};
|
|
133
|
+
/** A cut as one value (`Cut` → bytes and back), for the room's storage. */
|
|
134
|
+
export const encodeCut = (cut) => {
|
|
135
|
+
const e = encoding.createEncoder();
|
|
136
|
+
for (const part of [cut.sv, cut.ds, cut.map])
|
|
137
|
+
encoding.writeVarUint8Array(e, part);
|
|
138
|
+
return encoding.toUint8Array(e);
|
|
139
|
+
};
|
|
140
|
+
export const decodeCut = (bytes) => {
|
|
141
|
+
const d = decoding.createDecoder(bytes);
|
|
142
|
+
const [sv, ds, map] = Array.from({ length: 3 }, () => decoding.readVarUint8Array(d));
|
|
143
|
+
return { sv: sv, ds: ds, map: map };
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* The new epoch's document (`room.reseed.cut`): `seed` builds it from the
|
|
147
|
+
* old one's base (`baseOf`: the caller's semantics and actor), then its
|
|
148
|
+
* blocks take the old ones' attribution, and it takes the old one's actor
|
|
149
|
+
* profiles (`u/<actorId>`: names and colors). Reads `old`, writes only the
|
|
150
|
+
* new document.
|
|
151
|
+
*/
|
|
152
|
+
export const seedFrom = (old, base, seed) => {
|
|
153
|
+
const fresh = seed(base.json);
|
|
154
|
+
const { ensureRecord } = bindBlockAttribution(Y);
|
|
155
|
+
fresh.doc.transact(() => {
|
|
156
|
+
const [was, now] = [old.doc.get(ATTRIBUTION_ROOT), fresh.doc.get(ATTRIBUTION_ROOT)];
|
|
157
|
+
for (const key of was.attrKeys())
|
|
158
|
+
if (key.startsWith(ACTOR_PREFIX))
|
|
159
|
+
now.setAttr(key, was.getAttr(key));
|
|
160
|
+
for (const id of fresh.facade.order()) {
|
|
161
|
+
const node = blockNodeOf(fresh.doc, id);
|
|
162
|
+
if (node === null)
|
|
163
|
+
continue;
|
|
164
|
+
// The record and `lastChangedBy` become the old block's, whatever the seed wrote.
|
|
165
|
+
const att = blockAttributionOf(old.doc, id);
|
|
166
|
+
const rec = ensureRecord(fresh.doc, id, node);
|
|
167
|
+
for (const key of rec.attrKeys())
|
|
168
|
+
if (key.startsWith(CONTRIBUTOR_PREFIX))
|
|
169
|
+
rec.deleteAttr(key);
|
|
170
|
+
for (const actor of att?.contributors ?? [])
|
|
171
|
+
rec.setAttr(CONTRIBUTOR_PREFIX + actor, true);
|
|
172
|
+
if (att?.createdBy === undefined)
|
|
173
|
+
rec.deleteAttr(CREATED_KEY);
|
|
174
|
+
else
|
|
175
|
+
rec.setAttr(CREATED_KEY, att.createdBy);
|
|
176
|
+
if (att?.lastChangedBy === undefined)
|
|
177
|
+
node.deleteAttr(LAST_CHANGED_ATTR);
|
|
178
|
+
else
|
|
179
|
+
node.setAttr(LAST_CHANGED_ATTR, att.lastChangedBy);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
return fresh;
|
|
183
|
+
};
|
|
184
|
+
/**
|
|
185
|
+
* The cut between `old` and `fresh`, the new epoch seeded from its base
|
|
186
|
+
* ({@link seedFrom}): each seed unit stands for the base's unit at its
|
|
187
|
+
* place.
|
|
188
|
+
*/
|
|
189
|
+
export const cutBetween = (old, base, fresh) => {
|
|
190
|
+
const runs = [];
|
|
191
|
+
for (const [block, ids] of identities(fresh.facade)) {
|
|
192
|
+
const was = base.ids.get(block) ?? [];
|
|
193
|
+
ids.forEach((id, i) => {
|
|
194
|
+
if (was[i] !== undefined)
|
|
195
|
+
addRun(runs, id, was[i]);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const update = Y.encodeStateAsUpdate(asYDoc(old.doc));
|
|
199
|
+
const sv = Y.encodeStateVector(asYDoc(old.doc));
|
|
200
|
+
return { sv, ds: diffUpdate(update, sv), map: encodeRuns(runs) };
|
|
201
|
+
};
|
|
202
|
+
/** Cut `old` (`room.reseed.cut`): the new epoch's document and the cut ({@link seedFrom}, {@link cutBetween}). */
|
|
203
|
+
export const cutDocument = (old, seed) => {
|
|
204
|
+
const base = baseOf(old.facade);
|
|
205
|
+
const document = seedFrom(old, base, seed);
|
|
206
|
+
return { document, cut: cutBetween(old, base, document) };
|
|
207
|
+
};
|
|
208
|
+
/** A map of runs as a lookup: an item's id → the old item's it stands for (`undefined`: none). */
|
|
209
|
+
const lookupOf = (map) => {
|
|
210
|
+
const byClient = new Map();
|
|
211
|
+
for (const run of decodeRuns(map)) {
|
|
212
|
+
const list = byClient.get(run.c);
|
|
213
|
+
if (list)
|
|
214
|
+
list.push(run);
|
|
215
|
+
else
|
|
216
|
+
byClient.set(run.c, [run]);
|
|
217
|
+
}
|
|
218
|
+
for (const list of byClient.values())
|
|
219
|
+
list.sort((a, b) => a.k - b.k);
|
|
220
|
+
return (id) => {
|
|
221
|
+
const [c, k] = id.split(':').map(Number);
|
|
222
|
+
const list = byClient.get(c);
|
|
223
|
+
if (!list)
|
|
224
|
+
return undefined;
|
|
225
|
+
let [lo, hi] = [0, list.length];
|
|
226
|
+
while (lo < hi) {
|
|
227
|
+
const mid = (lo + hi) >> 1;
|
|
228
|
+
if (list[mid].k + list[mid].len <= k)
|
|
229
|
+
lo = mid + 1;
|
|
230
|
+
else
|
|
231
|
+
hi = mid;
|
|
232
|
+
}
|
|
233
|
+
const run = list[lo];
|
|
234
|
+
return run && run.k <= k ? `${run.oc}:${run.ok + (k - run.k)}` : undefined;
|
|
235
|
+
};
|
|
236
|
+
};
|
|
237
|
+
/**
|
|
238
|
+
* Make block `id`'s content read `want` keeping every unit whose identity
|
|
239
|
+
* the two key lists share in order: each gap between kept units deleted
|
|
240
|
+
* and written again, a kept character whose marks differ formatted (runs
|
|
241
|
+
* of the same change in one write), a kept atom whose data differs patched
|
|
242
|
+
* in place (written again when it has no id, or another kind). Edits
|
|
243
|
+
* through the display.
|
|
244
|
+
*/
|
|
245
|
+
const rewriteKeyed = (facade, id, have, want, haveKeys, wantKeys) => {
|
|
246
|
+
let [at, i, j] = [0, 0, 0];
|
|
247
|
+
let run = null;
|
|
248
|
+
const flush = () => {
|
|
249
|
+
if (run !== null)
|
|
250
|
+
facade.apply(facade.prepare.formatRange(id, run.at, run.len, run.marks));
|
|
251
|
+
run = null;
|
|
252
|
+
};
|
|
253
|
+
for (const [pi, pj] of [...common(haveKeys, wantKeys), [have.length, want.length]]) {
|
|
254
|
+
const gone = sum(have.slice(i, pi));
|
|
255
|
+
if (gone > 0 || pj > j)
|
|
256
|
+
flush();
|
|
257
|
+
if (gone > 0)
|
|
258
|
+
facade.apply(facade.prepare.deleteText(id, at, gone));
|
|
259
|
+
for (const item of runsOf(want.slice(j, pj))) {
|
|
260
|
+
if (item.kind === 'text')
|
|
261
|
+
facade.apply(facade.prepare.insertText(id, at, item.text, item.marks ?? {}));
|
|
262
|
+
else
|
|
263
|
+
facade.apply(facade.prepare.insertInline(id, at, { id: item.id, type: item.type, data: item.data }));
|
|
264
|
+
at += item.kind === 'text' ? item.text.length : 1;
|
|
265
|
+
}
|
|
266
|
+
if (pi === have.length)
|
|
267
|
+
break;
|
|
268
|
+
const [h, w] = [have[pi], want[pj]];
|
|
269
|
+
if (h.key !== w.key && w.item.kind === 'inline') {
|
|
270
|
+
flush();
|
|
271
|
+
const { item } = h;
|
|
272
|
+
if (item.kind === 'inline' &&
|
|
273
|
+
item.id !== '' &&
|
|
274
|
+
item.id === w.item.id &&
|
|
275
|
+
item.type === w.item.type)
|
|
276
|
+
facade.apply(facade.prepare.patchData({ block: id, atom: item.id }, [{ path: [], value: w.item.data }]));
|
|
277
|
+
else {
|
|
278
|
+
facade.apply(facade.prepare.deleteText(id, at, 1));
|
|
279
|
+
facade.apply(facade.prepare.insertInline(id, at, {
|
|
280
|
+
id: w.item.id,
|
|
281
|
+
type: w.item.type,
|
|
282
|
+
data: w.item.data
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
else if (h.key !== w.key && h.item.kind === 'text' && w.item.kind === 'text') {
|
|
287
|
+
const [hm, wm] = [h.item.marks ?? {}, w.item.marks ?? {}];
|
|
288
|
+
const marks = {};
|
|
289
|
+
for (const k of new Set([...Object.keys(hm), ...Object.keys(wm)]))
|
|
290
|
+
if (canonKey({ v: hm[k] ?? null }) !== canonKey({ v: wm[k] ?? null }))
|
|
291
|
+
marks[k] = wm[k] ?? null;
|
|
292
|
+
const tag = canonKey(marks);
|
|
293
|
+
if (run !== null && run.tag === tag && run.at + run.len === at)
|
|
294
|
+
run.len += width(h);
|
|
295
|
+
else {
|
|
296
|
+
flush();
|
|
297
|
+
run = { at, len: width(h), marks, tag };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else
|
|
301
|
+
flush();
|
|
302
|
+
at += width(h);
|
|
303
|
+
[i, j] = [pi + 1, pj + 1];
|
|
304
|
+
}
|
|
305
|
+
flush();
|
|
306
|
+
};
|
|
307
|
+
/**
|
|
308
|
+
* Make `doc` show `json`, where `ids` names each unit of each of its blocks
|
|
309
|
+
* (an identity `doc` holds is that unit, kept where it can be; any other
|
|
310
|
+
* is new text). Splits and merges first: each block of `doc` is cut where
|
|
311
|
+
* its units go to another block of `json` (or where another block's text
|
|
312
|
+
* comes between them), then each block of `json` is assembled from its
|
|
313
|
+
* pieces in order (its first piece takes its id, the others are merged
|
|
314
|
+
* into it; a unit `json` no longer shows stays in the piece before it).
|
|
315
|
+
* Then `restoreDocument` writes the rest, its content by identity, and
|
|
316
|
+
* keeps the pending suggestions. An identity `doc` does not hold (`new:`,
|
|
317
|
+
* `~`, an old unit it never had) names no unit of it: a unit this restore
|
|
318
|
+
* wrote and `ids` names nowhere else stands for it, so it is written once
|
|
319
|
+
* (a `new:` one, the caller's own new unit, any such unit). Runs inside the
|
|
320
|
+
* caller's transaction.
|
|
321
|
+
*/
|
|
322
|
+
export const restoreByIdentity = (doc, facade, json, ids) => {
|
|
323
|
+
const where = new Map();
|
|
324
|
+
for (const [x, list] of ids)
|
|
325
|
+
list.forEach((id, i) => where.set(id, { x, i }));
|
|
326
|
+
const base = modelView(doc).order.ids.slice();
|
|
327
|
+
const srcOf = new Map();
|
|
328
|
+
for (const a of base)
|
|
329
|
+
for (const k of idsOf(facade, a))
|
|
330
|
+
srcOf.set(k, a);
|
|
331
|
+
const pieces = new Map();
|
|
332
|
+
let temp = 0;
|
|
333
|
+
for (const a of base) {
|
|
334
|
+
const list = [];
|
|
335
|
+
let cur = null;
|
|
336
|
+
idsOf(facade, a).forEach((k, u) => {
|
|
337
|
+
const w = where.get(k);
|
|
338
|
+
if (!w)
|
|
339
|
+
return;
|
|
340
|
+
const between = (ids.get(w.x) ?? []).slice((cur?.last ?? 0) + 1, w.i);
|
|
341
|
+
if (cur !== null &&
|
|
342
|
+
cur.dest === w.x &&
|
|
343
|
+
w.i > cur.last &&
|
|
344
|
+
between.every((key) => (srcOf.get(key) ?? a) === a)) {
|
|
345
|
+
cur.last = w.i;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const first = list.length === 0;
|
|
349
|
+
cur = { start: first ? 0 : u, dest: w.x, first: w.i, last: w.i, id: first ? a : '' };
|
|
350
|
+
list.push(cur);
|
|
351
|
+
});
|
|
352
|
+
pieces.set(a, list);
|
|
353
|
+
}
|
|
354
|
+
const byDest = new Map();
|
|
355
|
+
for (const list of pieces.values())
|
|
356
|
+
for (const piece of list)
|
|
357
|
+
byDest.set(piece.dest, [...(byDest.get(piece.dest) ?? []), piece]);
|
|
358
|
+
for (const [x, list] of byDest) {
|
|
359
|
+
list.sort((a, b) => a.first - b.first);
|
|
360
|
+
if (list[0].id === '' && blockNodeOf(doc, x) === null)
|
|
361
|
+
list[0].id = x;
|
|
362
|
+
}
|
|
363
|
+
const free = () => {
|
|
364
|
+
let id;
|
|
365
|
+
do
|
|
366
|
+
id = `~cut${temp++}`;
|
|
367
|
+
while (blockNodeOf(doc, id) !== null || ids.has(id));
|
|
368
|
+
return id;
|
|
369
|
+
};
|
|
370
|
+
for (const list of pieces.values())
|
|
371
|
+
for (const piece of list)
|
|
372
|
+
if (piece.id === '')
|
|
373
|
+
piece.id = free();
|
|
374
|
+
for (const [a, list] of pieces)
|
|
375
|
+
for (let k = list.length - 1; k >= 1; k--) {
|
|
376
|
+
const at = sum(unitsOf(facade.contentItems(a)).slice(0, list[k].start));
|
|
377
|
+
facade.apply(facade.prepare.splitBlock(a, at, list[k].id));
|
|
378
|
+
}
|
|
379
|
+
for (const [x, list] of byDest) {
|
|
380
|
+
const head = list[0];
|
|
381
|
+
if (blockNodeOf(doc, x) === null && head.id !== x) {
|
|
382
|
+
facade.apply(facade.prepare.splitBlock(head.id, 0, x));
|
|
383
|
+
head.id = x;
|
|
384
|
+
}
|
|
385
|
+
for (const piece of list)
|
|
386
|
+
if (piece.id !== x)
|
|
387
|
+
facade.apply(facade.prepare.mergeBlocks(piece.id, x));
|
|
388
|
+
}
|
|
389
|
+
const want = (id) => [...(ids.get(id) ?? [])];
|
|
390
|
+
const report = restoreDocument(doc, facade, json, {
|
|
391
|
+
rewrite: (f, id, have, wanted) => {
|
|
392
|
+
const r = f;
|
|
393
|
+
rewriteKeyed(r, id, have, wanted, keyed(idsOf(r, id), have), keyed(want(id), wanted));
|
|
394
|
+
return true;
|
|
395
|
+
},
|
|
396
|
+
suggestions: 'keep'
|
|
397
|
+
});
|
|
398
|
+
// Same text, another identity (a unit deleted and typed again): by identity too.
|
|
399
|
+
const named = new Set([...ids.values()].flat());
|
|
400
|
+
for (const [id, list] of ids) {
|
|
401
|
+
const have = idsOf(facade, id);
|
|
402
|
+
// A unit this restore wrote stands for it; a unit `doc` held stands only for the
|
|
403
|
+
// caller's own new one (`new:`): another unit of `doc` would answer for a unit
|
|
404
|
+
// it is not, which a later edit of `doc`'s own unit would then hit.
|
|
405
|
+
const wanted = list.map((k, i) => !srcOf.has(k) &&
|
|
406
|
+
have[i] !== undefined &&
|
|
407
|
+
!named.has(have[i]) &&
|
|
408
|
+
(k.startsWith('new:') || !srcOf.has(have[i]))
|
|
409
|
+
? have[i]
|
|
410
|
+
: k);
|
|
411
|
+
if (have.length === wanted.length && have.every((k, i) => k === wanted[i]))
|
|
412
|
+
continue;
|
|
413
|
+
const units = unitsOf(facade.contentItems(id));
|
|
414
|
+
rewriteKeyed(facade, id, units, units, keyed(have, units), keyed(wanted, units));
|
|
415
|
+
}
|
|
416
|
+
return report;
|
|
417
|
+
};
|
|
418
|
+
export const encodeMerged = (merged) => {
|
|
419
|
+
const e = encoding.createEncoder();
|
|
420
|
+
for (const part of [merged.sv, merged.ds])
|
|
421
|
+
encoding.writeVarUint8Array(e, part);
|
|
422
|
+
encoding.writeVarUint(e, merged.maps.length);
|
|
423
|
+
for (const map of merged.maps)
|
|
424
|
+
encoding.writeVarUint8Array(e, map);
|
|
425
|
+
return encoding.toUint8Array(e);
|
|
426
|
+
};
|
|
427
|
+
export const decodeMerged = (bytes) => {
|
|
428
|
+
const d = decoding.createDecoder(bytes);
|
|
429
|
+
const [sv, ds] = [decoding.readVarUint8Array(d), decoding.readVarUint8Array(d)];
|
|
430
|
+
const maps = Array.from({ length: decoding.readVarUint(d) }, () => decoding.readVarUint8Array(d));
|
|
431
|
+
return { sv, ds, maps };
|
|
432
|
+
};
|
|
433
|
+
/** Whether `upload` holds anything `merged` does not cover: what a rebase merges, if anything. */
|
|
434
|
+
export const hasEdits = (upload, merged) => {
|
|
435
|
+
const own = Y.decodeUpdate(diffUpdate(upload, merged.sv));
|
|
436
|
+
return (!own.structs.every((s) => s instanceof Y.Skip) ||
|
|
437
|
+
!Y.diffIdSet(own.ds, Y.decodeUpdate(merged.ds).ds).isEmpty());
|
|
438
|
+
};
|
|
439
|
+
/**
|
|
440
|
+
* Merge a client's unsaved edits, `upload` (its old document, whole),
|
|
441
|
+
* into `target`, the current document: `cuts` are the re-seeds since the
|
|
442
|
+
* client's epoch, oldest first (each one's map reads the next epoch's seed
|
|
443
|
+
* units as the previous epoch's), `merged` what its user's earlier rebases
|
|
444
|
+
* from the oldest cut merged (default: the cut's own state), `scratch`
|
|
445
|
+
* builds a document from an update. Writes `target` inside the caller's
|
|
446
|
+
* transaction; answers the report and `merged` grown by this rebase.
|
|
447
|
+
*/
|
|
448
|
+
export const rebaseOnto = (target, cuts, upload, scratch, merged = { sv: cuts[0].sv, ds: cuts[0].ds, maps: [] }) => {
|
|
449
|
+
const mine = diffUpdate(upload, merged.sv);
|
|
450
|
+
const own = Y.decodeUpdate(mine);
|
|
451
|
+
const done = Y.decodeUpdate(merged.ds).ds;
|
|
452
|
+
if (own.structs.every((s) => s instanceof Y.Skip) && Y.diffIdSet(own.ds, done).isEmpty())
|
|
453
|
+
return { report: { status: 'noop' }, merged };
|
|
454
|
+
// 1 · The base, without the pending suggestions the cut dropped.
|
|
455
|
+
const shadow = scratch(clipUpdate(upload, merged.sv, done));
|
|
456
|
+
dropSuggestions(shadow.facade);
|
|
457
|
+
// 2 · The current document, each unit read down the cuts to its old one.
|
|
458
|
+
const cutLookups = cuts.map((cut) => lookupOf(cut.map));
|
|
459
|
+
const mapLookups = merged.maps.map(lookupOf);
|
|
460
|
+
const old = (k) => {
|
|
461
|
+
let id = k;
|
|
462
|
+
for (let i = cuts.length - 1; i >= 0; i--) {
|
|
463
|
+
const was = mapLookups[i]?.(id);
|
|
464
|
+
if (was !== undefined)
|
|
465
|
+
return was;
|
|
466
|
+
const before = cutLookups[i](id);
|
|
467
|
+
// A unit of a later epoch the cuts do not name: no old unit stands for it.
|
|
468
|
+
if (before === undefined)
|
|
469
|
+
return `~${k}`;
|
|
470
|
+
id = before;
|
|
471
|
+
}
|
|
472
|
+
return id;
|
|
473
|
+
};
|
|
474
|
+
const theirs = target.facade.toJSON({ suggestions: true });
|
|
475
|
+
const current = identities(target.facade);
|
|
476
|
+
const asOld = new Map([...current].map(([id, list]) => [id, list.map(old)]));
|
|
477
|
+
shadow.doc.transact(() => restoreByIdentity(shadow.doc, shadow.facade, theirs, asOld));
|
|
478
|
+
// Each shadow unit's identity in the current document (they read the same now).
|
|
479
|
+
const toCurrent = new Map();
|
|
480
|
+
for (const [id, list] of identities(shadow.facade)) {
|
|
481
|
+
const now = current.get(id) ?? [];
|
|
482
|
+
list.forEach((k, i) => {
|
|
483
|
+
if (now[i] !== undefined)
|
|
484
|
+
toCurrent.set(k, now[i]);
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
// 3 · The client's edits, merged by the CRDT; its pending suggestions dropped.
|
|
488
|
+
Y.applyUpdate(asYDoc(shadow.doc), mine);
|
|
489
|
+
dropSuggestions(shadow.facade);
|
|
490
|
+
// 4 · Back onto the current document, by identity.
|
|
491
|
+
const result = shadow.facade.toJSON({ suggestions: true });
|
|
492
|
+
const ids = new Map([...identities(shadow.facade)].map(([id, list]) => [
|
|
493
|
+
id,
|
|
494
|
+
list.map((k) => toCurrent.get(k) ?? `new:${k}`)
|
|
495
|
+
]));
|
|
496
|
+
// The units this rebase writes: the target's writer's, past its state now.
|
|
497
|
+
const writer = asYDoc(target.doc).clientID;
|
|
498
|
+
const from = Y.decodeStateVector(Y.encodeStateVector(asYDoc(target.doc))).get(writer) ?? 0;
|
|
499
|
+
const report = restoreByIdentity(target.doc, target.facade, result, ids);
|
|
500
|
+
// What this rebase merged: the structs the shadow took in, the deletes, the units it wrote.
|
|
501
|
+
const took = Y.decodeStateVector(Y.encodeStateVector(asYDoc(shadow.doc)));
|
|
502
|
+
const sv = Y.decodeStateVector(merged.sv);
|
|
503
|
+
const top = new Map();
|
|
504
|
+
for (const struct of own.structs) {
|
|
505
|
+
const { client, clock } = struct.id;
|
|
506
|
+
top.set(client, Math.max(top.get(client) ?? 0, clock + struct.length));
|
|
507
|
+
if ((took.get(client) ?? 0) > (sv.get(client) ?? 0))
|
|
508
|
+
sv.set(client, took.get(client));
|
|
509
|
+
}
|
|
510
|
+
// The upload's deletes alone: its structs past every one it holds.
|
|
511
|
+
const deletes = diffUpdate(mine, Y.encodeStateVector(top));
|
|
512
|
+
const runs = decodeRuns(merged.maps[cuts.length - 1] ?? encodeRuns([]));
|
|
513
|
+
for (const [id, list] of ids) {
|
|
514
|
+
const now = idsOf(target.facade, id);
|
|
515
|
+
if (now.length !== list.length)
|
|
516
|
+
continue;
|
|
517
|
+
list.forEach((k, i) => {
|
|
518
|
+
const [c, clock] = now[i].split(':').map(Number);
|
|
519
|
+
if (k.startsWith('new:') && c === writer && clock >= from)
|
|
520
|
+
addRun(runs, now[i], k.slice(4));
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
const maps = Array.from({ length: cuts.length }, (_, i) => merged.maps[i] ?? encodeRuns([]));
|
|
524
|
+
maps[cuts.length - 1] = encodeRuns(runs);
|
|
525
|
+
return {
|
|
526
|
+
report: { status: 'applied', ...report },
|
|
527
|
+
merged: {
|
|
528
|
+
sv: Y.encodeStateVector(sv),
|
|
529
|
+
ds: Y.mergeUpdates([merged.ds, deletes]),
|
|
530
|
+
maps
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
};
|