@tldraw/sync-core 5.3.0-canary.fceaae5e9feb → 5.3.0-internal.5f14a24c179e

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.
Files changed (51) hide show
  1. package/dist-cjs/index.d.ts +154 -3
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/InMemorySyncStorage.js +55 -20
  5. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  6. package/dist-cjs/lib/RoomSession.js.map +1 -1
  7. package/dist-cjs/lib/SQLiteSyncStorage.js +115 -52
  8. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  9. package/dist-cjs/lib/TLSocketRoom.js +27 -2
  10. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncClient.js +6 -1
  12. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncRoom.js +160 -10
  14. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  15. package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
  16. package/dist-cjs/lib/protocol.js.map +2 -2
  17. package/dist-esm/index.d.mts +154 -3
  18. package/dist-esm/index.mjs +1 -1
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/InMemorySyncStorage.mjs +55 -20
  21. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  22. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  23. package/dist-esm/lib/SQLiteSyncStorage.mjs +115 -52
  24. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  25. package/dist-esm/lib/TLSocketRoom.mjs +27 -2
  26. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncClient.mjs +6 -1
  28. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  29. package/dist-esm/lib/TLSyncRoom.mjs +160 -10
  30. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  31. package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
  32. package/dist-esm/lib/protocol.mjs.map +2 -2
  33. package/package.json +6 -6
  34. package/src/index.ts +3 -0
  35. package/src/lib/InMemorySyncStorage.ts +74 -21
  36. package/src/lib/RoomSession.ts +7 -2
  37. package/src/lib/SQLiteSyncStorage.test.ts +29 -2
  38. package/src/lib/SQLiteSyncStorage.ts +158 -59
  39. package/src/lib/TLSocketRoom.ts +61 -3
  40. package/src/lib/TLSyncClient.test.ts +9 -2
  41. package/src/lib/TLSyncClient.ts +15 -3
  42. package/src/lib/TLSyncRoom.ts +294 -10
  43. package/src/lib/TLSyncStorage.ts +8 -0
  44. package/src/lib/protocol.ts +17 -0
  45. package/src/test/TLSocketRoom.test.ts +37 -2
  46. package/src/test/TLSyncClientRebase.test.ts +285 -0
  47. package/src/test/TLSyncRoom.test.ts +25 -0
  48. package/src/test/TestServer.ts +14 -4
  49. package/src/test/objectStore.test.ts +630 -0
  50. package/src/test/storageContractSuite.ts +79 -0
  51. 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
- const { value: state } = res;
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
- if (message.diff && !session?.isReadonly) {
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 (!this.documentTypes.has(op[1].typeName)) {
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
- addDocument(txn, docChanges, id, op[1]);
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
- patchDocument(txn, docChanges, id, op[1]);
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