@tldraw/sync-core 5.3.0-canary.fa3355c81e86 → 5.3.0-internal.41291fb579ed
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/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
|
@@ -111,14 +111,41 @@ class TLSyncRoom {
|
|
|
111
111
|
storage;
|
|
112
112
|
serializedSchema;
|
|
113
113
|
documentTypes;
|
|
114
|
+
/**
|
|
115
|
+
* Record types served by the object-store lane. Object records ride the same wire messages
|
|
116
|
+
* as document records but are gated by the session's `objectAccess` instead of `isReadonly`,
|
|
117
|
+
* and are excluded from `documentTypes` so hosts can persist them in a separate lane.
|
|
118
|
+
*/
|
|
119
|
+
objectTypes;
|
|
114
120
|
presenceType;
|
|
115
121
|
log;
|
|
116
122
|
schema;
|
|
123
|
+
authorizeRecord;
|
|
117
124
|
sessionIdleTimeout;
|
|
125
|
+
/**
|
|
126
|
+
* The authorizer registered for a record type, widened to the room's record union. Each entry in
|
|
127
|
+
* `authorizeRecord` is typed to its specific record; the cast here is the one place we can't
|
|
128
|
+
* statically correlate a runtime `typeName` with its record type, so it lives in the library
|
|
129
|
+
* rather than in every consumer.
|
|
130
|
+
*/
|
|
131
|
+
authorizerFor(typeName) {
|
|
132
|
+
const authorize = this.authorizeRecord?.[typeName];
|
|
133
|
+
if (!authorize) return void 0;
|
|
134
|
+
return (args) => {
|
|
135
|
+
try {
|
|
136
|
+
return authorize(args);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
this.log?.error?.("record authorizer threw; rejecting the write", e);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
118
143
|
constructor(opts) {
|
|
119
144
|
this.schema = opts.schema;
|
|
120
145
|
this.log = opts.log;
|
|
121
146
|
this.onPresenceChange = opts.onPresenceChange;
|
|
147
|
+
this.onCommittedChanges = opts.onCommittedChanges;
|
|
148
|
+
this.authorizeRecord = opts.authorizeRecord;
|
|
122
149
|
this.storage = opts.storage;
|
|
123
150
|
this.sessionIdleTimeout = opts.clientTimeout ?? SESSION_IDLE_TIMEOUT;
|
|
124
151
|
assert(
|
|
@@ -126,8 +153,17 @@ class TLSyncRoom {
|
|
|
126
153
|
"TLSyncRoom is supposed to run either on Cloudflare Workersor on a 18+ version of Node.js, which both support the native structuredClone API"
|
|
127
154
|
);
|
|
128
155
|
this.serializedSchema = JSON.parse(JSON.stringify(this.schema.serialize()));
|
|
156
|
+
this.objectTypes = new Set(opts.objectTypes ?? []);
|
|
157
|
+
for (const typeName of this.objectTypes) {
|
|
158
|
+
const type = getOwnProperty(this.schema.types, typeName);
|
|
159
|
+
assert(type, `TLSyncRoom: object type '${typeName}' is not registered in the schema`);
|
|
160
|
+
assert(
|
|
161
|
+
type.scope === "document",
|
|
162
|
+
`TLSyncRoom: object type '${typeName}' must have scope 'document', got '${type.scope}'`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
129
165
|
this.documentTypes = new Set(
|
|
130
|
-
Object.values(this.schema.types).filter((t) => t.scope === "document").map((t) => t.typeName)
|
|
166
|
+
Object.values(this.schema.types).filter((t) => t.scope === "document" && !this.objectTypes.has(t.typeName)).map((t) => t.typeName)
|
|
131
167
|
);
|
|
132
168
|
const presenceTypes = new Set(
|
|
133
169
|
Object.values(this.schema.types).filter((t) => t.scope === "presence")
|
|
@@ -138,6 +174,12 @@ class TLSyncRoom {
|
|
|
138
174
|
);
|
|
139
175
|
}
|
|
140
176
|
this.presenceType = presenceTypes.values().next()?.value ?? null;
|
|
177
|
+
if (this.presenceType && this.authorizeRecord) {
|
|
178
|
+
assert(
|
|
179
|
+
!getOwnProperty(this.authorizeRecord, this.presenceType.typeName),
|
|
180
|
+
`TLSyncRoom: authorizeRecord['${this.presenceType.typeName}'] is a presence type; presence records are not authorized`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
141
183
|
const { documentClock } = this.storage.transaction((txn) => {
|
|
142
184
|
this.schema.migrateStorage(txn);
|
|
143
185
|
});
|
|
@@ -264,6 +306,7 @@ class TLSyncRoom {
|
|
|
264
306
|
cancellationTime: Date.now(),
|
|
265
307
|
meta: session.meta,
|
|
266
308
|
isReadonly: session.isReadonly,
|
|
309
|
+
objectAccess: session.objectAccess,
|
|
267
310
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
268
311
|
supportsStringAppend: session.supportsStringAppend
|
|
269
312
|
});
|
|
@@ -355,7 +398,7 @@ class TLSyncRoom {
|
|
|
355
398
|
* @internal
|
|
356
399
|
*/
|
|
357
400
|
handleNewSession(opts) {
|
|
358
|
-
const { sessionId, socket, meta, isReadonly } = opts;
|
|
401
|
+
const { sessionId, socket, meta, isReadonly, objectAccess } = opts;
|
|
359
402
|
const existing = this.sessions.get(sessionId);
|
|
360
403
|
this.sessions.set(sessionId, {
|
|
361
404
|
state: RoomSessionState.AwaitingConnectMessage,
|
|
@@ -365,6 +408,7 @@ class TLSyncRoom {
|
|
|
365
408
|
sessionStartTime: Date.now(),
|
|
366
409
|
meta,
|
|
367
410
|
isReadonly: isReadonly ?? false,
|
|
411
|
+
objectAccess: objectAccess ?? "write",
|
|
368
412
|
// this gets set later during handleConnectMessage
|
|
369
413
|
requiresLegacyRejection: false,
|
|
370
414
|
supportsStringAppend: true
|
|
@@ -384,6 +428,7 @@ class TLSyncRoom {
|
|
|
384
428
|
socket,
|
|
385
429
|
meta,
|
|
386
430
|
isReadonly,
|
|
431
|
+
objectAccess,
|
|
387
432
|
serializedSchema,
|
|
388
433
|
presenceId,
|
|
389
434
|
presenceRecord,
|
|
@@ -404,6 +449,7 @@ class TLSyncRoom {
|
|
|
404
449
|
outstandingDataMessages: [],
|
|
405
450
|
meta,
|
|
406
451
|
isReadonly,
|
|
452
|
+
objectAccess: objectAccess ?? "write",
|
|
407
453
|
requiresLegacyRejection,
|
|
408
454
|
supportsStringAppend
|
|
409
455
|
});
|
|
@@ -664,6 +710,7 @@ class TLSyncRoom {
|
|
|
664
710
|
supportsStringAppend: session.supportsStringAppend,
|
|
665
711
|
meta: session.meta,
|
|
666
712
|
isReadonly: session.isReadonly,
|
|
713
|
+
objectAccess: session.objectAccess,
|
|
667
714
|
requiresLegacyRejection: session.requiresLegacyRejection
|
|
668
715
|
});
|
|
669
716
|
this._unsafe_sendMessage(session.sessionId, msg);
|
|
@@ -708,7 +755,8 @@ class TLSyncRoom {
|
|
|
708
755
|
schema: this.schema.serialize(),
|
|
709
756
|
serverClock: txn.getClock(),
|
|
710
757
|
diff: { ...presenceDiff.value, ...docDiff },
|
|
711
|
-
isReadonly: session.isReadonly
|
|
758
|
+
isReadonly: session.isReadonly,
|
|
759
|
+
objectAccess: session.objectAccess
|
|
712
760
|
};
|
|
713
761
|
});
|
|
714
762
|
this.lastDocumentClock = documentClock;
|
|
@@ -742,13 +790,18 @@ class TLSyncRoom {
|
|
|
742
790
|
exhaustiveSwitchError(op[0]);
|
|
743
791
|
}
|
|
744
792
|
};
|
|
745
|
-
const addDocument = (storage, changes2, id, _state) => {
|
|
793
|
+
const addDocument = (storage, changes2, id, _state, authorize) => {
|
|
746
794
|
const res = session ? this.schema.migratePersistedRecord(_state, session.serializedSchema, "up") : { type: "success", value: _state };
|
|
747
795
|
if (res.type === "error") {
|
|
748
796
|
throw new TLSyncError(res.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
|
|
749
797
|
}
|
|
750
|
-
|
|
798
|
+
let { value: state } = res;
|
|
751
799
|
const doc = storage.get(id);
|
|
800
|
+
if (authorize) {
|
|
801
|
+
const result2 = authorize(doc ?? null, state);
|
|
802
|
+
if (!result2) return Result.ok(void 0);
|
|
803
|
+
if (!doc) state = result2;
|
|
804
|
+
}
|
|
752
805
|
if (doc) {
|
|
753
806
|
const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName));
|
|
754
807
|
const diff = diffAndValidateRecord(doc, state, recordType);
|
|
@@ -764,7 +817,7 @@ class TLSyncRoom {
|
|
|
764
817
|
}
|
|
765
818
|
return Result.ok(void 0);
|
|
766
819
|
};
|
|
767
|
-
const patchDocument = (storage, changes2, id, patch) => {
|
|
820
|
+
const patchDocument = (storage, changes2, id, patch, authorize) => {
|
|
768
821
|
const doc = storage.get(id);
|
|
769
822
|
if (!doc) return;
|
|
770
823
|
const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName));
|
|
@@ -775,6 +828,7 @@ class TLSyncRoom {
|
|
|
775
828
|
if (downgraded.value === doc) {
|
|
776
829
|
const diff = applyAndDiffRecord(doc, patch, recordType, legacyAppendMode);
|
|
777
830
|
if (diff) {
|
|
831
|
+
if (authorize && !authorize(doc, diff[1])) return;
|
|
778
832
|
storage.set(id, diff[1]);
|
|
779
833
|
propagateOp(changes2, id, [RecordOpType.Patch, diff[0]], doc, diff[1]);
|
|
780
834
|
}
|
|
@@ -786,6 +840,7 @@ class TLSyncRoom {
|
|
|
786
840
|
}
|
|
787
841
|
const diff = diffAndValidateRecord(doc, upgraded.value, recordType, legacyAppendMode);
|
|
788
842
|
if (diff) {
|
|
843
|
+
if (authorize && !authorize(doc, upgraded.value)) return;
|
|
789
844
|
storage.set(id, upgraded.value);
|
|
790
845
|
propagateOp(changes2, id, [RecordOpType.Patch, diff], doc, upgraded.value);
|
|
791
846
|
}
|
|
@@ -820,21 +875,89 @@ class TLSyncRoom {
|
|
|
820
875
|
}
|
|
821
876
|
}
|
|
822
877
|
}
|
|
823
|
-
|
|
878
|
+
const canWrite = (typeName) => !session || (this.objectTypes.has(typeName) ? session.objectAccess !== "read" : !session.isReadonly);
|
|
879
|
+
if (message.diff) {
|
|
824
880
|
for (const [id, op] of objectMapEntriesIterable(message.diff)) {
|
|
825
881
|
switch (op[0]) {
|
|
826
882
|
case RecordOpType.Put: {
|
|
827
|
-
if (!
|
|
883
|
+
if (!canWrite(op[1].typeName)) continue;
|
|
884
|
+
if (!this.documentTypes.has(op[1].typeName) && !this.objectTypes.has(op[1].typeName)) {
|
|
828
885
|
throw new TLSyncError(
|
|
829
886
|
"invalid record",
|
|
830
887
|
TLSyncErrorCloseEventReason.INVALID_RECORD
|
|
831
888
|
);
|
|
832
889
|
}
|
|
833
|
-
|
|
890
|
+
const record = op[1];
|
|
891
|
+
let authorize;
|
|
892
|
+
if (session && this.authorizeRecord) {
|
|
893
|
+
const prev = txn.get(id) ?? null;
|
|
894
|
+
if (prev && prev.typeName !== record.typeName) {
|
|
895
|
+
this.log?.warn?.(
|
|
896
|
+
"skipping put that changes typeName",
|
|
897
|
+
`${prev.typeName} -> ${record.typeName}`,
|
|
898
|
+
id,
|
|
899
|
+
"session:",
|
|
900
|
+
session.sessionId
|
|
901
|
+
);
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
const authorizePut = this.authorizerFor(record.typeName);
|
|
905
|
+
if (authorizePut) {
|
|
906
|
+
authorize = (prevRec, next) => {
|
|
907
|
+
const result2 = authorizePut(
|
|
908
|
+
prevRec ? {
|
|
909
|
+
session: { sessionId: session.sessionId, meta: session.meta },
|
|
910
|
+
type: "update",
|
|
911
|
+
prev: prevRec,
|
|
912
|
+
next
|
|
913
|
+
} : {
|
|
914
|
+
session: { sessionId: session.sessionId, meta: session.meta },
|
|
915
|
+
type: "create",
|
|
916
|
+
prev: null,
|
|
917
|
+
next
|
|
918
|
+
}
|
|
919
|
+
);
|
|
920
|
+
if (!result2) {
|
|
921
|
+
this.log?.warn?.(
|
|
922
|
+
"authorizer vetoed put",
|
|
923
|
+
record.typeName,
|
|
924
|
+
id,
|
|
925
|
+
"session:",
|
|
926
|
+
session.sessionId
|
|
927
|
+
);
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
return prevRec ? next : result2;
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
addDocument(txn, docChanges, id, record, authorize);
|
|
834
935
|
break;
|
|
835
936
|
}
|
|
836
937
|
case RecordOpType.Patch: {
|
|
837
|
-
|
|
938
|
+
const doc = txn.get(id);
|
|
939
|
+
if (!doc) continue;
|
|
940
|
+
if (!canWrite(doc.typeName)) continue;
|
|
941
|
+
const authorizePatch = session && this.authorizerFor(doc.typeName);
|
|
942
|
+
const authorize = authorizePatch ? (prev, next) => {
|
|
943
|
+
const result2 = authorizePatch({
|
|
944
|
+
session: { sessionId: session.sessionId, meta: session.meta },
|
|
945
|
+
type: "update",
|
|
946
|
+
prev,
|
|
947
|
+
next
|
|
948
|
+
});
|
|
949
|
+
if (!result2) {
|
|
950
|
+
this.log?.warn?.(
|
|
951
|
+
"authorizer vetoed patch",
|
|
952
|
+
doc.typeName,
|
|
953
|
+
id,
|
|
954
|
+
"session:",
|
|
955
|
+
session.sessionId
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
return result2;
|
|
959
|
+
} : void 0;
|
|
960
|
+
patchDocument(txn, docChanges, id, op[1], authorize);
|
|
838
961
|
break;
|
|
839
962
|
}
|
|
840
963
|
case RecordOpType.Remove: {
|
|
@@ -842,6 +965,23 @@ class TLSyncRoom {
|
|
|
842
965
|
if (!doc) {
|
|
843
966
|
continue;
|
|
844
967
|
}
|
|
968
|
+
if (!canWrite(doc.typeName)) continue;
|
|
969
|
+
const authorizeRemove = session && this.authorizerFor(doc.typeName);
|
|
970
|
+
if (authorizeRemove && !authorizeRemove({
|
|
971
|
+
session: { sessionId: session.sessionId, meta: session.meta },
|
|
972
|
+
type: "delete",
|
|
973
|
+
prev: doc,
|
|
974
|
+
next: null
|
|
975
|
+
})) {
|
|
976
|
+
this.log?.warn?.(
|
|
977
|
+
"authorizer vetoed delete",
|
|
978
|
+
doc.typeName,
|
|
979
|
+
id,
|
|
980
|
+
"session:",
|
|
981
|
+
session.sessionId
|
|
982
|
+
);
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
845
985
|
txn.delete(id);
|
|
846
986
|
propagateOp(docChanges, id, op, doc, void 0);
|
|
847
987
|
break;
|
|
@@ -916,6 +1056,16 @@ class TLSyncRoom {
|
|
|
916
1056
|
this.onPresenceChange?.();
|
|
917
1057
|
});
|
|
918
1058
|
}
|
|
1059
|
+
if (result.docChanges.diffs && this.onCommittedChanges) {
|
|
1060
|
+
const diff = result.docChanges.diffs.diff;
|
|
1061
|
+
queueMicrotask(() => {
|
|
1062
|
+
try {
|
|
1063
|
+
this.onCommittedChanges?.({ diff, documentClock });
|
|
1064
|
+
} catch (e) {
|
|
1065
|
+
this.log?.error?.("onCommittedChanges threw", e);
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
919
1069
|
}
|
|
920
1070
|
/**
|
|
921
1071
|
* Handle the event when a client disconnects. Cleans up the session and
|