edytor 0.1.0-next.21 → 0.1.0-next.23
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 +1 -1
- package/dist/cloudflare/DocumentRoom.d.ts +302 -10
- package/dist/cloudflare/DocumentRoom.js +965 -88
- package/dist/cloudflare/index.d.ts +1 -1
- package/dist/cloudflare/index.js +1 -1
- package/dist/cloudflare/routeDocumentSocket.js +30 -6
- package/dist/collaboration/awarenessSelection.d.ts +7 -1
- package/dist/collaboration/awarenessSelection.js +7 -1
- package/dist/collaboration/index.d.ts +2 -2
- package/dist/collaboration/index.js +2 -2
- package/dist/collaboration/providers.d.ts +4 -0
- package/dist/collaboration/providers.js +4 -0
- package/dist/components/Edytor.svelte +14 -2
- package/dist/components/Edytor.svelte.d.ts +8 -1
- package/dist/crdt/admission.d.ts +6 -1
- package/dist/crdt/admission.js +9 -2
- package/dist/crdt/data.d.ts +3 -1
- package/dist/crdt/data.js +42 -32
- package/dist/crdt/document.d.ts +31 -8
- package/dist/crdt/document.js +53 -18
- package/dist/crdt/edytor-doc.d.ts +31 -3
- package/dist/crdt/edytor-doc.js +131 -14
- package/dist/crdt/engine.d.ts +6 -0
- package/dist/crdt/engine.js +7 -0
- package/dist/crdt/flow.d.ts +2 -0
- package/dist/crdt/flow.js +2 -2
- package/dist/crdt/index.d.ts +20 -9
- package/dist/crdt/index.js +3 -3
- package/dist/crdt/migration/migrate.js +9 -3
- package/dist/crdt/placement/model.d.ts +9 -1
- package/dist/crdt/placement/model.js +29 -10
- package/dist/crdt/placement/rank.d.ts +20 -0
- package/dist/crdt/placement/rank.js +79 -0
- package/dist/crdt/protocols/envelope.d.ts +20 -1
- package/dist/crdt/protocols/envelope.js +14 -2
- package/dist/crdt/providers/container.d.ts +15 -0
- package/dist/crdt/providers/container.js +17 -3
- package/dist/crdt/providers/index.d.ts +21 -1
- package/dist/crdt/providers/index.js +86 -3
- package/dist/crdt/providers/indexeddb.js +60 -40
- package/dist/crdt/providers/room.d.ts +18 -2
- package/dist/crdt/providers/room.js +29 -5
- package/dist/crdt/providers/websocket.d.ts +9 -1
- package/dist/crdt/providers/websocket.js +21 -6
- package/dist/crdt/storage.d.ts +21 -0
- package/dist/crdt/storage.js +37 -0
- package/dist/crdt/structs.d.ts +17 -0
- package/dist/crdt/structs.js +41 -0
- package/dist/crdt/text/model.d.ts +9 -1
- package/dist/crdt/text/model.js +24 -14
- package/dist/crdt/text/rows.d.ts +94 -0
- package/dist/crdt/text/rows.js +193 -0
- package/dist/crdt/text/runs.d.ts +30 -10
- package/dist/crdt/text/runs.js +1235 -264
- package/dist/edytor.svelte.d.ts +3 -1
- package/dist/edytor.svelte.js +9 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plugins/image/ImageEmpty.svelte +19 -6
- package/dist/plugins/image/ImagePlugin.svelte +9 -3
- package/dist/plugins/image/ImagePlugin.svelte.d.ts +2 -2
- package/dist/plugins/image/image.d.ts +16 -0
- package/dist/plugins/image/image.js +19 -0
- package/dist/plugins/index.d.ts +1 -1
- package/dist/plugins/index.js +1 -1
- package/dist/plugins.d.ts +11 -0
- package/dist/session/commands.d.ts +1 -0
- package/dist/session/commands.js +4 -1
- package/package.json +1 -1
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* `cloudflare:workers`); built on the Worker-safe CRDT entry. Documented in
|
|
5
5
|
* the site's `server/` section (room, authorization, extending, protocol).
|
|
6
6
|
*/
|
|
7
|
-
export { DocumentRoom, AttachedDocument, attachDocument, SOCKET_TAG, closedSocket, type AttachDocumentOptions, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, IDENTITY_HEADERS, noTimers, type Attachment, type DocumentRoomEnv, type LoadedDocument, type Refusal, type ReplicaOwner, type SavedDocument, type SocketIdentity } from './DocumentRoom.js';
|
|
7
|
+
export { DocumentRoom, AttachedDocument, attachDocument, SOCKET_TAG, closedSocket, type AttachDocumentOptions, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, IDENTITY_HEADERS, PROBE_HEADER, noTimers, type Attachment, type DocumentRoomEnv, type LoadedDocument, type Refusal, type ReplicaOwner, type SavedDocument, type SocketIdentity, type StoredRecord, type FrameValidation, type ValidatedBlock, type RoomMetrics, type RoomLogEntry, type Timing } from './DocumentRoom.js';
|
|
8
8
|
export { routeDocumentSocket, requestedReplica, type AuthorizeDocumentSocket, type ExpiredCredential, type DocumentNamespace, type DocumentIdentity } from './routeDocumentSocket.js';
|
package/dist/cloudflare/index.js
CHANGED
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* `cloudflare:workers`); built on the Worker-safe CRDT entry. Documented in
|
|
5
5
|
* the site's `server/` section (room, authorization, extending, protocol).
|
|
6
6
|
*/
|
|
7
|
-
export { DocumentRoom, AttachedDocument, attachDocument, SOCKET_TAG, closedSocket, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, IDENTITY_HEADERS, noTimers } from './DocumentRoom.js';
|
|
7
|
+
export { DocumentRoom, AttachedDocument, attachDocument, SOCKET_TAG, closedSocket, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, IDENTITY_HEADERS, PROBE_HEADER, noTimers } from './DocumentRoom.js';
|
|
8
8
|
export { routeDocumentSocket, requestedReplica } from './routeDocumentSocket.js';
|
|
@@ -13,21 +13,42 @@
|
|
|
13
13
|
* with its `params` read again, so a refreshed token gets in. A document
|
|
14
14
|
* id the room cannot have (`validRoomId`: empty, `.` or `..`, over 256
|
|
15
15
|
* characters, with a lone surrogate) is closed `4400`.
|
|
16
|
+
*
|
|
17
|
+
* A plain `GET <room>?lastUpdated` (no upgrade) is the room's probe (H12):
|
|
18
|
+
* authorized the same way, it answers `{ lastUpdated }` as JSON (the room's
|
|
19
|
+
* last stored change, ms since the epoch, or `null`), or `400`, `401` or
|
|
20
|
+
* `403` as an HTTP status.
|
|
16
21
|
*/
|
|
17
22
|
import { CLOSE, validRoomId } from '../crdt/providers/room.js';
|
|
18
|
-
import { IDENTITY_HEADERS, closedSocket, parseReplica } from './DocumentRoom.js';
|
|
23
|
+
import { IDENTITY_HEADERS, PROBE_HEADER, closedSocket, parseReplica } from './DocumentRoom.js';
|
|
24
|
+
/** The HTTP status a `lastUpdated` probe gets for each refusal a dial is closed with. */
|
|
25
|
+
const PROBE_STATUS = {
|
|
26
|
+
[CLOSE.invalidDocument]: 400,
|
|
27
|
+
[CLOSE.expired]: 401,
|
|
28
|
+
[CLOSE.denied]: 403
|
|
29
|
+
};
|
|
19
30
|
/** The replica a client asked for in `?replica=<doc.clientID>` (or `param`), or `null`. */
|
|
20
31
|
export const requestedReplica = (request, param = 'replica') => parseReplica(new URL(request.url).searchParams.get(param));
|
|
21
32
|
export async function routeDocumentSocket(request, rooms, documentId, authorize) {
|
|
22
|
-
|
|
33
|
+
// The `lastUpdated` probe (H12): a plain GET, authorized like a dial,
|
|
34
|
+
// answered with JSON (`{ lastUpdated }`) or an HTTP status.
|
|
35
|
+
const probe = request.method === 'GET' &&
|
|
36
|
+
request.headers.get('Upgrade') === null &&
|
|
37
|
+
new URL(request.url).searchParams.has('lastUpdated');
|
|
38
|
+
if (!probe &&
|
|
39
|
+
(request.method !== 'GET' || request.headers.get('Upgrade')?.toLowerCase() !== 'websocket')) {
|
|
23
40
|
return new Response('WebSocket upgrade required', { status: 426 });
|
|
24
41
|
}
|
|
42
|
+
// One refusal path: a dial gets the close the provider reads, a probe its HTTP status.
|
|
43
|
+
const refuse = (code, reason) => probe
|
|
44
|
+
? new Response(reason, { status: PROBE_STATUS[code] ?? 403 })
|
|
45
|
+
: closedSocket(code, reason);
|
|
25
46
|
if (!validRoomId(documentId)) {
|
|
26
|
-
return
|
|
47
|
+
return refuse(CLOSE.invalidDocument, 'invalid document id');
|
|
27
48
|
}
|
|
28
49
|
const decision = await authorize(request, documentId);
|
|
29
50
|
if (decision && 'expired' in decision && decision.expired === true) {
|
|
30
|
-
return
|
|
51
|
+
return refuse(CLOSE.expired, 'expired');
|
|
31
52
|
}
|
|
32
53
|
const identity = decision && 'userId' in decision ? decision : null;
|
|
33
54
|
const replica = identity?.replica ?? null;
|
|
@@ -38,13 +59,16 @@ export async function routeDocumentSocket(request, rooms, documentId, authorize)
|
|
|
38
59
|
// No header, even percent-encoded, carries a lone surrogate.
|
|
39
60
|
/\p{Cs}/u.test(identity.userId) ||
|
|
40
61
|
(replica !== null && parseReplica(replica) === null)) {
|
|
41
|
-
return
|
|
62
|
+
return refuse(CLOSE.denied, 'document access denied');
|
|
42
63
|
}
|
|
43
64
|
const headers = new Headers({
|
|
44
|
-
Upgrade: 'websocket',
|
|
45
65
|
[IDENTITY_HEADERS.user]: encodeURIComponent(identity.userId),
|
|
46
66
|
[IDENTITY_HEADERS.access]: identity.readOnly ? 'read' : 'write'
|
|
47
67
|
});
|
|
68
|
+
if (probe)
|
|
69
|
+
headers.set(PROBE_HEADER, 'lastUpdated');
|
|
70
|
+
else
|
|
71
|
+
headers.set('Upgrade', 'websocket');
|
|
48
72
|
if (replica !== null)
|
|
49
73
|
headers.set(IDENTITY_HEADERS.replica, String(replica));
|
|
50
74
|
return rooms.getByName(documentId).fetch(new Request(request.url, { method: 'GET', headers }));
|
|
@@ -68,10 +68,16 @@ export declare const mintPresenceKey: () => string;
|
|
|
68
68
|
export declare const publishPresence: (awareness: AwarenessLike, key: string, payload: PresenceSelection | null) => boolean;
|
|
69
69
|
/** What a view shares of its selection: the selection itself, the block holding its focus, or nothing. */
|
|
70
70
|
export type PresenceShare = 'caret' | 'block' | 'none';
|
|
71
|
+
/**
|
|
72
|
+
* The least ms between two presence writes of a view by default (P9): a
|
|
73
|
+
* caret held down or a drag publishes about 20 writes a second instead of
|
|
74
|
+
* one per selection change, which every peer and the room relay.
|
|
75
|
+
*/
|
|
76
|
+
export declare const DEFAULT_PRESENCE_THROTTLE = 50;
|
|
71
77
|
export type PresenceOptions = {
|
|
72
78
|
/** `'caret'` (default): the selection; `'block'`: the focused block only, so moving inside it publishes nothing; `'none'`: nothing. */
|
|
73
79
|
share?: PresenceShare;
|
|
74
|
-
/** Minimum ms between two presence writes (default `0
|
|
80
|
+
/** Minimum ms between two presence writes (default {@link DEFAULT_PRESENCE_THROTTLE}, 50; `0`: every change). The first goes at once; later ones collapse into one write of the newest at the window's end. */
|
|
75
81
|
throttle?: number;
|
|
76
82
|
};
|
|
77
83
|
/**
|
|
@@ -28,6 +28,12 @@ export const publishPresence = (awareness, key, payload) => {
|
|
|
28
28
|
awareness.setLocalState(Object.keys(next).length > 0 ? { ...state, selections: next } : state);
|
|
29
29
|
return true;
|
|
30
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* The least ms between two presence writes of a view by default (P9): a
|
|
33
|
+
* caret held down or a drag publishes about 20 writes a second instead of
|
|
34
|
+
* one per selection change, which every peer and the room relay.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_PRESENCE_THROTTLE = 50;
|
|
31
37
|
/**
|
|
32
38
|
* One view's presence writer (R1): the only writer of its key, under the
|
|
33
39
|
* view's `share` and `throttle`. `select()` hands it the full payload; the
|
|
@@ -44,7 +50,7 @@ export class PresenceWriter {
|
|
|
44
50
|
#held;
|
|
45
51
|
/** The last payload and focused block handed in, kept so a share change republishes at once. */
|
|
46
52
|
#latest = [null, null];
|
|
47
|
-
constructor(awareness, key, { share = 'caret', throttle =
|
|
53
|
+
constructor(awareness, key, { share = 'caret', throttle = DEFAULT_PRESENCE_THROTTLE } = {}) {
|
|
48
54
|
this.awareness = awareness;
|
|
49
55
|
this.key = key;
|
|
50
56
|
this.#share = share;
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { freshestPublishedSelection, publishPresence, PresenceWriter, type PresenceOptions, type PresenceShare, type EdytorAwarenessSelection, type EdytorAwarenessState, type EdytorAwarenessUser, type EdytorAwarenessViewSelection } from './awarenessSelection.js';
|
|
1
|
+
export { freshestPublishedSelection, publishPresence, PresenceWriter, DEFAULT_PRESENCE_THROTTLE, type PresenceOptions, type PresenceShare, type EdytorAwarenessSelection, type EdytorAwarenessState, type EdytorAwarenessUser, type EdytorAwarenessViewSelection } from './awarenessSelection.js';
|
|
2
2
|
export { whenDocumentReady } from './documentSync.js';
|
|
3
|
-
export { createIndexeddbSync, createWebsocketSync, clearDocument, IndexeddbPersistence, storeState, WebsocketProvider, type EdytorSync, type EdytorSyncCleanup, type EdytorSyncPayload, type IndexeddbSyncOptions, type WebsocketSync, type WebsocketSyncOptions } from './providers.js';
|
|
3
|
+
export { createIndexeddbSync, createWebsocketSync, clearDocument, IndexeddbPersistence, storeState, WebsocketProvider, prefetch, lastUpdated, type EdytorSync, type EdytorSyncCleanup, type EdytorSyncPayload, type IndexeddbSyncOptions, type WebsocketSync, type WebsocketSyncOptions } from './providers.js';
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { freshestPublishedSelection, publishPresence, PresenceWriter } from './awarenessSelection.js';
|
|
1
|
+
export { freshestPublishedSelection, publishPresence, PresenceWriter, DEFAULT_PRESENCE_THROTTLE } from './awarenessSelection.js';
|
|
2
2
|
export { whenDocumentReady } from './documentSync.js';
|
|
3
|
-
export { createIndexeddbSync, createWebsocketSync, clearDocument, IndexeddbPersistence, storeState, WebsocketProvider } from './providers.js';
|
|
3
|
+
export { createIndexeddbSync, createWebsocketSync, clearDocument, IndexeddbPersistence, storeState, WebsocketProvider, prefetch, lastUpdated } from './providers.js';
|
|
@@ -11,3 +11,7 @@ export declare const clearDocument: ProviderStack['clearDocument'];
|
|
|
11
11
|
export declare const storeState: ProviderStack['storeState'];
|
|
12
12
|
export declare const IndexeddbPersistence: ProviderStack['IndexeddbPersistence'];
|
|
13
13
|
export declare const WebsocketProvider: ProviderStack['WebsocketProvider'];
|
|
14
|
+
/** Keep a document's local copy fresh without opening it (sync once, then close). */
|
|
15
|
+
export declare const prefetch: ProviderStack['prefetch'];
|
|
16
|
+
/** When a room last stored a change: one authorized HTTP request, no document opened. */
|
|
17
|
+
export declare const lastUpdated: ProviderStack['lastUpdated'];
|
|
@@ -19,3 +19,7 @@ export const clearDocument = providers.clearDocument;
|
|
|
19
19
|
export const storeState = providers.storeState;
|
|
20
20
|
export const IndexeddbPersistence = providers.IndexeddbPersistence;
|
|
21
21
|
export const WebsocketProvider = providers.WebsocketProvider;
|
|
22
|
+
/** Keep a document's local copy fresh without opening it (sync once, then close). */
|
|
23
|
+
export const prefetch = providers.prefetch;
|
|
24
|
+
/** When a room last stored a change: one authorized HTTP request, no document opened. */
|
|
25
|
+
export const lastUpdated = providers.lastUpdated;
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
} from '../crdt/index.js';
|
|
13
13
|
import { CLOSE, validRoomId } from '../crdt/providers/room.js';
|
|
14
14
|
import type { EdytorSync, WebsocketSyncOptions } from '../collaboration/index.js';
|
|
15
|
-
import
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_PRESENCE_THROTTLE,
|
|
17
|
+
type PresenceOptions
|
|
18
|
+
} from '../collaboration/awarenessSelection.js';
|
|
16
19
|
import { createIndexeddbSync, createWebsocketSync } from '../collaboration/providers.js';
|
|
17
20
|
export { EdytorClass as EdytorContext, useEdytor };
|
|
18
21
|
import type { Placeholder, Plugin } from '../plugins.js';
|
|
@@ -138,6 +141,13 @@
|
|
|
138
141
|
onSyncRefused?: (refusal: SyncRefusedError) => void;
|
|
139
142
|
/** Advanced: a custom sync factory. Overrides `room`/`server`. */
|
|
140
143
|
sync?: EdytorSync;
|
|
144
|
+
/**
|
|
145
|
+
* Keep the view read-only, and seed nothing, until its document was
|
|
146
|
+
* fetched once (a provider synced): a first visit offline shows no
|
|
147
|
+
* `value` that the room's content would later duplicate. For the
|
|
148
|
+
* document this view owns (with `document`, set it there). Read once.
|
|
149
|
+
*/
|
|
150
|
+
requireHydration?: boolean;
|
|
141
151
|
};
|
|
142
152
|
</script>
|
|
143
153
|
|
|
@@ -167,6 +177,7 @@
|
|
|
167
177
|
params,
|
|
168
178
|
onSyncExpired,
|
|
169
179
|
onSyncRefused,
|
|
180
|
+
requireHydration,
|
|
170
181
|
awareness,
|
|
171
182
|
actor,
|
|
172
183
|
presence,
|
|
@@ -194,6 +205,7 @@
|
|
|
194
205
|
doc,
|
|
195
206
|
awareness,
|
|
196
207
|
actor,
|
|
208
|
+
requireHydration,
|
|
197
209
|
hotKeys,
|
|
198
210
|
onSelectionChange,
|
|
199
211
|
onChange,
|
|
@@ -273,7 +285,7 @@
|
|
|
273
285
|
edytor.readonly = readonly;
|
|
274
286
|
});
|
|
275
287
|
$effect(() => {
|
|
276
|
-
edytor.presence.throttle = presence?.throttle ??
|
|
288
|
+
edytor.presence.throttle = presence?.throttle ?? DEFAULT_PRESENCE_THROTTLE;
|
|
277
289
|
edytor.presence.share = presence?.share ?? 'caret';
|
|
278
290
|
});
|
|
279
291
|
// A readonly change shows or hides chrome: the overlay repositions it.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Edytor as EdytorClass, useEdytor, type Snippets } from '../edytor.svelte.js';
|
|
2
2
|
import { SyncRefusedError, type Awareness, type DocumentActor, type EdytorDocument, type YDoc } from '../crdt/index.js';
|
|
3
3
|
import type { EdytorSync, WebsocketSyncOptions } from '../collaboration/index.js';
|
|
4
|
-
import type
|
|
4
|
+
import { type PresenceOptions } from '../collaboration/awarenessSelection.js';
|
|
5
5
|
export { EdytorClass as EdytorContext, useEdytor };
|
|
6
6
|
import type { Placeholder, Plugin } from '../plugins.js';
|
|
7
7
|
import { type BlockHandlesOptions } from '../plugins/blockHandles/blockHandlesPlugin.js';
|
|
@@ -68,6 +68,13 @@ export type EdytorProps = Snippets & {
|
|
|
68
68
|
onSyncRefused?: (refusal: SyncRefusedError) => void;
|
|
69
69
|
/** Advanced: a custom sync factory. Overrides `room`/`server`. */
|
|
70
70
|
sync?: EdytorSync;
|
|
71
|
+
/**
|
|
72
|
+
* Keep the view read-only, and seed nothing, until its document was
|
|
73
|
+
* fetched once (a provider synced): a first visit offline shows no
|
|
74
|
+
* `value` that the room's content would later duplicate. For the
|
|
75
|
+
* document this view owns (with `document`, set it there). Read once.
|
|
76
|
+
*/
|
|
77
|
+
requireHydration?: boolean;
|
|
71
78
|
};
|
|
72
79
|
import type { JSONBlock, JSONDoc } from '../utils/json.js';
|
|
73
80
|
import type { HotKey, HotKeyCombination } from '../session/keymap.js';
|
package/dist/crdt/admission.d.ts
CHANGED
|
@@ -137,5 +137,10 @@ export declare const bindAdmission: (Y: EngineApi) => {
|
|
|
137
137
|
* Throws {@link UndecodableUpdateError} for payloads that fail to
|
|
138
138
|
* decode/integrate at all, then the usual typed refusals.
|
|
139
139
|
*/
|
|
140
|
-
admitUpdate: (update: Uint8Array
|
|
140
|
+
admitUpdate: (update: Uint8Array | ReadonlyArray<Uint8Array | {
|
|
141
|
+
v2: Uint8Array;
|
|
142
|
+
}>, docName?: string, options?: {
|
|
143
|
+
/** Prepare the scratch doc before anything is applied (e.g. its `gcFilter`). */
|
|
144
|
+
prepare?: (doc: YDoc) => void;
|
|
145
|
+
}) => YDoc;
|
|
141
146
|
};
|
package/dist/crdt/admission.js
CHANGED
|
@@ -133,10 +133,17 @@ export const bindAdmission = (Y) => ({
|
|
|
133
133
|
* Throws {@link UndecodableUpdateError} for payloads that fail to
|
|
134
134
|
* decode/integrate at all, then the usual typed refusals.
|
|
135
135
|
*/
|
|
136
|
-
admitUpdate: (update, docName = 'loaded document') => {
|
|
136
|
+
admitUpdate: (update, docName = 'loaded document', options = {}) => {
|
|
137
137
|
const doc = new Y.Doc();
|
|
138
|
+
options.prepare?.(doc);
|
|
139
|
+
const apply = (part) => part instanceof Uint8Array ? Y.applyUpdate(doc, part) : Y.applyUpdateV2(doc, part.v2);
|
|
138
140
|
try {
|
|
139
|
-
|
|
141
|
+
// Several updates (`{ v2 }`: in the v2 encoding) are applied in one
|
|
142
|
+
// transaction, with no merge pass.
|
|
143
|
+
if (update instanceof Uint8Array)
|
|
144
|
+
apply(update);
|
|
145
|
+
else
|
|
146
|
+
Y.transact(doc, () => update.forEach(apply));
|
|
140
147
|
}
|
|
141
148
|
catch (cause) {
|
|
142
149
|
throw new UndecodableUpdateError(docName, cause);
|
package/dist/crdt/data.d.ts
CHANGED
|
@@ -57,6 +57,8 @@ export declare const keySegment: (k: string) => string;
|
|
|
57
57
|
export declare const segmentKey: (s: string) => string;
|
|
58
58
|
/** A new node's data, as leaves. */
|
|
59
59
|
export declare const dataLeaves: (data: unknown) => Map<string, unknown>;
|
|
60
|
+
/** A path of the data as a leaf key (`d/` and its pointer). */
|
|
61
|
+
export declare const leafKey: (path: readonly string[]) => string;
|
|
60
62
|
/** `node`'s data (`undefined` when it has none). */
|
|
61
63
|
export declare const readData: (node: EngineNode) => JsonObj | undefined;
|
|
62
64
|
/**
|
|
@@ -75,7 +77,7 @@ export declare const applyPatch: (data: JsonObj, patches: readonly DataPatch[])
|
|
|
75
77
|
* they still read as the patched value, else it is exploded: every leaf
|
|
76
78
|
* written, the attr deleted. An unchanged value writes nothing.
|
|
77
79
|
*/
|
|
78
|
-
export declare const patchWrites: (node: EngineNode, patches: readonly DataPatch[], client?: number, rand?: () => number) => LeafWrite[] | null;
|
|
80
|
+
export declare const patchWrites: (node: EngineNode, patches: readonly DataPatch[], client?: number, rand?: () => number, atomic?: readonly string[]) => LeafWrite[] | null;
|
|
79
81
|
/** Write `writes` on `node` (a detached one too: a block or atom being created). */
|
|
80
82
|
export declare const writeLeaves: (node: EngineNode, writes: Iterable<LeafWrite>) => void;
|
|
81
83
|
export {};
|
package/dist/crdt/data.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DATA, DATA_LEAF_PREFIX } from './schema.js';
|
|
2
|
-
import {
|
|
2
|
+
import { rankAfter } from './placement/rank.js';
|
|
3
3
|
import { hash32 } from './rand.js';
|
|
4
4
|
import { jsonEquals } from '../utils/json.js';
|
|
5
5
|
export const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -17,13 +17,6 @@ export const keySegment = (k) => (k.startsWith('~') ? `~0${k.slice(1)}` : k);
|
|
|
17
17
|
export const segmentKey = (s) => (s.startsWith('~0') ? `~${s.slice(2)}` : s);
|
|
18
18
|
const RANK = /^(?:[-\w]{16})+$/;
|
|
19
19
|
const index = (k) => (/^(0|[1-9]\d*)$/.test(k) ? Number(k) : -1);
|
|
20
|
-
/**
|
|
21
|
-
* The digit that opens a client's run after an item it made (`between`):
|
|
22
|
-
* below every digit a plain insert extends a rank with (`0`, a gap's), so
|
|
23
|
-
* the run sorts right after its item, and far above the digit minimum, so
|
|
24
|
-
* the inserts before it (each one digit lower) never run out of room.
|
|
25
|
-
*/
|
|
26
|
-
const RUN = -(2 ** 39);
|
|
27
20
|
const rank = (v) => (typeof v === 'string' && RANK.test(v) ? v : undefined);
|
|
28
21
|
/** Item `id`'s place: where a move put it (`id>`), else where it was made (`id#`). */
|
|
29
22
|
const placeOf = (t, id) => rank(t.k.get(`${id}>`)?.v) ?? rank(t.k.get(`${id}#`)?.v);
|
|
@@ -103,28 +96,11 @@ const edits = (rand, client = 0) => {
|
|
|
103
96
|
const remove = (t, id) => [id, `${id}#`, `${id}>`].forEach((s) => t.k.delete(s));
|
|
104
97
|
/**
|
|
105
98
|
* A rank in `(left, right)`. After an item this client placed, it is in
|
|
106
|
-
* the client's run there (`
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* meanwhile. Elsewhere a plain rank between the two.
|
|
99
|
+
* the client's run there (`rankAfter`): what it inserts after its own
|
|
100
|
+
* items stays together, as `Y.Array` keeps an insert after its origin,
|
|
101
|
+
* whatever a peer inserts in that gap meanwhile. Elsewhere a plain rank.
|
|
110
102
|
*/
|
|
111
|
-
const between = (left, right) =>
|
|
112
|
-
const plain = () => rankBetween(left, right, client, pick);
|
|
113
|
-
if (left === undefined)
|
|
114
|
-
return plain();
|
|
115
|
-
const segs = decodeRank(left);
|
|
116
|
-
const k = segs.findLastIndex((g) => g.v === RUN);
|
|
117
|
-
const run = k >= 0 && segs[k].t === client
|
|
118
|
-
? encodeRank(segs.slice(0, k + 1))
|
|
119
|
-
: segs.at(-1).t === client
|
|
120
|
-
? left + encodeRank([{ v: RUN, t: client }])
|
|
121
|
-
: undefined;
|
|
122
|
-
if (run === undefined)
|
|
123
|
-
return plain();
|
|
124
|
-
const tail = (r) => r && r.length > run.length && r.startsWith(run) ? r.slice(run.length) : undefined;
|
|
125
|
-
const rank = run + rankBetween(tail(left), tail(right), client, pick);
|
|
126
|
-
return right === undefined || rank < right ? rank : plain();
|
|
127
|
-
};
|
|
103
|
+
const between = (left, right) => rankAfter(left, right, client, pick);
|
|
128
104
|
/** Give `entries` (kept items, moved items, new values) their places in `t`, in order. */
|
|
129
105
|
const lay = (t, entries) => {
|
|
130
106
|
let left;
|
|
@@ -314,9 +290,35 @@ const effective = (base, leaves) => {
|
|
|
314
290
|
under(`${key}/`, v);
|
|
315
291
|
out.set(key, []);
|
|
316
292
|
}
|
|
293
|
+
else if (isObject(v) && Object.keys(v).length) {
|
|
294
|
+
// An object written as one leaf (an atomic path, `data.atomic`) reads as its keys.
|
|
295
|
+
under(`${key}/`, v);
|
|
296
|
+
out.set(key, {});
|
|
297
|
+
}
|
|
317
298
|
}
|
|
318
299
|
return out;
|
|
319
300
|
};
|
|
301
|
+
/** A path of the data as a leaf key (`d/` and its pointer). */
|
|
302
|
+
export const leafKey = (path) => DATA_LEAF_PREFIX + path.map((k) => enc(k)).join('/');
|
|
303
|
+
/**
|
|
304
|
+
* `leaves` with every atomic path (`data.atomic`, H8: a leaf key a kind
|
|
305
|
+
* declares) holding one leaf: the value of everything at and under it.
|
|
306
|
+
*/
|
|
307
|
+
const collapse = (leaves, atomic) => {
|
|
308
|
+
if (atomic.length === 0)
|
|
309
|
+
return leaves;
|
|
310
|
+
const root = treeOf(leaves);
|
|
311
|
+
for (const key of atomic) {
|
|
312
|
+
let t = root;
|
|
313
|
+
for (const s of key.slice(DATA_LEAF_PREFIX.length).split('/'))
|
|
314
|
+
t = t?.k.get(s);
|
|
315
|
+
if (t === undefined || t.k.size === 0)
|
|
316
|
+
continue;
|
|
317
|
+
t.v = valueOf(t);
|
|
318
|
+
t.k.clear();
|
|
319
|
+
}
|
|
320
|
+
return leavesFrom(root);
|
|
321
|
+
};
|
|
320
322
|
const read = (base, leaves) => valueOf(treeOf(effective(base, leaves)));
|
|
321
323
|
/** `node`'s data (`undefined` when it has none). */
|
|
322
324
|
export const readData = (node) => read(node.getAttr(DATA), leavesOf(node));
|
|
@@ -351,7 +353,7 @@ export const applyPatch = (data, patches) => {
|
|
|
351
353
|
* they still read as the patched value, else it is exploded: every leaf
|
|
352
354
|
* written, the attr deleted. An unchanged value writes nothing.
|
|
353
355
|
*/
|
|
354
|
-
export const patchWrites = (node, patches, client = 0, rand) => {
|
|
356
|
+
export const patchWrites = (node, patches, client = 0, rand, atomic = []) => {
|
|
355
357
|
const [base, now] = [node.getAttr(DATA), leavesOf(node)];
|
|
356
358
|
const was = effective(base, now);
|
|
357
359
|
const root = treeOf(was);
|
|
@@ -359,13 +361,21 @@ export const patchWrites = (node, patches, client = 0, rand) => {
|
|
|
359
361
|
const { apply } = edits(rand, client);
|
|
360
362
|
if (!patches.every((p) => apply(root, p)))
|
|
361
363
|
return null;
|
|
362
|
-
const
|
|
364
|
+
const value = valueOf(root);
|
|
363
365
|
if (jsonEquals(before, value))
|
|
364
366
|
return [];
|
|
367
|
+
// An atomic path is written as one leaf, and every leaf stored under it goes (H8).
|
|
368
|
+
const want = collapse(leavesFrom(root), atomic);
|
|
369
|
+
const under = (key) => atomic.some((a) => key.startsWith(`${a}/`));
|
|
365
370
|
const diff = (from) => [...new Set([...from.keys(), ...want.keys()])]
|
|
366
371
|
.filter((key) => !jsonEquals(from.get(key), want.get(key)))
|
|
367
372
|
.map((key) => [key, want.get(key)]);
|
|
368
|
-
const own =
|
|
373
|
+
const own = [
|
|
374
|
+
...diff(collapse(was, atomic)),
|
|
375
|
+
...[...now.keys()]
|
|
376
|
+
.filter((key) => under(key) && !want.has(key))
|
|
377
|
+
.map((key) => [key, undefined])
|
|
378
|
+
];
|
|
369
379
|
const after = new Map(now);
|
|
370
380
|
for (const [key, value] of own)
|
|
371
381
|
value === undefined ? after.delete(key) : after.set(key, value);
|
package/dist/crdt/document.d.ts
CHANGED
|
@@ -52,11 +52,14 @@ export type DocumentOptions = {
|
|
|
52
52
|
* History tuning — applied when the document's undo manager attaches.
|
|
53
53
|
* `captureTimeout` is the merge window in ms (engine default 500):
|
|
54
54
|
* commits closer than this fuse into one undo step; `0` makes every
|
|
55
|
-
* commit its own step.
|
|
56
|
-
* {@link
|
|
55
|
+
* commit its own step. `limit` is the number of undo steps kept (default
|
|
56
|
+
* {@link DEFAULT_HISTORY_LIMIT}, 200; `Infinity` keeps every step): an
|
|
57
|
+
* older step is dropped and the deleted content it kept is collected.
|
|
58
|
+
* {@link EdytorDocument.clearHistory} drops both stacks.
|
|
57
59
|
*/
|
|
58
60
|
history?: {
|
|
59
61
|
captureTimeout?: number;
|
|
62
|
+
limit?: number;
|
|
60
63
|
};
|
|
61
64
|
/**
|
|
62
65
|
* Opt-in per-block lineage ring — `depth > 0` captures a subtree
|
|
@@ -71,6 +74,17 @@ export type DocumentOptions = {
|
|
|
71
74
|
lineage?: {
|
|
72
75
|
depth?: number;
|
|
73
76
|
};
|
|
77
|
+
/**
|
|
78
|
+
* Decide an EMPTY document only from a provider that synced (H12): the
|
|
79
|
+
* room answered, or a local store hydrated. A provider's bound or
|
|
80
|
+
* failure never seeds it, so a first visit offline writes nothing — no
|
|
81
|
+
* seed of `value` that a different seed in the room would later meet as
|
|
82
|
+
* duplicate blocks — and stays `pending` (a view keeps it read-only)
|
|
83
|
+
* until the document is fetched once. A document holding content is
|
|
84
|
+
* decided as before; an explicit {@link EdytorDocument.sync} still
|
|
85
|
+
* decides.
|
|
86
|
+
*/
|
|
87
|
+
requireHydration?: boolean;
|
|
74
88
|
};
|
|
75
89
|
export type CreateDocumentOptions = DocumentOptions & {
|
|
76
90
|
/**
|
|
@@ -158,10 +172,12 @@ export type EdytorDocumentInit = {
|
|
|
158
172
|
semantics?: DocumentSemanticsConfig;
|
|
159
173
|
history?: {
|
|
160
174
|
captureTimeout?: number;
|
|
175
|
+
limit?: number;
|
|
161
176
|
};
|
|
162
177
|
lineage?: {
|
|
163
178
|
depth?: number;
|
|
164
179
|
};
|
|
180
|
+
requireHydration?: boolean;
|
|
165
181
|
};
|
|
166
182
|
/**
|
|
167
183
|
* The assembled document — see the module header for the lifecycle,
|
|
@@ -253,6 +269,8 @@ export declare class EdytorDocument {
|
|
|
253
269
|
private _defaultType;
|
|
254
270
|
private readonly _historyOptions;
|
|
255
271
|
private readonly _lineageDepth;
|
|
272
|
+
/** {@link DocumentOptions.requireHydration}: an empty document waits for a provider's `synced`. */
|
|
273
|
+
readonly requireHydration: boolean;
|
|
256
274
|
/** Attached providers keyed by transport target (O75): one per target. */
|
|
257
275
|
private _providers;
|
|
258
276
|
private _pendingSyncs;
|
|
@@ -299,6 +317,12 @@ export declare class EdytorDocument {
|
|
|
299
317
|
* @internal
|
|
300
318
|
*/
|
|
301
319
|
get historyCaptureTimeout(): number | undefined;
|
|
320
|
+
/**
|
|
321
|
+
* The undo steps this document's history keeps (P6): the configured
|
|
322
|
+
* `history.limit`, else {@link DEFAULT_HISTORY_LIMIT}. The `attachDocument`
|
|
323
|
+
* dedupe check reads it (a reattach naming another limit is a conflict).
|
|
324
|
+
*/
|
|
325
|
+
get historyLimit(): number;
|
|
302
326
|
/**
|
|
303
327
|
* The lineage ring depth this document was configured with — read by
|
|
304
328
|
* the `attachDocument` dedupe compatibility check (a reattach naming
|
|
@@ -406,12 +430,11 @@ export declare class EdytorDocument {
|
|
|
406
430
|
untrackOrigin: (origin: unknown) => void;
|
|
407
431
|
private _attachHistory;
|
|
408
432
|
/**
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
* nothing to clear — it does NOT force attach or throw on pending).
|
|
433
|
+
* Drop the undo/redo stacks. The undo stack already keeps only its
|
|
434
|
+
* newest `history.limit` steps (P6); call this where undo no longer makes
|
|
435
|
+
* sense, e.g. after a save checkpoint. No-op while history has not
|
|
436
|
+
* attached (there is nothing to clear — it does NOT force attach or throw
|
|
437
|
+
* on pending).
|
|
415
438
|
*/
|
|
416
439
|
clearHistory: () => void;
|
|
417
440
|
/** Local document transaction — headless edits, one undo step per call. */
|