@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
@@ -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
- const { value: state } = res;
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
- if (message.diff && !session?.isReadonly) {
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 (!this.documentTypes.has(op[1].typeName)) {
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
- addDocument(txn, docChanges, id, op[1]);
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
- patchDocument(txn, docChanges, id, op[1]);
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