@tldraw/sync-core 5.3.0-canary.fceaae5e9feb → 5.3.0-internal.c5c7f1d817d0
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 +154 -3
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +55 -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 +115 -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 +160 -10
- 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 +154 -3
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +55 -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 +115 -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 +160 -10
- 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 +74 -21
- package/src/lib/RoomSession.ts +7 -2
- package/src/lib/SQLiteSyncStorage.test.ts +29 -2
- package/src/lib/SQLiteSyncStorage.ts +158 -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 +294 -10
- package/src/lib/TLSyncStorage.ts +8 -0
- package/src/lib/protocol.ts +17 -0
- package/src/test/TLSocketRoom.test.ts +37 -2
- package/src/test/TLSyncClientRebase.test.ts +285 -0
- package/src/test/TLSyncRoom.test.ts +25 -0
- package/src/test/TestServer.ts +14 -4
- package/src/test/objectStore.test.ts +630 -0
- package/src/test/storageContractSuite.ts +79 -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) => {
|
|
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
|
-
|
|
796
|
+
let { value: state } = res;
|
|
749
797
|
const doc = 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,7 +815,7 @@ class TLSyncRoom {
|
|
|
762
815
|
}
|
|
763
816
|
return import_utils.Result.ok(void 0);
|
|
764
817
|
};
|
|
765
|
-
const patchDocument = (storage, changes2, id, patch) => {
|
|
818
|
+
const patchDocument = (storage, changes2, id, patch, authorize) => {
|
|
766
819
|
const doc = 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));
|
|
@@ -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,89 @@ 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
|
+
let authorize;
|
|
890
|
+
if (session && this.authorizeRecord) {
|
|
891
|
+
const prev = txn.get(id) ?? null;
|
|
892
|
+
if (prev && prev.typeName !== record.typeName) {
|
|
893
|
+
this.log?.warn?.(
|
|
894
|
+
"skipping put that changes typeName",
|
|
895
|
+
`${prev.typeName} -> ${record.typeName}`,
|
|
896
|
+
id,
|
|
897
|
+
"session:",
|
|
898
|
+
session.sessionId
|
|
899
|
+
);
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
const authorizePut = this.authorizerFor(record.typeName);
|
|
903
|
+
if (authorizePut) {
|
|
904
|
+
authorize = (prevRec, next) => {
|
|
905
|
+
const result2 = authorizePut(
|
|
906
|
+
prevRec ? {
|
|
907
|
+
session: { sessionId: session.sessionId, meta: session.meta },
|
|
908
|
+
type: "update",
|
|
909
|
+
prev: prevRec,
|
|
910
|
+
next
|
|
911
|
+
} : {
|
|
912
|
+
session: { sessionId: session.sessionId, meta: session.meta },
|
|
913
|
+
type: "create",
|
|
914
|
+
prev: null,
|
|
915
|
+
next
|
|
916
|
+
}
|
|
917
|
+
);
|
|
918
|
+
if (!result2) {
|
|
919
|
+
this.log?.warn?.(
|
|
920
|
+
"authorizer vetoed put",
|
|
921
|
+
record.typeName,
|
|
922
|
+
id,
|
|
923
|
+
"session:",
|
|
924
|
+
session.sessionId
|
|
925
|
+
);
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
return prevRec ? next : result2;
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
addDocument(txn, docChanges, id, record, authorize);
|
|
832
933
|
break;
|
|
833
934
|
}
|
|
834
935
|
case import_diff.RecordOpType.Patch: {
|
|
835
|
-
|
|
936
|
+
const doc = txn.get(id);
|
|
937
|
+
if (!doc) continue;
|
|
938
|
+
if (!canWrite(doc.typeName)) continue;
|
|
939
|
+
const authorizePatch = session && this.authorizerFor(doc.typeName);
|
|
940
|
+
const authorize = authorizePatch ? (prev, next) => {
|
|
941
|
+
const result2 = authorizePatch({
|
|
942
|
+
session: { sessionId: session.sessionId, meta: session.meta },
|
|
943
|
+
type: "update",
|
|
944
|
+
prev,
|
|
945
|
+
next
|
|
946
|
+
});
|
|
947
|
+
if (!result2) {
|
|
948
|
+
this.log?.warn?.(
|
|
949
|
+
"authorizer vetoed patch",
|
|
950
|
+
doc.typeName,
|
|
951
|
+
id,
|
|
952
|
+
"session:",
|
|
953
|
+
session.sessionId
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
return result2;
|
|
957
|
+
} : void 0;
|
|
958
|
+
patchDocument(txn, docChanges, id, op[1], authorize);
|
|
836
959
|
break;
|
|
837
960
|
}
|
|
838
961
|
case import_diff.RecordOpType.Remove: {
|
|
@@ -840,6 +963,23 @@ class TLSyncRoom {
|
|
|
840
963
|
if (!doc) {
|
|
841
964
|
continue;
|
|
842
965
|
}
|
|
966
|
+
if (!canWrite(doc.typeName)) continue;
|
|
967
|
+
const authorizeRemove = session && this.authorizerFor(doc.typeName);
|
|
968
|
+
if (authorizeRemove && !authorizeRemove({
|
|
969
|
+
session: { sessionId: session.sessionId, meta: session.meta },
|
|
970
|
+
type: "delete",
|
|
971
|
+
prev: doc,
|
|
972
|
+
next: null
|
|
973
|
+
})) {
|
|
974
|
+
this.log?.warn?.(
|
|
975
|
+
"authorizer vetoed delete",
|
|
976
|
+
doc.typeName,
|
|
977
|
+
id,
|
|
978
|
+
"session:",
|
|
979
|
+
session.sessionId
|
|
980
|
+
);
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
843
983
|
txn.delete(id);
|
|
844
984
|
propagateOp(docChanges, id, op, doc, void 0);
|
|
845
985
|
break;
|
|
@@ -914,6 +1054,16 @@ class TLSyncRoom {
|
|
|
914
1054
|
this.onPresenceChange?.();
|
|
915
1055
|
});
|
|
916
1056
|
}
|
|
1057
|
+
if (result.docChanges.diffs && this.onCommittedChanges) {
|
|
1058
|
+
const diff = result.docChanges.diffs.diff;
|
|
1059
|
+
queueMicrotask(() => {
|
|
1060
|
+
try {
|
|
1061
|
+
this.onCommittedChanges?.({ diff, documentClock });
|
|
1062
|
+
} catch (e) {
|
|
1063
|
+
this.log?.error?.("onCommittedChanges threw", e);
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
917
1067
|
}
|
|
918
1068
|
/**
|
|
919
1069
|
* Handle the event when a client disconnects. Cleans up the session and
|