Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/config/vitest/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ if (typeof HTMLImageElement !== 'undefined') {
}
}

// CSS.supports polyfill for tests that use color spaces (e.g., highlight shapes)
if (typeof CSS === 'undefined') {
;(global as any).CSS = {}
}
if (typeof CSS.supports === 'undefined') {
CSS.supports = () => false
}

function convertNumbersInObject(obj: any, roundToNearest: number): any {
if (!obj) return obj
if (Array.isArray(obj)) {
Expand Down
42 changes: 18 additions & 24 deletions packages/sync-core/api-report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { TLStoreSnapshot } from '@tldraw/tlschema';
import { UnknownRecord } from '@tldraw/store';

// @internal
export type AppendOp = [type: typeof ValueOpType.Append, values: unknown[], offset: number];
export type AppendOp = [type: typeof ValueOpType.Append, value: string | unknown[], offset: number];

// @internal
export function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectDiff): T;
Expand Down Expand Up @@ -54,16 +54,16 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLSocket
export type DeleteOp = [type: typeof ValueOpType.Delete];

// @internal
export function diffRecord(prev: object, next: object): null | ObjectDiff;
export function diffRecord(prev: object, next: object, legacyAppendMode?: boolean): null | ObjectDiff;

// @internal
export class DocumentState<R extends UnknownRecord> {
static createAndValidate<R extends UnknownRecord>(state: R, lastChangedClock: number, recordType: RecordType<R, any>): Result<DocumentState<R>, Error>;
static createWithoutValidating<R extends UnknownRecord>(state: R, lastChangedClock: number, recordType: RecordType<R, any>): DocumentState<R>;
// (undocumented)
readonly lastChangedClock: number;
mergeDiff(diff: ObjectDiff, clock: number): Result<[ObjectDiff, DocumentState<R>] | null, Error>;
replaceState(state: R, clock: number): Result<[ObjectDiff, DocumentState<R>] | null, Error>;
mergeDiff(diff: ObjectDiff, clock: number, legacyAppendMode?: boolean): Result<[ObjectDiff, DocumentState<R>] | null, Error>;
replaceState(state: R, clock: number, legacyAppendMode?: boolean): Result<[ObjectDiff, DocumentState<R>] | null, Error>;
// (undocumented)
readonly state: R;
}
Expand Down Expand Up @@ -132,37 +132,30 @@ export const RecordOpType: {
export type RecordOpType = (typeof RecordOpType)[keyof typeof RecordOpType];

// @internal
export type RoomSession<R extends UnknownRecord, Meta> = {
export type RoomSession<R extends UnknownRecord, Meta> = (RoomSessionBase<R, Meta> & {
state: typeof RoomSessionState.AwaitingConnectMessage;
meta: Meta;
presenceId: null | string;
sessionStartTime: number;
sessionId: string;
socket: TLRoomSocket<R>;
isReadonly: boolean;
requiresLegacyRejection: boolean;
} | {
}) | (RoomSessionBase<R, Meta> & {
state: typeof RoomSessionState.AwaitingRemoval;
meta: Meta;
presenceId: null | string;
cancellationTime: number;
sessionId: string;
socket: TLRoomSocket<R>;
isReadonly: boolean;
requiresLegacyRejection: boolean;
} | {
}) | (RoomSessionBase<R, Meta> & {
state: typeof RoomSessionState.Connected;
meta: Meta;
presenceId: null | string;
outstandingDataMessages: TLSocketServerSentDataEvent<R>[];
serializedSchema: SerializedSchema;
debounceTimer: null | ReturnType<typeof setTimeout>;
lastInteractionTime: number;
sessionId: string;
socket: TLRoomSocket<R>;
});

// @internal
export interface RoomSessionBase<R extends UnknownRecord, Meta> {
isReadonly: boolean;
meta: Meta;
presenceId: null | string;
requiresLegacyRejection: boolean;
};
sessionId: string;
socket: TLRoomSocket<R>;
supportsStringAppend: boolean;
}

// @internal
export const RoomSessionState: {
Expand Down Expand Up @@ -505,6 +498,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
}>;
// (undocumented)
_flushDataMessages(sessionId: string): void;
getCanEmitStringAppend(): boolean;
getSnapshot(): RoomSnapshot;
handleClose(sessionId: string): void;
handleMessage(sessionId: string, message: TLSocketClientSentEvent<R>): Promise<void>;
Expand Down
2 changes: 1 addition & 1 deletion packages/sync-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export {
type TLSocketServerSentDataEvent,
type TLSocketServerSentEvent,
} from './lib/protocol'
export { RoomSessionState, type RoomSession } from './lib/RoomSession'
export { RoomSessionState, type RoomSession, type RoomSessionBase } from './lib/RoomSession'
export type { PersistedRoomSnapshotForSupabase } from './lib/server-types'
export type { WebSocketMinimal } from './lib/ServerSocketAdapter'
export { TLRemoteSyncError } from './lib/TLRemoteSyncError'
Expand Down
3 changes: 3 additions & 0 deletions packages/sync-core/src/lib/RoomSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe('RoomSession state transitions', () => {
const initialSession: RoomSession<TLRecord, { userId: string }> = {
state: RoomSessionState.AwaitingConnectMessage,
sessionStartTime: Date.now(),
supportsStringAppend: true,
...baseSessionData,
}

Expand All @@ -67,6 +68,7 @@ describe('RoomSession state transitions', () => {
isReadonly: initialSession.isReadonly,
requiresLegacyRejection: initialSession.requiresLegacyRejection,
serializedSchema: mockSerializedSchema,
supportsStringAppend: true,
lastInteractionTime: Date.now(),
debounceTimer: null,
outstandingDataMessages: [],
Expand All @@ -81,6 +83,7 @@ describe('RoomSession state transitions', () => {
meta: connectedSession.meta,
isReadonly: connectedSession.isReadonly,
requiresLegacyRejection: connectedSession.requiresLegacyRejection,
supportsStringAppend: connectedSession.supportsStringAppend,
cancellationTime: Date.now(),
}

Expand Down
70 changes: 28 additions & 42 deletions packages/sync-core/src/lib/RoomSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,28 @@ export const SESSION_REMOVAL_WAIT_TIME = 5000
*/
export const SESSION_IDLE_TIMEOUT = 20000

/**
* Base properties shared by all room session states.
*
* @internal
*/
export interface RoomSessionBase<R extends UnknownRecord, Meta> {
/** Unique identifier for this session */
sessionId: string
/** Presence identifier for live cursor/selection tracking, if available */
presenceId: string | null
/** WebSocket connection wrapper for this session */
socket: TLRoomSocket<R>
/** Custom metadata associated with this session */
meta: Meta
/** Whether this session has read-only permissions */
isReadonly: boolean
/** Whether this session requires legacy protocol rejection handling */
requiresLegacyRejection: boolean
/** Whether this session supports string append operations */
supportsStringAppend: boolean
}

/**
* Represents a client session within a collaborative room, tracking the connection
* state, permissions, and synchronization details for a single user.
Expand Down Expand Up @@ -93,51 +115,21 @@ export const SESSION_IDLE_TIMEOUT = 20000
* @internal
*/
export type RoomSession<R extends UnknownRecord, Meta> =
| {
| (RoomSessionBase<R, Meta> & {
/** Current state of the session */
state: typeof RoomSessionState.AwaitingConnectMessage
/** Unique identifier for this session */
sessionId: string
/** Presence identifier for live cursor/selection tracking, if available */
presenceId: string | null
/** WebSocket connection wrapper for this session */
socket: TLRoomSocket<R>
/** Timestamp when the session was created */
sessionStartTime: number
/** Custom metadata associated with this session */
meta: Meta
/** Whether this session has read-only permissions */
isReadonly: boolean
/** Whether this session requires legacy protocol rejection handling */
requiresLegacyRejection: boolean
}
| {
})
| (RoomSessionBase<R, Meta> & {
/** Current state of the session */
state: typeof RoomSessionState.AwaitingRemoval
/** Unique identifier for this session */
sessionId: string
/** Presence identifier for live cursor/selection tracking, if available */
presenceId: string | null
/** WebSocket connection wrapper for this session */
socket: TLRoomSocket<R>
/** Timestamp when the session was marked for removal */
cancellationTime: number
/** Custom metadata associated with this session */
meta: Meta
/** Whether this session has read-only permissions */
isReadonly: boolean
/** Whether this session requires legacy protocol rejection handling */
requiresLegacyRejection: boolean
}
| {
})
| (RoomSessionBase<R, Meta> & {
/** Current state of the session */
state: typeof RoomSessionState.Connected
/** Unique identifier for this session */
sessionId: string
/** Presence identifier for live cursor/selection tracking, if available */
presenceId: string | null
/** WebSocket connection wrapper for this session */
socket: TLRoomSocket<R>
/** Serialized schema information for this connected session */
serializedSchema: SerializedSchema
/** Timestamp of the last interaction or message from this session */
Expand All @@ -146,10 +138,4 @@ export type RoomSession<R extends UnknownRecord, Meta> =
debounceTimer: ReturnType<typeof setTimeout> | null
/** Queue of data messages waiting to be sent to this session */
outstandingDataMessages: TLSocketServerSentDataEvent<R>[]
/** Custom metadata associated with this session */
meta: Meta
/** Whether this session has read-only permissions */
isReadonly: boolean
/** Whether this session requires legacy protocol rejection handling */
requiresLegacyRejection: boolean
}
})
49 changes: 42 additions & 7 deletions packages/sync-core/src/lib/TLSyncRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,15 @@ export class DocumentState<R extends UnknownRecord> {
*
* @param state - The new record state
* @param clock - The new clock value
* @param legacyAppendMode - If true, string append operations will be converted to Put operations
* @returns Result containing the diff and new DocumentState, or null if no changes, or validation error
*/
replaceState(state: R, clock: number): Result<[ObjectDiff, DocumentState<R>] | null, Error> {
const diff = diffRecord(this.state, state)
replaceState(
state: R,
clock: number,
legacyAppendMode = false
): Result<[ObjectDiff, DocumentState<R>] | null, Error> {
const diff = diffRecord(this.state, state, legacyAppendMode)
if (!diff) return Result.ok(null)
try {
this.recordType.validate(state)
Expand All @@ -173,11 +178,16 @@ export class DocumentState<R extends UnknownRecord> {
*
* @param diff - The object diff to apply
* @param clock - The new clock value
* @param legacyAppendMode - If true, string append operations will be converted to Put operations
* @returns Result containing the final diff and new DocumentState, or null if no changes, or validation error
*/
mergeDiff(diff: ObjectDiff, clock: number): Result<[ObjectDiff, DocumentState<R>] | null, Error> {
mergeDiff(
diff: ObjectDiff,
clock: number,
legacyAppendMode = false
): Result<[ObjectDiff, DocumentState<R>] | null, Error> {
const newState = applyObjectDiff(this.state, diff)
return this.replaceState(newState, clock)
return this.replaceState(newState, clock, legacyAppendMode)
}
}

Expand Down Expand Up @@ -720,6 +730,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
meta: session.meta,
isReadonly: session.isReadonly,
requiresLegacyRejection: session.requiresLegacyRejection,
supportsStringAppend: session.supportsStringAppend,
})

try {
Expand Down Expand Up @@ -843,10 +854,28 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
isReadonly: isReadonly ?? false,
// this gets set later during handleConnectMessage
requiresLegacyRejection: false,
supportsStringAppend: true,
})
return this
}

/**
* Checks if all connected sessions support string append operations (protocol version 8+).
* If any client is on an older version, returns false to enable legacy append mode.
*
* @returns True if all connected sessions are on protocol version 8 or higher
*/
getCanEmitStringAppend(): boolean {
for (const session of this.sessions.values()) {
if (session.state === RoomSessionState.Connected) {
if (!session.supportsStringAppend) {
return false
}
}
}
return true
}

/**
* When we send a diff to a client, if that client is on a lower version than us, we need to make
* the diff compatible with their version. At the moment this means migrating each affected record
Expand Down Expand Up @@ -1010,6 +1039,10 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
if (theirProtocolVersion === 6) {
theirProtocolVersion++
}
if (theirProtocolVersion === 7) {
theirProtocolVersion++
session.supportsStringAppend = false
}

if (theirProtocolVersion == null || theirProtocolVersion < getTlsyncProtocolVersion()) {
this.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
Expand Down Expand Up @@ -1045,6 +1078,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
lastInteractionTime: Date.now(),
debounceTimer: null,
outstandingDataMessages: [],
supportsStringAppend: session.supportsStringAppend,
meta: session.meta,
isReadonly: session.isReadonly,
requiresLegacyRejection: session.requiresLegacyRejection,
Expand Down Expand Up @@ -1150,6 +1184,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
const initialDocumentClock = this.documentClock
let didPresenceChange = false
transaction((rollback) => {
const legacyAppendMode = !this.getCanEmitStringAppend()
// collect actual ops that resulted from the push
// these will be broadcast to other users
interface ActualChanges {
Expand Down Expand Up @@ -1198,7 +1233,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
if (doc) {
// If there's an existing document, replace it with the new state
// but propagate a diff rather than the entire value
const diff = doc.replaceState(state, this.clock)
const diff = doc.replaceState(state, this.clock, legacyAppendMode)
if (!diff.ok) {
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
}
Expand Down Expand Up @@ -1238,7 +1273,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {

if (downgraded.value === doc.state) {
// If the versions are compatible, apply the patch and propagate the patch op
const diff = doc.mergeDiff(patch, this.clock)
const diff = doc.mergeDiff(patch, this.clock, legacyAppendMode)
if (!diff.ok) {
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
}
Expand All @@ -1260,7 +1295,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
return fail(TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
}
// replace the state with the upgraded version and propagate the patch op
const diff = doc.replaceState(upgraded.value, this.clock)
const diff = doc.replaceState(upgraded.value, this.clock, legacyAppendMode)
if (!diff.ok) {
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
}
Expand Down
Loading
Loading