@tldraw/sync-core 5.3.0-next.4123e97459a9 → 5.3.0-next.5cb71bb7e8fe
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/dist-cjs/index.d.ts +165 -3
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +66 -20
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/SQLiteSyncStorage.js +130 -52
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +27 -2
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +6 -1
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +178 -12
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +2 -2
- package/dist-esm/index.d.mts +165 -3
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +66 -20
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/SQLiteSyncStorage.mjs +130 -52
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +27 -2
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +6 -1
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +178 -12
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs.map +2 -2
- package/package.json +6 -6
- package/src/index.ts +3 -0
- package/src/lib/InMemorySyncStorage.ts +86 -21
- package/src/lib/RoomSession.ts +7 -2
- package/src/lib/SQLiteSyncStorage.test.ts +29 -2
- package/src/lib/SQLiteSyncStorage.ts +174 -59
- package/src/lib/TLSocketRoom.ts +61 -3
- package/src/lib/TLSyncClient.test.ts +9 -2
- package/src/lib/TLSyncClient.ts +15 -3
- package/src/lib/TLSyncRoom.ts +328 -13
- package/src/lib/TLSyncStorage.ts +15 -0
- package/src/lib/protocol.ts +17 -0
- package/src/test/TLSocketRoom.test.ts +37 -2
- package/src/test/TLSyncRoom.test.ts +25 -0
- package/src/test/TestServer.ts +14 -4
- package/src/test/objectStore.test.ts +679 -0
- package/src/test/storageContractSuite.ts +101 -0
- package/src/test/upgradeDowngrade.test.ts +144 -2
|
@@ -109,14 +109,41 @@ class TLSyncRoom {
|
|
|
109
109
|
storage;
|
|
110
110
|
serializedSchema;
|
|
111
111
|
documentTypes;
|
|
112
|
+
/**
|
|
113
|
+
* Record types served by the object-store lane. Object records ride the same wire messages
|
|
114
|
+
* as document records but are gated by the session's `objectAccess` instead of `isReadonly`,
|
|
115
|
+
* and are excluded from `documentTypes` so hosts can persist them in a separate lane.
|
|
116
|
+
*/
|
|
117
|
+
objectTypes;
|
|
112
118
|
presenceType;
|
|
113
119
|
log;
|
|
114
120
|
schema;
|
|
121
|
+
authorizeRecord;
|
|
115
122
|
sessionIdleTimeout;
|
|
123
|
+
/**
|
|
124
|
+
* The authorizer registered for a record type, widened to the room's record union. Each entry in
|
|
125
|
+
* `authorizeRecord` is typed to its specific record; the cast here is the one place we can't
|
|
126
|
+
* statically correlate a runtime `typeName` with its record type, so it lives in the library
|
|
127
|
+
* rather than in every consumer.
|
|
128
|
+
*/
|
|
129
|
+
authorizerFor(typeName) {
|
|
130
|
+
const authorize = this.authorizeRecord?.[typeName];
|
|
131
|
+
if (!authorize) return void 0;
|
|
132
|
+
return (args) => {
|
|
133
|
+
try {
|
|
134
|
+
return authorize(args);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
this.log?.error?.("record authorizer threw; rejecting the write", e);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
116
141
|
constructor(opts) {
|
|
117
142
|
this.schema = opts.schema;
|
|
118
143
|
this.log = opts.log;
|
|
119
144
|
this.onPresenceChange = opts.onPresenceChange;
|
|
145
|
+
this.onCommittedChanges = opts.onCommittedChanges;
|
|
146
|
+
this.authorizeRecord = opts.authorizeRecord;
|
|
120
147
|
this.storage = opts.storage;
|
|
121
148
|
this.sessionIdleTimeout = opts.clientTimeout ?? import_RoomSession.SESSION_IDLE_TIMEOUT;
|
|
122
149
|
(0, import_utils.assert)(
|
|
@@ -124,8 +151,17 @@ class TLSyncRoom {
|
|
|
124
151
|
"TLSyncRoom is supposed to run either on Cloudflare Workersor on a 18+ version of Node.js, which both support the native structuredClone API"
|
|
125
152
|
);
|
|
126
153
|
this.serializedSchema = JSON.parse(JSON.stringify(this.schema.serialize()));
|
|
154
|
+
this.objectTypes = new Set(opts.objectTypes ?? []);
|
|
155
|
+
for (const typeName of this.objectTypes) {
|
|
156
|
+
const type = (0, import_utils.getOwnProperty)(this.schema.types, typeName);
|
|
157
|
+
(0, import_utils.assert)(type, `TLSyncRoom: object type '${typeName}' is not registered in the schema`);
|
|
158
|
+
(0, import_utils.assert)(
|
|
159
|
+
type.scope === "document",
|
|
160
|
+
`TLSyncRoom: object type '${typeName}' must have scope 'document', got '${type.scope}'`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
127
163
|
this.documentTypes = new Set(
|
|
128
|
-
Object.values(this.schema.types).filter((t) => t.scope === "document").map((t) => t.typeName)
|
|
164
|
+
Object.values(this.schema.types).filter((t) => t.scope === "document" && !this.objectTypes.has(t.typeName)).map((t) => t.typeName)
|
|
129
165
|
);
|
|
130
166
|
const presenceTypes = new Set(
|
|
131
167
|
Object.values(this.schema.types).filter((t) => t.scope === "presence")
|
|
@@ -136,6 +172,12 @@ class TLSyncRoom {
|
|
|
136
172
|
);
|
|
137
173
|
}
|
|
138
174
|
this.presenceType = presenceTypes.values().next()?.value ?? null;
|
|
175
|
+
if (this.presenceType && this.authorizeRecord) {
|
|
176
|
+
(0, import_utils.assert)(
|
|
177
|
+
!(0, import_utils.getOwnProperty)(this.authorizeRecord, this.presenceType.typeName),
|
|
178
|
+
`TLSyncRoom: authorizeRecord['${this.presenceType.typeName}'] is a presence type; presence records are not authorized`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
139
181
|
const { documentClock } = this.storage.transaction((txn) => {
|
|
140
182
|
this.schema.migrateStorage(txn);
|
|
141
183
|
});
|
|
@@ -262,6 +304,7 @@ class TLSyncRoom {
|
|
|
262
304
|
cancellationTime: Date.now(),
|
|
263
305
|
meta: session.meta,
|
|
264
306
|
isReadonly: session.isReadonly,
|
|
307
|
+
objectAccess: session.objectAccess,
|
|
265
308
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
266
309
|
supportsStringAppend: session.supportsStringAppend
|
|
267
310
|
});
|
|
@@ -353,7 +396,7 @@ class TLSyncRoom {
|
|
|
353
396
|
* @internal
|
|
354
397
|
*/
|
|
355
398
|
handleNewSession(opts) {
|
|
356
|
-
const { sessionId, socket, meta, isReadonly } = opts;
|
|
399
|
+
const { sessionId, socket, meta, isReadonly, objectAccess } = opts;
|
|
357
400
|
const existing = this.sessions.get(sessionId);
|
|
358
401
|
this.sessions.set(sessionId, {
|
|
359
402
|
state: import_RoomSession.RoomSessionState.AwaitingConnectMessage,
|
|
@@ -363,6 +406,7 @@ class TLSyncRoom {
|
|
|
363
406
|
sessionStartTime: Date.now(),
|
|
364
407
|
meta,
|
|
365
408
|
isReadonly: isReadonly ?? false,
|
|
409
|
+
objectAccess: objectAccess ?? "write",
|
|
366
410
|
// this gets set later during handleConnectMessage
|
|
367
411
|
requiresLegacyRejection: false,
|
|
368
412
|
supportsStringAppend: true
|
|
@@ -382,6 +426,7 @@ class TLSyncRoom {
|
|
|
382
426
|
socket,
|
|
383
427
|
meta,
|
|
384
428
|
isReadonly,
|
|
429
|
+
objectAccess,
|
|
385
430
|
serializedSchema,
|
|
386
431
|
presenceId,
|
|
387
432
|
presenceRecord,
|
|
@@ -402,6 +447,7 @@ class TLSyncRoom {
|
|
|
402
447
|
outstandingDataMessages: [],
|
|
403
448
|
meta,
|
|
404
449
|
isReadonly,
|
|
450
|
+
objectAccess: objectAccess ?? "write",
|
|
405
451
|
requiresLegacyRejection,
|
|
406
452
|
supportsStringAppend
|
|
407
453
|
});
|
|
@@ -662,6 +708,7 @@ class TLSyncRoom {
|
|
|
662
708
|
supportsStringAppend: session.supportsStringAppend,
|
|
663
709
|
meta: session.meta,
|
|
664
710
|
isReadonly: session.isReadonly,
|
|
711
|
+
objectAccess: session.objectAccess,
|
|
665
712
|
requiresLegacyRejection: session.requiresLegacyRejection
|
|
666
713
|
});
|
|
667
714
|
this._unsafe_sendMessage(session.sessionId, msg);
|
|
@@ -706,7 +753,8 @@ class TLSyncRoom {
|
|
|
706
753
|
schema: this.schema.serialize(),
|
|
707
754
|
serverClock: txn.getClock(),
|
|
708
755
|
diff: { ...presenceDiff.value, ...docDiff },
|
|
709
|
-
isReadonly: session.isReadonly
|
|
756
|
+
isReadonly: session.isReadonly,
|
|
757
|
+
objectAccess: session.objectAccess
|
|
710
758
|
};
|
|
711
759
|
});
|
|
712
760
|
this.lastDocumentClock = documentClock;
|
|
@@ -740,13 +788,18 @@ class TLSyncRoom {
|
|
|
740
788
|
(0, import_utils.exhaustiveSwitchError)(op[0]);
|
|
741
789
|
}
|
|
742
790
|
};
|
|
743
|
-
const addDocument = (storage, changes2, id, _state) => {
|
|
791
|
+
const addDocument = (storage, changes2, id, _state, authorize, prevDoc) => {
|
|
744
792
|
const res = session ? this.schema.migratePersistedRecord(_state, session.serializedSchema, "up") : { type: "success", value: _state };
|
|
745
793
|
if (res.type === "error") {
|
|
746
794
|
throw new import_TLSyncClient.TLSyncError(res.reason, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
|
|
747
795
|
}
|
|
748
|
-
|
|
749
|
-
const doc = storage.get(id);
|
|
796
|
+
let { value: state } = res;
|
|
797
|
+
const doc = prevDoc !== void 0 ? prevDoc ?? void 0 : storage.get(id);
|
|
798
|
+
if (authorize) {
|
|
799
|
+
const result2 = authorize(doc ?? null, state);
|
|
800
|
+
if (!result2) return import_utils.Result.ok(void 0);
|
|
801
|
+
if (!doc) state = result2;
|
|
802
|
+
}
|
|
750
803
|
if (doc) {
|
|
751
804
|
const recordType = (0, import_utils.assertExists)((0, import_utils.getOwnProperty)(this.schema.types, doc.typeName));
|
|
752
805
|
const diff = (0, import_recordDiff.diffAndValidateRecord)(doc, state, recordType);
|
|
@@ -762,8 +815,8 @@ class TLSyncRoom {
|
|
|
762
815
|
}
|
|
763
816
|
return import_utils.Result.ok(void 0);
|
|
764
817
|
};
|
|
765
|
-
const patchDocument = (storage, changes2, id, patch) => {
|
|
766
|
-
const doc = storage.get(id);
|
|
818
|
+
const patchDocument = (storage, changes2, id, patch, authorize, prevDoc) => {
|
|
819
|
+
const doc = prevDoc ?? storage.get(id);
|
|
767
820
|
if (!doc) return;
|
|
768
821
|
const recordType = (0, import_utils.assertExists)((0, import_utils.getOwnProperty)(this.schema.types, doc.typeName));
|
|
769
822
|
const downgraded = session ? this.schema.migratePersistedRecord(doc, session.serializedSchema, "down") : { type: "success", value: doc };
|
|
@@ -773,6 +826,7 @@ class TLSyncRoom {
|
|
|
773
826
|
if (downgraded.value === doc) {
|
|
774
827
|
const diff = (0, import_recordDiff.applyAndDiffRecord)(doc, patch, recordType, legacyAppendMode);
|
|
775
828
|
if (diff) {
|
|
829
|
+
if (authorize && !authorize(doc, diff[1])) return;
|
|
776
830
|
storage.set(id, diff[1]);
|
|
777
831
|
propagateOp(changes2, id, [import_diff.RecordOpType.Patch, diff[0]], doc, diff[1]);
|
|
778
832
|
}
|
|
@@ -784,6 +838,7 @@ class TLSyncRoom {
|
|
|
784
838
|
}
|
|
785
839
|
const diff = (0, import_recordDiff.diffAndValidateRecord)(doc, upgraded.value, recordType, legacyAppendMode);
|
|
786
840
|
if (diff) {
|
|
841
|
+
if (authorize && !authorize(doc, upgraded.value)) return;
|
|
787
842
|
storage.set(id, upgraded.value);
|
|
788
843
|
propagateOp(changes2, id, [import_diff.RecordOpType.Patch, diff], doc, upgraded.value);
|
|
789
844
|
}
|
|
@@ -818,21 +873,101 @@ class TLSyncRoom {
|
|
|
818
873
|
}
|
|
819
874
|
}
|
|
820
875
|
}
|
|
821
|
-
|
|
876
|
+
const canWrite = (typeName) => !session || (this.objectTypes.has(typeName) ? session.objectAccess !== "read" : !session.isReadonly);
|
|
877
|
+
if (message.diff) {
|
|
822
878
|
for (const [id, op] of (0, import_utils.objectMapEntriesIterable)(message.diff)) {
|
|
823
879
|
switch (op[0]) {
|
|
824
880
|
case import_diff.RecordOpType.Put: {
|
|
825
|
-
if (!
|
|
881
|
+
if (!canWrite(op[1].typeName)) continue;
|
|
882
|
+
if (!this.documentTypes.has(op[1].typeName) && !this.objectTypes.has(op[1].typeName)) {
|
|
826
883
|
throw new import_TLSyncClient.TLSyncError(
|
|
827
884
|
"invalid record",
|
|
828
885
|
import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD
|
|
829
886
|
);
|
|
830
887
|
}
|
|
831
|
-
|
|
888
|
+
const record = op[1];
|
|
889
|
+
const prevRecord = session ? txn.get(id) ?? null : void 0;
|
|
890
|
+
if (session && prevRecord && prevRecord.typeName !== record.typeName) {
|
|
891
|
+
this.log?.warn?.(
|
|
892
|
+
"skipping put that changes typeName",
|
|
893
|
+
`${prevRecord.typeName} -> ${record.typeName}`,
|
|
894
|
+
id,
|
|
895
|
+
"session:",
|
|
896
|
+
session.sessionId
|
|
897
|
+
);
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
let authorize;
|
|
901
|
+
if (session && this.authorizeRecord) {
|
|
902
|
+
const authorizePut = this.authorizerFor(record.typeName);
|
|
903
|
+
if (authorizePut) {
|
|
904
|
+
authorize = (prevRec, next) => {
|
|
905
|
+
const result2 = authorizePut(
|
|
906
|
+
prevRec ? {
|
|
907
|
+
session: {
|
|
908
|
+
sessionId: session.sessionId,
|
|
909
|
+
isReadonly: session.isReadonly,
|
|
910
|
+
meta: session.meta
|
|
911
|
+
},
|
|
912
|
+
type: "update",
|
|
913
|
+
prev: prevRec,
|
|
914
|
+
next
|
|
915
|
+
} : {
|
|
916
|
+
session: {
|
|
917
|
+
sessionId: session.sessionId,
|
|
918
|
+
isReadonly: session.isReadonly,
|
|
919
|
+
meta: session.meta
|
|
920
|
+
},
|
|
921
|
+
type: "create",
|
|
922
|
+
prev: null,
|
|
923
|
+
next
|
|
924
|
+
}
|
|
925
|
+
);
|
|
926
|
+
if (!result2) {
|
|
927
|
+
this.log?.warn?.(
|
|
928
|
+
"authorizer vetoed put",
|
|
929
|
+
record.typeName,
|
|
930
|
+
id,
|
|
931
|
+
"session:",
|
|
932
|
+
session.sessionId
|
|
933
|
+
);
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
936
|
+
return prevRec ? next : result2;
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
addDocument(txn, docChanges, id, record, authorize, prevRecord);
|
|
832
941
|
break;
|
|
833
942
|
}
|
|
834
943
|
case import_diff.RecordOpType.Patch: {
|
|
835
|
-
|
|
944
|
+
const doc = txn.get(id);
|
|
945
|
+
if (!doc) continue;
|
|
946
|
+
if (!canWrite(doc.typeName)) continue;
|
|
947
|
+
const authorizePatch = session && this.authorizerFor(doc.typeName);
|
|
948
|
+
const authorize = authorizePatch ? (prev, next) => {
|
|
949
|
+
const result2 = authorizePatch({
|
|
950
|
+
session: {
|
|
951
|
+
sessionId: session.sessionId,
|
|
952
|
+
isReadonly: session.isReadonly,
|
|
953
|
+
meta: session.meta
|
|
954
|
+
},
|
|
955
|
+
type: "update",
|
|
956
|
+
prev,
|
|
957
|
+
next
|
|
958
|
+
});
|
|
959
|
+
if (!result2) {
|
|
960
|
+
this.log?.warn?.(
|
|
961
|
+
"authorizer vetoed patch",
|
|
962
|
+
doc.typeName,
|
|
963
|
+
id,
|
|
964
|
+
"session:",
|
|
965
|
+
session.sessionId
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
return result2;
|
|
969
|
+
} : void 0;
|
|
970
|
+
patchDocument(txn, docChanges, id, op[1], authorize, doc);
|
|
836
971
|
break;
|
|
837
972
|
}
|
|
838
973
|
case import_diff.RecordOpType.Remove: {
|
|
@@ -840,6 +975,27 @@ class TLSyncRoom {
|
|
|
840
975
|
if (!doc) {
|
|
841
976
|
continue;
|
|
842
977
|
}
|
|
978
|
+
if (!canWrite(doc.typeName)) continue;
|
|
979
|
+
const authorizeRemove = session && this.authorizerFor(doc.typeName);
|
|
980
|
+
if (authorizeRemove && !authorizeRemove({
|
|
981
|
+
session: {
|
|
982
|
+
sessionId: session.sessionId,
|
|
983
|
+
isReadonly: session.isReadonly,
|
|
984
|
+
meta: session.meta
|
|
985
|
+
},
|
|
986
|
+
type: "delete",
|
|
987
|
+
prev: doc,
|
|
988
|
+
next: null
|
|
989
|
+
})) {
|
|
990
|
+
this.log?.warn?.(
|
|
991
|
+
"authorizer vetoed delete",
|
|
992
|
+
doc.typeName,
|
|
993
|
+
id,
|
|
994
|
+
"session:",
|
|
995
|
+
session.sessionId
|
|
996
|
+
);
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
843
999
|
txn.delete(id);
|
|
844
1000
|
propagateOp(docChanges, id, op, doc, void 0);
|
|
845
1001
|
break;
|
|
@@ -914,6 +1070,16 @@ class TLSyncRoom {
|
|
|
914
1070
|
this.onPresenceChange?.();
|
|
915
1071
|
});
|
|
916
1072
|
}
|
|
1073
|
+
if (result.docChanges.diffs && this.onCommittedChanges) {
|
|
1074
|
+
const diff = result.docChanges.diffs.diff;
|
|
1075
|
+
queueMicrotask(() => {
|
|
1076
|
+
try {
|
|
1077
|
+
this.onCommittedChanges?.({ diff, documentClock });
|
|
1078
|
+
} catch (e) {
|
|
1079
|
+
this.log?.error?.("onCommittedChanges threw", e);
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
917
1083
|
}
|
|
918
1084
|
/**
|
|
919
1085
|
* Handle the event when a client disconnects. Cleans up the session and
|