diff --git a/CHANGELOG.md b/CHANGELOG.md index 687525708fe..022a38078e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## vNEXT (not yet released) +- Export internal utility + ## v3.19.4 ### `@liveblocks/client` diff --git a/packages/liveblocks-core/src/index.ts b/packages/liveblocks-core/src/index.ts index 6929898125b..d6ee2488747 100644 --- a/packages/liveblocks-core/src/index.ts +++ b/packages/liveblocks-core/src/index.ts @@ -87,6 +87,7 @@ export type { LsonObject, ToJson, } from "./crdts/Lson"; +export { deepLiveify } from "./crdts/reconcile"; export type { LiveListUpdate, LiveMapUpdate, diff --git a/packages/liveblocks-server/CHANGELOG.md b/packages/liveblocks-server/CHANGELOG.md deleted file mode 120000 index a93633a4a6a..00000000000 --- a/packages/liveblocks-server/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -../../tools/liveblocks-cli/CHANGELOG.md \ No newline at end of file diff --git a/packages/liveblocks-server/CHANGELOG.md b/packages/liveblocks-server/CHANGELOG.md new file mode 100644 index 00000000000..5417f97cd93 --- /dev/null +++ b/packages/liveblocks-server/CHANGELOG.md @@ -0,0 +1,69 @@ +## vNEXT (not yet released) + +## v1.6.0 + +- Update internal storage format of dev server. Note that your local dev rooms + are not automatically migrated and will appear as empty rooms after the + upgrade. + +## v1.5.0 + +- Add `--random-port` (`-P`) flag to `liveblocks dev`: bind a random free port + instead of an explicit port number. With `--cmd` (`-c`), the chosen port is + exposed to the command via `LIVEBLOCKS_DEV_SERVER_PORT`. Ideal for CI (no port + collisions ever). +- Fix `LiveList.push()` so concurrent pushes from multiple clients no longer + settle out of order. + +## v1.4.1 + +- Fix: `client.getOrCreateRoom()` no longer errors when the room already exists, + matching production behavior. +- Fix: Yjs document updates made via `PUT /v2/rooms//ydoc` now get + broadcast to connected WebSocket clients, matching production behavior. + +## v1.4.0 + +- Add support for `client.mutateStorage()` (from `@liveblocks/node`) + +## v1.3.0 + +- Add feeds support (`feeds:write` permission) +- Add verbose logging toggle +- Fix permission validation to accept all valid permission combinations +- Support passing extra arguments to `--cmd` (`-c`), appended to the command or + replacing `{}` if present + +## v1.2.0 + +- Add live socket inspector view +- Add maintenance mode toggle (to reject new WebSocket connections) + +## v1.1.0 + +### Added + +- ID token authentication support +- Read-only rooms support +- Room permissions and room metadata +- Room filtering support + +### Changed + +- Room Node.js methods and REST APIs are now fully supported + +See https://liveblocks.io/docs/tools/dev-server for the updated feature matrix. + +## v1.0.17 + +Initial release. Dev server supports: + +- Storage (all CRDTs) +- Presence +- Broadcast +- Text editors (Tiptap, BlockNote, Lexical) +- Public key authentication +- Access token authentication +- Room Node.js methods and REST APIs (partial) + +See https://liveblocks.io/docs/tools/dev-server for all details. diff --git a/packages/liveblocks-server/package.json b/packages/liveblocks-server/package.json index d478a1eb0c4..7708f0aaf12 100644 --- a/packages/liveblocks-server/package.json +++ b/packages/liveblocks-server/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/server", - "version": "1.5.0", + "version": "1.6.0", "description": "Liveblocks backend server foundation.", "type": "module", "main": "./dist/index.js", @@ -69,7 +69,7 @@ }, "sideEffects": false, "dependencies": { - "@liveblocks/core": "3.20.0-pre1", + "@liveblocks/core": "3.19.5-pre1", "async-mutex": "^0.4.0", "decoders": "^2.9.0", "itertools": "^2.7.1", diff --git a/packages/liveblocks-server/src/Room.ts b/packages/liveblocks-server/src/Room.ts index 09afc3320df..1322458fe00 100644 --- a/packages/liveblocks-server/src/Room.ts +++ b/packages/liveblocks-server/src/Room.ts @@ -121,7 +121,6 @@ function groupNodesForWebSocketMessages( return chunkedByCost(input, (tup) => tup.length, MAX_SIZE, idealSize); } -export type LoadingState = "initial" | "loading" | "loaded"; export type ActorID = Brand; /** Number of milliseconds since Unix epoch. */ export type Millis = Brand; @@ -400,12 +399,6 @@ export type CreateTicketOptions = { actor?: ActorID; }; -type InternalData = { - readonly storage: Storage; - readonly yjsStorage: YjsStorage; - readonly mutex: Mutex; -}; - type RoomOptions = { /** * Bring your own persistence backend @@ -424,15 +417,8 @@ type RoomOptions = { /** Called whenever the server acknowledged a ping with a pong */ onDidPong?: (ctx?: C) => void | Promise; - /** Called before the room is attempted to be loaded */ - onRoomWillLoad?: (ctx?: C) => void | Promise; - /** Called right after the room's contents are loaded, but before any session has been started */ - onRoomDidLoad?: (ctx?: C) => void | Promise; - /** Called right before the room is attempted to be unloaded. Synchronous. May throw to abort the unloading. */ onRoomWillUnload?: (ctx?: C) => void; - /** Called right after the room has been unloaded from memory. Synchronous. */ - onRoomDidUnload?: (ctx?: C) => void; /** Called when a new user entered the room. */ onSessionDidStart?: ( @@ -494,8 +480,13 @@ export class Room { */ private readonly _maintenanceMode = new Mutex(); - private _loadData$: Promise | null = null; - private _data: InternalData | null = null; + private _storage: Storage; + private _yjsStorage: YjsStorage; + public readonly mutex: Mutex = new Mutex(); + + public get storage(): Storage { return this._storage; } // prettier-ignore + public get yjsStorage(): YjsStorage { return this._yjsStorage; } // prettier-ignore + private _qsize = 0; private readonly sessions = new UniqueMap< @@ -512,11 +503,7 @@ export class Room { onDidPong?: (ctx?: C) => void | Promise; - onRoomWillLoad?: (ctx?: C) => void | Promise; - onRoomDidLoad?: (ctx?: C) => void | Promise; - onRoomWillUnload?: (ctx?: C) => void; - onRoomDidUnload?: (ctx?: C) => void; onSessionDidStart?: ( session: BrowserSession, @@ -541,6 +528,9 @@ export class Room { const driver = options?.storage ?? makeNewInMemoryDriver(); this.meta = meta; this.driver = driver; + this._storage = new Storage(this.driver); + this._yjsStorage = new YjsStorage(this.driver); + this.#logger = options?.logger ?? BLACK_HOLE; this.hooks = { isClientMsgAllowed: @@ -551,12 +541,7 @@ export class Room { }; }), - // YYY .load() isn't called on the RoomServer yet! As soon as it does, these hooks will get called - onRoomWillLoad: options?.hooks?.onRoomWillLoad, - onRoomDidLoad: options?.hooks?.onRoomDidLoad, - onRoomWillUnload: options?.hooks?.onRoomWillUnload, - onRoomDidUnload: options?.hooks?.onRoomDidUnload, onSessionDidStart: options?.hooks?.onSessionDidStart, onSessionDidEnd: options?.hooks?.onSessionDidEnd, @@ -576,25 +561,8 @@ export class Room { this.#logger = this.#logger.withContext(attrs); } - public get loadingState(): LoadingState { - if (this._loadData$ === null) { - return "initial"; - } else if (this._data === null) { - return "loading"; - } else { - return "loaded"; - } - } - public get numSessions(): number { return this.sessions.size; } // prettier-ignore - public get storage(): Storage { return this.data.storage; } // prettier-ignore - public get yjsStorage(): YjsStorage { return this.data.yjsStorage; } // prettier-ignore - - public get mutex(): Mutex { return this.data.mutex; } // prettier-ignore - - private get data(): InternalData { return this._data ?? raise("Cannot use room before it's loaded"); } // prettier-ignore - // ------------------------------------------------------------------------------------ // Maintenance mode // ------------------------------------------------------------------------------------ @@ -612,7 +580,9 @@ export class Room { * If the room is already in maintenance mode, throws E_ALREADY_LOCKED * immediately instead of queuing the request. */ - public async runInMaintenanceMode(callback: () => Promise): Promise { + public async runInMaintenanceMode( + callback: () => T | Promise + ): Promise { return tryAcquire(this._maintenanceMode).runExclusive(callback); } @@ -621,38 +591,19 @@ export class Room { // ------------------------------------------------------------------------------------ /** - * Initializes the Room, so it's ready to start accepting connections. Safe - * to call multiple times. After awaiting `room.load()` the Room is ready to - * be used. - */ - // XXXX We should now be able to remove this asyncness here too - public async load(ctx?: C): Promise { - if (this._loadData$ === null) { - this._data = null; - this._loadData$ = this._load(ctx).catch((e) => { - this._data = null; - this._loadData$ = null; - throw e; - }); - } - return this._loadData$; - } - - /** - * Releases the currently-loaded storage tree from worker memory, freeing it - * up to be garbage collected. The next time a user will join the room, the - * room will be reloaded from storage. + * Releases the currently-loaded storage tree and Yjs documents from worker + * memory, freeing them up to be garbage collected. The next time the room + * is used, fresh instances will lazily be created. */ public unload(ctx?: C): void { this.hooks.onRoomWillUnload?.(ctx); // May throw to cancel unloading - if (this._data) { - this.storage.unload(); - this.yjsStorage.unload(); - } - // YYY Abort any potentially in-flight _loadData$ calls here - this._loadData$ = null; - // this._data = null; // YYY Should we also clear _data? I think so! - this.hooks.onRoomDidUnload?.(ctx); + + // NOTE: this.mutex is deliberately left untouched here. It guards + // in-flight message processing for the lifetime of this Room instance, + // and force-releasing a held lock would allow concurrent writers. + this.driver.reinitialize(); + this._storage = new Storage(this.driver); + this._yjsStorage = new YjsStorage(this.driver); } /** @@ -741,7 +692,7 @@ export class Room { } } - private async sendSessionStartMessages( + private sendSessionStartMessages( newSession: BrowserSession, ticket: Ticket, ctx?: C, @@ -751,7 +702,7 @@ export class Room { "Pass a `defer` callback to sendSessionStartMessages() to collect async side effects." ); } - ): Promise { + ): void { const users: Record = {}; // Add regular sessions for (const session of this.otherSessions(ticket.sessionKey)) { @@ -763,10 +714,7 @@ export class Room { } // List all active server sessions - const leasedSessions: LeasedSession[] = await this.listLeasedSessions( - ctx, - defer - ); + const leasedSessions: LeasedSession[] = this.listLeasedSessions(ctx, defer); // Add server sessions for (const leasedSession of leasedSessions) { users[leasedSession.actorId as ActorID] = { @@ -807,7 +755,7 @@ export class Room { * - Sends a ROOM_STATE message to the socket. * - Broadcasts a USER_JOINED message to all other sessions in the room. */ - public async startBrowserSession( + public startBrowserSession( ticket: Ticket, socket: IServerWebSocket, ctx?: C, @@ -817,7 +765,7 @@ export class Room { "Pass a `defer` callback to startBrowserSession() to collect async side effects." ); } - ): Promise { + ): void { let existing: SessionKey | undefined; while ( (existing = this.sessions.lookupPrimaryKey(ticket.actor)) !== undefined @@ -845,7 +793,7 @@ export class Room { this.sessions.set(ticket.sessionKey, newSession); // send sessions start messages - await this.sendSessionStartMessages(newSession, ticket, ctx, defer); + this.sendSessionStartMessages(newSession, ticket, ctx, defer); this.sendToOthers( ticket.sessionKey, @@ -1012,7 +960,6 @@ export class Room { messages: ClientMsg[], ctx?: C ): Promise { - await this.load(ctx); const { defer, waitAll } = collectSideEffects(); await this.mutex.runExclusive(() => this._processClientMsg_withExclusiveAccess(key, messages, ctx, defer) @@ -1047,7 +994,6 @@ export class Room { messages: ClientMsg[], ctx?: C ): Promise { - await this.load(ctx); const { defer, waitAll } = collectSideEffects(); await this.mutex.runExclusive(() => this._processClientMsgFromBackendSession_withExclusiveAccess( @@ -1170,7 +1116,7 @@ export class Room { /** * List all server sessions. As a side effect, it will delete expired sessions. */ - public async listLeasedSessions( + public listLeasedSessions( ctx?: C, defer: (promise: Promise) => void = () => { throw new Error( @@ -1178,8 +1124,7 @@ export class Room { "Pass a `defer` callback to listLeasedSessions() to collect async side effects." ); } - ): Promise { - await this.load(ctx); + ): LeasedSession[] { const sessions = this.driver.list_leased_sessions(); const validSessions: LeasedSession[] = []; const toDelete: LeasedSession[] = []; @@ -1226,7 +1171,7 @@ export class Room { /** * Delete all server sessions and broadcast USER_LEFT to all sessions. */ - public async deleteAllLeasedSessions( + public deleteAllLeasedSessions( ctx?: C, defer: (promise: Promise) => void = () => { throw new Error( @@ -1234,8 +1179,7 @@ export class Room { "Pass a `defer` callback to deleteAllLeasedSessions() to collect async side effects." ); } - ): Promise { - await this.load(ctx); + ): void { const sessions = this.driver.list_leased_sessions(); for (const [_, session] of sessions) { this.deleteLeasedSession(session, ctx, defer); @@ -1422,35 +1366,6 @@ export class Room { // Private APIs // ------------------------------------------------------------------------------------ - private async _loadStorage(): Promise { - const storage = new Storage(this.driver); - await storage.load(this.#logger); - return storage; - } - - private async _loadYjsStorage(): Promise { - const yjsStorage = new YjsStorage(this.driver); - await yjsStorage.load(this.#logger); - return yjsStorage; - } - - // Don't ever manually call this! - private async _load(ctx?: C): Promise { - await this.hooks.onRoomWillLoad?.(ctx); - - // YYY Maybe later run these in parallel? See https://github.com/liveblocks/liveblocks-cloudflare/pull/721#discussion_r1489076389 - const storage = await this._loadStorage(); - const yjsStorage = await this._loadYjsStorage(); - - this._data = { - mutex: new Mutex(), - storage, - yjsStorage, - }; - - await this.hooks.onRoomDidLoad?.(ctx); - } - /** * Iterates over all *other* Sessions and their session keys. */ @@ -1499,12 +1414,12 @@ export class Room { } } - private async _processClientMsg_withExclusiveAccess( + private _processClientMsg_withExclusiveAccess( sessionKey: SessionKey, messages: ClientMsg[], ctx: C | undefined, defer: (p: Promise) => void - ): Promise { + ): void { const session = this.sessions.get(sessionKey); if (!session) { this.#logger @@ -1527,7 +1442,7 @@ export class Room { for (const msg of messages) { const isMsgAllowed = this.hooks.isClientMsgAllowed(msg, session); if (isMsgAllowed.allowed) { - await this.handleOne( + this.handleOne( session, msg, replyImmediately, @@ -1563,12 +1478,12 @@ export class Room { // TODO It's a bit bothering how much duplication there is between this method // and the _processClientMsg_withExclusiveAccess version. A better // abstraction is needed. - private async _processClientMsgFromBackendSession_withExclusiveAccess( + private _processClientMsgFromBackendSession_withExclusiveAccess( session: BackendSession, messages: ClientMsg[], ctx: C | undefined, defer: (p: Promise) => void - ): Promise { + ): void { // Keep two ServerMsg buffers to send at the end: // - Messages to fan-out to all *others* (current session not included) // - Messages to reply back to the current sender (i.e. acks and rejections) @@ -1599,7 +1514,7 @@ export class Room { const scheduleReply = (msg: ServerMsg) => void toReplyAfter.push(msg); for (const msg of messages) { - await this.handleOne( + this.handleOne( session, msg, replyImmediately, @@ -1626,7 +1541,7 @@ export class Room { } } - private async handleOne( + private handleOne( session: BrowserSession, msg: ClientMsg, replyImmediately: ( @@ -1636,7 +1551,7 @@ export class Room { scheduleReply: (msg: ServerMsg) => void, ctx: C | undefined, defer: (p: Promise) => void - ): Promise { + ): void { if (!this.mutex.isLocked()) { throw new Error("Handling messages requires exclusive access"); } @@ -1674,7 +1589,7 @@ export class Room { // of parsing, mutating, serializing, and measuring each individual // node, which can add up in large documents. // - const rawStream = this.storage.loadedDriver.iter_nodes_optimized(); + const rawStream = this.storage.driver.iter_nodes_optimized(); for (const chunk of groupNodesForWebSocketMessages(rawStream)) { const frame = `{"type":${ServerMsgCode.STORAGE_CHUNK},"nodes":[${chunk.join(",")}]}` as jstring; @@ -1684,7 +1599,7 @@ export class Room { } else { replyImmediately({ type: ServerMsgCode.STORAGE_STATE_V7, - items: Array.from(this.storage.loadedDriver.iter_nodes()), + items: Array.from(this.storage.driver.iter_nodes()), }); } break; @@ -1753,11 +1668,17 @@ export class Room { const vector = msg.vector; const guid = msg.guid as Guid | undefined; const isV2 = msg.v2; - const [update, stateVector, snapshotHash] = await Promise.all([ - this.yjsStorage.getYDocUpdate(this.#logger, vector, guid, isV2), - this.yjsStorage.getYStateVector(this.#logger, guid), - this.yjsStorage.getSnapshotHash(this.#logger, { guid, isV2 }), - ]); + const update = this.yjsStorage.getYDocUpdate( + this.#logger, + vector, + guid, + isV2 + ); + const stateVector = this.yjsStorage.getYStateVector(this.#logger, guid); + const snapshotHash = this.yjsStorage.getSnapshotHash(this.#logger, { + guid, + isV2, + }); if (update !== null && snapshotHash !== null) { replyImmediately({ @@ -1777,7 +1698,7 @@ export class Room { const update = msg.update; const guid = msg.guid as Guid | undefined; const isV2 = msg.v2; - const [result, error] = await tryCatch( + const [result, error] = tryCatch(() => this.yjsStorage.addYDocUpdate(this.#logger, update, guid, isV2) ); @@ -1809,7 +1730,7 @@ export class Room { // Feed messages case FeedMsgCode.FETCH_FEEDS: { const fetchMsg = msg; - const [result, err] = await tryCatch(() => + const [result, err] = tryCatch(() => this.listFeeds({ cursor: fetchMsg.cursor, since: fetchMsg.since, @@ -1832,7 +1753,7 @@ export class Room { case FeedMsgCode.FETCH_FEED_MESSAGES: { const fetchMsg = msg; - const [result, err] = await tryCatch(() => + const [result, err] = tryCatch(() => this.listFeedMessages(fetchMsg.feedId, { cursor: fetchMsg.cursor, since: fetchMsg.since, @@ -1855,7 +1776,7 @@ export class Room { case FeedMsgCode.ADD_FEED: { const addMsg = msg; - const [feed, err] = await tryCatch(() => + const [feed, err] = tryCatch(() => this.createFeed({ feedId: addMsg.feedId, metadata: (addMsg.metadata as Json) ?? {}, @@ -1876,7 +1797,7 @@ export class Room { case FeedMsgCode.UPDATE_FEED: { const updateMsg = msg; - const [, metaErr] = await tryCatch(() => + const [, metaErr] = tryCatch(() => this.updateFeedMetadata(updateMsg.feedId, updateMsg.metadata as Json) ); if (metaErr) { @@ -1904,7 +1825,7 @@ export class Room { case FeedMsgCode.DELETE_FEED: { const deleteMsg = msg; - const [, err] = await tryCatch(() => this.deleteFeed(deleteMsg.feedId)); + const [, err] = tryCatch(() => this.deleteFeed(deleteMsg.feedId)); if (err) { replyImmediately(feedFailureServerMsg(deleteMsg.requestId, err)); break; @@ -1920,7 +1841,7 @@ export class Room { case FeedMsgCode.ADD_FEED_MESSAGE: { const addMsg = msg; - const [message, err] = await tryCatch(() => + const [message, err] = tryCatch(() => this.addFeedMessage(addMsg.feedId, { data: addMsg.data as Json, id: addMsg.id, @@ -1942,7 +1863,7 @@ export class Room { case FeedMsgCode.UPDATE_FEED_MESSAGE: { const updateMsg = msg; - const [message, err] = await tryCatch(() => + const [message, err] = tryCatch(() => this.updateFeedMessage( updateMsg.feedId, updateMsg.messageId, @@ -1965,7 +1886,7 @@ export class Room { case FeedMsgCode.DELETE_FEED_MESSAGE: { const deleteMsg = msg; - const [, err] = await tryCatch(() => + const [, err] = tryCatch(() => this.deleteFeedMessage(deleteMsg.feedId, deleteMsg.messageId) ); if (err) { diff --git a/packages/liveblocks-server/src/Storage.ts b/packages/liveblocks-server/src/Storage.ts index bd98e17f0b9..2d57658594d 100644 --- a/packages/liveblocks-server/src/Storage.ts +++ b/packages/liveblocks-server/src/Storage.ts @@ -24,8 +24,7 @@ import { OpCode, } from "@liveblocks/core"; -import type { IStorageDriver, IStorageDriverNodeAPI } from "~/interfaces"; -import type { Logger } from "~/lib/Logger"; +import type { IStorageDriver } from "~/interfaces"; import type { ClientWireOp, CreateOp, @@ -99,45 +98,20 @@ function nodeFromCreateChildOp(op: CreateOp): SerializedChild { export class Storage { // The actual underlying storage API (could be backed by in-memory store, - // SQLite, Redis, Postgres, Cloudflare Durable Object Storage, etc.) - private readonly coreDriver: IStorageDriver; - private _loadedDriver: IStorageDriverNodeAPI | undefined; + // SQLite, Postgres, etc.) + public readonly driver: IStorageDriver; - constructor(coreDriver: IStorageDriver) { - this.coreDriver = coreDriver; + constructor(driver: IStorageDriver) { + this.driver = driver; } // ------------------------------------------------------------------------- // Public API (for Storage) // ------------------------------------------------------------------------- - get loadedDriver(): IStorageDriverNodeAPI { - if (this._loadedDriver === undefined) { - throw new Error("Cannot access tree before it's been loaded"); - } - return this._loadedDriver; - } - // REFACTOR NOTE: Eventually raw_iter_nodes has to be removed here raw_iter_nodes(): Iterable<[string, SerializedCrdt]> { - return this.coreDriver.raw_iter_nodes(); - } - - /** - * Load the room data from object storage into memory. Persisted room - * data consists of the main node map, which represents the Liveblocks - * Storage tree, and special keys where we store usage metrics, or room - * metadata. - */ - // XXXX Now that the driver has become fully sync, we no longer need this - // .load() construct. We should be able to refactor it away. - // eslint-disable-next-line @typescript-eslint/require-await - async load(logger: Logger): Promise { - this._loadedDriver = this.coreDriver.load_nodes_api(logger); - } - - unload(): void { - this._loadedDriver = undefined; + return this.driver.raw_iter_nodes(); } /** @@ -189,14 +163,14 @@ export class Storage { } private applyCreateOp(op: CreateOp & HasOpId): ApplyOpResult { - if (this.loadedDriver.has_node(op.id)) { + if (this.driver.has_node(op.id)) { // Node already exists, the operation is ignored return ignore(op); } const node = nodeFromCreateChildOp(op); - const parent = this.loadedDriver.get_node(node.parentId); + const parent = this.driver.get_node(node.parentId); if (parent === undefined) { // Parent does not exist because the op is invalid or because it was deleted in race condition. return ignore(op); @@ -216,7 +190,7 @@ export class Storage { case CrdtType.MAP: // Children of maps and objects require no special needs - this.loadedDriver.set_child(op.id, node, true); + this.driver.set_child(op.id, node, true); return accept(op); case CrdtType.LIST: @@ -267,15 +241,15 @@ export class Storage { const deletedId = op.deletedId !== undefined && op.deletedId !== op.id && - this.loadedDriver.get_node(op.deletedId)?.parentId === node.parentId + this.driver.get_node(op.deletedId)?.parentId === node.parentId ? op.deletedId : undefined; if (deletedId !== undefined) { - this.loadedDriver.delete_node(deletedId); + this.driver.delete_node(deletedId); } - const prevItemId = this.loadedDriver.get_child_at( + const prevItemId = this.driver.get_child_at( node.parentId, node.parentKey ); @@ -289,7 +263,7 @@ export class Storage { }; } - this.loadedDriver.set_child(op.id, node, true); + this.driver.set_child(op.id, node, true); return accept(op, fix); } else { @@ -319,17 +293,17 @@ export class Storage { private applyDeleteObjectKeyOp( op: DeleteObjectKeyOp & HasOpId ): ApplyOpResult { - this.loadedDriver.delete_child_key(op.id, op.key); + this.driver.delete_child_key(op.id, op.key); return accept(op); } private applyUpdateObjectOp(op: UpdateObjectOp & HasOpId): ApplyOpResult { - this.loadedDriver.set_object_data(op.id, op.data, true); + this.driver.set_object_data(op.id, op.data, true); return accept(op); } private applyDeleteCrdtOp(op: DeleteCrdtOp & HasOpId): ApplyOpResult { - this.loadedDriver.delete_node(op.id); + this.driver.delete_node(op.id); return accept(op); } @@ -371,7 +345,7 @@ export class Storage { if (key !== node.parentKey) { node = { ...node, parentKey: key }; } - this.loadedDriver.set_child(id, node); + this.driver.set_child(id, node); return node.parentKey; } @@ -386,13 +360,13 @@ export class Storage { * Returns the final key that was used for the insertion. */ private appendToList(id: string, node: SerializedChild): string { - const lastPos = this.loadedDriver.get_last_sibling(node.parentId); + const lastPos = this.driver.get_last_sibling(node.parentId); const preferredPos = asPos(node.parentKey); const finalKey = lastPos === undefined || preferredPos > lastPos ? preferredPos : makePosition(lastPos); - this.loadedDriver.set_child( + this.driver.set_child( id, finalKey !== node.parentKey ? { ...node, parentKey: finalKey } : node ); @@ -412,12 +386,12 @@ export class Storage { * a no-op for non-list items. */ private moveToPosInList(id: string, targetKey: string): string | undefined { - const node = this.loadedDriver.get_node(id); + const node = this.driver.get_node(id); if (node?.parentId === undefined) { return; /* reject */ } - if (this.loadedDriver.get_node(node.parentId)?.type !== CrdtType.LIST) { + if (this.driver.get_node(node.parentId)?.type !== CrdtType.LIST) { // SetParentKeyOp is a no-op for all nodes, except list items return; /* reject */ } @@ -430,7 +404,7 @@ export class Storage { // First, compute the key to use to insert this node const key = this.findFreeListPosition(node.parentId, asPos(targetKey)); if (key !== node.parentKey) { - this.loadedDriver.move_sibling(id, key); + this.driver.move_sibling(id, key); } return key; } @@ -443,12 +417,12 @@ export class Storage { * to use as parentKey. */ private findFreeListPosition(parentId: string, parentPos: Pos): Pos { - if (!this.loadedDriver.has_child_at(parentId, parentPos)) { + if (!this.driver.has_child_at(parentId, parentPos)) { return parentPos; } const currPos = parentPos; - const nextPos = this.loadedDriver.get_next_sibling(parentId, currPos); + const nextPos = this.driver.get_next_sibling(parentId, currPos); if (nextPos !== undefined) { return makePosition(currPos, nextPos); // Between current and next } else { diff --git a/packages/liveblocks-server/src/YjsStorage.ts b/packages/liveblocks-server/src/YjsStorage.ts index b8a48b9cf91..d37de0f8d2c 100644 --- a/packages/liveblocks-server/src/YjsStorage.ts +++ b/packages/liveblocks-server/src/YjsStorage.ts @@ -15,6 +15,8 @@ * along with this program. If not, see . */ +import { createHash } from "node:crypto"; + import { Base64 } from "js-base64"; import { nanoid } from "nanoid"; import * as Y from "yjs"; @@ -34,9 +36,15 @@ export class YjsStorage { private readonly driver: IStorageDriver; private readonly updateCountThreshold: number; - private readonly doc: Y.Doc = new Y.Doc(); // the root document + /** + * The root Y.Doc instance, which may not have been hydrated from storage + * yet. Use getRootDoc() instead, which lazily hydrates it on first access — + * only touch this field directly when the unhydrated instance is fine + * (e.g. identity checks, subdoc traversal). + */ + private readonly rawRootDoc: Y.Doc = new Y.Doc(); private readonly lastSnapshotById = new Map(); - private readonly initPromisesById: Map = new Map(); + private readonly docsById: Map = new Map(); private readonly storedKeysById: Map = new Map(); constructor( @@ -45,7 +53,7 @@ export class YjsStorage { ) { this.driver = driver; this.updateCountThreshold = updateCountThreshold; - this.doc.on("subdocs", ({ removed }) => { + this.rawRootDoc.on("subdocs", ({ removed }) => { removed.forEach((subdoc: Y.Doc) => { subdoc.destroy(); // will remove listeners }); @@ -56,9 +64,33 @@ export class YjsStorage { // Public API // ------------------------------------------------------------------------------------ - // eslint-disable-next-line @typescript-eslint/require-await - public async getYDoc(logger: Logger, docId: YDocId): Promise { - return this.loadDocByIdIfNotAlreadyLoaded(logger, docId); + public getYDoc(logger: Logger, docId: YDocId): Y.Doc { + if (docId !== ROOT_YDOC_ID) { + // Subdocs hang off the root doc, so make sure that one is loaded first + // (otherwise the subdoc lookup below would come up empty) + this.getRootDoc(logger); + } + + let loaded = this.docsById.get(docId); + let doc = + docId === ROOT_YDOC_ID ? this.rawRootDoc : this.findYSubdocByGuid(docId); + if (!doc) { + // An API call can load a subdoc without the root doc being loaded, we account for that by just instantiating a doc here. + doc = new Y.Doc(); + } + if (loaded === undefined) { + loaded = this._loadYDocFromDurableStorage(doc, docId); + this.docsById.set(docId, loaded); + } + return loaded; + } + + /** + * Returns the root Y.Doc, lazily loading it from storage first if it + * hasn't been loaded yet during this instance's lifetime. + */ + private getRootDoc(logger: Logger): Y.Doc { + return this.getYDoc(logger, ROOT_YDOC_ID); } /** @@ -67,30 +99,27 @@ export class YjsStorage { * @param stateVector a base64 encoded target state vector created by running Y.encodeStateVector(Doc) on the client * @returns a base64 encoded array of YJS updates */ - public async getYDocUpdate( + public getYDocUpdate( logger: Logger, stateVector: string = "", guid?: Guid, isV2: boolean = false - ): Promise { - const update = await this.getYDocUpdateBinary( - logger, - stateVector, - guid, - isV2 - ); + ): string | null { + const update = this.getYDocUpdateBinary(logger, stateVector, guid, isV2); if (!update) return null; return Base64.fromUint8Array(update); } - public async getYDocUpdateBinary( + public getYDocUpdateBinary( logger: Logger, stateVector: string = "", guid?: Guid, isV2: boolean = false - ): Promise | null> { + ): Uint8Array | null { const doc = - guid !== undefined ? await this.getYSubdoc(logger, guid) : this.doc; + guid !== undefined + ? this.getYSubdoc(logger, guid) + : this.getRootDoc(logger); if (!doc) { return null; } @@ -110,29 +139,25 @@ export class YjsStorage { return Y.encodeStateAsUpdate(doc, encodedTargetVector); } - public async getYStateVector( - logger: Logger, - guid?: Guid - ): Promise { + public getYStateVector(logger: Logger, guid?: Guid): string | null { const doc = - guid !== undefined ? await this.getYSubdoc(logger, guid) : this.doc; + guid !== undefined + ? this.getYSubdoc(logger, guid) + : this.getRootDoc(logger); if (!doc) { return null; } return Base64.fromUint8Array(Y.encodeStateVector(doc)); } - public async getSnapshotHash( + public getSnapshotHash( logger: Logger, - options: { - guid?: Guid; - isV2?: boolean; - } - ): Promise { + options: { guid?: Guid; isV2?: boolean } + ): string | null { const doc = options.guid !== undefined - ? await this.getYSubdoc(logger, options.guid) - : this.doc; + ? this.getYSubdoc(logger, options.guid) + : this.getRootDoc(logger); if (!doc) { return null; } @@ -146,14 +171,16 @@ export class YjsStorage { * isUpdated: true if the update had an effect on the YDoc * snapshotHash: the hash of the new snapshot */ - public async addYDocUpdate( + public addYDocUpdate( logger: Logger, update: string | Uint8Array, guid?: Guid, isV2?: boolean - ): Promise<{ isUpdated: boolean; snapshotHash: string }> { + ): { isUpdated: boolean; snapshotHash: string } { const doc = - guid !== undefined ? await this.getYSubdoc(logger, guid) : this.doc; + guid !== undefined + ? this.getYSubdoc(logger, guid) + : this.getRootDoc(logger); if (!doc) { throw new Error(`YDoc with guid ${guid} not found`); } @@ -175,7 +202,7 @@ export class YjsStorage { return { isUpdated: updated, - snapshotHash: await this.calculateSnapshotHash(afterSnapshot, { isV2 }), + snapshotHash: this.calculateSnapshotHash(afterSnapshot, { isV2 }), }; } catch (e) { // The only reason this would happen is if a user would send bad data @@ -186,40 +213,6 @@ export class YjsStorage { } } - public loadDocByIdIfNotAlreadyLoaded(logger: Logger, docId: YDocId): Y.Doc { - let loaded = this.initPromisesById.get(docId); - let doc = docId === ROOT_YDOC_ID ? this.doc : this.findYSubdocByGuid(docId); - if (!doc) { - // An API call can load a subdoc without the root doc (this._doc) being loaded, we account for that by just instantiating a doc here. - doc = new Y.Doc(); - } - if (loaded === undefined) { - loaded = this._loadYDocFromDurableStorage(logger, doc, docId); - this.initPromisesById.set(docId, loaded); - } - return loaded; - } - - // XXXX We should now be able to remove this asyncness here too - // eslint-disable-next-line @typescript-eslint/require-await - public async load(logger: Logger): Promise { - this.loadDocByIdIfNotAlreadyLoaded(logger, ROOT_YDOC_ID); - } - - /** - * Unloads the Yjs documents from memory. - */ - public unload(): void { - // YYY Implement this later! - // YYY We're currently never unloading data read into memory, but let's - // sync this with the .unload() method from Storage, so there will not be - // any surprises here later! - // - // this.doc = new Y.Doc(); - // this.initPromisesById.clear(); - // this.lastSnapshotById.clear(); - } - // ------------------------------------------------------------------------------------ // Private APIs // ------------------------------------------------------------------------------------ @@ -227,7 +220,7 @@ export class YjsStorage { // NOTE: We could instead store the hash of snapshot instead of the whole snapshot to optimize memory usage. private _getOrPutLastSnapshot(doc: Y.Doc): Y.Snapshot { const docId: YDocId = - doc.guid === this.doc.guid ? ROOT_YDOC_ID : (doc.guid as Guid); + doc.guid === this.rawRootDoc.guid ? ROOT_YDOC_ID : (doc.guid as Guid); const snapshot = this.lastSnapshotById.get(docId); if (snapshot) { return snapshot; @@ -238,7 +231,7 @@ export class YjsStorage { // NOTE: We could instead store the hash of snapshot instead of the whole snapshot to optimize memory usage. private _putLastSnapshot(doc: Y.Doc): Y.Snapshot { const docId: YDocId = - doc.guid === this.doc.guid ? ROOT_YDOC_ID : (doc.guid as Guid); + doc.guid === this.rawRootDoc.guid ? ROOT_YDOC_ID : (doc.guid as Guid); const snapshot = Y.snapshot(doc); this.lastSnapshotById.set(docId, snapshot); return snapshot; @@ -258,11 +251,7 @@ export class YjsStorage { this.storedKeysById.set(docId, [newKey]); }; - private _loadYDocFromDurableStorage = ( - _logger: Logger, - doc: Y.Doc, - docId: YDocId - ): Y.Doc => { + private _loadYDocFromDurableStorage = (doc: Y.Doc, docId: YDocId): Y.Doc => { const docUpdates = Object.fromEntries(this.driver.iter_y_updates(docId)); const updates = Object.values(docUpdates); const beforeSize = updates.reduce((acc, update) => acc + update.length, 0); @@ -284,7 +273,7 @@ export class YjsStorage { }; private findYSubdocByGuid(guid: Guid): Y.Doc | null { - for (const subdoc of this.doc.getSubdocs()) { + for (const subdoc of this.rawRootDoc.getSubdocs()) { if (subdoc.guid === guid) { return subdoc; } @@ -292,28 +281,26 @@ export class YjsStorage { return null; } - private async calculateSnapshotHash( + private calculateSnapshotHash( snapshot: Y.Snapshot, { isV2 }: { isV2?: boolean } - ): Promise { + ): string { const encodedSnapshot = isV2 ? Y.encodeSnapshotV2(snapshot) : Y.encodeSnapshot(snapshot); - return Base64.fromUint8Array( - new Uint8Array( - await crypto.subtle.digest("SHA-256", new Uint8Array(encodedSnapshot)) - ) - ); + return createHash("sha256").update(encodedSnapshot).digest("base64"); } // gets a subdoc, it will be loaded if not already loaded - // eslint-disable-next-line @typescript-eslint/require-await - private async getYSubdoc(logger: Logger, guid: Guid): Promise { + private getYSubdoc(logger: Logger, guid: Guid): Y.Doc | null { + // Subdocs hang off the root doc, so make sure that one is loaded first + this.getRootDoc(logger); + const subdoc = this.findYSubdocByGuid(guid); if (!subdoc) { return null; } - this.loadDocByIdIfNotAlreadyLoaded(logger, guid); + this.getYDoc(logger, guid); return subdoc; } @@ -327,7 +314,7 @@ export class YjsStorage { // this will be easy for sqlite drivers, but not for the KV driver const v1update = isV2 ? Y.convertUpdateFormatV2ToV1(update) : update; const docId: YDocId = - doc.guid === this.doc.guid ? ROOT_YDOC_ID : (doc.guid as Guid); + doc.guid === this.rawRootDoc.guid ? ROOT_YDOC_ID : (doc.guid as Guid); const storedKeys = this.storedKeysById.get(docId); diff --git a/packages/liveblocks-server/src/interfaces/IStorageDriver.ts b/packages/liveblocks-server/src/interfaces/IStorageDriver.ts index 6031f10c2f2..c7ce20d72a1 100644 --- a/packages/liveblocks-server/src/interfaces/IStorageDriver.ts +++ b/packages/liveblocks-server/src/interfaces/IStorageDriver.ts @@ -27,7 +27,6 @@ import type { } from "@liveblocks/core"; import type { YDocId } from "~/decoders/y-types"; -import type { Logger } from "~/lib/Logger"; import type { Feed, FeedMessage, jstring, LeasedSession, Pos } from "~/types"; /** @@ -108,14 +107,24 @@ export interface IReadableSnapshot { } /** - * CRDT node storage with synchronous reads and async persistence. + * Persistent storage backend for Liveblocks room data: CRDT nodes, metadata, + * actor IDs, and Yjs updates. + * + * The node methods will initialize lazily (but synchronously): on first node + * access, the driver loads/repairs its node state internally, at most once per + * instance. DANGEROUSLY_reset_nodes() invalidates that state, after which the + * next access transparently re-runs the load/repair sanitization. * * INVARIANTS: * - A virtual root node `{type: OBJECT, data: {}}` always exists. * - All non-root nodes have parentId and parentKey forming a tree. - * - Reads reflect writes immediately, before the returned Promise resolves. + * - Reads reflect writes immediately. */ -export interface IStorageDriverNodeAPI { +export interface IStorageDriver { + // --------------------------------------------------------------------------- + // Node APIs + // --------------------------------------------------------------------------- + /** * Return the node with the given id, or undefined if no such node exists. * Must always return a valid root node for id="root", even if empty. @@ -232,35 +241,20 @@ export interface IStorageDriverNodeAPI { * access. */ get_snapshot(lowMemory?: boolean): IReadableSnapshot; -} - -/** - * Persistent storage backend for Liveblocks room data: CRDT nodes, metadata, - * actor IDs, and Yjs updates. All methods may be async. - */ -export interface IStorageDriver { - // --------------------------------------------------------------------------- - // Node APIs - // --------------------------------------------------------------------------- /** - * Load all nodes from storage, validate/repair corruptions (orphans, cycles, - * conflicting siblings), and return an IStorageDriverNodeAPI for operations. - * - * After DANGEROUSLY_reset_nodes(), any previously-loaded instance is - * invalid—must call this again to get a fresh one. + * Release any lazily-initialized in-memory node state (caches/indexes + * built on first node access), freeing it up for garbage collection. + * Persisted data is NOT touched — the next node access transparently + * re-initializes. (Contrast with DANGEROUSLY_reset_nodes(), which deletes + * the actual data.) */ - // XXXX Now that this method has become sync, we could rip it out entirely and - // just move the methods from the IStorageDriverNodeAPI interface up to - // IStorageDriver. The main reason this interface existed was to support the - // KV case which was asynchronous. None of our remaining implementations are - // async any longer, so... YAGNI! - load_nodes_api(logger: Logger): IStorageDriverNodeAPI; + reinitialize(): void; /** * Delete all CRDT nodes and replace them with the given document. * Does NOT affect metadata, actor IDs, or Yjs updates. - * Invalidates any previously-loaded IStorageDriverNodeAPI. + * Invalidates the driver's internally loaded node state. * * Pass `{ liveblocksType: "LiveObject", data: {} }` to reset to an empty root. */ diff --git a/packages/liveblocks-server/src/interfaces/index.ts b/packages/liveblocks-server/src/interfaces/index.ts index a61985b9d81..46cce78e962 100644 --- a/packages/liveblocks-server/src/interfaces/index.ts +++ b/packages/liveblocks-server/src/interfaces/index.ts @@ -19,7 +19,6 @@ export type { IServerWebSocket } from "./IServerWebSocket"; export type { IReadableSnapshot, IStorageDriver, - IStorageDriverNodeAPI, ListFeedMessagesOptions, ListFeedMessagesResult, ListFeedsOptions, diff --git a/packages/liveblocks-server/src/lib/tryCatch.ts b/packages/liveblocks-server/src/lib/tryCatch.ts index 9ef42be79b5..4af3a73599e 100644 --- a/packages/liveblocks-server/src/lib/tryCatch.ts +++ b/packages/liveblocks-server/src/lib/tryCatch.ts @@ -39,13 +39,38 @@ * } * doAnotherThing(result); * + * When given a sync callback, the tuple is returned synchronously (no await + * needed): + * + * const [result, error] = tryCatch(() => doSomethingSync()); + * */ -export async function tryCatch( - promise: Promise | (() => Promise) | (() => T) -): Promise<[T, undefined] | [undefined, E]> { +function isThenable(value: T | PromiseLike): value is PromiseLike { + return ( + value !== null && + (typeof value === "object" || typeof value === "function") && + typeof (value as { then?: unknown }).then === "function" + ); +} + +export function tryCatch( + promise: PromiseLike | (() => PromiseLike) +): Promise<[T, undefined] | [undefined, E]>; +export function tryCatch( + fn: () => T +): [T, undefined] | [undefined, E]; +export function tryCatch( + promise: PromiseLike | (() => PromiseLike) | (() => T) +): Promise<[T, undefined] | [undefined, E]> | [T, undefined] | [undefined, E] { try { - const data = await (typeof promise === "function" ? promise() : promise); - return [data, undefined]; + const result = typeof promise === "function" ? promise() : promise; + if (isThenable(result)) { + return Promise.resolve(result).then( + (data: T): [T, undefined] => [data, undefined], + (error: unknown): [undefined, E] => [undefined, error as E] + ); + } + return [result, undefined]; } catch (error) { return [undefined, error as E]; } diff --git a/packages/liveblocks-server/src/plugins/InMemoryDriver.ts b/packages/liveblocks-server/src/plugins/InMemoryDriver.ts index de6ea7cac01..bef786b293d 100644 --- a/packages/liveblocks-server/src/plugins/InMemoryDriver.ts +++ b/packages/liveblocks-server/src/plugins/InMemoryDriver.ts @@ -42,7 +42,6 @@ import { plainLsonToNodeStream } from "~/formats/PlainLson"; import type { IReadableSnapshot, IStorageDriver, - IStorageDriverNodeAPI, LeasedSession, ListFeedMessagesOptions, ListFeedMessagesResult, @@ -126,6 +125,28 @@ function hasStaticDataAt( ); } +/** + * The lazily-initialized part of the driver that implements all node-related + * APIs. + */ +type NodesAPI = Pick< + IStorageDriver, + | "get_node" + | "iter_nodes" + | "iter_nodes_optimized" + | "has_node" + | "get_child_at" + | "has_child_at" + | "get_next_sibling" + | "get_last_sibling" + | "set_child" + | "move_sibling" + | "delete_node" + | "delete_child_key" + | "set_object_data" + | "get_snapshot" +>; + /** * Implements the most basic in-memory store. Used if no explicit store is * provided. @@ -133,6 +154,7 @@ function hasStaticDataAt( export class InMemoryDriver implements IStorageDriver { private _nextActor; private _nodes: NodeMap; + private _nodesApi?: NodesAPI; private _metadb: Map; private _ydb: Map; private _leasedSessions: Map; @@ -161,8 +183,16 @@ export class InMemoryDriver implements IStorageDriver { return this._nodes[Symbol.iterator](); } + reinitialize(): void { + this._nodesApi = undefined; + } + /** Deletes all nodes and replaces them with the given document. */ DANGEROUSLY_reset_nodes(doc: PlainLsonObject) { + // Invalidate the cached node API: its reverse-lookup index is built from + // the node map we're about to replace. The next access rebuilds it. + this.reinitialize(); + this._nodes.clear(); for (const [id, node] of plainLsonToNodeStream(doc)) { this._nodes.set(id, node); @@ -475,8 +505,80 @@ export class InMemoryDriver implements IStorageDriver { this._ydb.clear(); } - // Intercept load_nodes_api to add caching layer - load_nodes_api(): IStorageDriverNodeAPI { + // --------------------------------------------------------------------------- + // Node APIs + // --------------------------------------------------------------------------- + + /** + * The lazily-built node API. Building it ensures the root node exists and + * constructs the reverse-lookup index. Invalidated by + * DANGEROUSLY_reset_nodes(), after which the next access rebuilds it. + */ + private get loadedApi(): NodesAPI { + return (this._nodesApi ??= this._loadNodesApi()); + } + + get_node(id: string): SerializedCrdt | undefined { + return this.loadedApi.get_node(id); + } + + iter_nodes(): NodeStream { + return this.loadedApi.iter_nodes(); + } + + iter_nodes_optimized(): Iterable> { + return this.loadedApi.iter_nodes_optimized(); + } + + has_node(id: string): boolean { + return this.loadedApi.has_node(id); + } + + get_child_at(parentId: string, parentKey: string): string | undefined { + return this.loadedApi.get_child_at(parentId, parentKey); + } + + has_child_at(parentId: string, parentKey: string): boolean { + return this.loadedApi.has_child_at(parentId, parentKey); + } + + get_next_sibling(parentId: string, pos: Pos): Pos | undefined { + return this.loadedApi.get_next_sibling(parentId, pos); + } + + get_last_sibling(parentId: string): Pos | undefined { + return this.loadedApi.get_last_sibling(parentId); + } + + set_child(id: string, node: SerializedChild, allowOverwrite?: boolean): void { + this.loadedApi.set_child(id, node, allowOverwrite); + } + + move_sibling(id: string, newPos: Pos): void { + this.loadedApi.move_sibling(id, newPos); + } + + delete_node(id: string): void { + this.loadedApi.delete_node(id); + } + + delete_child_key(id: string, key: string): void { + this.loadedApi.delete_child_key(id, key); + } + + set_object_data( + id: string, + data: JsonObject, + allowOverwrite?: boolean + ): void { + this.loadedApi.set_object_data(id, data, allowOverwrite); + } + + get_snapshot(lowMemory?: boolean): IReadableSnapshot { + return this.loadedApi.get_snapshot(lowMemory); + } + + private _loadNodesApi(): NodesAPI { // For the in-memory backend, this._nodes IS the "on-disk" storage, // so we operate on it directly (no separate cache needed). const nodes = this._nodes; @@ -656,7 +758,7 @@ export class InMemoryDriver implements IStorageDriver { } } - const api: IStorageDriverNodeAPI = { + const api: NodesAPI = { /** * Return the node with the given id, or undefined if no such node exists. * Must always return a valid root node for id="root", even if empty. diff --git a/packages/liveblocks-server/test-d/server.test-d.ts b/packages/liveblocks-server/test-d/server.test-d.ts index 6603ac286b1..c00e740a52e 100644 --- a/packages/liveblocks-server/test-d/server.test-d.ts +++ b/packages/liveblocks-server/test-d/server.test-d.ts @@ -45,7 +45,7 @@ async () => { const ticket = room.createTicket({ meta: { my: "session-meta" } }); // Room.startBrowserSession() API - expectType(await room.startBrowserSession(ticket, socket)); + expectType(room.startBrowserSession(ticket, socket)); // Room.endBrowserSession() API expectType( diff --git a/packages/liveblocks-server/test/MetadataDB.test.ts b/packages/liveblocks-server/test/MetadataDB.test.ts index a131626fbb4..70fc75a7fd3 100644 --- a/packages/liveblocks-server/test/MetadataDB.test.ts +++ b/packages/liveblocks-server/test/MetadataDB.test.ts @@ -20,25 +20,11 @@ import { CrdtType } from "@liveblocks/core"; import { integer, number, string } from "decoders"; import { describe, expect, test } from "vitest"; -import { Logger as LoggerImpl, LogLevel, LogTarget } from "~/lib/Logger"; import { makeMetadataDB } from "~/MetadataDB"; import { makeNewInMemoryDriver } from "~/plugins/InMemoryDriver"; const rounded = number.transform((n) => Math.round(n)); -class VoidTarget extends LogTarget { - constructor() { - super((LogLevel.ERROR as number) + 1); - } - - log(): void { - /* Do nothing */ - } -} - -/** A black hole, where logs disappear beyond the event horizon */ -const blackHole = new LoggerImpl(new VoidTarget()); - describe("test in-memory driver", () => { test("basic", () => { const driver = makeNewInMemoryDriver(); @@ -47,8 +33,7 @@ describe("test in-memory driver", () => { // Before loading, root node should not exist expect(new Map(driver.raw_iter_nodes())).toEqual(new Map()); // After loading, root node should exist - const nodesApi = driver.load_nodes_api(blackHole); - expect(new Map(nodesApi.iter_nodes())).toEqual( + expect(new Map(driver.iter_nodes())).toEqual( new Map([["root", { type: CrdtType.OBJECT, data: {} }]]) ); }); @@ -96,7 +81,6 @@ describe("test in-memory driver", () => { test("two namespaces of the same type don't conflict", () => { const driver = makeNewInMemoryDriver(); const metadataDB = makeMetadataDB(driver); - const nodeDriver = driver.load_nodes_api(blackHole); metadataDB.put("foo", null); metadataDB.put("foo", 123); @@ -115,8 +99,8 @@ describe("test in-memory driver", () => { parentId: "root", parentKey: "b", }; - nodeDriver.set_child("foo", node1); - nodeDriver.set_child("bar", node2); + driver.set_child("foo", node1); + driver.set_child("bar", node2); // Namespaces don't conflict expect(metadataDB.get("foo")).toEqual("bar"); @@ -129,7 +113,7 @@ describe("test in-memory driver", () => { ["bar", node2], ]) ); - expect(new Map(nodeDriver.iter_nodes())).toEqual( + expect(new Map(driver.iter_nodes())).toEqual( new Map([ ["root", { type: CrdtType.OBJECT, data: {} }], ["foo", node1], @@ -142,9 +126,7 @@ describe("test in-memory driver", () => { expect(metadataDB.get("foo")).toEqual(undefined); expect(new Map(driver.raw_iter_nodes()).get("foo")).toEqual(node1); expect( - new Map( - driver.load_nodes_api(blackHole).iter_nodes() - ).get("foo") + new Map(driver.iter_nodes()).get("foo") ).toEqual(node1); }); }); diff --git a/packages/liveblocks-server/test/lib/tryCatch.test.ts b/packages/liveblocks-server/test/lib/tryCatch.test.ts new file mode 100644 index 00000000000..abd60c8de45 --- /dev/null +++ b/packages/liveblocks-server/test/lib/tryCatch.test.ts @@ -0,0 +1,113 @@ +/** + * Copyright (c) Liveblocks Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, test } from "vitest"; + +import { tryCatch } from "~/lib/tryCatch"; + +describe("tryCatch", () => { + test("resolving promise", async () => { + const [data, error] = await tryCatch(Promise.resolve(42)); + expect(data).toEqual(42); + expect(error).toBeUndefined(); + }); + + test("rejecting promise", async () => { + const err = new Error("nope"); + const [data, error] = await tryCatch(Promise.reject(err)); + expect(data).toBeUndefined(); + expect(error).toBe(err); + }); + + test("async function that returns", async () => { + const [data, error] = await tryCatch(() => Promise.resolve("hi")); + expect(data).toEqual("hi"); + expect(error).toBeUndefined(); + }); + + test("async function that throws", async () => { + const err = new Error("nope"); + const [data, error] = await tryCatch(() => Promise.reject(err)); + expect(data).toBeUndefined(); + expect(error).toBe(err); + }); + + test("promise-likes (thenables) are treated as async, like real promises", async () => { + const thenable: PromiseLike = { + then(onFulfilled) { + return Promise.resolve(42).then(onFulfilled); + }, + }; + const [data, error] = await tryCatch(() => thenable); + expect(data).toEqual(42); + expect(error).toBeUndefined(); + }); + + test("rejecting promise-likes (thenables) are caught, like real promises", async () => { + const err = new Error("nope"); + const thenable: PromiseLike = { + then(onFulfilled, onRejected) { + return Promise.reject(err).then(onFulfilled, onRejected); + }, + }; + const [data, error] = await tryCatch(() => thenable); + expect(data).toBeUndefined(); + expect(error).toBe(err); + }); + + test("sync callback returns a sync tuple, not a promise", () => { + const result = tryCatch(() => 42); + expect(result).toEqual([42, undefined]); + }); + + test("throwing sync callback returns a sync tuple, not a promise", () => { + const err = new Error("nope"); + const result = tryCatch((): number => { + throw err; + }); + expect(result).toEqual([undefined, err]); + }); + + test("function that throws synchronously, before creating a promise", async () => { + const err = new Error("nope"); + // Caveat: tryCatch cannot know this callback was *supposed* to return + // a promise, so the error tuple is returned synchronously here, despite + // the Promise return type. Awaiting it (like all call sites do) works + // either way. + const [data, error] = await tryCatch((): Promise => { + throw err; // No promise is ever created + }); + expect(data).toBeUndefined(); + expect(error).toBe(err); + }); + + test("non-Error throw values are captured as-is", () => { + const [data, error] = tryCatch((): number => { + throw "a string, not an Error"; + }); + expect(data).toBeUndefined(); + expect(error).toEqual("a string, not an Error"); + }); + + test("falsy resolved values are still successes", async () => { + for (const value of [undefined, null, 0, "", false]) { + const [data, error] = await tryCatch(Promise.resolve(value)); + expect(data).toBe(value); + expect(error).toBeUndefined(); + } + }); +}); diff --git a/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts b/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts index cd09f99278c..5674b1561c3 100644 --- a/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts +++ b/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts @@ -74,8 +74,7 @@ import * as Y from "yjs"; import type { Guid, Pos } from "~"; import type { YDocId } from "~/decoders/y-types"; -import type { IStorageDriver, IStorageDriverNodeAPI } from "~/interfaces"; -import type { Logger } from "~/lib/Logger"; +import type { IStorageDriver } from "~/interfaces"; import { Logger as LoggerImpl, LogLevel, LogTarget } from "~/lib/Logger"; import { quote } from "~/lib/text"; import type { @@ -111,15 +110,9 @@ function bytesToB64(bytes: Uint8Array): string { /** Empty document for resetting storage in tests */ const EMPTY_DOC: PlainLsonObject = { liveblocksType: "LiveObject", data: {} }; -function asPromise(fn: () => Awaitable): Promise { - return (async () => fn())(); -} - -/** - * Expect-throw assertion that works with both sync and async functions. - */ -function expectToThrow(fn: () => Awaitable, errorPattern: RegExp) { - return expect(asPromise(() => fn())).rejects.toThrow(errorPattern); +/** Expect-throw assertion. */ +function expectToThrow(fn: () => unknown, errorPattern: RegExp) { + expect(fn).toThrow(errorPattern); } // SYNC-SLOT: directives @@ -155,17 +148,14 @@ function wouldNotOverwriteDefaultDoc(n: { * This way, every test can assume that these "roots" objects * exist, in order to express tests more succinctly. */ -async function withDefaultDocument(driver: IStorageDriver) { +function resetToDefaultNodes(driver: T) { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await write_nodes(db, [ + write_nodes(driver, [ list("0:dl", "root", "l"), map("0:dm", "root", "m"), obj("0:do", {}, "root", "o"), ]); - - return db; } function assert( @@ -194,7 +184,7 @@ function assert( * state. */ export function selfCheck(storage: Storage): void { - const driver: IStorageDriverNodeAPI = storage.loadedDriver; + const driver: IStorageDriver = storage.driver; { // Check in-memory node tree integrity @@ -979,7 +969,7 @@ export function generateArbitraries() { } function getAll(storage: Storage) { - return Array.from(storage.loadedDriver.iter_nodes()); + return Array.from(storage.driver.iter_nodes()); } class VoidTarget extends LogTarget { @@ -994,7 +984,7 @@ class VoidTarget extends LogTarget { function countChildrenOf(storage: Storage, parentId: string): number { let count = 0; - for (const [, node] of storage.loadedDriver.iter_nodes()) { + for (const [, node] of storage.driver.iter_nodes()) { if (node.parentId === parentId) { count++; } @@ -1002,21 +992,21 @@ function countChildrenOf(storage: Storage, parentId: string): number { return count; } -async function write_nodes(db: IStorageDriverNodeAPI, nodeStream: NodeStream) { +function write_nodes(driver: IStorageDriver, nodeStream: NodeStream) { for (const node of nodeStream) { if (isRootStorageNode(node)) { const crdt = node[1]; - await db.set_object_data("root", crdt.data, true); + driver.set_object_data("root", crdt.data, true); } else { const [id, crdt] = node; - await db.set_child(id, crdt, true); + driver.set_child(id, crdt, true); } } } -async function delete_nodes(db: IStorageDriverNodeAPI, ids: Iterable) { +function delete_nodes(driver: IStorageDriver, ids: Iterable) { for (const id of ids) { - await db.delete_node(id); + driver.delete_node(id); } } @@ -1073,11 +1063,10 @@ export function generateFullTestSuite(config: { describe("nodes API impl", () => { test("get_node and iter_nodes with empty store contains root", () => runTest((driver) => { - const db = driver.load_nodes_api(blackHole); - expect(db.get_node("non-existing")).toEqual(undefined); + expect(driver.get_node("non-existing")).toEqual(undefined); // Root node always exists, even in an empty store - expect(db.get_node("root")).toEqual({ + expect(driver.get_node("root")).toEqual({ type: CrdtType.OBJECT, data: {}, }); @@ -1092,9 +1081,8 @@ export function generateFullTestSuite(config: { (key) => { fc.pre(key !== "root"); - const db = driver.load_nodes_api(blackHole); - expect(db.get_node(key)).toEqual(undefined); - expect(Array.from(db.iter_nodes())).toEqual([ + expect(driver.get_node(key)).toEqual(undefined); + expect(Array.from(driver.iter_nodes())).toEqual([ ["root", { type: CrdtType.OBJECT, data: {} }], ]); } @@ -1103,32 +1091,57 @@ export function generateFullTestSuite(config: { )); test("set_object_data + get_node", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.rootNodeTuple(), - async ([key, value]) => { - const db = await withDefaultDocument(driver); + ([key, value]) => { + resetToDefaultNodes(driver); - await db.set_object_data("root", value.data, true); - expect(db.get_node(key)).toEqual(value); + driver.set_object_data("root", value.data, true); + expect(driver.get_node(key)).toEqual(value); } ) ) )); + test("set_object_data: no-op if called on a non-object node", () => + runTest((driver) => { + resetToDefaultNodes(driver); + + const listBefore = driver.get_node("0:dl"); + const mapBefore = driver.get_node("0:dm"); + + // set_object_data on a LiveList is a no-op + driver.set_object_data("0:dl", { foo: 42 }); + expect(driver.get_node("0:dl")).toEqual(listBefore); + + // set_object_data on a LiveMap is a no-op + driver.set_object_data("0:dm", { foo: 42 }); + expect(driver.get_node("0:dm")).toEqual(mapBefore); + + // set_object_data on a LiveObject should work + driver.set_object_data("0:do", { foo: 42 }); + expect(driver.get_node("0:do")).toEqual({ + type: CrdtType.OBJECT, + parentId: "root", + parentKey: "o", + data: { foo: 42 }, + }); + })); + test("set_child + get_node", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.childNodeTuple(), - async ([key, value]) => { - const db = await withDefaultDocument(driver); + ([key, value]) => { + resetToDefaultNodes(driver); - await db.set_child(key, value, true); - expect(db.get_node(key)).toEqual(value); + driver.set_child(key, value, true); + expect(driver.get_node(key)).toEqual(value); } ) ) @@ -1137,50 +1150,50 @@ export function generateFullTestSuite(config: { test("has_node: root always exists", () => runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.has_node("root")).toBe(true); + expect(driver.has_node("root")).toBe(true); })); test("has_node: non-existing node returns false", () => runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.has_node("non-existing")).toBe(false); + expect(driver.has_node("non-existing")).toBe(false); })); test("has_node: returns true after set, false after delete", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.childNodeTuple(), - async ([key, value]) => { - const db = await withDefaultDocument(driver); + ([key, value]) => { + resetToDefaultNodes(driver); - expect(db.has_node(key)).toBe(false); - await db.set_child(key, value, true); - expect(db.has_node(key)).toBe(true); - await db.delete_node(key); - expect(db.has_node(key)).toBe(false); + expect(driver.has_node(key)).toBe(false); + driver.set_child(key, value, true); + expect(driver.has_node(key)).toBe(true); + driver.delete_node(key); + expect(driver.has_node(key)).toBe(false); } ) ) )); test("has_node: consistent with get_node", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream(), - async (entries) => { + (entries) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await write_nodes(db, entries); + + write_nodes(driver, entries); // has_node should be true iff get_node returns a value for (const [key] of entries) { - expect(db.has_node(key)).toBe(db.get_node(key) !== undefined); + expect(driver.has_node(key)).toBe( + driver.get_node(key) !== undefined + ); } } ) @@ -1190,9 +1203,9 @@ export function generateFullTestSuite(config: { test("get_child_at: returns undefined for empty store", () => runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.get_child_at("root", "someKey")).toBe(undefined); - expect(db.get_child_at("non-existing", "someKey")).toBe(undefined); + + expect(driver.get_child_at("root", "someKey")).toBe(undefined); + expect(driver.get_child_at("non-existing", "someKey")).toBe(undefined); })); test("get_child_at: returns undefined for empty store (property)", () => @@ -1204,22 +1217,20 @@ export function generateFullTestSuite(config: { (parentId, parentKey) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.get_child_at(parentId, parentKey)).toBe(undefined); + expect(driver.get_child_at(parentId, parentKey)).toBe(undefined); } ) ) )); test("set_child: throws if parent does not exist (orphan)", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // Trying to add a node with a non-existent parentId should throw - await expectToThrow( + expectToThrow( () => - db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "nonexistent-parent", parentKey: "myList", @@ -1229,14 +1240,13 @@ export function generateFullTestSuite(config: { })); test("set_child: throws if parent does not exist (ref cycle)", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // Trying to add a node with a non-existent parentId should throw - await expectToThrow( + expectToThrow( () => - db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "0:0", parentKey: "myList", @@ -1246,13 +1256,12 @@ export function generateFullTestSuite(config: { })); test("set_child: orphan node is not added after throw", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // Try to add orphan, catch the error try { - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "nonexistent-parent", parentKey: "myList", @@ -1262,15 +1271,15 @@ export function generateFullTestSuite(config: { } // The orphan node should not exist in storage - expect(db.get_node("0:0")).toBe(undefined); + expect(driver.get_node("0:0")).toBe(undefined); })); test("set_child: throws if adding a register under an object", () => - runTest(async (driver) => { - const db = await withDefaultDocument(driver); + runTest((driver) => { + resetToDefaultNodes(driver); // Register under LiveList works - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.REGISTER, parentId: "0:dl", // LiveList parentKey: "r", @@ -1278,7 +1287,7 @@ export function generateFullTestSuite(config: { }); // Register under LiveMap works - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:dm", // LiveMap parentKey: "r", @@ -1286,9 +1295,9 @@ export function generateFullTestSuite(config: { }); // Register under LiveObject does not work - await expectToThrow( + expectToThrow( () => - db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:do", parentKey: "r", @@ -1298,9 +1307,9 @@ export function generateFullTestSuite(config: { ); // Root is also a LiveObject, so this also does not work - await expectToThrow( + expectToThrow( () => - db.set_child("0:3", { + driver.set_child("0:3", { type: CrdtType.REGISTER, parentId: "root", parentKey: "r", @@ -1311,93 +1320,89 @@ export function generateFullTestSuite(config: { })); test("get_child_at: returns child id after set", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - expect(db.get_child_at("root", "myList")).toBe("0:0"); - expect(db.get_child_at("root", "otherKey")).toBe(undefined); + expect(driver.get_child_at("root", "myList")).toBe("0:0"); + expect(driver.get_child_at("root", "otherKey")).toBe(undefined); })); test("get_child_at: returns undefined after delete", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - expect(db.get_child_at("root", "myList")).toBe("0:0"); + expect(driver.get_child_at("root", "myList")).toBe("0:0"); - await db.delete_node("0:0"); - expect(db.get_child_at("root", "myList")).toBe(undefined); + driver.delete_node("0:0"); + expect(driver.get_child_at("root", "myList")).toBe(undefined); })); test("get_child_at: returns undefined after set_object_data would overwrite it", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - expect(db.get_child_at("root", "myList")).toBe("0:0"); + expect(driver.get_child_at("root", "myList")).toBe("0:0"); - await db.set_object_data("root", { myList: [1, 2, 3] }, true); - expect(db.get_child_at("root", "myList")).toBe(undefined); + driver.set_object_data("root", { myList: [1, 2, 3] }, true); + expect(driver.get_child_at("root", "myList")).toBe(undefined); })); test("get_child_at: tracks nested children", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // root -> list -> register - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "hello", }); - expect(db.get_child_at("root", "myList")).toBe("0:0"); - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); - expect(db.get_child_at("0:1", "anything")).toBe(undefined); + expect(driver.get_child_at("root", "myList")).toBe("0:0"); + expect(driver.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); + expect(driver.get_child_at("0:1", "anything")).toBe(undefined); })); test("get_child_at: consistent with iter_nodes", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream(), - async (entries) => { + (entries) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await write_nodes(db, entries); + + write_nodes(driver, entries); // For each node with a parent, get_child_at(parentId, parentKey) should return the node's id - for (const node of db.iter_nodes()) { + for (const node of driver.iter_nodes()) { if (!isRootStorageNode(node)) { const [id, crdt] = node; - expect(db.get_child_at(crdt.parentId, crdt.parentKey)).toBe( - id - ); + expect( + driver.get_child_at(crdt.parentId, crdt.parentKey) + ).toBe(id); } } } @@ -1408,9 +1413,9 @@ export function generateFullTestSuite(config: { test("has_child_at: returns false for empty store", () => runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.has_child_at("root", "someKey")).toBe(false); - expect(db.has_child_at("non-existing", "someKey")).toBe(false); + + expect(driver.has_child_at("root", "someKey")).toBe(false); + expect(driver.has_child_at("non-existing", "someKey")).toBe(false); })); test("has_child_at: returns false for empty store (property)", () => @@ -1422,49 +1427,48 @@ export function generateFullTestSuite(config: { (parentId, parentKey) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.has_child_at(parentId, parentKey)).toBe(false); + + expect(driver.has_child_at(parentId, parentKey)).toBe(false); } ) ) )); test("has_child_at: returns true after set, false after delete", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.has_child_at("root", "myList")).toBe(false); + expect(driver.has_child_at("root", "myList")).toBe(false); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - expect(db.has_child_at("root", "myList")).toBe(true); - expect(db.has_child_at("root", "otherKey")).toBe(false); + expect(driver.has_child_at("root", "myList")).toBe(true); + expect(driver.has_child_at("root", "otherKey")).toBe(false); - await db.delete_node("0:0"); - expect(db.has_child_at("root", "myList")).toBe(false); + driver.delete_node("0:0"); + expect(driver.has_child_at("root", "myList")).toBe(false); })); test("has_child_at: consistent with get_child_at", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream(), arb.key(), arb.key(), - async (entries, parentId, parentKey) => { + (entries, parentId, parentKey) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await write_nodes(db, entries); + + write_nodes(driver, entries); // has_child_at should be true iff get_child_at returns a value - expect(db.has_child_at(parentId, parentKey)).toBe( - db.get_child_at(parentId, parentKey) !== undefined + expect(driver.has_child_at(parentId, parentKey)).toBe( + driver.get_child_at(parentId, parentKey) !== undefined ); } ) @@ -1474,27 +1478,25 @@ export function generateFullTestSuite(config: { test("get_next_sibling: returns undefined for empty parent", () => runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // No children under root, so no next position - expect(db.get_next_sibling("root", FIRST_POSITION)).toBe(undefined); - expect(db.get_next_sibling("non-existing", FIRST_POSITION)).toBe( + expect(driver.get_next_sibling("root", FIRST_POSITION)).toBe(undefined); + expect(driver.get_next_sibling("non-existing", FIRST_POSITION)).toBe( undefined ); })); test("get_next_sibling: returns undefined when no positions after", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // Add a list with one item at FIRST_POSITION - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, @@ -1502,47 +1504,46 @@ export function generateFullTestSuite(config: { }); // No position after 2nd - expect(db.get_next_sibling("0:0", SECOND_POSITION)).toBe(undefined); + expect(driver.get_next_sibling("0:0", SECOND_POSITION)).toBe(undefined); // Position before 2nd should find 1st - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe( + expect(driver.get_next_sibling("0:0", FIRST_POSITION)).toBe( SECOND_POSITION ); })); test("get_next_sibling: finds next position in ordered list", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // Create list with items at FIRST, SECOND, THIRD positions - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, data: "item1", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: THIRD_POSITION, data: "item2", }); - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe( + expect(driver.get_next_sibling("0:0", FIRST_POSITION)).toBe( SECOND_POSITION ); - expect(db.get_next_sibling("0:0", SECOND_POSITION)).toBe( + expect(driver.get_next_sibling("0:0", SECOND_POSITION)).toBe( THIRD_POSITION ); - expect(db.get_next_sibling("0:0", THIRD_POSITION)).toBe(undefined); + expect(driver.get_next_sibling("0:0", THIRD_POSITION)).toBe(undefined); // From 2.5'th position, still goes to 3rd expect( - db.get_next_sibling( + driver.get_next_sibling( "0:0", makePosition(SECOND_POSITION, THIRD_POSITION) ) @@ -1550,147 +1551,144 @@ export function generateFullTestSuite(config: { })); test("get_next_sibling: updates after delete", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item1", }); - await db.set_child("0:3", { + driver.set_child("0:3", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: THIRD_POSITION, data: "item3", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, data: "item2", }); - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe( + expect(driver.get_next_sibling("0:0", FIRST_POSITION)).toBe( SECOND_POSITION ); - await db.delete_node("0:2"); - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe(THIRD_POSITION); + driver.delete_node("0:2"); + expect(driver.get_next_sibling("0:0", FIRST_POSITION)).toBe( + THIRD_POSITION + ); - await db.delete_node("0:3"); - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe(undefined); + driver.delete_node("0:3"); + expect(driver.get_next_sibling("0:0", FIRST_POSITION)).toBe(undefined); })); test("get_last_sibling: returns undefined for empty parent", () => runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.get_last_sibling("root")).toBe(undefined); - expect(db.get_last_sibling("non-existing")).toBe(undefined); + expect(driver.get_last_sibling("root")).toBe(undefined); + expect(driver.get_last_sibling("non-existing")).toBe(undefined); })); test("get_last_sibling: returns the rightmost position", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item1", }); - expect(db.get_last_sibling("0:0")).toBe(FIRST_POSITION); + expect(driver.get_last_sibling("0:0")).toBe(FIRST_POSITION); // Insert later positions out of order; the rightmost one wins - await db.set_child("0:3", { + driver.set_child("0:3", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: THIRD_POSITION, data: "item3", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, data: "item2", }); - expect(db.get_last_sibling("0:0")).toBe(THIRD_POSITION); + expect(driver.get_last_sibling("0:0")).toBe(THIRD_POSITION); })); test("get_last_sibling: updates after delete", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item1", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, data: "item2", }); - expect(db.get_last_sibling("0:0")).toBe(SECOND_POSITION); + expect(driver.get_last_sibling("0:0")).toBe(SECOND_POSITION); - await db.delete_node("0:2"); - expect(db.get_last_sibling("0:0")).toBe(FIRST_POSITION); + driver.delete_node("0:2"); + expect(driver.get_last_sibling("0:0")).toBe(FIRST_POSITION); - await db.delete_node("0:1"); - expect(db.get_last_sibling("0:0")).toBe(undefined); + driver.delete_node("0:1"); + expect(driver.get_last_sibling("0:0")).toBe(undefined); })); test("move: changes parentKey of node", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item", }); - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe(undefined); + expect(driver.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); + expect(driver.get_child_at("0:0", SECOND_POSITION)).toBe(undefined); - await db.move_sibling("0:1", SECOND_POSITION); + driver.move_sibling("0:1", SECOND_POSITION); - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe(undefined); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe("0:1"); + expect(driver.get_child_at("0:0", FIRST_POSITION)).toBe(undefined); + expect(driver.get_child_at("0:0", SECOND_POSITION)).toBe("0:1"); // Verify node data is preserved - const node = db.get_node("0:1"); + const node = driver.get_node("0:1"); expect(node).toEqual({ type: CrdtType.REGISTER, parentId: "0:0", @@ -1700,22 +1698,21 @@ export function generateFullTestSuite(config: { })); test("move: updates get_next_sibling correctly", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item1", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: THIRD_POSITION, @@ -1723,72 +1720,72 @@ export function generateFullTestSuite(config: { }); // Before move: FIRST -> THIRD - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe(THIRD_POSITION); + expect(driver.get_next_sibling("0:0", FIRST_POSITION)).toBe( + THIRD_POSITION + ); // Move first item to second position - await db.move_sibling("0:1", SECOND_POSITION); + driver.move_sibling("0:1", SECOND_POSITION); // After move: SECOND -> THIRD - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe( + expect(driver.get_next_sibling("0:0", FIRST_POSITION)).toBe( SECOND_POSITION ); - expect(db.get_next_sibling("0:0", SECOND_POSITION)).toBe( + expect(driver.get_next_sibling("0:0", SECOND_POSITION)).toBe( THIRD_POSITION ); })); test("move: multiple moves on same node", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item", }); - await db.move_sibling("0:1", SECOND_POSITION); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe("0:1"); + driver.move_sibling("0:1", SECOND_POSITION); + expect(driver.get_child_at("0:0", SECOND_POSITION)).toBe("0:1"); - await db.move_sibling("0:1", THIRD_POSITION); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe(undefined); - expect(db.get_child_at("0:0", THIRD_POSITION)).toBe("0:1"); + driver.move_sibling("0:1", THIRD_POSITION); + expect(driver.get_child_at("0:0", SECOND_POSITION)).toBe(undefined); + expect(driver.get_child_at("0:0", THIRD_POSITION)).toBe("0:1"); - await db.move_sibling("0:1", FIRST_POSITION); - expect(db.get_child_at("0:0", THIRD_POSITION)).toBe(undefined); - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); + driver.move_sibling("0:1", FIRST_POSITION); + expect(driver.get_child_at("0:0", THIRD_POSITION)).toBe(undefined); + expect(driver.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); })); test("move: preserves other siblings", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item1", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, data: "item2", }); - await db.set_child("0:3", { + driver.set_child("0:3", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: THIRD_POSITION, @@ -1800,32 +1797,33 @@ export function generateFullTestSuite(config: { FIRST_POSITION, SECOND_POSITION ); - await db.move_sibling("0:2", BETWEEN_FIRST_AND_SECOND); + driver.move_sibling("0:2", BETWEEN_FIRST_AND_SECOND); // Other items should be unaffected - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); - expect(db.get_child_at("0:0", THIRD_POSITION)).toBe("0:3"); - expect(db.get_child_at("0:0", BETWEEN_FIRST_AND_SECOND)).toBe("0:2"); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe(undefined); + expect(driver.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); + expect(driver.get_child_at("0:0", THIRD_POSITION)).toBe("0:3"); + expect(driver.get_child_at("0:0", BETWEEN_FIRST_AND_SECOND)).toBe( + "0:2" + ); + expect(driver.get_child_at("0:0", SECOND_POSITION)).toBe(undefined); })); test("move: throws when target position is occupied", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item1", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, @@ -1833,155 +1831,149 @@ export function generateFullTestSuite(config: { }); // Move 0:1 to SECOND_POSITION should throw since 0:2 is there - await expectToThrow( - () => db.move_sibling("0:1", SECOND_POSITION), + expectToThrow( + () => driver.move_sibling("0:1", SECOND_POSITION), /pos.*already taken/i ); // Both nodes should remain unchanged - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe("0:2"); - expect(db.has_node("0:1")).toBe(true); - expect(db.has_node("0:2")).toBe(true); + expect(driver.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); + expect(driver.get_child_at("0:0", SECOND_POSITION)).toBe("0:2"); + expect(driver.has_node("0:1")).toBe(true); + expect(driver.has_node("0:2")).toBe(true); })); test("delete_child_key: removes static data key from object", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_object_data("root", { a: 1, b: 2, c: 3 }); + driver.set_object_data("root", { a: 1, b: 2, c: 3 }); - expect(db.get_node("root")).toEqual({ + expect(driver.get_node("root")).toEqual({ type: CrdtType.OBJECT, data: { a: 1, b: 2, c: 3 }, }); - await db.delete_child_key("root", "b"); + driver.delete_child_key("root", "b"); - expect(db.get_node("root")).toEqual({ + expect(driver.get_node("root")).toEqual({ type: CrdtType.OBJECT, data: { a: 1, c: 3 }, }); })); test("delete_child_key: removes child node", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - expect(db.has_node("0:0")).toBe(true); - expect(db.get_child_at("root", "myList")).toBe("0:0"); + expect(driver.has_node("0:0")).toBe(true); + expect(driver.get_child_at("root", "myList")).toBe("0:0"); - await db.delete_child_key("root", "myList"); + driver.delete_child_key("root", "myList"); - expect(db.has_node("0:0")).toBe(false); - expect(db.get_child_at("root", "myList")).toBe(undefined); + expect(driver.has_node("0:0")).toBe(false); + expect(driver.get_child_at("root", "myList")).toBe(undefined); })); test("delete_child_key: removes child node and its descendants recursively", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // root -> list -> register - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item1", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, data: "item2", }); - expect(db.has_node("0:0")).toBe(true); - expect(db.has_node("0:1")).toBe(true); - expect(db.has_node("0:2")).toBe(true); + expect(driver.has_node("0:0")).toBe(true); + expect(driver.has_node("0:1")).toBe(true); + expect(driver.has_node("0:2")).toBe(true); - await db.delete_child_key("root", "myList"); + driver.delete_child_key("root", "myList"); - expect(db.has_node("0:0")).toBe(false); - expect(db.has_node("0:1")).toBe(false); - expect(db.has_node("0:2")).toBe(false); + expect(driver.has_node("0:0")).toBe(false); + expect(driver.has_node("0:1")).toBe(false); + expect(driver.has_node("0:2")).toBe(false); })); test("delete_child_key: no-op for non-existing key", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_object_data("root", { a: 1 }); + driver.set_object_data("root", { a: 1 }); // Should not throw - await db.delete_child_key("root", "nonexistent"); - await db.delete_child_key("nonexistent-node", "somekey"); + driver.delete_child_key("root", "nonexistent"); + driver.delete_child_key("nonexistent-node", "somekey"); - expect(db.get_node("root")).toEqual({ + expect(driver.get_node("root")).toEqual({ type: CrdtType.OBJECT, data: { a: 1 }, }); })); test("delete_child_key: preserves sibling data and nodes", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_object_data("root", { a: 1, b: 2 }); - await db.set_child("0:0", { + driver.set_object_data("root", { a: 1, b: 2 }); + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "list1", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "list2", }); - await db.delete_child_key("root", "a"); - await db.delete_child_key("root", "list1"); + driver.delete_child_key("root", "a"); + driver.delete_child_key("root", "list1"); // Siblings should be preserved - const rootNode = db.get_node("root"); + const rootNode = driver.get_node("root"); expect(rootNode).toEqual({ type: CrdtType.OBJECT, data: { b: 2 }, }); - expect(db.has_node("0:0")).toBe(false); - expect(db.has_node("0:1")).toBe(true); - expect(db.get_child_at("root", "list2")).toBe("0:1"); + expect(driver.has_node("0:0")).toBe(false); + expect(driver.has_node("0:1")).toBe(true); + expect(driver.get_child_at("root", "list2")).toBe("0:1"); })); test("set + iter_nodes", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeMap(), - async (entries) => { + (entries) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await write_nodes(db, entries as NodeStream); + write_nodes(driver, entries as NodeStream); // iter_nodes includes all inserted children - for (const [key, value] of db.iter_nodes()) { + for (const [key, value] of driver.iter_nodes()) { if (entries.has(key)) { expect(value).toEqual(entries.get(key)!); } else { @@ -1994,33 +1986,33 @@ export function generateFullTestSuite(config: { )); test("set_child overwrites existing node, delete_node removes it", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.key(), arb.serializedChild(), arb.serializedChild(), - async (key, value1, value2) => { + (key, value1, value2) => { fc.pre(key !== "root"); - const db = await withDefaultDocument(driver); + resetToDefaultNodes(driver); - await db.set_child(key, value1, true); - await db.set_child(key, value2, true); // overwrite same key - expect(db.get_node(key)).toEqual(value2); + driver.set_child(key, value1, true); + driver.set_child(key, value2, true); // overwrite same key + expect(driver.get_node(key)).toEqual(value2); - await delete_nodes(db, [key]); - expect(db.get_node(key)).toEqual(undefined); + delete_nodes(driver, [key]); + expect(driver.get_node(key)).toEqual(undefined); } ) ) )); test("delete_nodes (single key)", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( // Generate 3 non-root pairs where all node IDs and parentIds are unique // (6 unique values total), ensuring no cross-references between entries fc @@ -2041,25 +2033,26 @@ export function generateFullTestSuite(config: { ]).size === 6 ), - async ([entry1, entry2, entry3]) => { - const db = await withDefaultDocument(driver); - await write_nodes(db, [entry1, entry2, entry3]); + ([entry1, entry2, entry3]) => { + resetToDefaultNodes(driver); + + write_nodes(driver, [entry1, entry2, entry3]); const key1 = entry1[0]; const key2 = entry2[0]; const key3 = entry3[0]; - expect(new Set(imap(db.iter_nodes(), ([k]) => k))).toEqual( + expect(new Set(imap(driver.iter_nodes(), ([k]) => k))).toEqual( new Set([...KNOWN_DOC_KEYS, key1, key2, key3]) ); - await delete_nodes(db, [key1]); - await delete_nodes(db, [key1]); // Deleting twice has no effect - expect(new Set(imap(db.iter_nodes(), ([k]) => k))).toEqual( + delete_nodes(driver, [key1]); + delete_nodes(driver, [key1]); // Deleting twice has no effect + expect(new Set(imap(driver.iter_nodes(), ([k]) => k))).toEqual( new Set([...KNOWN_DOC_KEYS, key2, key3]) ); - await delete_nodes(db, [key2]); - await delete_nodes(db, [key2]); // Deleting twice has no effect - expect(new Set(imap(db.iter_nodes(), ([k]) => k))).toEqual( + delete_nodes(driver, [key2]); + delete_nodes(driver, [key2]); // Deleting twice has no effect + expect(new Set(imap(driver.iter_nodes(), ([k]) => k))).toEqual( new Set([...KNOWN_DOC_KEYS, key3]) ); } @@ -2068,41 +2061,41 @@ export function generateFullTestSuite(config: { )); test("deleting the root is a no-op", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_object_data("root", { foo: 42 }); + + driver.set_object_data("root", { foo: 42 }); // Try to delete the root node - await db.delete_node("root"); + driver.delete_node("root"); // ...it should not work - expect(Array.from(db.iter_nodes())).toEqual([ + expect(Array.from(driver.iter_nodes())).toEqual([ ["root", { type: CrdtType.OBJECT, data: { foo: 42 } }], // ^^^^^^^^^^^ Still there ]); })); test("write_nodes + get_node loop", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeMap(), - async (entries) => { + (entries) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(Array.from(db.iter_nodes())).toEqual([ + + expect(Array.from(driver.iter_nodes())).toEqual([ ["root", { type: CrdtType.OBJECT, data: {} }], ]); // Write all the entries (can have dupes) - await write_nodes(db, entries as NodeStream); + write_nodes(driver, entries as NodeStream); // Check that get_node returns expected results const map = new Map(entries); // de-dupe for (const [key, expected] of map) { - expect(db.get_node(key)).toEqual(expected); + expect(driver.get_node(key)).toEqual(expected); } } ) @@ -2110,22 +2103,22 @@ export function generateFullTestSuite(config: { )); test("write_nodes + iter_nodes", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeMap(), - async (entries) => { + (entries) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(Array.from(db.iter_nodes())).toEqual([ + + expect(Array.from(driver.iter_nodes())).toEqual([ ["root", { type: CrdtType.OBJECT, data: {} }], ]); // Write all the entries (can have dupes) - await write_nodes(db, entries as NodeStream); + write_nodes(driver, entries as NodeStream); - for (const [key, value] of db.iter_nodes()) { + for (const [key, value] of driver.iter_nodes()) { if (entries.has(key)) { expect(value).toEqual(entries.get(key)!); } else { @@ -2136,7 +2129,7 @@ export function generateFullTestSuite(config: { // Check that readone will also have the same results const map = new Map(entries); // de-dupe for (const [key, expected] of map) { - expect(db.get_node(key)).toEqual(expected); + expect(driver.get_node(key)).toEqual(expected); } } ) @@ -2144,26 +2137,26 @@ export function generateFullTestSuite(config: { )); test("iter_nodes_optimized agrees with iter_nodes (cross-driver parity)", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeMap(), - async (entries) => { + (entries) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await write_nodes(db, entries as NodeStream); + + write_nodes(driver, entries as NodeStream); // Parse the wire tuples back into CompactNodes and compare // against what the canonical nodeStreamToCompactNodes would // produce from iter_nodes(). Catches any divergence between the // optimized SQL path and the readable JS path (string encoding, // escaping, missing fields, type coercion, etc.). - const fromWire = Array.from(db.iter_nodes_optimized()).map( + const fromWire = Array.from(driver.iter_nodes_optimized()).map( (t) => JSON.parse(t) as unknown ); const fromIter = Array.from( - nodeStreamToCompactNodes(db.iter_nodes()) + nodeStreamToCompactNodes(driver.iter_nodes()) ) as unknown[]; const byId = (a: unknown, b: unknown) => (a as [string])[0].localeCompare((b as [string])[0]); @@ -2175,21 +2168,21 @@ export function generateFullTestSuite(config: { )); test("delete_nodes", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream(), - async (entries) => { + (entries) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await write_nodes(db, entries); - await delete_nodes( - db, + write_nodes(driver, entries); + + delete_nodes( + driver, new Map(entries).keys() ); - for (const [key] of db.iter_nodes()) { + for (const [key] of driver.iter_nodes()) { // Only "root" should remain - all entries should be deleted expect(key).toEqual("root"); } @@ -2199,31 +2192,31 @@ export function generateFullTestSuite(config: { )); test("delete_nodes (with more than 64 keys)", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream({ depthSize: "xlarge", maxKeys: 100, maxLength: 100, }), - async (entries) => { - const db = await withDefaultDocument(driver); - await write_nodes(db, entries); + (entries) => { + resetToDefaultNodes(driver); + write_nodes(driver, entries); - expect(Array.from(db.iter_nodes()).length).toBeGreaterThan(2); // "root" + at least 1 node + expect(Array.from(driver.iter_nodes()).length).toBeGreaterThan(2); // "root" + at least 1 node const originalRootData = ( - db.get_node("root") as SerializedRootObject + driver.get_node("root") as SerializedRootObject ).data; - await delete_nodes( - db, + delete_nodes( + driver, new Map(entries).keys() ); - await delete_nodes(db, KNOWN_DOC_KEYS); // finally also delete the keys from the default document - expect(Array.from(db.iter_nodes())).toEqual([ + delete_nodes(driver, KNOWN_DOC_KEYS); // finally also delete the keys from the default document + expect(Array.from(driver.iter_nodes())).toEqual([ ["root", { type: CrdtType.OBJECT, data: originalRootData }], ]); } @@ -2233,21 +2226,21 @@ export function generateFullTestSuite(config: { )); test("get_snapshot: get_node returns same data as driver (when unchanged)", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream(), - async (entries) => { - const db = await withDefaultDocument(driver); - await write_nodes(db, entries); + (entries) => { + resetToDefaultNodes(driver); + write_nodes(driver, entries); - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); // Snapshot should return the same data as the driver for (const [id] of entries) { expect(snapshot.get_node(id)).toEqual( - db.get_node(id) as SerializedChild + driver.get_node(id) as SerializedChild ); } } @@ -2256,20 +2249,20 @@ export function generateFullTestSuite(config: { )); test("get_snapshot: iter_children returns same data as driver (when unchanged)", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream(), - async (entries) => { - const db = await withDefaultDocument(driver); - await write_nodes(db, entries); + (entries) => { + resetToDefaultNodes(driver); + write_nodes(driver, entries); - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); // Build expected children of root from driver's nodes const expected: [string, string][] = []; - for (const node of db.iter_nodes()) { + for (const node of driver.iter_nodes()) { if (!isRootStorageNode(node)) { const [childId, crdt] = node; if (crdt.parentId === "root") { @@ -2293,11 +2286,11 @@ export function generateFullTestSuite(config: { // This test verifies snapshot isolation. Writes after taking a snapshot // should NOT be visible in the snapshot. test("get_snapshot: new nodes after snapshot are not visible", () => - runTest(async (driver) => { - const db = await withDefaultDocument(driver); + runTest((driver) => { + resetToDefaultNodes(driver); // Take snapshot before adding new node - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); // Add a new node after taking the snapshot const newNode = { @@ -2305,10 +2298,10 @@ export function generateFullTestSuite(config: { parentId: "root", parentKey: "newList", } as const; - await db.set_child("si:new-node-id", newNode); + driver.set_child("si:new-node-id", newNode); // The new node should exist in the driver - expect(db.get_node("si:new-node-id")).toEqual(newNode); + expect(driver.get_node("si:new-node-id")).toEqual(newNode); // But NOT in the snapshot (snapshot isolation) expect(() => snapshot.get_node("si:new-node-id")).toThrow(); @@ -2321,8 +2314,8 @@ export function generateFullTestSuite(config: { // This test verifies snapshot isolation. Deletes after taking a snapshot // should NOT affect the snapshot. test("get_snapshot: deleted nodes after snapshot are still visible", () => - runTest(async (driver) => { - const db = await withDefaultDocument(driver); + runTest((driver) => { + resetToDefaultNodes(driver); // Add a node first const node = { @@ -2330,19 +2323,19 @@ export function generateFullTestSuite(config: { parentId: "root", parentKey: "myMap", } as const; - await db.set_child("si:node-to-delete", node); + driver.set_child("si:node-to-delete", node); // Take snapshot before deleting - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); // Verify node exists in both driver and snapshot - expect(db.get_node("si:node-to-delete")).toEqual(node); + expect(driver.get_node("si:node-to-delete")).toEqual(node); // Delete the node after taking the snapshot - await db.delete_node("si:node-to-delete"); + driver.delete_node("si:node-to-delete"); // The node should NOT exist in the driver anymore - expect(db.get_node("si:node-to-delete")).toBeUndefined(); + expect(driver.get_node("si:node-to-delete")).toBeUndefined(); // But should STILL exist in the snapshot (snapshot isolation) const snapshotChildren = [...snapshot.iter_children("root")]; @@ -2354,19 +2347,19 @@ export function generateFullTestSuite(config: { })); test("get_snapshot: delete_child_key on static data does not affect snapshot", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_object_data("root", { a: 1, b: 2, c: 3 }); + + driver.set_object_data("root", { a: 1, b: 2, c: 3 }); // Take snapshot before deleting a key - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); // Delete a static data key after taking the snapshot - await db.delete_child_key("root", "b"); + driver.delete_child_key("root", "b"); // Driver should reflect the deletion - expect(db.get_node("root")).toEqual({ + expect(driver.get_node("root")).toEqual({ type: CrdtType.OBJECT, data: { a: 1, c: 3 }, }); @@ -2379,19 +2372,19 @@ export function generateFullTestSuite(config: { })); test("get_snapshot: set_object_data after snapshot does not affect snapshot", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_object_data("root", { a: 1, b: 2 }); + + driver.set_object_data("root", { a: 1, b: 2 }); // Take snapshot before mutating - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); // Mutate data after taking the snapshot - await db.set_object_data("root", { a: 1, b: 2, c: 3 }); + driver.set_object_data("root", { a: 1, b: 2, c: 3 }); // Driver should reflect the update - expect(db.get_node("root")).toEqual({ + expect(driver.get_node("root")).toEqual({ type: CrdtType.OBJECT, data: { a: 1, b: 2, c: 3 }, }); @@ -2404,23 +2397,23 @@ export function generateFullTestSuite(config: { })); test("get_snapshot: iter_all returns same nodes as driver", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream(), - async (entries) => { - const db = await withDefaultDocument(driver); - await write_nodes(db, entries); + (entries) => { + resetToDefaultNodes(driver); + write_nodes(driver, entries); - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); const snapshotNodes = new Map([ ...snapshot.iter_all(), ]); // Should contain the same node ids as the driver const driverNodes = new Map([ - ...db.iter_nodes(), + ...driver.iter_nodes(), ]); expect(snapshotNodes.size).toBe(driverNodes.size); for (const [id, crdt] of driverNodes) { @@ -2432,8 +2425,8 @@ export function generateFullTestSuite(config: { )); test("get_snapshot: iter_all has snapshot isolation", () => - runTest(async (driver) => { - const db = await withDefaultDocument(driver); + runTest((driver) => { + resetToDefaultNodes(driver); // Add a node const node = { @@ -2441,13 +2434,13 @@ export function generateFullTestSuite(config: { parentId: "root", parentKey: "myList", } as const; - await db.set_child("si:before-snap", node); + driver.set_child("si:before-snap", node); // Take snapshot - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); // Add another node after snapshot - await db.set_child("si:after-snap", { + driver.set_child("si:after-snap", { type: CrdtType.MAP, parentId: "root", parentKey: "myMap", @@ -2465,420 +2458,13 @@ export function generateFullTestSuite(config: { test("get_snapshot: iter_all on empty room returns only root", () => runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - const snapshot = db.get_snapshot(); + + const snapshot = driver.get_snapshot(); const nodes = [...snapshot.iter_all()]; expect(nodes).toEqual([["root", { type: CrdtType.OBJECT, data: {} }]]); })); }); - describe("synchronous read-after-write semantics", () => { - // These tests verify that writes are immediately visible to synchronous reads, - // even before the returned Promise is awaited. This is critical behavior for - // the INewNodeStorageDriver API. - - test("set: read sees new node before await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - const node = { - type: CrdtType.LIST, - parentId: "root", - parentKey: "myList", - } as const; - - // Call set but don't await yet - const p$ = db.set_child("0:0", node); - - // Read should see the node immediately (synchronously) - expect(db.get_node("0:0")).toEqual(node); - expect(db.has_node("0:0")).toBe(true); - expect(db.get_child_at("root", "myList")).toBe("0:0"); - expect(db.has_child_at("root", "myList")).toBe(true); - - // After await, should still see the node - await p$; - expect(db.get_node("0:0")).toEqual(node); - expect(db.has_node("0:0")).toBe(true); - expect(db.get_child_at("root", "myList")).toBe("0:0"); - expect(db.has_child_at("root", "myList")).toBe(true); - })); - - test("delete: read sees deletion before await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // First create a node - await db.set_child("0:0", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "myList", - }); - expect(db.has_node("0:0")).toBe(true); - - // Call delete but don't await yet - const p$ = db.delete_node("0:0"); - - // Read should NOT see the node immediately (synchronously) - expect(db.get_node("0:0")).toBe(undefined); - expect(db.has_node("0:0")).toBe(false); - expect(db.get_child_at("root", "myList")).toBe(undefined); - expect(db.has_child_at("root", "myList")).toBe(false); - - // After await, should still NOT see the node - await p$; - expect(db.get_node("0:0")).toBe(undefined); - expect(db.has_node("0:0")).toBe(false); - expect(db.get_child_at("root", "myList")).toBe(undefined); - expect(db.has_child_at("root", "myList")).toBe(false); - })); - - test("move: read sees new position before await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // Create a list with an item - await db.set_child("0:0", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "myList", - }); - await db.set_child("0:1", { - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "item", - }); - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe(undefined); - - // Call move but don't await yet - const p$ = db.move_sibling("0:1", SECOND_POSITION); - - // Read should see the new position immediately (synchronously) - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe(undefined); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe("0:1"); - expect(db.get_node("0:1")).toEqual({ - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: SECOND_POSITION, - data: "item", - }); - - // After await, should still see the new position - await p$; - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe(undefined); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe("0:1"); - expect(db.get_node("0:1")).toEqual({ - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: SECOND_POSITION, - data: "item", - }); - })); - - test("set_object_data: read sees new data before await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // Start with empty root - expect(db.get_node("root")).toEqual({ - type: CrdtType.OBJECT, - data: {}, - }); - - // Call set_object_data but don't await yet - const p$ = db.set_object_data("root", { foo: 42, bar: "hello" }); - - // Read should see the new data immediately (synchronously) - expect(db.get_node("root")).toEqual({ - type: CrdtType.OBJECT, - data: { foo: 42, bar: "hello" }, - }); - - // After await, should still see the new data - await p$; - expect(db.get_node("root")).toEqual({ - type: CrdtType.OBJECT, - data: { foo: 42, bar: "hello" }, - }); - })); - - test("set_object_data: no-op if called on a non-object node", () => - runTest(async (driver) => { - const db = await withDefaultDocument(driver); - - const listBefore = db.get_node("0:dl"); - const mapBefore = db.get_node("0:dm"); - - // set_object_data on a LiveList is a no-op - await db.set_object_data("0:dl", { foo: 42 }); - expect(db.get_node("0:dl")).toEqual(listBefore); - - // set_object_data on a LiveMap is a no-op - await db.set_object_data("0:dm", { foo: 42 }); - expect(db.get_node("0:dm")).toEqual(mapBefore); - - // set_object_data on a LiveObject should work - await db.set_object_data("0:do", { foo: 42 }); - expect(db.get_node("0:do")).toEqual({ - type: CrdtType.OBJECT, - parentId: "root", - parentKey: "o", - data: { foo: 42 }, - }); - })); - - test("delete_child_key: read sees deletion before await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // Set up root with static data and a child node - await db.set_object_data("root", { a: 1, b: 2 }); - await db.set_child("0:0", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "myList", - }); - - expect(db.get_node("root")).toEqual({ - type: CrdtType.OBJECT, - data: { a: 1, b: 2 }, - }); - expect(db.has_node("0:0")).toBe(true); - - // Call delete_child_key for static data but don't await yet - const p1$ = db.delete_child_key("root", "a"); - - // Read should see the deletion immediately (synchronously) - expect(db.get_node("root")).toEqual({ - type: CrdtType.OBJECT, - data: { b: 2 }, - }); - - await p1$; - - // Call delete_child_key for child node but don't await yet - const p2$ = db.delete_child_key("root", "myList"); - - // Read should see the child deletion immediately (synchronously) - expect(db.has_node("0:0")).toBe(false); - expect(db.get_child_at("root", "myList")).toBe(undefined); - expect(db.has_child_at("root", "myList")).toBe(false); - - // After await, should still see the deletion - await p2$; - expect(db.has_node("0:0")).toBe(false); - expect(db.get_child_at("root", "myList")).toBe(undefined); - expect(db.has_child_at("root", "myList")).toBe(false); - })); - - test("multiple writes: all visible before any await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // Perform multiple writes without awaiting any of them - const p1$ = db.set_child("0:0", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "list1", - }); - const p2$ = db.set_child("0:1", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "list2", - }); - const p3$ = db.set_child("0:2", { - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "item", - }); - - // All writes should be visible synchronously - expect(db.has_node("0:0")).toBe(true); - expect(db.has_node("0:1")).toBe(true); - expect(db.has_node("0:2")).toBe(true); - expect(db.get_child_at("root", "list1")).toBe("0:0"); - expect(db.get_child_at("root", "list2")).toBe("0:1"); - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe("0:2"); - - // iter_nodes should see all nodes - const nodes = new Map(db.iter_nodes()); - expect(nodes.size).toBe(4); // root + 3 nodes - expect(nodes.has("root")).toBe(true); - expect(nodes.has("0:0")).toBe(true); - expect(nodes.has("0:1")).toBe(true); - expect(nodes.has("0:2")).toBe(true); - - // After awaiting, should still see all nodes - await Promise.all([p1$, p2$, p3$]); - expect(db.has_node("0:0")).toBe(true); - expect(db.has_node("0:1")).toBe(true); - expect(db.has_node("0:2")).toBe(true); - })); - - test("interleaved write and delete: reads see correct state", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // Create a node - const p1$ = db.set_child("0:0", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "myList", - }); - - // Should be visible immediately - expect(db.has_node("0:0")).toBe(true); - - // Delete it (without awaiting the create) - const p2$ = db.delete_node("0:0"); - - // Should NOT be visible anymore - expect(db.has_node("0:0")).toBe(false); - expect(db.get_child_at("root", "myList")).toBe(undefined); - - // Re-create it (without awaiting the delete) - const p3$ = db.set_child("0:1", { - type: CrdtType.MAP, - parentId: "root", - parentKey: "myList", - }); - - // Should see the new node - expect(db.has_node("0:1")).toBe(true); - expect(db.get_child_at("root", "myList")).toBe("0:1"); - expect(db.get_node("0:1")).toEqual({ - type: CrdtType.MAP, - parentId: "root", - parentKey: "myList", - }); - - // After awaiting all, state should be consistent - await Promise.all([p1$, p2$, p3$]); - expect(db.has_node("0:0")).toBe(false); - expect(db.has_node("0:1")).toBe(true); - expect(db.get_child_at("root", "myList")).toBe("0:1"); - })); - - test("get_next_sibling: updated before await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // Create a list with two items - await db.set_child("0:0", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "myList", - }); - await db.set_child("0:1", { - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "first", - }); - await db.set_child("0:2", { - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: THIRD_POSITION, - data: "third", - }); - - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe(THIRD_POSITION); - - // Add a node in between without awaiting - const p$ = db.set_child("0:3", { - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: SECOND_POSITION, - data: "second", - }); - - // get_next_sibling should see the new node immediately - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe( - SECOND_POSITION - ); - expect(db.get_next_sibling("0:0", SECOND_POSITION)).toBe( - THIRD_POSITION - ); - - // After await, should still work correctly - await p$; - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe( - SECOND_POSITION - ); - expect(db.get_next_sibling("0:0", SECOND_POSITION)).toBe( - THIRD_POSITION - ); - })); - - test("set with overwrite: read sees update before await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // Create a list to hold registers - await db.set_child("0:0", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "myList", - }); - - // Create initial register in the list - await db.set_child("0:1", { - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "initial", - }); - - expect(db.get_node("0:1")).toEqual({ - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "initial", - }); - - // Overwrite with allowOverwrite=true but don't await - const p$ = db.set_child( - "0:1", - { - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "updated", - }, - true - ); - - // Should see updated value immediately - expect(db.get_node("0:1")).toEqual({ - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "updated", - }); - - // After await, should still see updated value - await p$; - expect(db.get_node("0:1")).toEqual({ - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "updated", - }); - })); - }); - describe("meta API impl", () => { test("get_meta with empty store is undefined", () => runTest((driver) => @@ -2947,18 +2533,16 @@ export function generateFullTestSuite(config: { )); test("put_meta loop + get_meta loop", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.metaEntries(), - async (entries) => { + (entries) => { // Write all the entries (can have dupes) - await Promise.all( - Array.from(entries).map(([key, value]) => - driver.put_meta(key, value) - ) - ); + for (const [key, value] of entries) { + driver.put_meta(key, value); + } // Check that readone will also have the same results const map = new Map(entries); // de-dupe @@ -3015,23 +2599,21 @@ export function generateFullTestSuite(config: { )); test("delete_meta", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.metaPair(), arb.metaPair(), arb.metaPair(), - async (entry1, entry2, entry3) => { + (entry1, entry2, entry3) => { fc.pre(entry1[0] !== entry2[0]); fc.pre(entry1[0] !== entry3[0]); fc.pre(entry2[0] !== entry3[0]); - await Promise.all([ - driver.put_meta(...entry1), - driver.put_meta(...entry2), - driver.put_meta(...entry3), - ]); + driver.put_meta(...entry1); + driver.put_meta(...entry2); + driver.put_meta(...entry3); const key1 = entry1[0]; const key2 = entry2[0]; @@ -3245,14 +2827,14 @@ export function generateFullTestSuite(config: { )); test("delete specific session leaves others intact", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.leasedSessionPair(), arb.leasedSessionPair(), arb.leasedSessionPair(), - async (entry1, entry2, entry3) => { + (entry1, entry2, entry3) => { const [sessionId1, session1] = entry1; const [sessionId2, session2] = entry2; const [sessionId3, session3] = entry3; @@ -3266,11 +2848,9 @@ export function generateFullTestSuite(config: { session1.sessionId = sessionId1; session2.sessionId = sessionId2; session3.sessionId = sessionId3; - await Promise.all([ - driver.put_leased_session(session1), - driver.put_leased_session(session2), - driver.put_leased_session(session3), - ]); + driver.put_leased_session(session1); + driver.put_leased_session(session2); + driver.put_leased_session(session3); // Verify all exist expect(driver.get_leased_session(sessionId1)).toEqual(session1); @@ -3339,28 +2919,24 @@ export function generateFullTestSuite(config: { )); test("concurrent put_leased_session operations", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( fc .array(arb.leasedSessionPair(), { minLength: 3, maxLength: 10 }) .map((x) => new Map(x)) .filter((m) => m.size >= 3), - async (entries) => { - // Put all sessions concurrently - await Promise.all( - Array.from(entries).map(([sessionId, session]) => { - session.sessionId = sessionId; - return driver.put_leased_session(session); - }) - ); + (entries) => { + // Put all sessions + for (const [sessionId, session] of entries) { + session.sessionId = sessionId; + driver.put_leased_session(session); + } // Verify all sessions exist - const results = await Promise.all( - Array.from(entries.keys()).map((sessionId) => - driver.get_leased_session(sessionId) - ) + const results = Array.from(entries.keys()).map((sessionId) => + driver.get_leased_session(sessionId) ); let idx = 0; @@ -3370,11 +2946,9 @@ export function generateFullTestSuite(config: { } // Cleanup: delete all sessions added in this iteration - await Promise.all( - Array.from(entries.keys()).map((sessionId) => - driver.delete_leased_session(sessionId) - ) - ); + for (const sessionId of entries.keys()) { + driver.delete_leased_session(sessionId); + } } ) ) @@ -3893,16 +3467,9 @@ export function generateFullTestSuite(config: { // Create the access layer around the plugin const storage = new Storage(driver); - const logger = { - warn: () => {}, - error: () => {}, - } as unknown as Logger; - return (async () => { - await storage.load(logger); - // Self-check after loading - await selfCheck(storage); + selfCheck(storage); return callback({ storage }); })(); @@ -3926,9 +3493,9 @@ export function generateFullTestSuite(config: { * - If it exists → it MUST be empty * - If it doesn't exist → that's fine too */ - async function assert(s: Storage, expectedNodes: StorageNode[]) { - const memory = Array.from(s.loadedDriver.iter_nodes()); - const storage = Array.from(await s.raw_iter_nodes()); + function assert(s: Storage, expectedNodes: StorageNode[]) { + const memory = Array.from(s.driver.iter_nodes()); + const storage = Array.from(s.raw_iter_nodes()); expectedNodes = [...expectedNodes].sort(cmpById); const inMemoryNodes = [...memory].sort(cmpById); @@ -3965,19 +3532,19 @@ export function generateFullTestSuite(config: { } test("loading storage should not persist empty root node in storage", () => - runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await assert(storage, [rootObj()]); - }); - })); + runTest((driver) => + runWithStorage(driver, ({ storage }) => { + assert(storage, [rootObj()]); + }) + )); test("loading storage should persist root node in storage (with data)", () => { const nodesOnDisk: NodeMap = new Map(); nodesOnDisk.set("root", { data: { a: 1 }, type: CrdtType.OBJECT }); return runTest({ initialNodes: nodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await assert(storage, [rootObj({ a: 1 })]); + await runWithStorage(driver, ({ storage }) => { + assert(storage, [rootObj({ a: 1 })]); }); }); }); @@ -3992,11 +3559,8 @@ export function generateFullTestSuite(config: { }); return runTest({ initialNodes: nodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await assert(storage, [ - obj("0:1", { a: 1 }, "root", "child"), - rootObj(), - ]); + await runWithStorage(driver, ({ storage }) => { + assert(storage, [obj("0:1", { a: 1 }, "root", "child"), rootObj()]); }); }); }); @@ -4019,9 +3583,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Child node wins - "static value" should have been removed - await assert(storage, [ + assert(storage, [ rootObj({ otherkey: "kept" }), // mykey removed from data obj("0:0", { nested: 42 }, "root", "mykey"), // child node preserved ]); @@ -4047,9 +3611,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Register under object is dropped (it's a corruption) - await assert(storage, [ + assert(storage, [ rootObj({ existing: "value" }), // register is gone, not converted ]); }); @@ -4079,9 +3643,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Register under object is dropped - await assert(storage, [ + assert(storage, [ rootObj(), obj("0:0", { existing: "value" }, "root", "child"), ]); @@ -4126,9 +3690,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Entire illegal subtree is dropped - await assert(storage, [rootObj()]); + assert(storage, [rootObj()]); }); } ); @@ -4173,9 +3737,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Only root and list remain, the illegal subtree is dropped - await assert(storage, [ + assert(storage, [ rootObj(), list("0:0", "root", "list"), register("0:1", "0:0", "A0:", "valid register"), @@ -4206,9 +3770,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // The "a" key should be removed from root's data, "b" kept - await assert(storage, [ + assert(storage, [ rootObj({ b: "keep" }), obj("0:0", { nested: "value" }, "root", "a"), ]); @@ -4260,8 +3824,8 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await assert(storage, [ + await runWithStorage(driver, ({ storage }) => { + assert(storage, [ rootObj({ keep: "yes" }), obj("0:0", { nested: "value" }, "root", key), ]); @@ -4311,9 +3875,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Highest node id wins (deterministic across all backends) - await assert(storage, [ + assert(storage, [ rootObj(), obj("1:0", { id: "1:0", winner: true }, "root", "slot1"), obj("1:1", { id: "1:1", winner: true }, "root", "slot2"), @@ -4324,10 +3888,12 @@ export function generateFullTestSuite(config: { }); test("loading storage fixes orphaned nodes", () => { - // DOS-SQLite has a FK constraint (parent_id REFERENCES nodes(id)), so orphaned - // nodes can't exist - the database itself prevents inserting nodes with - // non-existent parents. This test only applies to backends without FK constraints. - if (config.name === "dos-sqlite") return Promise.resolve(); + // The SQLite backends (dos-sqlite, bun-sqlite) have a FK constraint + // (parent_id REFERENCES nodes(id)), so orphaned nodes can't exist - the + // database itself prevents inserting nodes with non-existent parents. + // This test only applies to backends without FK constraints. + if (config.name === "dos-sqlite" || config.name === "bun-sqlite") + return Promise.resolve(); const corruptedNodesOnDisk: NodeMap = new Map(); corruptedNodesOnDisk.set("root", { @@ -4357,22 +3923,21 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Orphans are excluded from in-memory view - await assert(storage, [ - rootObj(), - obj("0:0", {}, "root", "connected"), - ]); + assert(storage, [rootObj(), obj("0:0", {}, "root", "connected")]); }); } ); }); test("loading storage removes node cycles as if the entire tree is orphaned", () => { - // DOS-SQLite has a FK constraint (parent_id REFERENCES nodes(id)), so cycles - // can't be created - inserting a node requires its parent to already exist. - // This test only applies to backends without FK constraints. - if (config.name === "dos-sqlite") return Promise.resolve(); + // The SQLite backends (dos-sqlite, bun-sqlite) have a FK constraint + // (parent_id REFERENCES nodes(id)), so cycles can't be created - + // inserting a node requires its parent to already exist. This test only + // applies to backends without FK constraints. + if (config.name === "dos-sqlite" || config.name === "bun-sqlite") + return Promise.resolve(); const corruptedNodesOnDisk: NodeMap = new Map(); corruptedNodesOnDisk.set("root", { @@ -4408,12 +3973,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Cycles are unreachable from root, treated as orphans - await assert(storage, [ - rootObj(), - obj("0:0", {}, "root", "connected"), - ]); + assert(storage, [rootObj(), obj("0:0", {}, "root", "connected")]); }); } ); @@ -4422,15 +3984,15 @@ export function generateFullTestSuite(config: { test("UpdateObject is a no-op on non-object nodes", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createObjectOp("0:1", "root", "o", { a: 0 }), createListOp("0:2", "root", "l"), createMapOp("0:3", "root", "m"), createRegisterOp("0:4", "0:3", "r", 42), ]); - await assert(storage, [ + assert(storage, [ rootObj(), obj("0:1", { a: 0 }, "root", "o"), list("0:2", "root", "l"), @@ -4439,12 +4001,12 @@ export function generateFullTestSuite(config: { ]); // Calling updateObjectOp on non-objects is a no-op... - await storage.applyOps([updateObjectOp("0:0", { a: 1337 })]); - await storage.applyOps([updateObjectOp("0:2", { a: 1337 })]); - await storage.applyOps([updateObjectOp("0:3", { a: 1337 })]); - await storage.applyOps([updateObjectOp("0:4", { a: 1337 })]); + storage.applyOps([updateObjectOp("0:0", { a: 1337 })]); + storage.applyOps([updateObjectOp("0:2", { a: 1337 })]); + storage.applyOps([updateObjectOp("0:3", { a: 1337 })]); + storage.applyOps([updateObjectOp("0:4", { a: 1337 })]); - await assert(storage, [ + assert(storage, [ rootObj(), obj("0:1", { a: 0 }, "root", "o"), list("0:2", "root", "l"), @@ -4453,9 +4015,9 @@ export function generateFullTestSuite(config: { ]); // ...but calling it on an object will not be - await storage.applyOps([updateObjectOp("0:1", { a: 1337 })]); + storage.applyOps([updateObjectOp("0:1", { a: 1337 })]); - await assert(storage, [ + assert(storage, [ rootObj(), obj("0:1", { a: 1337 }, "root", "o"), list("0:2", "root", "l"), @@ -4467,74 +4029,72 @@ export function generateFullTestSuite(config: { test("UpdateObject missing object should not create a new object", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([updateObjectOp("xxx", { a: 0 })]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([updateObjectOp("xxx", { a: 0 })]); - await assert(storage, [rootObj()]); + assert(storage, [rootObj()]); }); })); test("UpdateObject single property with native value", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([updateObjectOp("root", { a: 0 })]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([updateObjectOp("root", { a: 0 })]); - await assert(storage, [rootObj({ a: 0 })]); + assert(storage, [rootObj({ a: 0 })]); }); })); test("UpdateObject remove child object recursively", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createObjectOp("0:1", "root", "child", { a: 0 }), createObjectOp("0:2", "0:1", "grandChild", { b: 0 }), ]); - await assert(storage, [ + assert(storage, [ rootObj(), obj("0:1", { a: 0 }, "root", "child"), obj("0:2", { b: 0 }, "0:1", "grandChild"), ]); - await storage.applyOps([updateObjectOp("root", { child: null })]); + storage.applyOps([updateObjectOp("root", { child: null })]); - await assert(storage, [rootObj({ child: null })]); + assert(storage, [rootObj({ child: null })]); }); })); test("UpdateObject can be used to update the root object", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await assert(storage, [rootObj()]); + await runWithStorage(driver, ({ storage }) => { + assert(storage, [rootObj()]); - await storage.applyOps([updateObjectOp("root", { a: 0 })]); - await assert(storage, [rootObj({ a: 0 })]); + storage.applyOps([updateObjectOp("root", { a: 0 })]); + assert(storage, [rootObj({ a: 0 })]); - await storage.applyOps([deleteObjectKeyOp("root", "a")]); - await assert(storage, [rootObj()]); + storage.applyOps([deleteObjectKeyOp("root", "a")]); + assert(storage, [rootObj()]); }); })); test("CreateRegister in map should remove previous one", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createMapOp("0:1", "root", "map"), createRegisterOp("0:2", "0:1", "boolean", true), ]); - await assert(storage, [ + assert(storage, [ rootObj(), map("0:1", "root", "map"), register("0:2", "0:1", "boolean", true), ]); - await storage.applyOps([ - createRegisterOp("0:3", "0:1", "boolean", false), - ]); + storage.applyOps([createRegisterOp("0:3", "0:1", "boolean", false)]); - await assert(storage, [ + assert(storage, [ rootObj(), map("0:1", "root", "map"), register("0:3", "0:1", "boolean", false), @@ -4544,11 +4104,11 @@ export function generateFullTestSuite(config: { test("CreateRegister inside a list", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([createListOp("0:0", "root", "items")]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([createListOp("0:0", "root", "items")]); const op = createRegisterOp("1:0", "0:0", FIRST_POSITION, "A"); - const result = await storage.applyOps([op]); + const result = storage.applyOps([op]); expect(result).toEqual([{ action: "accepted", op }]); @@ -4562,14 +4122,14 @@ export function generateFullTestSuite(config: { test("CreateRegister inside a list with a parent key that already exists should fix operation", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createRegisterOp("0:1", "0:0", FIRST_POSITION, "A"), ]); const inputOp = createRegisterOp("1:0", "0:0", FIRST_POSITION, "B"); - const result = await storage.applyOps([inputOp]); + const result = storage.applyOps([inputOp]); expect(result).toMatchObject([ { @@ -4598,8 +4158,8 @@ export function generateFullTestSuite(config: { test("CreateRegister inside a list with a parent key that already exists and set intent should replace existing child", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createRegisterOp("0:1", "0:0", FIRST_POSITION, "A"), ]); @@ -4612,7 +4172,7 @@ export function generateFullTestSuite(config: { "set", "0:1" ); - const result = await storage.applyOps([op]); + const result = storage.applyOps([op]); expect(result).toEqual([{ action: "accepted", op }]); @@ -4626,16 +4186,16 @@ export function generateFullTestSuite(config: { test("CreateRegister inside a list with set intent should set it as child", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([createListOp("0:0", "root", "items")]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([createListOp("0:0", "root", "items")]); // Should only happen when a client has deleted a child while another client replace it with a set const op = createRegisterOp("1:0", "0:0", FIRST_POSITION, "B", "set"); - const result = await storage.applyOps([op]); + const result = storage.applyOps([op]); expect(result).toEqual([{ action: "accepted", op }]); - await assert(storage, [ + assert(storage, [ rootObj(), list("0:0", "root", "items"), register("1:0", "0:0", FIRST_POSITION, "B"), @@ -4645,17 +4205,15 @@ export function generateFullTestSuite(config: { test("CreateRegister when register already exists should ignore op and not throw error", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createMapOp("0:0", "root", "map"), createRegisterOp("0:1", "0:0", "boolean", true), ]); - await storage.applyOps([ - createRegisterOp("0:1", "0:0", "boolean", false), - ]); + storage.applyOps([createRegisterOp("0:1", "0:0", "boolean", false)]); - await assert(storage, [ + assert(storage, [ rootObj(), map("0:0", "root", "map"), register("0:1", "0:0", "boolean", true), @@ -4678,50 +4236,50 @@ export function generateFullTestSuite(config: { ]); return runTest({ initialNodes: nodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { const expectedNodes = [ list("0:0", "root", "list"), register("0:1", "0:0", "reg", 42), rootObj(), ]; - await assert(storage, expectedNodes); + assert(storage, expectedNodes); - await storage.applyOps([createObjectOp("0:2", "0:1", "obj", {})]); - await assert(storage, expectedNodes); + storage.applyOps([createObjectOp("0:2", "0:1", "obj", {})]); + assert(storage, expectedNodes); - await storage.applyOps([createListOp("0:3", "0:1", "list")]); - await assert(storage, expectedNodes); + storage.applyOps([createListOp("0:3", "0:1", "list")]); + assert(storage, expectedNodes); - await storage.applyOps([createMapOp("0:4", "0:1", "map")]); - await assert(storage, expectedNodes); + storage.applyOps([createMapOp("0:4", "0:1", "map")]); + assert(storage, expectedNodes); - await storage.applyOps([ + storage.applyOps([ createRegisterOp("0:5", "0:1", "anotherreg", 1337), ]); - await assert(storage, expectedNodes); + assert(storage, expectedNodes); }); }); }); test("CreateMap in root", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([createMapOp("0:1", "root", "map")]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([createMapOp("0:1", "root", "map")]); - await assert(storage, [rootObj(), map("0:1", "root", "map")]); + assert(storage, [rootObj(), map("0:1", "root", "map")]); }); })); test("CreateMap inside a list with a parent key that already exists should fix operation", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createMapOp("0:1", "0:0", FIRST_POSITION), ]); const inputOp = createMapOp("1:0", "0:0", FIRST_POSITION); - const result = await storage.applyOps([inputOp]); + const result = storage.applyOps([inputOp]); expect(result).toMatchObject([ { @@ -4750,14 +4308,14 @@ export function generateFullTestSuite(config: { test("CreateMap inside a list with a parent key that already exists and set intent should replace existing child", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createMapOp("0:1", "0:0", FIRST_POSITION), ]); const op = createMapOp("1:0", "0:0", FIRST_POSITION, "set", "0:1"); - const result = await storage.applyOps([op]); + const result = storage.applyOps([op]); expect(result).toEqual([{ action: "accepted", op }]); @@ -4771,18 +4329,18 @@ export function generateFullTestSuite(config: { test("CreateMap when map already exists should ignore op and not throw error", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([createMapOp("0:1", "root", "map")]); - await storage.applyOps([createMapOp("0:1", "root", "map")]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([createMapOp("0:1", "root", "map")]); + storage.applyOps([createMapOp("0:1", "root", "map")]); - await assert(storage, [rootObj(), map("0:1", "root", "map")]); + assert(storage, [rootObj(), map("0:1", "root", "map")]); }); })); test("SetParentKey changes positions of list items", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:1", "root", "items"), createRegisterOp("0:2", "0:1", FIRST_POSITION, "A"), createRegisterOp("0:3", "0:1", SECOND_POSITION, "B"), @@ -4790,7 +4348,7 @@ export function generateFullTestSuite(config: { const op = setParentKeyOp("0:2", THIRD_POSITION); - const ops = await storage.applyOps([op]); + const ops = storage.applyOps([op]); expect(ops).toEqual([{ action: "accepted", op }]); }); @@ -4798,15 +4356,15 @@ export function generateFullTestSuite(config: { test("SetParentKey changes positions of list items (with conflict)", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:1", "root", "items"), createRegisterOp("0:2", "0:1", FIRST_POSITION, "A"), createRegisterOp("0:3", "0:1", SECOND_POSITION, "B"), ]); const inputOp = setParentKeyOp("0:2", SECOND_POSITION); - const ops = await storage.applyOps([inputOp]); + const ops = storage.applyOps([inputOp]); expect(ops).toMatchObject([ { @@ -4828,18 +4386,18 @@ export function generateFullTestSuite(config: { test("SetParentKey persists list item position changes to disk", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:1", "root", "items"), createRegisterOp("0:2", "0:1", FIRST_POSITION, "A"), createRegisterOp("0:3", "0:1", SECOND_POSITION, "B"), ]); // Move item from FIRST_POSITION to THIRD_POSITION - await storage.applyOps([setParentKeyOp("0:2", THIRD_POSITION)]); + storage.applyOps([setParentKeyOp("0:2", THIRD_POSITION)]); // selfCheck verifies disk matches memory - await selfCheck(storage); + selfCheck(storage); }); })); @@ -4858,8 +4416,8 @@ export function generateFullTestSuite(config: { async (parentNode) => { await runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ parentNode, createRegisterOp("0:2", parentNode.id, FIRST_POSITION, "A"), createRegisterOp("0:3", parentNode.id, SECOND_POSITION, "B"), @@ -4870,7 +4428,7 @@ export function generateFullTestSuite(config: { // The actual test: setParentKeyOp should be a no-op for all nodes // that aren't list child items const op = setParentKeyOp("0:2", THIRD_POSITION); - const results = await storage.applyOps([op]); + const results = storage.applyOps([op]); // Nothing should have changed! expect(getAll(storage)).toEqual(nodesBefore); @@ -4893,19 +4451,19 @@ export function generateFullTestSuite(config: { ]); return runTest({ initialNodes: nodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Move the map to a new position in the list const op = setParentKeyOp("0:2", SECOND_POSITION); - await storage.applyOps([op]); + storage.applyOps([op]); // Verify the children where not deleted - expect(storage.loadedDriver.get_node("0:3")).toEqual({ + expect(storage.driver.get_node("0:3")).toEqual({ type: CrdtType.REGISTER, parentId: "0:2", parentKey: "a", data: "A", }); - expect(storage.loadedDriver.get_node("0:4")).toEqual({ + expect(storage.driver.get_node("0:4")).toEqual({ type: CrdtType.REGISTER, parentId: "0:2", parentKey: "b", @@ -4917,30 +4475,23 @@ export function generateFullTestSuite(config: { test("CreateObject in root", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ - createObjectOp("0:1", "root", "child", { a: 0 }), - ]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([createObjectOp("0:1", "root", "child", { a: 0 })]); - await assert(storage, [ - rootObj(), - obj("0:1", { a: 0 }, "root", "child"), - ]); + assert(storage, [rootObj(), obj("0:1", { a: 0 }, "root", "child")]); }); })); test("CreateObject in map", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([createMapOp("0:1", "root", "map")]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([createMapOp("0:1", "root", "map")]); - await assert(storage, [rootObj(), map("0:1", "root", "map")]); + assert(storage, [rootObj(), map("0:1", "root", "map")]); - await storage.applyOps([ - createObjectOp("0:2", "0:1", "first", { a: 0 }), - ]); + storage.applyOps([createObjectOp("0:2", "0:1", "first", { a: 0 })]); - await assert(storage, [ + assert(storage, [ rootObj(), map("0:1", "root", "map"), obj("0:2", { a: 0 }, "0:1", "first"), @@ -4950,12 +4501,12 @@ export function generateFullTestSuite(config: { test("CreateObject should ignore op if parent does not exist", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - const result = await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + const result = storage.applyOps([ createObjectOp("1:0", "42:42", "item", {}), ]); - await assert(storage, [rootObj()]); + assert(storage, [rootObj()]); expect(result.filter((r) => r.action !== "ignored")).toEqual([]); }); @@ -4963,14 +4514,14 @@ export function generateFullTestSuite(config: { test("CreateObject inside a list with a parent key that already exists should fix operation", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createObjectOp("0:1", "0:0", FIRST_POSITION, {}), ]); const inputOp = createObjectOp("1:0", "0:0", FIRST_POSITION, {}); - const result = await storage.applyOps([inputOp]); + const result = storage.applyOps([inputOp]); expect(result).toMatchObject([ { @@ -4999,8 +4550,8 @@ export function generateFullTestSuite(config: { test("CreateObject inside a list with a parent key that already exists and set intent should replace existing child", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createObjectOp("0:1", "0:0", FIRST_POSITION, {}), ]); @@ -5013,7 +4564,7 @@ export function generateFullTestSuite(config: { "set", "0:1" ); - const result = await storage.applyOps([op]); + const result = storage.applyOps([op]); expect(result).toEqual([{ action: "accepted", op }]); @@ -5048,15 +4599,15 @@ export function generateFullTestSuite(config: { test("CreateObject inside a list with that matches multiple keys should modify the op with the right subsequent key", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:1", "root", "items"), createObjectOp("0:2", "0:1", FIRST_POSITION, {}), createObjectOp("0:3", "0:1", SECOND_POSITION, {}), ]); const inputOp = createObjectOp("1:0", "0:1", FIRST_POSITION, {}); - const result = await storage.applyOps([inputOp]); + const result = storage.applyOps([inputOp]); // The new expected position is in between the first and second positions const expectedPosition = makePosition( @@ -5083,14 +4634,14 @@ export function generateFullTestSuite(config: { test("DeleteObjectKey from root", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([updateObjectOp("root", { a: 0, b: 0 })]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([updateObjectOp("root", { a: 0, b: 0 })]); - await assert(storage, [rootObj({ a: 0, b: 0 })]); + assert(storage, [rootObj({ a: 0, b: 0 })]); - await storage.applyOps([deleteObjectKeyOp("root", "a")]); + storage.applyOps([deleteObjectKeyOp("root", "a")]); - await assert(storage, [rootObj({ b: 0 })]); + assert(storage, [rootObj({ b: 0 })]); }); })); @@ -5109,18 +4660,18 @@ export function generateFullTestSuite(config: { }); return runTest({ initialNodes: nodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([deleteObjectKeyOp("0:1", "x")]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([deleteObjectKeyOp("0:1", "x")]); - await assert(storage, [obj("0:1", {}, "root", "o"), rootObj()]); + assert(storage, [obj("0:1", {}, "root", "o"), rootObj()]); }); }); }); test("DeleteCrdt with the root node is a no-op (virtual)", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:1", "root", "items"), createRegisterOp("0:2", "0:1", FIRST_POSITION, "A"), ]); @@ -5131,17 +4682,17 @@ export function generateFullTestSuite(config: { register("0:2", "0:1", FIRST_POSITION, "A"), ]; - await assert(storage, expectedNodes); + assert(storage, expectedNodes); - await storage.applyOps([deleteCrdtOp("root")]); - await assert(storage, expectedNodes); + storage.applyOps([deleteCrdtOp("root")]); + assert(storage, expectedNodes); }); })); test("DeleteCrdt with the root node is a no-op (explicit)", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ updateObjectOp("root", { a: 1, b: 2 }), createListOp("0:1", "root", "items"), createRegisterOp("0:2", "0:1", FIRST_POSITION, "A"), @@ -5153,22 +4704,22 @@ export function generateFullTestSuite(config: { register("0:2", "0:1", FIRST_POSITION, "A"), ]; - await assert(storage, expectedNodes); + assert(storage, expectedNodes); - await storage.applyOps([deleteCrdtOp("root")]); - await assert(storage, expectedNodes); + storage.applyOps([deleteCrdtOp("root")]); + assert(storage, expectedNodes); }); })); test("DeleteCrdt should remove link", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:1", "root", "items"), createRegisterOp("0:2", "0:1", FIRST_POSITION, "A"), ]); - await assert(storage, [ + assert(storage, [ rootObj(), list("0:1", "root", "items"), register("0:2", "0:1", FIRST_POSITION, "A"), @@ -5176,23 +4727,23 @@ export function generateFullTestSuite(config: { expect(countChildrenOf(storage, "0:1")).toBe(1); - await storage.applyOps([deleteCrdtOp("0:2")]); + storage.applyOps([deleteCrdtOp("0:2")]); - await assert(storage, [rootObj(), list("0:1", "root", "items")]); + assert(storage, [rootObj(), list("0:1", "root", "items")]); expect(countChildrenOf(storage, "0:1")).toBe(0); }); })); test("DeleteCrdt remove child records recursively with delete record", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:1", "root", "items"), createObjectOp("0:2", "0:1", FIRST_POSITION, {}), createObjectOp("0:3", "0:2", "child", { a: 0 }), ]); - await assert(storage, [ + assert(storage, [ rootObj(), list("0:1", "root", "items"), obj("0:2", {}, "0:1", FIRST_POSITION), @@ -5202,9 +4753,9 @@ export function generateFullTestSuite(config: { expect(countChildrenOf(storage, "0:1")).toBe(1); expect(countChildrenOf(storage, "0:2")).toBe(1); - await storage.applyOps([deleteCrdtOp("0:2")]); + storage.applyOps([deleteCrdtOp("0:2")]); - await assert(storage, [rootObj(), list("0:1", "root", "items")]); + assert(storage, [rootObj(), list("0:1", "root", "items")]); expect(countChildrenOf(storage, "0:1")).toBe(0); expect(countChildrenOf(storage, "0:2")).toBe(0); @@ -5213,7 +4764,7 @@ export function generateFullTestSuite(config: { test("DeleteCrdt remove multiple batch child records recursively with delete record", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { const ops: ClientWireOp[] = [ createListOp("0:1", "root", "items"), createObjectOp("0:2", "0:1", FIRST_POSITION, {}), @@ -5223,35 +4774,35 @@ export function generateFullTestSuite(config: { ops.push(createObjectOp(`0:${i}`, "0:2", `child-${i}`, { a: 0 })); } - await storage.applyOps(ops); + storage.applyOps(ops); - await storage.applyOps([deleteCrdtOp("0:2")]); + storage.applyOps([deleteCrdtOp("0:2")]); - await assert(storage, [rootObj(), list("0:1", "root", "items")]); + assert(storage, [rootObj(), list("0:1", "root", "items")]); }); })); test("CreateList when list already exists should ignore op and not throw error", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([createListOp("0:1", "root", "list")]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([createListOp("0:1", "root", "list")]); - await storage.applyOps([createListOp("0:1", "root", "list")]); + storage.applyOps([createListOp("0:1", "root", "list")]); - await assert(storage, [rootObj(), list("0:1", "root", "list")]); + assert(storage, [rootObj(), list("0:1", "root", "list")]); }); })); test("CreateList inside a list with a parent key that already exists should fix operation", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createListOp("0:1", "0:0", FIRST_POSITION), ]); const inputOp = createListOp("1:0", "0:0", FIRST_POSITION); - const result = await storage.applyOps([inputOp]); + const result = storage.applyOps([inputOp]); expect(result).toMatchObject([ { @@ -5280,14 +4831,14 @@ export function generateFullTestSuite(config: { test("CreateList inside a list with a parent key that already exists and set intent should replace existing child", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createListOp("0:1", "0:0", FIRST_POSITION), ]); const op = createListOp("1:0", "0:0", FIRST_POSITION, "set", "0:1"); - const result = await storage.applyOps([op]); + const result = storage.applyOps([op]); expect(result).toEqual([{ action: "accepted", op }]); @@ -5301,8 +4852,8 @@ export function generateFullTestSuite(config: { test("CreateList inside a list with a parent key that already exists and set intent should replace existing child (even if it got moved in the mean time)", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createListOp("0:1", "0:0", FIRST_POSITION), createListOp("0:2", "0:0", SECOND_POSITION), @@ -5313,14 +4864,14 @@ export function generateFullTestSuite(config: { // - Client B tries to replace the item in first position with a new one // Client A - await storage.applyOps([ + storage.applyOps([ setParentKeyOp("0:1", THIRD_POSITION), setParentKeyOp("0:2", FIRST_POSITION), ]); // Client B const op = createListOp("0:3", "0:0", FIRST_POSITION, "set", "0:1"); - const result = await storage.applyOps([op]); + const result = storage.applyOps([op]); expect(result).toMatchObject([ { @@ -5360,14 +4911,14 @@ export function generateFullTestSuite(config: { test("ensure execution order of Ops is well-defined", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createObjectOp("0:0", "root", "child", { a: 0 }), updateObjectOp("0:0", { a: 1 }), createObjectOp("0:1", "0:0", "a", { b: 0 }), ]); - await assert(storage, [ + assert(storage, [ rootObj(), obj("0:0", {}, "root", "child"), obj("0:1", { b: 0 }, "0:0", "a"), @@ -5379,8 +4930,8 @@ export function generateFullTestSuite(config: { // incoming Ops, ever, get ignored test("ensure no operations get ignored", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - const result = await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + const result = storage.applyOps([ createMapOp("0:0", "root", "map"), createRegisterOp("0:1", "0:0", "first", 0), createRegisterOp("0:2", "0:0", "first", 1), @@ -5398,8 +4949,8 @@ export function generateFullTestSuite(config: { ]); return runTest({ initialNodes: nodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:2", "0:1", "b", "set"), createObjectOp("0:3", "0:2", "c", {}, "set", "0:1"), // ^^^^^ 🔑 @@ -5407,9 +4958,9 @@ export function generateFullTestSuite(config: { // should not cause data corruption! ]); - await selfCheck(storage); + selfCheck(storage); - await assert(storage, [ + assert(storage, [ list("0:1", "root", "a"), rootObj(), list("0:2", "0:1", "b"), @@ -5433,20 +4984,20 @@ export function generateFullTestSuite(config: { ]); return runTest({ initialNodes: nodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await assert(storage, [ + await runWithStorage(driver, ({ storage }) => { + assert(storage, [ obj("0:1", { a: 1, b: 2, c: 3, d: 4, e: 5 }, "root", "obj"), rootObj(), ]); - await storage.applyOps([ + storage.applyOps([ createRegisterOp("0:2", "0:1", "a", "stuff"), // Register Op will get ignored because parent is object createObjectOp("0:3", "0:1", "b", {}), createListOp("0:4", "0:1", "c"), createMapOp("0:5", "0:1", "d"), ]); - await assert(storage, [ + assert(storage, [ obj("0:1", { a: 1, e: 5 }, "root", "obj"), // ^^^^^^^^^^^^^^ The overwritten fields should be gone rootObj(), @@ -5456,7 +5007,7 @@ export function generateFullTestSuite(config: { ]); // Which means that if we remove the children again... - await storage.applyOps([ + storage.applyOps([ deleteCrdtOp("0:2"), deleteCrdtOp("0:3"), deleteCrdtOp("0:4"), @@ -5464,7 +5015,7 @@ export function generateFullTestSuite(config: { ]); // ...the previously masked static fields become visible again to clients - await assert(storage, [ + assert(storage, [ obj("0:1", { a: 1, e: 5 }, "root", "obj"), // ^^^^^^^^^^^^^^ Still gone rootObj(), @@ -5475,14 +5026,14 @@ export function generateFullTestSuite(config: { test("creating registers under objects is impossible", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await assert(storage, [rootObj()]); - await selfCheck(storage); + await runWithStorage(driver, ({ storage }) => { + assert(storage, [rootObj()]); + selfCheck(storage); - await storage.applyOps([createRegisterOp("0:0", "root", "a", 42)]); // 👈 Op should get ignored - await selfCheck(storage); + storage.applyOps([createRegisterOp("0:0", "root", "a", 42)]); // 👈 Op should get ignored + selfCheck(storage); - await assert(storage, [rootObj()]); + assert(storage, [rootObj()]); }); })); }); @@ -5506,7 +5057,7 @@ export function generateFullTestSuite(config: { const yjsStorage = new YjsStorage(driver); // Load the root doc - await yjsStorage.loadDocByIdIfNotAlreadyLoaded(blackHole, guid); + await yjsStorage.getYDoc(blackHole, guid); return await callback({ yjsStorage }); } @@ -5561,9 +5112,9 @@ export function generateFullTestSuite(config: { await runWithYjsStorage(driver, [], async ({ yjsStorage }) => { const update = getSampleYDocUpdate(); await yjsStorage.addYDocUpdate(blackHole, bytesToB64(update)); - await expect( + expect(() => yjsStorage.addYDocUpdate(blackHole, "obviouslynotvalidbase64update") - ).rejects.toThrow( + ).toThrow( "Bad YDoc update. Data is corrupted, or data does not match the encoding." ); const loadedUpdate = await yjsStorage.getYDocUpdate(blackHole); diff --git a/packages/liveblocks-server/test/room.test.ts b/packages/liveblocks-server/test/room.test.ts index 8de34d2d3ba..978d26a25b1 100644 --- a/packages/liveblocks-server/test/room.test.ts +++ b/packages/liveblocks-server/test/room.test.ts @@ -85,37 +85,9 @@ describe("room", () => { } }); - test("loading multiple times is a no-op (but fine)", async () => { - // In series - { - const room = new Room("my-room"); - await room.load(); - await room.load(); - await room.load(); - } - - // Or in parallel - { - const room = new Room("my-room"); - await Promise.all([room.load(), room.load(), room.load()]); - } - }); - - test("loading state", async () => { - const room = new Room("my-room"); - expect(room.loadingState).toBe("initial"); - const promise$ = room.load(); - expect(room.loadingState).toBe("loading"); - await promise$; - expect(room.loadingState).toBe("loaded"); - room.unload(); - expect(room.loadingState).toBe("initial"); - }); - // YYY Ideally, this test would be possible to re-enable test.skip('room will throw "no such session" errors when used before the session is started', async () => { const room = new Room("my-room"); - await room.load(); const ticket = room.createTicket(); const key = ticket.sessionKey; @@ -124,42 +96,39 @@ describe("room", () => { await expectToThrow(() => room.handleData(key, "[]"), "No such session"); }); - test("starting multiple sessions", async () => { + test("starting multiple sessions", () => { const room = new Room("my-room"); - await room.load(); const ticket1 = room.createTicket(); const ticket2 = room.createTicket(); const ticket3 = room.createTicket(); const ticket4 = room.createTicket(); const ticket5 = room.createTicket(); - await room.startBrowserSession(ticket1, new MockServerWebSocket()); - await room.startBrowserSession(ticket2, new MockServerWebSocket()); - await room.startBrowserSession(ticket3, new MockServerWebSocket()); - await room.startBrowserSession(ticket4, new MockServerWebSocket()); - await room.startBrowserSession(ticket5, new MockServerWebSocket()); + room.startBrowserSession(ticket1, new MockServerWebSocket()); + room.startBrowserSession(ticket2, new MockServerWebSocket()); + room.startBrowserSession(ticket3, new MockServerWebSocket()); + room.startBrowserSession(ticket4, new MockServerWebSocket()); + room.startBrowserSession(ticket5, new MockServerWebSocket()); expect(room.numSessions).toEqual(5); }); - test("starting a second session for an actor kicks the first one", async () => { + test("starting a second session for an actor kicks the first one", () => { const room = new Room("my-room"); - await room.load(); const ticket1 = room.createTicket({ actor: 13 as ActorID }); const ticket2 = room.createTicket(); const ticket3 = room.createTicket({ actor: 13 as ActorID }); expect(room.numSessions).toEqual(0); - await room.startBrowserSession(ticket1, new MockServerWebSocket()); + room.startBrowserSession(ticket1, new MockServerWebSocket()); expect(room.numSessions).toEqual(1); - await room.startBrowserSession(ticket2, new MockServerWebSocket()); + room.startBrowserSession(ticket2, new MockServerWebSocket()); expect(room.numSessions).toEqual(2); - await room.startBrowserSession(ticket3, new MockServerWebSocket()); + room.startBrowserSession(ticket3, new MockServerWebSocket()); expect(room.numSessions).toEqual(2); // Session 1 will have been kicked! }); - test("enter + leave", async () => { + test("enter + leave", () => { const room = new Room("my-room"); - await room.load(); const ticket = room.createTicket(); - await room.startBrowserSession(ticket, new MockServerWebSocket()); + room.startBrowserSession(ticket, new MockServerWebSocket()); room.endBrowserSession(ticket.sessionKey, 1001, "bleh"); }); }); @@ -257,10 +226,9 @@ describe("maintenance mode", () => { }); describe("room (w/ last actor ID)", () => { - test("many simultaneous calls will all get a unique actor (with specific start)", async () => { + test("many simultaneous calls will all get a unique actor (with specific start)", () => { const storage = setupStorage({ actorId: 7 }); const room = new Room("my-room", { storage }); - await room.load(); // Create 1000 tickets const actors = new Set( diff --git a/packages/liveblocks-server/test/storage/model-based/index.model.test.ts b/packages/liveblocks-server/test/storage/model-based/index.model.test.ts index 7c00a44b990..04c4019ef09 100644 --- a/packages/liveblocks-server/test/storage/model-based/index.model.test.ts +++ b/packages/liveblocks-server/test/storage/model-based/index.model.test.ts @@ -51,7 +51,7 @@ describe("Storage (model-based test)", () => { runWithStorage(initialNodes, ({ storage: real }) => { selfCheck(real); - const model = new Model(real.loadedDriver.iter_nodes()); + const model = new Model(real.driver.iter_nodes()); // Tries running randomized sequences of commands (think calling // "applyOp()" a million times) diff --git a/packages/liveblocks-server/test/storage/push.test.ts b/packages/liveblocks-server/test/storage/push.test.ts index 3afcb7eaa46..43b3620354f 100644 --- a/packages/liveblocks-server/test/storage/push.test.ts +++ b/packages/liveblocks-server/test/storage/push.test.ts @@ -33,7 +33,7 @@ describe("push intent — server-authoritative append", () => { test("keeps the guessed position when it already sorts after the tail (no fix)", () => runWithStorage( [rootObj(), list("0:1", "root", "list")], - ({ storage, loadedDriver }) => { + ({ storage, driver }) => { // Empty list; the client guessed THIRD. It already sorts after every // existing sibling (there are none), so the server keeps it as-is // rather than pointlessly relocating it to the canonical first slot. @@ -41,7 +41,7 @@ describe("push intent — server-authoritative append", () => { createRegisterOp("1:0", "0:1", THIRD, "a", "push"), ]); - expect(loadedDriver.get_child_at("0:1", THIRD)).toBe("1:0"); + expect(driver.get_child_at("0:1", THIRD)).toBe("1:0"); if (!res || res.action !== "accepted") { throw new Error("expected the push op to be accepted"); } @@ -56,14 +56,14 @@ describe("push intent — server-authoritative append", () => { list("0:1", "root", "list"), register("0:2", "0:1", FIRST, "a"), ], - ({ storage, loadedDriver }) => { + ({ storage, driver }) => { // Client knows about "a" and guesses SECOND, which is also where the // server appends — no correction needed. const [res] = storage.applyOps([ createRegisterOp("1:0", "0:1", SECOND, "b", "push"), ]); - expect(loadedDriver.get_child_at("0:1", SECOND)).toBe("1:0"); + expect(driver.get_child_at("0:1", SECOND)).toBe("1:0"); if (!res || res.action !== "accepted") { throw new Error("expected the push op to be accepted"); } @@ -79,13 +79,13 @@ describe("push intent — server-authoritative append", () => { register("0:2", "0:1", FIRST, "a"), register("0:3", "0:1", SECOND, "b"), ], - ({ storage, loadedDriver }) => { + ({ storage, driver }) => { // Stale guess of the head position; the server appends after "b". const [res] = storage.applyOps([ createRegisterOp("1:0", "0:1", FIRST, "c", "push"), ]); - expect(loadedDriver.get_child_at("0:1", THIRD)).toBe("1:0"); + expect(driver.get_child_at("0:1", THIRD)).toBe("1:0"); if (!res || res.action !== "accepted") { throw new Error("expected the push op to be accepted"); } @@ -100,7 +100,7 @@ describe("push intent — server-authoritative append", () => { test("concurrent pushes all guessing the head settle in arrival order", () => runWithStorage( [rootObj(), list("0:1", "root", "list")], - ({ storage, loadedDriver }) => { + ({ storage, driver }) => { // Three independent clients each guess the head position. Applied // serially (as the room mutex guarantees), each appends after the // previous one — strictly increasing keys, no wedge. @@ -110,9 +110,9 @@ describe("push intent — server-authoritative append", () => { createRegisterOp("3:0", "0:1", FIRST, "c", "push"), ]); - expect(loadedDriver.get_child_at("0:1", FIRST)).toBe("1:0"); - expect(loadedDriver.get_child_at("0:1", SECOND)).toBe("2:0"); - expect(loadedDriver.get_child_at("0:1", THIRD)).toBe("3:0"); + expect(driver.get_child_at("0:1", FIRST)).toBe("1:0"); + expect(driver.get_child_at("0:1", SECOND)).toBe("2:0"); + expect(driver.get_child_at("0:1", THIRD)).toBe("3:0"); } )); }); diff --git a/packages/liveblocks-server/test/storage/utils.ts b/packages/liveblocks-server/test/storage/utils.ts index a5fce341dd1..fbedc8d3964 100644 --- a/packages/liveblocks-server/test/storage/utils.ts +++ b/packages/liveblocks-server/test/storage/utils.ts @@ -27,7 +27,6 @@ import type { } from "@liveblocks/core"; import { CrdtType } from "@liveblocks/core"; -import type { Logger } from "~/lib/Logger"; import { makeNewInMemoryDriver } from "~/plugins/InMemoryDriver"; import { Storage } from "~/Storage"; import { selfCheck } from "~test/plugins/_generateFullTestSuite"; @@ -73,11 +72,11 @@ export function register( * the initial nodes (which can contain invalid/corrupted data) to the * backend, then loads Storage on top. */ -export async function runWithStorage( +export function runWithStorage( nodeStream: NodeStream, callback: (arg: { storage: Storage; - loadedDriver: Storage["loadedDriver"]; + driver: Storage["driver"]; }) => R | Promise ): Promise { const nodeMap: NodeMap = new Map(nodeStream); @@ -88,16 +87,10 @@ export async function runWithStorage( // Create the *access layer* around it (the API that we use) const storage = new Storage(backend); - const logger = { - warn: () => {}, - error: () => {}, - } as unknown as Logger; - await storage.load(logger); - // Also run an integrity check after initializing _corrupted_ storage. // Because the Storage class ignores any such corruptions, even when loading // corruptions the in-memory nodemap should be consistent. selfCheck(storage); - return callback({ storage, loadedDriver: storage.loadedDriver }); + return Promise.resolve(callback({ storage, driver: storage.driver })); } diff --git a/tools/liveblocks-cli/CHANGELOG.md b/tools/liveblocks-cli/CHANGELOG.md index c207c83d2e7..5417f97cd93 100644 --- a/tools/liveblocks-cli/CHANGELOG.md +++ b/tools/liveblocks-cli/CHANGELOG.md @@ -1,5 +1,11 @@ ## vNEXT (not yet released) +## v1.6.0 + +- Update internal storage format of dev server. Note that your local dev rooms + are not automatically migrated and will appear as empty rooms after the + upgrade. + ## v1.5.0 - Add `--random-port` (`-P`) flag to `liveblocks dev`: bind a random free port diff --git a/tools/liveblocks-cli/package.json b/tools/liveblocks-cli/package.json index f0bbcfa0523..386271ec899 100644 --- a/tools/liveblocks-cli/package.json +++ b/tools/liveblocks-cli/package.json @@ -1,6 +1,6 @@ { "name": "liveblocks", - "version": "1.5.0", + "version": "1.6.0", "description": "Liveblocks command line interface", "type": "module", "bin": { @@ -46,7 +46,7 @@ "typescript": "^5.9.3" }, "dependencies": { - "@liveblocks/core": "3.20.0-pre1", + "@liveblocks/core": "3.19.5-pre1", "@liveblocks/query-parser": "workspace:^", "@liveblocks/server": "workspace:^", "@liveblocks/zenrouter": "^1.0.18", diff --git a/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts b/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts index 0c1ac7b17c7..f01f640da1c 100644 --- a/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts +++ b/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts @@ -23,36 +23,29 @@ import type { JsonObject, NodeStream, PlainLsonObject, + Relax, SerializedChild, SerializedCrdt, SerializedObject, SerializedRootObject, } from "@liveblocks/core"; -import { - asPos, - CrdtType, - nn, - nodeStreamToCompactNodes, -} from "@liveblocks/core"; +import { asPos, CrdtType, raise } from "@liveblocks/core"; import type { Feed, FeedMessage, IReadableSnapshot, IStorageDriver, - IStorageDriverNodeAPI, jstring, LeasedSession, ListFeedMessagesOptions, ListFeedMessagesResult, ListFeedsOptions, ListFeedsResult, - Logger, Pos, YDocId, } from "@liveblocks/server"; import { makeInMemorySnapshot, - NestedMap, plainLsonToNodeStream, quote, } from "@liveblocks/server"; @@ -68,10 +61,57 @@ function tryParseJson( } } -type StorageNodesRow = { - node_id: string; - crdt_json: jstring; -}; +function parseJson(text: string): J { + return JSON.parse(text) as J; +} + +// Table `nodes` +type NodeRow = + | { + id: "root"; + type: CrdtType.OBJECT; + parent_id: null; + parent_key: null; + jdata: jstring; + } + | { + id: string; + type: CrdtType.OBJECT | CrdtType.REGISTER; + parent_id: string; + parent_key: string; + jdata: jstring; + } + | { + id: string; + type: CrdtType.LIST | CrdtType.MAP; + parent_id: string; + parent_key: string; + jdata: null; + }; + +function rowToSerializedCrdt(row: NodeRow): SerializedCrdt { + const { type, parent_id: parentId, parent_key: parentKey, jdata } = row; + switch (type) { + case CrdtType.OBJECT: + return parentId === null + ? { type, data: parseJson(jdata) } + : { type, parentId, parentKey, data: parseJson(jdata) }; + + case CrdtType.REGISTER: + return { type, parentId, parentKey, data: parseJson(jdata) }; + + case CrdtType.LIST: + case CrdtType.MAP: + return { type, parentId, parentKey }; + + default: + raise("Unhandled case"); + } +} + +function rowToIdTuple(row: NodeRow): [string, SerializedCrdt] { + return [row.id, rowToSerializedCrdt(row)]; +} type MetadataRow = { key: string; @@ -123,222 +163,370 @@ function nparams(count: number): string { return new Array(count).fill("?").join(","); } -function consume(it: Iterable): T[] { - return Array.isArray(it) ? (it as T[]) : Array.from(it); +// ---------------------------------------------------------------------------- +// Sanitization helpers +// Run lazily on first node access (at most once per driver instance) to fix +// any data corruption. +// Optimized for the happy path (no corruption) to be as fast as possible. +// ---------------------------------------------------------------------------- + +/** + * Ensures the root node exists. Required for FK constraints to work. + * + * Common case is also the happy path: root already exists, this is a no-op. + */ +function sanitize_missingRoot(db: Database): void { + db.run( + `INSERT OR IGNORE INTO nodes (id, type, parent_id, parent_key, jdata) + VALUES ('root', 0, NULL, NULL, '{}')` + ); } -function safeSelect( - db: Database -): Iterable<[key: string, value: SerializedCrdt]> { - return ( - db - .query("SELECT node_id, crdt_json FROM nodes") - .values() as [string, string][] - ).map(([node_id, crdt_json]) => [ - node_id, - tryParseJson(crdt_json)!, - ]); +/** + * Deletes illegal tree nodes and their subtrees: + * 1. Registers under Objects + * 2. Any child node under a Register + * + * Common case is also the happy path: no rows match, this is a no-op. + */ +function sanitize_illegalNodes(db: Database): void { + // Find all illegal nodes (roots of subtrees to delete) + const illegalNodes = db + .query, []>( + `SELECT c.id + FROM nodes c + JOIN nodes p ON p.id = c.parent_id + WHERE + (c.type = ${CrdtType.REGISTER} AND p.type = ${CrdtType.OBJECT}) + OR + p.type = ${CrdtType.REGISTER}` + ) + .all(); + + // Delete each illegal subtree + for (const { id } of illegalNodes) { + delete_node(db, id); + } } -function safeUpdate(db: Database, entries: Iterable<[string, SerializedCrdt]>) { - const stm = db.prepare( - `INSERT INTO nodes (node_id, crdt_json) - VALUES (?, ?) - ON CONFLICT (node_id) DO UPDATE SET crdt_json = ?` - ); - const insertMany = db.transaction( - (pairs: Iterable<[string, SerializedCrdt]>) => { - for (const [key, value] of pairs) { - const crdt_json = JSON.stringify(value); - stm.run(key, crdt_json, crdt_json); - } - } +/** + * Fixes Object nodes where static data keys conflict with child node keys. + * Child nodes always win - conflicting keys are removed from jdata. + * + * Common case is also the happy path: no nodes have conflicts, this is a no-op. + */ +function sanitize_staticDataConflicts(db: Database): void { + // Detection query - returns empty on happy path + const conflicts = db + .query<{ id: string; key: string }, []>( + `SELECT p.id, c.parent_key AS key + FROM nodes p + JOIN nodes c ON c.parent_id = p.id + WHERE p.type = ${CrdtType.OBJECT} + AND json_type(p.jdata, '$.' || json_quote(c.parent_key)) IS NOT NULL` + ) + .all(); + + // Remove each conflicting key directly in SQLite (no JS parsing needed) + for (const { id, key } of conflicts) { + db.query( + "UPDATE nodes SET jdata = json_remove(jdata, '$.' || json_quote(?)) WHERE id = ?" + ).run(key, id); + } +} + +function hasStaticDataAt( + node: SerializedCrdt | undefined, + key: string +): node is SerializedObject | SerializedRootObject { + return ( + node?.type === CrdtType.OBJECT && + Object.prototype.hasOwnProperty.call(node.data, key) && + node.data[key] !== undefined ); - insertMany(entries); } -function safeDelete(db: Database, ids_: Iterable) { - // Since we need to know the number of items here, we'll need to consume - // the (potentially lazy) iterable entirely first - const ids = consume(ids_); - const n = ids.length; +// ---------------------------------------------------------------------------- +// Node storage operations +// ---------------------------------------------------------------------------- + +function get_node(db: Database, id: string): SerializedCrdt | undefined { + const row = db + .query< + NodeRow, + [string] + >("SELECT id, type, parent_id, parent_key, jdata FROM nodes WHERE id = ?") + .get(id); + return row ? rowToSerializedCrdt(row) : undefined; +} - db.query(`DELETE FROM nodes WHERE node_id IN (${nparams(n)})`).run(...ids); +function iter_nodes(db: Database): Iterable<[string, SerializedCrdt]> { + return db + .query< + NodeRow, + [] + >("SELECT id, type, parent_id, parent_key, jdata FROM nodes") + .all() + .map(rowToIdTuple); } // -// TODO: Refactor to leverage SQL more. Ideas: -// - Remove in-memory revNodes building, use SQL indexes/queries instead -// - Use SQL constraints (UNIQUE, FOREIGN KEY) for integrity instead of runtime fixes -// - Consider a normalized schema: separate parent_id/parent_key columns with indexes +// IMPORTANT: the JSON tuples emitted by this query MUST match the +// `CompactNode` shape (from @liveblocks/core) if they get JSON.parse()'ed. +// +// This contract is unit tested in `_generateFullTestSuite`. // +function iter_nodes_optimized(db: Database): Iterable> { + return db + .query<{ tuple: jstring }, []>( + `SELECT + CASE + WHEN parent_id IS NULL THEN + '[' || json_quote(id) || ',' || jdata || ']' + WHEN jdata IS NULL THEN + '[' || json_quote(id) || ',' || type || ',' || + json_quote(parent_id) || ',' || json_quote(parent_key) || ']' + ELSE + '[' || json_quote(id) || ',' || type || ',' || + json_quote(parent_id) || ',' || json_quote(parent_key) || ',' || jdata || ']' + END AS tuple + FROM nodes` + ) + .all() + .map((row) => row.tuple); +} -function buildRevNodes(nodeStream: NodeStream) { - const result = new NestedMap(); - for (const [id, node] of nodeStream) { - if (node.parentId === undefined) continue; - // Highest node id wins in case of conflict (deterministic across backends) - const existing = result.get(node.parentId, node.parentKey); - if (existing === undefined || id > existing) { - result.set(node.parentId, node.parentKey, id); - } - } - return result; +function get_child_at( + db: Database, + parentId: string, + parentKey: string +): string | undefined { + const row = db + .query< + Pick, + [string, string] + >("SELECT id FROM nodes WHERE parent_id = ? AND parent_key = ?") + .get(parentId, parentKey); + return row?.id; +} + +function has_child_at( + db: Database, + parentId: string, + parentKey: string +): boolean { + return get_child_at(db, parentId, parentKey) !== undefined; +} + +function get_next_sibling( + db: Database, + parentId: string, + pos: Pos +): Pos | undefined { + const row = db + .query< + Pick, + [string, string] + >("SELECT parent_key FROM nodes WHERE parent_id = ? AND parent_key > ? ORDER BY parent_key LIMIT 1") + .get(parentId, pos); + // parent_key is never null here since we filter on parent_id (root has parent_id=null) + return row ? asPos(row.parent_key!) : undefined; +} + +function get_last_sibling(db: Database, parentId: string): Pos | undefined { + const row = db + .query< + Pick, + [string] + >("SELECT parent_key FROM nodes WHERE parent_id = ? ORDER BY parent_key DESC LIMIT 1") + .get(parentId); + // parent_key is never null here since we filter on parent_id (root has parent_id=null) + return row ? asPos(row.parent_key!) : undefined; +} + +function upsert_node(db: Database, id: string, node: SerializedCrdt): void { + const parentId = id === "root" ? null : (node.parentId ?? null); + const parentKey = id === "root" ? null : (node.parentKey ?? null); + const jdata = + node.type === CrdtType.OBJECT || node.type === CrdtType.REGISTER + ? JSON.stringify(node.data) + : null; + + db.query( + `INSERT INTO nodes (id, type, parent_id, parent_key, jdata) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (id) DO + UPDATE SET type = excluded.type, parent_id = excluded.parent_id, parent_key = excluded.parent_key, jdata = excluded.jdata` + ).run(id, node.type, parentId, parentKey, jdata); } /** - * Builds the reverse node index, and corrects any data corruption found - * along the way. + * Deletes a node and all its descendants using a depth-first traversal. + * + * Because the FK is ON DELETE RESTRICT (mirroring the production driver), we + * must delete leaves before their parents rather than relying on cascade. */ -function buildReverseLookup( - db: Database, - logger: Logger -): { - nodes: Map; - revNodes: NestedMap; -} { - const rawNodes = new Map(safeSelect(db)); - if (!rawNodes.has("root")) { - // In-memory, the root node always exists (this is not true on disk per se) - rawNodes.set("root", { type: CrdtType.OBJECT, data: {} }); - } +function delete_node(db: Database, id: string): void { + if (id === "root") return; - // Record in here which nodes have been changed (in case their data keys had a conflict) - const nodesToFix: Set = new Set(); - - // - // Now, we're going to build up the raw NodeMap (which may not be sound, i.e. - // have corruptions) in a way that will guarantee that the final result will - // always be a sound (correct) NodeMap. - // - // The algorithm works as follows: - // 1. We generate a reverse lookup table from the rawNodes input. This - // will effectively give us the nodes to pick as "winners" in case of - // conflict, because it's not possible to have multiple values under the - // same key. Also, we need the last entry to "win", because that's how - // clients work, too, so this automatically works. - // 2. We build up the tree from the root, then adding all of its "winner" - // children to the map, etc. This way, it's impossible to read conflicts - // into memory. Orphans, ref cycles, and conflicting siblings cannot be - // included this way. - // 3. Reporting pass. We loop over the raw node map once more, to see which - // nodes we dropped along the way, and report about those. - // - - const queue: string[] = ["root"]; - - const nodes: Map = new Map(); - - // Step (1) - const winners: NestedMap = buildRevNodes( - rawNodes as NodeStream - ); + // Stack items: either "visit this node" or "delete this node" + // Visit going down (growing the stack), delete going back up (shrinking the stack). + const stack: Relax<{ visit: string } | { delete: string }>[] = [ + { visit: id }, + ]; - // Step (2) - while (queue.length > 0) { - const nodeId = queue.pop()!; - const node = nn(rawNodes.get(nodeId)); - - if (node.type === CrdtType.OBJECT) { - // Now check if any of the child keys exist in the node's data. If so, - // remove those from the `data` key. Nodes always win over `data` fields. - for (const key of winners.keysAt(nodeId)) { - if (Object.prototype.hasOwnProperty.call(node.data, key)) { - // TODO: Investigate this later? V8 might not always like the delete keyword. - // See this blog post https://akashsingh.blog/javascript-delete-operator-might-cause-some-unexpected-performance-issues - delete node.data[key]; - nodesToFix.add(nodeId); - logger.warn(`[integrity] Found data key ${quote(key)} from ${quote(nodeId)} (conflicted with child node)`); // prettier-ignore - } - } - } + while (stack.length > 0) { + const item = stack.pop()!; - // Add all "winner" child nodes to the queue to process in the next loop - // (except if the current node is a register, which is not allowed to have children) - if (node.type !== CrdtType.REGISTER) { - queue.push(...winners.valuesAt(nodeId)); + if (item.delete !== undefined) { + db.query("DELETE FROM nodes WHERE id = ?").run(item.delete); } else { - // This is a REGISTER node. If its parent turns out to be an OBJECT node, - // it means there is a data corruption, because objects should store JSON - // key/value pairs directly into their `data` attribute instead of using - // registers. It will be dropped as if it were an orphan. - const parent = rawNodes.get(node.parentId); - if (parent?.type === CrdtType.OBJECT) { - continue; + // Schedule delete (will run after children due to LIFO) + stack.push({ delete: item.visit }); + // Schedule children to be visited first + const children = db + .query< + { id: string }, + [string] + >("SELECT id FROM nodes WHERE parent_id = ?") + .all(item.visit); + for (const row of children) { + stack.push({ visit: row.id }); } } + } +} - // "Hang" the validated node in the tree - nodes.set(nodeId, node); +function delete_child_key(db: Database, id: string, key: string): void { + // At most one of these will do something, the other is a no-op + const node = get_node(db, id); + if (hasStaticDataAt(node, key)) { + delete node.data[key]; + upsert_node(db, id, node); + } + + const childId = get_child_at(db, id, key); + if (childId !== undefined) { + delete_node(db, childId); + } +} + +function set_child( + db: Database, + id: string, + node: SerializedChild, + allowOverwrite = false +): void { + const parentNode = get_node(db, node.parentId); + if (parentNode === undefined) { + throw new Error(`No such parent ${quote(node.parentId)}`); } - // Step (3) - // Collect all node IDs here that need to be removed, because they are - // unreachable orphans, contain ref cycles, or are at conflict with other - // data. We'll collect them here in order to batch-delete them at the end. - const nodesToDelete: Set = new Set(); - - // Emit logs so we can see what corruptions have been resolved - for (const [id, node] of rawNodes) { - if (!nodes.has(id)) { - if (node.parentId !== undefined && nodes.has(node.parentId)) { - if (nodes.get(node.parentId)?.type === CrdtType.REGISTER) { - logger.warn(`[integrity] Found unreachable node ${quote(id)} (child of live register)`); // prettier-ignore - } else { - logger.warn(`[integrity] Found conflicting sibling ${quote(id)} (conflicted with ${quote(winners.get(node.parentId, node.parentKey))} at ${quote(node.parentKey)})`); // prettier-ignore - } + if (node.type === CrdtType.REGISTER && parentNode.type === CrdtType.OBJECT) { + throw new Error("Cannot add register under object"); + } + + const conflictingSiblingId = get_child_at(db, node.parentId, node.parentKey); + if (conflictingSiblingId !== id) { + const hasConflictingData = hasStaticDataAt(parentNode, node.parentKey); + if (conflictingSiblingId !== undefined || hasConflictingData) { + if (allowOverwrite) { + delete_child_key(db, node.parentId, node.parentKey); } else { - logger.warn(`[integrity] Found orphan ${quote(id)}`); // prettier-ignore + throw new Error(`Key ${quote(node.parentKey)} already exists`); // prettier-ignore } - - nodesToDelete.add(id); - nodesToFix.delete(id); // Just in case this node was also marked as "to update" } } - if (nodesToFix.size > 0 || nodesToDelete.size > 0) { - // Persist the fixes to disk - if (nodesToFix.size > 0) { - const fixedNodes = new Map(); - for (const id of nodesToFix) { - const node = nodes.get(id); - if (node !== undefined) { - fixedNodes.set(id, node); - } + upsert_node(db, id, node); +} + +function move_sibling(db: Database, id: string, newPos: Pos): void { + const node = get_node(db, id); + if (node?.parentId === undefined) { + return; + } + + // If there is a conflicting sibling at the new position, disallow the move + if (has_child_at(db, node.parentId, newPos)) + throw new Error(`Pos ${quote(newPos)} already taken`); // prettier-ignore + + const newNode = { ...node, parentKey: newPos }; + upsert_node(db, id, newNode); +} + +function set_object_data( + db: Database, + id: string, + data: JsonObject, + allowOverwrite = false +): void { + const node = get_node(db, id); + if (node?.type !== CrdtType.OBJECT) { + // Nothing to do + return; + } + + let changed = false; + + for (const [key, value] of Object.entries(data)) { + // Drop `__proto__`: assigning to it would invoke Object.prototype's + // setter rather than create an own property, and rebuilding the object + // via defineProperty on every write is too expensive for this hot path. + if (key === "__proto__") continue; + + // Handle if conflict! + const childId = get_child_at(db, id, key); + if (childId !== undefined) { + if (allowOverwrite) { + delete_node(db, childId); + } else { + throw new Error(`Child node already exists under ${key}`); // prettier-ignore } - safeUpdate(db, fixedNodes); } - if (nodesToDelete.size > 0) { - safeDelete(db, nodesToDelete); + + if (node.data[key] === value) { + // No change, so skip + continue; } - } - const revNodes = - // If no nodes were dropped (i.e. the 99% happy path), `winners` is our - // correct reverse node map already, no need to recompute it. Otherwise, - // on the non-happy path, we'll simply recompute it again at the end. This - // way we don't have to keep a complex administration. - nodesToDelete.size === 0 ? winners : buildRevNodes(nodes as NodeStream); + node.data[key] = value; + changed = true; + } - return { nodes, revNodes }; + if (changed) { + upsert_node(db, id, node); + } } -function hasStaticDataAt( - node: SerializedCrdt | undefined, - key: string -): node is SerializedObject | SerializedRootObject { - return ( - node?.type === CrdtType.OBJECT && - Object.prototype.hasOwnProperty.call(node.data, key) && - node.data[key] !== undefined - ); -} +/** + * The lazily-initialized part of the driver that implements all node-related + * APIs. + */ +type NodesAPI = Pick< + IStorageDriver, + | "get_node" + | "iter_nodes" + | "iter_nodes_optimized" + | "has_node" + | "get_child_at" + | "has_child_at" + | "get_next_sibling" + | "get_last_sibling" + | "set_child" + | "move_sibling" + | "delete_node" + | "delete_child_key" + | "set_object_data" + | "get_snapshot" +>; /** * Implements a simple SQLite-backed store. */ export class BunSQLiteDriver implements IStorageDriver { private db: Database; + private _nodesApi?: NodesAPI; constructor(path: string) { const db = new Database(path, { create: true }); @@ -359,10 +547,26 @@ export class BunSQLiteDriver implements IStorageDriver { // Create a table to read/write CRDT nodes db.run( `CREATE TABLE IF NOT EXISTS nodes ( - node_id TEXT NOT NULL, - crdt_json TEXT NOT NULL, - PRIMARY KEY (node_id) - )` + id TEXT NOT NULL PRIMARY KEY, + + type INTEGER NOT NULL CHECK (type >= 0 AND type <= 3), + -- ^^^^^^^ 0=LiveObject, 1=LiveList, 2=LiveMap, 3=Register + parent_id TEXT, -- NULL only for root + parent_key TEXT, -- NULL only for root + jdata TEXT, -- JSON data for LiveObject and Register; NULL for LiveList/LiveMap + + UNIQUE (parent_id, parent_key), + + -- Root must always be a LiveObject + CHECK (id != 'root' OR type = 0), + + -- Only 'root' is allowed to have no parent! + CHECK (id != 'root' OR (parent_id IS NULL AND parent_key IS NULL)), + CHECK (id = 'root' OR (parent_id IS NOT NULL AND parent_key IS NOT NULL)), + + -- Foreign key: parent_id must reference an existing node + FOREIGN KEY (parent_id) REFERENCES nodes (id) ON DELETE RESTRICT + ) STRICT` ); // Create a table to read/write JSON values @@ -431,322 +635,139 @@ export class BunSQLiteDriver implements IStorageDriver { this.db = db; } - load_nodes_api(logger: Logger): IStorageDriverNodeAPI { - const db = this.db; - - // REFACTOR NOTE: This logic was inlined here for backward compatibility, - // but it makes no sense to build this reverse lookup table here in the - // Bun-SQLite setting, where we could directly read the reverse lookups - // from the database instead of keeping them in memory. Refactor later. - const { nodes, revNodes } = buildReverseLookup(db, logger); - - // Helpers used by multiple sync functions below - const get_node = (id: string) => nodes.get(id); - const get_child_at = (parentId: string, parentKey: string) => - revNodes.get(parentId, parentKey); - const has_child_at = (parentId: string, parentKey: string) => - revNodes.has(parentId, parentKey); - - function get_next_sibling(parentId: string, pos: Pos): Pos | undefined { - let nextPos: Pos | undefined; - // Find the smallest position greater than current - for (const siblingKey of revNodes.keysAt(parentId)) { - const siblingPos = asPos(siblingKey); - if ( - siblingPos > pos && - (nextPos === undefined || siblingPos < nextPos) - ) { - nextPos = siblingPos; - } - } - return nextPos; - } - - function get_last_sibling(parentId: string): Pos | undefined { - let lastPos: Pos | undefined; - // Find the largest position under this parent - for (const siblingKey of revNodes.keysAt(parentId)) { - const siblingPos = asPos(siblingKey); - if (lastPos === undefined || siblingPos > lastPos) { - lastPos = siblingPos; - } - } - return lastPos; - } - - /** - * Inserts a node in the storage tree, deleting any nodes that already exist - * under this key (including all of its children), if any. - */ - function set_child( - id: string, - node: SerializedChild, - allowOverwrite = false - ): void { - const parentNode = get_node(node.parentId); - if (parentNode === undefined) { - throw new Error(`No such parent ${quote(node.parentId)}`); - } - - if ( - node.type === CrdtType.REGISTER && - parentNode.type === CrdtType.OBJECT - ) { - throw new Error("Cannot add register under object"); - } - - const conflictingSiblingId = get_child_at(node.parentId, node.parentKey); - if (conflictingSiblingId !== id) { - // Conflict! - const hasConflictingData = hasStaticDataAt(parentNode, node.parentKey); - if (conflictingSiblingId !== undefined || hasConflictingData) { - if (allowOverwrite) { - delete_child_key(node.parentId, node.parentKey); - } else { - throw new Error(`Key ${quote(node.parentKey)} already exists`); // prettier-ignore - } - } - - // Finally, modify revNodes - revNodes.set(node.parentId, node.parentKey, id); - } - - nodes.set(id, node); - safeUpdate(db, [[id, node]]); - } - - /** - * Conceptually this is like "detaching" the node from its parent, and - * "reattaching" it at the new position. - * - * However, this is a native operation, because doing a naive - * delete-then-insert would would immediately destroy all (grand)children - * when it's deleted. - */ - function move_sibling(id: string, newPos: Pos): void { - const node = get_node(id); - if (node?.parentId === undefined) { - return; - } - - // If there is a conflicting sibling at the new position, disallow the move - if (has_child_at(node.parentId, newPos)) - throw new Error(`Pos ${quote(newPos)} already taken`); // prettier-ignore - - revNodes.delete(node.parentId, node.parentKey); - const newNode = { ...node, parentKey: newPos }; - nodes.set(id, newNode); - revNodes.set(node.parentId, newPos, id); - safeUpdate(db, [[id, newNode]]); - } - - /** - * Sets some static data on a node. The node must be an OBJECT node, or this - * method will be a no-op. - * - * If any keys exist that also conflict with a child node, then the conflict - * mode will determine what will happen. By default, an error will be thrown. - * But if `allowOverwrite` is set to true, the conflicting child node (and - * its entire subtree) will be deleted to make room for the new static data. - */ - function set_object_data( - id: string, - data: JsonObject, - allowOverwrite = false - ): void { - const node = get_node(id); - if (node?.type !== CrdtType.OBJECT) { - // Nothing to do - return; - } - - for (const key of Object.keys(data)) { - // Handle if conflict! - const childId = get_child_at(id, key); - if (childId !== undefined) { - if (allowOverwrite) { - delete_node(childId); - } else { - throw new Error(`Child node already exists under ${key}`); // prettier-ignore - } - } - } - - const newNode = { ...node, data: { ...node.data, ...data } }; - nodes.set(id, newNode); - safeUpdate(db, [[id, newNode]]); - } - - /** - * Delete a node from the in-memory and on-disk tree, including all of its - * children. - */ - function delete_node(id: string): void { - const node = get_node(id); - if (node?.parentId === undefined) { - return; - } + /** + * The lazily-built node API. Building it runs the corruption sanitizers. + * Invalidated by DANGEROUSLY_reset_nodes(), after which the next access + * rebuilds it. + */ + private get loadedApi(): NodesAPI { + return (this._nodesApi ??= this._loadNodesApi()); + } - // Delete the entry in the parent's children administration for this node - revNodes.delete(node.parentId, node.parentKey); - - // Now proceed to deleting the node tree recursively - const idsToDelete: string[] = []; - const queue = [id]; - while (queue.length > 0) { - const currid = queue.pop()!; - queue.push(...revNodes.valuesAt(currid)); - nodes.delete(currid); - revNodes.deleteAll(currid); - idsToDelete.push(currid); - } - safeDelete(db, idsToDelete); - } + get_node(id: string): SerializedCrdt | undefined { + return this.loadedApi.get_node(id); + } - /** - * Deletes the child key under a given node, whether it's a static object - * field, or a child node. - */ - function delete_child_key(id: string, key: string): void { - // At most one of these will do something, the other is a no-op - const node = get_node(id); - if (hasStaticDataAt(node, key)) { - const { [key]: _, ...rest } = node.data; - const newNode = { ...node, data: rest }; - nodes.set(id, newNode); - safeUpdate(db, [[id, newNode]]); - } + iter_nodes(): NodeStream { + return this.loadedApi.iter_nodes(); + } - const childId = get_child_at(id, key); - if (childId !== undefined) { - delete_node(childId); - } - } + iter_nodes_optimized(): Iterable> { + return this.loadedApi.iter_nodes_optimized(); + } - const api: IStorageDriverNodeAPI = { - /** - * Return the node with the given id, or undefined if no such node exists. - * Must always return a valid root node for id="root", even if empty. - */ - get_node, + has_node(id: string): boolean { + return this.loadedApi.has_node(id); + } - /** - * Yield all nodes as [id, node] pairs. Must always include the root node. - */ - iter_nodes: () => nodes.entries() as NodeStream, + get_child_at(parentId: string, parentKey: string): string | undefined { + return this.loadedApi.get_child_at(parentId, parentKey); + } - /** - * Yield each node as a pre-built CompactNode JSON tuple string. - * Schema here stores nodes as a single JSON blob per row, so there's - * no SQL-level optimization to apply — wrap iter_nodes() and let the - * core converter produce the tuple shape. - */ - *iter_nodes_optimized() { - for (const compact of nodeStreamToCompactNodes( - nodes.entries() as NodeStream - )) { - yield JSON.stringify(compact) as jstring; - } - }, + has_child_at(parentId: string, parentKey: string): boolean { + return this.loadedApi.has_child_at(parentId, parentKey); + } - /** - * Return true iff a node with the given id exists. Must return true for "root". - */ - has_node: (id: string) => nodes.has(id), + get_next_sibling(parentId: string, pos: Pos): Pos | undefined { + return this.loadedApi.get_next_sibling(parentId, pos); + } - /** - * Return the id of the child node at (parentId, parentKey), or undefined if - * none. Only checks child nodes registered via set_child, NOT static data - * keys on OBJECT nodes. - */ - get_child_at, + get_last_sibling(parentId: string): Pos | undefined { + return this.loadedApi.get_last_sibling(parentId); + } - /** - * Return true iff a child node exists at (parentId, parentKey). Static data - * keys on OBJECT nodes do not count—return false for those. - */ - has_child_at, + set_child(id: string, node: SerializedChild, allowOverwrite?: boolean): void { + this.loadedApi.set_child(id, node, allowOverwrite); + } - /** - * Return the position of the closest sibling "to the right" of `pos` under - * parentId, or undefined if no such sibling exists. The given `pos` may, but - * does not have to exist already. Positions compare lexicographically. - */ - get_next_sibling, + move_sibling(id: string, newPos: Pos): void { + this.loadedApi.move_sibling(id, newPos); + } - /** - * Return the position of the last (rightmost) child under parentId, or - * undefined if the node has no children. - */ - get_last_sibling, + delete_node(id: string): void { + this.loadedApi.delete_node(id); + } - /** - * Insert a child node with the given id. - * - * If allowOverwrite=false (default): throw if a node with this id exists. - * If allowOverwrite=true: replace any existing node at this id, deleting its - * entire subtree if it has children. - */ - set_child, + delete_child_key(id: string, key: string): void { + this.loadedApi.delete_child_key(id, key); + } - /** - * Change a node's parentKey, effectively repositioning the node within its - * parent. The new position must be free. - * Throw if another node already occupies (parentId, newPos). - */ - move_sibling, + set_object_data( + id: string, + data: JsonObject, + allowOverwrite?: boolean + ): void { + this.loadedApi.set_object_data(id, data, allowOverwrite); + } - /** - * Delete a node and its entire subtree recursively. - * Ignore if id="root" (root is immortal). - */ - delete_node, + get_snapshot(lowMemory?: boolean): IReadableSnapshot { + return this.loadedApi.get_snapshot(lowMemory); + } - /** - * Delete a key from node `id`. Handle two cases: - * - * 1. If id is an OBJECT with `key` in its data: remove that data field. - * 2. If a child exists at (id, key): delete that child and all its - * descendants recursively. - * - * No-op if neither applies or if the node doesn't exist. - */ - delete_child_key, + private _loadNodesApi(): NodesAPI { + const db = this.db; - /** - * Replace the data object of an OBJECT node. - * - * If allowOverwrite=false (default): throw if any key in `data` conflicts - * with an existing child's parentKey. - * If allowOverwrite=true: first delete any conflicting children (and their - * entire subtrees), then set the data. - */ - set_object_data, + // Run sanitizers to fix any data corruption (optimized for happy path) + sanitize_missingRoot(db); + sanitize_illegalNodes(db); + sanitize_staticDataConflicts(db); + + const api: NodesAPI = { + get_node: (id) => get_node(db, id), + iter_nodes: () => iter_nodes(db) as NodeStream, + iter_nodes_optimized: () => iter_nodes_optimized(db), + has_node: (id) => get_node(db, id) !== undefined, + get_child_at: (parentId, parentKey) => + get_child_at(db, parentId, parentKey), + has_child_at: (parentId, parentKey) => + has_child_at(db, parentId, parentKey), + get_next_sibling: (parentId, pos) => get_next_sibling(db, parentId, pos), + get_last_sibling: (parentId) => get_last_sibling(db, parentId), + set_child: (id, node, allowOverwrite) => + set_child(db, id, node, allowOverwrite), + move_sibling: (id, newPos) => move_sibling(db, id, newPos), + delete_node: (id) => delete_node(db, id), + delete_child_key: (id, key) => delete_child_key(db, id, key), + set_object_data: (id, data, allowOverwrite) => + set_object_data(db, id, data, allowOverwrite), /** - * Return a readable snapshot of the storage tree. - * - * @param lowMemory When true, the call site hints that the snapshot should - * be optimized for lower memory consumption, even if that means slower - * access. + * Return a readable snapshot of the storage tree. The dev server always + * uses an in-memory snapshot regardless of the lowMemory hint. */ get_snapshot(_lowMemory?: boolean): IReadableSnapshot { - return makeInMemorySnapshot(nodes); + return makeInMemorySnapshot(iter_nodes(db) as NodeStream); }, }; return api; } + reinitialize(): void { + this._nodesApi = undefined; + } + /** Deletes all nodes and replaces them with the given document. */ DANGEROUSLY_reset_nodes(doc: PlainLsonObject) { - const deleteStm = this.db.prepare("DELETE FROM nodes"); + // Invalidate the cached node API: the next access re-runs the sanitizers + // against the replaced node set. + this.reinitialize(); + const insertStm = this.db.prepare( - "INSERT INTO nodes (node_id, crdt_json) VALUES (?, ?)" + "INSERT INTO nodes (id, type, parent_id, parent_key, jdata) VALUES (?, ?, ?, ?, ?)" ); const resetNodes = this.db.transaction(() => { - deleteStm.run(); - for (const [key, value] of plainLsonToNodeStream(doc)) { - insertStm.run(key, JSON.stringify(value)); + // Defer FK checks until the end of the transaction so the bulk DELETE + // doesn't transiently violate the self-referencing parent_id FK. + this.db.run("PRAGMA defer_foreign_keys = ON"); + this.db.run("DELETE FROM nodes"); + for (const [id, node] of plainLsonToNodeStream(doc)) { + const parentId = id === "root" ? null : (node.parentId ?? null); + const parentKey = id === "root" ? null : (node.parentKey ?? null); + const jdata = + node.type === CrdtType.OBJECT || node.type === CrdtType.REGISTER + ? JSON.stringify(node.data) + : null; + insertStm.run(id, node.type, parentId, parentKey, jdata); } }); resetNodes(); @@ -754,14 +775,13 @@ export class BunSQLiteDriver implements IStorageDriver { /* @deprecated Had to introduce this ugly API for now during refactoring, try to remove this again later */ raw_iter_nodes(): Iterable<[key: string, value: SerializedCrdt]> { - return ( - this.db - .query("SELECT node_id, crdt_json FROM nodes") - .values() as [string, string][] - ).map(([node_id, crdt_json]) => [ - node_id, - tryParseJson(crdt_json)!, - ]); + return this.db + .query< + NodeRow, + [] + >("SELECT id, type, parent_id, parent_key, jdata FROM nodes") + .all() + .map(rowToIdTuple); } get_meta(key: string) { diff --git a/tools/liveblocks-cli/src/dev-server/db/rooms.ts b/tools/liveblocks-cli/src/dev-server/db/rooms.ts index 927826eeea3..4f10618a0cb 100644 --- a/tools/liveblocks-cli/src/dev-server/db/rooms.ts +++ b/tools/liveblocks-cli/src/dev-server/db/rooms.ts @@ -56,7 +56,9 @@ export type ClientMeta = JsonObject; // Module state // --------------------------------------------------------------------------- -const DEFAULT_BASE_PATH = ".liveblocks/v1"; +// Bumped to v2 when the per-room node storage moved to a normalized SQLite +// schema. Old v1 data is left untouched on disk. +const DEFAULT_BASE_PATH = ".liveblocks/v2"; let basePath = DEFAULT_BASE_PATH; let isEphemeral = false; let _initializedDb: Database | null = null; diff --git a/tools/liveblocks-cli/src/dev-server/index.ts b/tools/liveblocks-cli/src/dev-server/index.ts index 0f23b3e8481..d4b0df2452e 100644 --- a/tools/liveblocks-cli/src/dev-server/index.ts +++ b/tools/liveblocks-cli/src/dev-server/index.ts @@ -265,7 +265,6 @@ const dev: SubCommand = { // Look up or create the room for the requested room ID const room = Rooms.getRoomInstance(roomId); - await room.load(); const ticket = room.createTicket(ticketData); const sessionKey = ticket.sessionKey; @@ -347,7 +346,7 @@ const dev: SubCommand = { websocket: { // The socket is opened - async open(ws): Promise { + open(ws): void { const { refuseConnection, room, ticket } = ws.data; // If this connection should be refused, close it immediately @@ -357,7 +356,7 @@ const dev: SubCommand = { } if (room && ticket) { - await room.startBrowserSession(ticket, ws); + room.startBrowserSession(ticket, ws); } }, diff --git a/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts b/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts index cee0a1a98bb..d385c19510f 100644 --- a/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts +++ b/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts @@ -269,7 +269,7 @@ zen.route("DELETE /v2/rooms/", async ({ p }) => { /** * Get storage for a room */ -zen.route("GET /v2/rooms//storage", async ({ url, p }) => { +zen.route("GET /v2/rooms//storage", ({ url, p }) => { if (!Rooms.getRoom(p.roomId)) { throw ROOM_NOT_FOUND(p.roomId); } @@ -279,9 +279,8 @@ zen.route("GET /v2/rooms//storage", async ({ url, p }) => { SerializationFormat.PlainLson; const room = Rooms.getRoomInstance(p.roomId); - await room.load(); - const snapshot = room.storage.loadedDriver.get_snapshot(false); + const snapshot = room.storage.driver.get_snapshot(false); const storage = format === SerializationFormat.LossyJson ? snapshotToLossyJson_eager(snapshot) @@ -307,16 +306,15 @@ zen.route( data: jsonObjectYolo, }).refineType(), - async ({ p, body }) => { + ({ p, body }) => { if (!Rooms.getRoom(p.roomId)) { throw ROOM_NOT_FOUND(p.roomId); } const room = Rooms.getRoomInstance(p.roomId); - await room.load(); // Check if storage already has data - const snapshot = room.storage.loadedDriver.get_snapshot(false); + const snapshot = room.storage.driver.get_snapshot(false); const existingStorage = snapshotToPlainLson_eager(snapshot); if (Object.keys(existingStorage.data).length > 0) { @@ -348,13 +346,12 @@ zen.route( /** * Delete storage for a room (reset to empty) */ -zen.route("DELETE /v2/rooms//storage", async ({ p }) => { +zen.route("DELETE /v2/rooms//storage", ({ p }) => { if (!Rooms.getRoom(p.roomId)) { throw ROOM_NOT_FOUND(p.roomId); } const room = Rooms.getRoomInstance(p.roomId); - await room.load(); const emptyStorage: PlainLsonObject = { liveblocksType: "LiveObject", @@ -366,20 +363,19 @@ zen.route("DELETE /v2/rooms//storage", async ({ p }) => { return new Response(null, { status: 204 }); }); -zen.route("GET /v2/rooms//ydoc", async ({ url, p }) => { +zen.route("GET /v2/rooms//ydoc", ({ url, p }) => { if (!Rooms.getRoom(p.roomId)) { throw ROOM_NOT_FOUND(p.roomId); } const room = Rooms.getRoomInstance(p.roomId); - await room.load(); const key = url.searchParams.get("key") ?? ""; const type = url.searchParams.get("type") ?? ""; const ydocId = (url.searchParams.get("guid") ?? ROOT_YDOC_ID) as YDocId; const formatting = url.searchParams.get("formatting") !== null; const logger = new Logger(new ConsoleTarget("warning")); - const doc = await room.yjsStorage.getYDoc(logger, ydocId); + const doc = room.yjsStorage.getYDoc(logger, ydocId); const result = yDocToJson(doc, key, formatting, type); return new Response(JSON.stringify(result), { @@ -408,7 +404,6 @@ zen.route("PUT /v2/rooms//ydoc", async ({ req, url, p }) => { } const room = Rooms.getRoomInstance(p.roomId); - await room.load(); const buffer: ArrayBuffer = await req.arrayBuffer(); const update = Base64.fromUint8Array(new Uint8Array(buffer)); @@ -463,19 +458,18 @@ zen.route("PUT /v2/rooms//ydoc", async ({ req, url, p }) => { } }); -zen.route("GET /v2/rooms//ydoc-binary", async ({ url, p }) => { +zen.route("GET /v2/rooms//ydoc-binary", ({ url, p }) => { if (!Rooms.getRoom(p.roomId)) { throw ROOM_NOT_FOUND(p.roomId); } const room = Rooms.getRoomInstance(p.roomId); - await room.load(); const ydocId = (url.searchParams.get("guid") ?? ROOT_YDOC_ID) as YDocId; const encoder = url.searchParams.get("encoder"); const logger = new Logger(new ConsoleTarget("warning")); - const doc = await room.yjsStorage.getYDoc(logger, ydocId); + const doc = room.yjsStorage.getYDoc(logger, ydocId); const update = encoder === "v2" ? Y.encodeStateAsUpdateV2(doc) @@ -633,16 +627,15 @@ zen.route("POST /v2/rooms//feeds//messages", ({ p }) => { zen.route( "POST /v2/rooms//request-storage-mutation", - async ({ p }) => { + ({ p }) => { if (!Rooms.getRoom(p.roomId)) { throw ROOM_NOT_FOUND(p.roomId); } const room = Rooms.getRoomInstance(p.roomId); - await room.load(); const actor = room.getNextActor(); - const snapshot = room.storage.loadedDriver.get_snapshot(false); + const snapshot = room.storage.driver.get_snapshot(false); return ndjsonStream( chain([{ actor }], snapshotToNodeStream(snapshot)) ); @@ -660,7 +653,6 @@ zen.route( } const room = Rooms.getRoomInstance(p.roomId); - await room.load(); const [session, capturedServerMsgs] = room.createBackendSession_experimental(); diff --git a/tools/liveblocks-cli/test/devserver/rest-api/ydoc.test.ts b/tools/liveblocks-cli/test/devserver/rest-api/ydoc.test.ts index 59d5f34626c..ecb5ea6ce1f 100644 --- a/tools/liveblocks-cli/test/devserver/rest-api/ydoc.test.ts +++ b/tools/liveblocks-cli/test/devserver/rest-api/ydoc.test.ts @@ -74,10 +74,9 @@ describe("PUT /v2/rooms//ydoc", () => { // Connect a real WebSocket session into the room (via a fake socket that // records messages sent to it). const room = Rooms.getRoomInstance(roomId); - await room.load(); const { received, socket } = makeFakeSocket(); const ticket = room.createTicket({}); - await room.startBrowserSession(ticket, socket); + room.startBrowserSession(ticket, socket); // Mark how much was sent during session bootstrap so we only inspect // messages produced by the REST PUT below. diff --git a/tools/liveblocks-cli/test/plugins/BunSQLiteDriver.test.ts b/tools/liveblocks-cli/test/plugins/BunSQLiteDriver.test.ts index 5f04dfb94bc..9074700a09a 100644 --- a/tools/liveblocks-cli/test/plugins/BunSQLiteDriver.test.ts +++ b/tools/liveblocks-cli/test/plugins/BunSQLiteDriver.test.ts @@ -20,6 +20,7 @@ import os from "node:os"; import stdPath from "node:path"; import type { NodeMap } from "@liveblocks/core"; +import { CrdtType } from "@liveblocks/core"; import { Database } from "bun:sqlite"; import { describe } from "bun:test"; @@ -27,15 +28,28 @@ import { BunSQLiteDriver } from "~/dev-server/db/BunSQLiteDriver"; import { generateFullTestSuite } from "./_generateFullTestSuite"; -/** Directly write raw nodes to Bun SQLite, bypassing driver */ +/** Directly insert raw nodes into SQLite, bypassing driver constraints */ function initBunSQLite(dbPath: string, rawNodes: NodeMap): void { const db = new Database(dbPath); - for (const [nodeId, node] of rawNodes) { - db.run("INSERT INTO nodes (node_id, crdt_json) VALUES (?, ?)", [ - nodeId, - JSON.stringify(node), - ]); + + // Disable FK constraints during test setup to allow inserting nodes in any + // order, or with missing parents (to test recovery scenarios) + db.run("PRAGMA foreign_keys = OFF"); + + for (const [id, node] of rawNodes) { + const parentId = id === "root" ? null : (node.parentId ?? null); + const parentKey = id === "root" ? null : (node.parentKey ?? null); + const jdata = + node.type === CrdtType.OBJECT || node.type === CrdtType.REGISTER + ? JSON.stringify(node.data) + : null; + + db.run( + "INSERT INTO nodes (id, type, parent_id, parent_key, jdata) VALUES (?, ?, ?, ?, ?)", + [id, node.type, parentId, parentKey, jdata] + ); } + db.close(); } diff --git a/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts b/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts index 2790f84d45e..2f84926841a 100644 --- a/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts +++ b/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts @@ -79,9 +79,7 @@ import type { Guid, HasOpId, IStorageDriver, - IStorageDriverNodeAPI, LeasedSession, - Logger, Pos, SetParentKeyOp, UpdateObjectOp, @@ -117,15 +115,9 @@ function bytesToB64(bytes: Uint8Array): string { /** Empty document for resetting storage in tests */ const EMPTY_DOC: PlainLsonObject = { liveblocksType: "LiveObject", data: {} }; -function asPromise(fn: () => Awaitable): Promise { - return (async () => fn())(); -} - -/** - * Expect-throw assertion that works with both sync and async functions. - */ -function expectToThrow(fn: () => Awaitable, errorPattern: RegExp) { - return expect(asPromise(() => fn())).rejects.toThrow(errorPattern); +/** Expect-throw assertion. */ +function expectToThrow(fn: () => unknown, errorPattern: RegExp) { + expect(fn).toThrow(errorPattern); } // SYNC-SLOT: directives @@ -161,17 +153,14 @@ function wouldNotOverwriteDefaultDoc(n: { * This way, every test can assume that these "roots" objects * exist, in order to express tests more succinctly. */ -async function withDefaultDocument(driver: IStorageDriver) { +function resetToDefaultNodes(driver: T) { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await write_nodes(db, [ + write_nodes(driver, [ list("0:dl", "root", "l"), map("0:dm", "root", "m"), obj("0:do", {}, "root", "o"), ]); - - return db; } function assert( @@ -200,7 +189,7 @@ function assert( * state. */ export function selfCheck(storage: Storage): void { - const driver: IStorageDriverNodeAPI = storage.loadedDriver; + const driver: IStorageDriver = storage.driver; { // Check in-memory node tree integrity @@ -985,7 +974,7 @@ export function generateArbitraries() { } function getAll(storage: Storage) { - return Array.from(storage.loadedDriver.iter_nodes()); + return Array.from(storage.driver.iter_nodes()); } class VoidTarget extends LogTarget { @@ -1000,7 +989,7 @@ class VoidTarget extends LogTarget { function countChildrenOf(storage: Storage, parentId: string): number { let count = 0; - for (const [, node] of storage.loadedDriver.iter_nodes()) { + for (const [, node] of storage.driver.iter_nodes()) { if (node.parentId === parentId) { count++; } @@ -1008,21 +997,21 @@ function countChildrenOf(storage: Storage, parentId: string): number { return count; } -async function write_nodes(db: IStorageDriverNodeAPI, nodeStream: NodeStream) { +function write_nodes(driver: IStorageDriver, nodeStream: NodeStream) { for (const node of nodeStream) { if (isRootStorageNode(node)) { const crdt = node[1]; - await db.set_object_data("root", crdt.data, true); + driver.set_object_data("root", crdt.data, true); } else { const [id, crdt] = node; - await db.set_child(id, crdt, true); + driver.set_child(id, crdt, true); } } } -async function delete_nodes(db: IStorageDriverNodeAPI, ids: Iterable) { +function delete_nodes(driver: IStorageDriver, ids: Iterable) { for (const id of ids) { - await db.delete_node(id); + driver.delete_node(id); } } @@ -1079,11 +1068,10 @@ export function generateFullTestSuite(config: { describe("nodes API impl", () => { test("get_node and iter_nodes with empty store contains root", () => runTest((driver) => { - const db = driver.load_nodes_api(blackHole); - expect(db.get_node("non-existing")).toEqual(undefined); + expect(driver.get_node("non-existing")).toEqual(undefined); // Root node always exists, even in an empty store - expect(db.get_node("root")).toEqual({ + expect(driver.get_node("root")).toEqual({ type: CrdtType.OBJECT, data: {}, }); @@ -1098,9 +1086,8 @@ export function generateFullTestSuite(config: { (key) => { fc.pre(key !== "root"); - const db = driver.load_nodes_api(blackHole); - expect(db.get_node(key)).toEqual(undefined); - expect(Array.from(db.iter_nodes())).toEqual([ + expect(driver.get_node(key)).toEqual(undefined); + expect(Array.from(driver.iter_nodes())).toEqual([ ["root", { type: CrdtType.OBJECT, data: {} }], ]); } @@ -1109,32 +1096,57 @@ export function generateFullTestSuite(config: { )); test("set_object_data + get_node", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.rootNodeTuple(), - async ([key, value]) => { - const db = await withDefaultDocument(driver); + ([key, value]) => { + resetToDefaultNodes(driver); - await db.set_object_data("root", value.data, true); - expect(db.get_node(key)).toEqual(value); + driver.set_object_data("root", value.data, true); + expect(driver.get_node(key)).toEqual(value); } ) ) )); + test("set_object_data: no-op if called on a non-object node", () => + runTest((driver) => { + resetToDefaultNodes(driver); + + const listBefore = driver.get_node("0:dl"); + const mapBefore = driver.get_node("0:dm"); + + // set_object_data on a LiveList is a no-op + driver.set_object_data("0:dl", { foo: 42 }); + expect(driver.get_node("0:dl")).toEqual(listBefore); + + // set_object_data on a LiveMap is a no-op + driver.set_object_data("0:dm", { foo: 42 }); + expect(driver.get_node("0:dm")).toEqual(mapBefore); + + // set_object_data on a LiveObject should work + driver.set_object_data("0:do", { foo: 42 }); + expect(driver.get_node("0:do")).toEqual({ + type: CrdtType.OBJECT, + parentId: "root", + parentKey: "o", + data: { foo: 42 }, + }); + })); + test("set_child + get_node", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.childNodeTuple(), - async ([key, value]) => { - const db = await withDefaultDocument(driver); + ([key, value]) => { + resetToDefaultNodes(driver); - await db.set_child(key, value, true); - expect(db.get_node(key)).toEqual(value); + driver.set_child(key, value, true); + expect(driver.get_node(key)).toEqual(value); } ) ) @@ -1143,50 +1155,50 @@ export function generateFullTestSuite(config: { test("has_node: root always exists", () => runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.has_node("root")).toBe(true); + expect(driver.has_node("root")).toBe(true); })); test("has_node: non-existing node returns false", () => runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.has_node("non-existing")).toBe(false); + expect(driver.has_node("non-existing")).toBe(false); })); test("has_node: returns true after set, false after delete", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.childNodeTuple(), - async ([key, value]) => { - const db = await withDefaultDocument(driver); + ([key, value]) => { + resetToDefaultNodes(driver); - expect(db.has_node(key)).toBe(false); - await db.set_child(key, value, true); - expect(db.has_node(key)).toBe(true); - await db.delete_node(key); - expect(db.has_node(key)).toBe(false); + expect(driver.has_node(key)).toBe(false); + driver.set_child(key, value, true); + expect(driver.has_node(key)).toBe(true); + driver.delete_node(key); + expect(driver.has_node(key)).toBe(false); } ) ) )); test("has_node: consistent with get_node", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream(), - async (entries) => { + (entries) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await write_nodes(db, entries); + + write_nodes(driver, entries); // has_node should be true iff get_node returns a value for (const [key] of entries) { - expect(db.has_node(key)).toBe(db.get_node(key) !== undefined); + expect(driver.has_node(key)).toBe( + driver.get_node(key) !== undefined + ); } } ) @@ -1196,9 +1208,9 @@ export function generateFullTestSuite(config: { test("get_child_at: returns undefined for empty store", () => runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.get_child_at("root", "someKey")).toBe(undefined); - expect(db.get_child_at("non-existing", "someKey")).toBe(undefined); + + expect(driver.get_child_at("root", "someKey")).toBe(undefined); + expect(driver.get_child_at("non-existing", "someKey")).toBe(undefined); })); test("get_child_at: returns undefined for empty store (property)", () => @@ -1210,22 +1222,20 @@ export function generateFullTestSuite(config: { (parentId, parentKey) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.get_child_at(parentId, parentKey)).toBe(undefined); + expect(driver.get_child_at(parentId, parentKey)).toBe(undefined); } ) ) )); test("set_child: throws if parent does not exist (orphan)", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // Trying to add a node with a non-existent parentId should throw - await expectToThrow( + expectToThrow( () => - db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "nonexistent-parent", parentKey: "myList", @@ -1235,14 +1245,13 @@ export function generateFullTestSuite(config: { })); test("set_child: throws if parent does not exist (ref cycle)", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // Trying to add a node with a non-existent parentId should throw - await expectToThrow( + expectToThrow( () => - db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "0:0", parentKey: "myList", @@ -1252,13 +1261,12 @@ export function generateFullTestSuite(config: { })); test("set_child: orphan node is not added after throw", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // Try to add orphan, catch the error try { - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "nonexistent-parent", parentKey: "myList", @@ -1268,15 +1276,15 @@ export function generateFullTestSuite(config: { } // The orphan node should not exist in storage - expect(db.get_node("0:0")).toBe(undefined); + expect(driver.get_node("0:0")).toBe(undefined); })); test("set_child: throws if adding a register under an object", () => - runTest(async (driver) => { - const db = await withDefaultDocument(driver); + runTest((driver) => { + resetToDefaultNodes(driver); // Register under LiveList works - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.REGISTER, parentId: "0:dl", // LiveList parentKey: "r", @@ -1284,7 +1292,7 @@ export function generateFullTestSuite(config: { }); // Register under LiveMap works - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:dm", // LiveMap parentKey: "r", @@ -1292,9 +1300,9 @@ export function generateFullTestSuite(config: { }); // Register under LiveObject does not work - await expectToThrow( + expectToThrow( () => - db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:do", parentKey: "r", @@ -1304,9 +1312,9 @@ export function generateFullTestSuite(config: { ); // Root is also a LiveObject, so this also does not work - await expectToThrow( + expectToThrow( () => - db.set_child("0:3", { + driver.set_child("0:3", { type: CrdtType.REGISTER, parentId: "root", parentKey: "r", @@ -1317,93 +1325,89 @@ export function generateFullTestSuite(config: { })); test("get_child_at: returns child id after set", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - expect(db.get_child_at("root", "myList")).toBe("0:0"); - expect(db.get_child_at("root", "otherKey")).toBe(undefined); + expect(driver.get_child_at("root", "myList")).toBe("0:0"); + expect(driver.get_child_at("root", "otherKey")).toBe(undefined); })); test("get_child_at: returns undefined after delete", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - expect(db.get_child_at("root", "myList")).toBe("0:0"); + expect(driver.get_child_at("root", "myList")).toBe("0:0"); - await db.delete_node("0:0"); - expect(db.get_child_at("root", "myList")).toBe(undefined); + driver.delete_node("0:0"); + expect(driver.get_child_at("root", "myList")).toBe(undefined); })); test("get_child_at: returns undefined after set_object_data would overwrite it", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - expect(db.get_child_at("root", "myList")).toBe("0:0"); + expect(driver.get_child_at("root", "myList")).toBe("0:0"); - await db.set_object_data("root", { myList: [1, 2, 3] }, true); - expect(db.get_child_at("root", "myList")).toBe(undefined); + driver.set_object_data("root", { myList: [1, 2, 3] }, true); + expect(driver.get_child_at("root", "myList")).toBe(undefined); })); test("get_child_at: tracks nested children", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // root -> list -> register - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "hello", }); - expect(db.get_child_at("root", "myList")).toBe("0:0"); - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); - expect(db.get_child_at("0:1", "anything")).toBe(undefined); + expect(driver.get_child_at("root", "myList")).toBe("0:0"); + expect(driver.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); + expect(driver.get_child_at("0:1", "anything")).toBe(undefined); })); test("get_child_at: consistent with iter_nodes", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream(), - async (entries) => { + (entries) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await write_nodes(db, entries); + + write_nodes(driver, entries); // For each node with a parent, get_child_at(parentId, parentKey) should return the node's id - for (const node of db.iter_nodes()) { + for (const node of driver.iter_nodes()) { if (!isRootStorageNode(node)) { const [id, crdt] = node; - expect(db.get_child_at(crdt.parentId, crdt.parentKey)).toBe( - id - ); + expect( + driver.get_child_at(crdt.parentId, crdt.parentKey) + ).toBe(id); } } } @@ -1414,9 +1418,9 @@ export function generateFullTestSuite(config: { test("has_child_at: returns false for empty store", () => runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.has_child_at("root", "someKey")).toBe(false); - expect(db.has_child_at("non-existing", "someKey")).toBe(false); + + expect(driver.has_child_at("root", "someKey")).toBe(false); + expect(driver.has_child_at("non-existing", "someKey")).toBe(false); })); test("has_child_at: returns false for empty store (property)", () => @@ -1428,49 +1432,48 @@ export function generateFullTestSuite(config: { (parentId, parentKey) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.has_child_at(parentId, parentKey)).toBe(false); + + expect(driver.has_child_at(parentId, parentKey)).toBe(false); } ) ) )); test("has_child_at: returns true after set, false after delete", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.has_child_at("root", "myList")).toBe(false); + expect(driver.has_child_at("root", "myList")).toBe(false); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - expect(db.has_child_at("root", "myList")).toBe(true); - expect(db.has_child_at("root", "otherKey")).toBe(false); + expect(driver.has_child_at("root", "myList")).toBe(true); + expect(driver.has_child_at("root", "otherKey")).toBe(false); - await db.delete_node("0:0"); - expect(db.has_child_at("root", "myList")).toBe(false); + driver.delete_node("0:0"); + expect(driver.has_child_at("root", "myList")).toBe(false); })); test("has_child_at: consistent with get_child_at", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream(), arb.key(), arb.key(), - async (entries, parentId, parentKey) => { + (entries, parentId, parentKey) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await write_nodes(db, entries); + + write_nodes(driver, entries); // has_child_at should be true iff get_child_at returns a value - expect(db.has_child_at(parentId, parentKey)).toBe( - db.get_child_at(parentId, parentKey) !== undefined + expect(driver.has_child_at(parentId, parentKey)).toBe( + driver.get_child_at(parentId, parentKey) !== undefined ); } ) @@ -1480,27 +1483,25 @@ export function generateFullTestSuite(config: { test("get_next_sibling: returns undefined for empty parent", () => runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // No children under root, so no next position - expect(db.get_next_sibling("root", FIRST_POSITION)).toBe(undefined); - expect(db.get_next_sibling("non-existing", FIRST_POSITION)).toBe( + expect(driver.get_next_sibling("root", FIRST_POSITION)).toBe(undefined); + expect(driver.get_next_sibling("non-existing", FIRST_POSITION)).toBe( undefined ); })); test("get_next_sibling: returns undefined when no positions after", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // Add a list with one item at FIRST_POSITION - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, @@ -1508,47 +1509,46 @@ export function generateFullTestSuite(config: { }); // No position after 2nd - expect(db.get_next_sibling("0:0", SECOND_POSITION)).toBe(undefined); + expect(driver.get_next_sibling("0:0", SECOND_POSITION)).toBe(undefined); // Position before 2nd should find 1st - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe( + expect(driver.get_next_sibling("0:0", FIRST_POSITION)).toBe( SECOND_POSITION ); })); test("get_next_sibling: finds next position in ordered list", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // Create list with items at FIRST, SECOND, THIRD positions - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, data: "item1", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: THIRD_POSITION, data: "item2", }); - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe( + expect(driver.get_next_sibling("0:0", FIRST_POSITION)).toBe( SECOND_POSITION ); - expect(db.get_next_sibling("0:0", SECOND_POSITION)).toBe( + expect(driver.get_next_sibling("0:0", SECOND_POSITION)).toBe( THIRD_POSITION ); - expect(db.get_next_sibling("0:0", THIRD_POSITION)).toBe(undefined); + expect(driver.get_next_sibling("0:0", THIRD_POSITION)).toBe(undefined); // From 2.5'th position, still goes to 3rd expect( - db.get_next_sibling( + driver.get_next_sibling( "0:0", makePosition(SECOND_POSITION, THIRD_POSITION) ) @@ -1556,147 +1556,144 @@ export function generateFullTestSuite(config: { })); test("get_next_sibling: updates after delete", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item1", }); - await db.set_child("0:3", { + driver.set_child("0:3", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: THIRD_POSITION, data: "item3", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, data: "item2", }); - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe( + expect(driver.get_next_sibling("0:0", FIRST_POSITION)).toBe( SECOND_POSITION ); - await db.delete_node("0:2"); - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe(THIRD_POSITION); + driver.delete_node("0:2"); + expect(driver.get_next_sibling("0:0", FIRST_POSITION)).toBe( + THIRD_POSITION + ); - await db.delete_node("0:3"); - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe(undefined); + driver.delete_node("0:3"); + expect(driver.get_next_sibling("0:0", FIRST_POSITION)).toBe(undefined); })); test("get_last_sibling: returns undefined for empty parent", () => runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(db.get_last_sibling("root")).toBe(undefined); - expect(db.get_last_sibling("non-existing")).toBe(undefined); + expect(driver.get_last_sibling("root")).toBe(undefined); + expect(driver.get_last_sibling("non-existing")).toBe(undefined); })); test("get_last_sibling: returns the rightmost position", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item1", }); - expect(db.get_last_sibling("0:0")).toBe(FIRST_POSITION); + expect(driver.get_last_sibling("0:0")).toBe(FIRST_POSITION); // Insert later positions out of order; the rightmost one wins - await db.set_child("0:3", { + driver.set_child("0:3", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: THIRD_POSITION, data: "item3", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, data: "item2", }); - expect(db.get_last_sibling("0:0")).toBe(THIRD_POSITION); + expect(driver.get_last_sibling("0:0")).toBe(THIRD_POSITION); })); test("get_last_sibling: updates after delete", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item1", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, data: "item2", }); - expect(db.get_last_sibling("0:0")).toBe(SECOND_POSITION); + expect(driver.get_last_sibling("0:0")).toBe(SECOND_POSITION); - await db.delete_node("0:2"); - expect(db.get_last_sibling("0:0")).toBe(FIRST_POSITION); + driver.delete_node("0:2"); + expect(driver.get_last_sibling("0:0")).toBe(FIRST_POSITION); - await db.delete_node("0:1"); - expect(db.get_last_sibling("0:0")).toBe(undefined); + driver.delete_node("0:1"); + expect(driver.get_last_sibling("0:0")).toBe(undefined); })); test("move: changes parentKey of node", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item", }); - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe(undefined); + expect(driver.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); + expect(driver.get_child_at("0:0", SECOND_POSITION)).toBe(undefined); - await db.move_sibling("0:1", SECOND_POSITION); + driver.move_sibling("0:1", SECOND_POSITION); - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe(undefined); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe("0:1"); + expect(driver.get_child_at("0:0", FIRST_POSITION)).toBe(undefined); + expect(driver.get_child_at("0:0", SECOND_POSITION)).toBe("0:1"); // Verify node data is preserved - const node = db.get_node("0:1"); + const node = driver.get_node("0:1"); expect(node).toEqual({ type: CrdtType.REGISTER, parentId: "0:0", @@ -1706,22 +1703,21 @@ export function generateFullTestSuite(config: { })); test("move: updates get_next_sibling correctly", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item1", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: THIRD_POSITION, @@ -1729,72 +1725,72 @@ export function generateFullTestSuite(config: { }); // Before move: FIRST -> THIRD - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe(THIRD_POSITION); + expect(driver.get_next_sibling("0:0", FIRST_POSITION)).toBe( + THIRD_POSITION + ); // Move first item to second position - await db.move_sibling("0:1", SECOND_POSITION); + driver.move_sibling("0:1", SECOND_POSITION); // After move: SECOND -> THIRD - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe( + expect(driver.get_next_sibling("0:0", FIRST_POSITION)).toBe( SECOND_POSITION ); - expect(db.get_next_sibling("0:0", SECOND_POSITION)).toBe( + expect(driver.get_next_sibling("0:0", SECOND_POSITION)).toBe( THIRD_POSITION ); })); test("move: multiple moves on same node", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item", }); - await db.move_sibling("0:1", SECOND_POSITION); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe("0:1"); + driver.move_sibling("0:1", SECOND_POSITION); + expect(driver.get_child_at("0:0", SECOND_POSITION)).toBe("0:1"); - await db.move_sibling("0:1", THIRD_POSITION); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe(undefined); - expect(db.get_child_at("0:0", THIRD_POSITION)).toBe("0:1"); + driver.move_sibling("0:1", THIRD_POSITION); + expect(driver.get_child_at("0:0", SECOND_POSITION)).toBe(undefined); + expect(driver.get_child_at("0:0", THIRD_POSITION)).toBe("0:1"); - await db.move_sibling("0:1", FIRST_POSITION); - expect(db.get_child_at("0:0", THIRD_POSITION)).toBe(undefined); - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); + driver.move_sibling("0:1", FIRST_POSITION); + expect(driver.get_child_at("0:0", THIRD_POSITION)).toBe(undefined); + expect(driver.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); })); test("move: preserves other siblings", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item1", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, data: "item2", }); - await db.set_child("0:3", { + driver.set_child("0:3", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: THIRD_POSITION, @@ -1806,32 +1802,33 @@ export function generateFullTestSuite(config: { FIRST_POSITION, SECOND_POSITION ); - await db.move_sibling("0:2", BETWEEN_FIRST_AND_SECOND); + driver.move_sibling("0:2", BETWEEN_FIRST_AND_SECOND); // Other items should be unaffected - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); - expect(db.get_child_at("0:0", THIRD_POSITION)).toBe("0:3"); - expect(db.get_child_at("0:0", BETWEEN_FIRST_AND_SECOND)).toBe("0:2"); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe(undefined); + expect(driver.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); + expect(driver.get_child_at("0:0", THIRD_POSITION)).toBe("0:3"); + expect(driver.get_child_at("0:0", BETWEEN_FIRST_AND_SECOND)).toBe( + "0:2" + ); + expect(driver.get_child_at("0:0", SECOND_POSITION)).toBe(undefined); })); test("move: throws when target position is occupied", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item1", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, @@ -1839,155 +1836,149 @@ export function generateFullTestSuite(config: { }); // Move 0:1 to SECOND_POSITION should throw since 0:2 is there - await expectToThrow( - () => db.move_sibling("0:1", SECOND_POSITION), + expectToThrow( + () => driver.move_sibling("0:1", SECOND_POSITION), /pos.*already taken/i ); // Both nodes should remain unchanged - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe("0:2"); - expect(db.has_node("0:1")).toBe(true); - expect(db.has_node("0:2")).toBe(true); + expect(driver.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); + expect(driver.get_child_at("0:0", SECOND_POSITION)).toBe("0:2"); + expect(driver.has_node("0:1")).toBe(true); + expect(driver.has_node("0:2")).toBe(true); })); test("delete_child_key: removes static data key from object", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_object_data("root", { a: 1, b: 2, c: 3 }); + driver.set_object_data("root", { a: 1, b: 2, c: 3 }); - expect(db.get_node("root")).toEqual({ + expect(driver.get_node("root")).toEqual({ type: CrdtType.OBJECT, data: { a: 1, b: 2, c: 3 }, }); - await db.delete_child_key("root", "b"); + driver.delete_child_key("root", "b"); - expect(db.get_node("root")).toEqual({ + expect(driver.get_node("root")).toEqual({ type: CrdtType.OBJECT, data: { a: 1, c: 3 }, }); })); test("delete_child_key: removes child node", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - expect(db.has_node("0:0")).toBe(true); - expect(db.get_child_at("root", "myList")).toBe("0:0"); + expect(driver.has_node("0:0")).toBe(true); + expect(driver.get_child_at("root", "myList")).toBe("0:0"); - await db.delete_child_key("root", "myList"); + driver.delete_child_key("root", "myList"); - expect(db.has_node("0:0")).toBe(false); - expect(db.get_child_at("root", "myList")).toBe(undefined); + expect(driver.has_node("0:0")).toBe(false); + expect(driver.get_child_at("root", "myList")).toBe(undefined); })); test("delete_child_key: removes child node and its descendants recursively", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); // root -> list -> register - await db.set_child("0:0", { + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "myList", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: FIRST_POSITION, data: "item1", }); - await db.set_child("0:2", { + driver.set_child("0:2", { type: CrdtType.REGISTER, parentId: "0:0", parentKey: SECOND_POSITION, data: "item2", }); - expect(db.has_node("0:0")).toBe(true); - expect(db.has_node("0:1")).toBe(true); - expect(db.has_node("0:2")).toBe(true); + expect(driver.has_node("0:0")).toBe(true); + expect(driver.has_node("0:1")).toBe(true); + expect(driver.has_node("0:2")).toBe(true); - await db.delete_child_key("root", "myList"); + driver.delete_child_key("root", "myList"); - expect(db.has_node("0:0")).toBe(false); - expect(db.has_node("0:1")).toBe(false); - expect(db.has_node("0:2")).toBe(false); + expect(driver.has_node("0:0")).toBe(false); + expect(driver.has_node("0:1")).toBe(false); + expect(driver.has_node("0:2")).toBe(false); })); test("delete_child_key: no-op for non-existing key", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_object_data("root", { a: 1 }); + driver.set_object_data("root", { a: 1 }); // Should not throw - await db.delete_child_key("root", "nonexistent"); - await db.delete_child_key("nonexistent-node", "somekey"); + driver.delete_child_key("root", "nonexistent"); + driver.delete_child_key("nonexistent-node", "somekey"); - expect(db.get_node("root")).toEqual({ + expect(driver.get_node("root")).toEqual({ type: CrdtType.OBJECT, data: { a: 1 }, }); })); test("delete_child_key: preserves sibling data and nodes", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_object_data("root", { a: 1, b: 2 }); - await db.set_child("0:0", { + driver.set_object_data("root", { a: 1, b: 2 }); + driver.set_child("0:0", { type: CrdtType.LIST, parentId: "root", parentKey: "list1", }); - await db.set_child("0:1", { + driver.set_child("0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "list2", }); - await db.delete_child_key("root", "a"); - await db.delete_child_key("root", "list1"); + driver.delete_child_key("root", "a"); + driver.delete_child_key("root", "list1"); // Siblings should be preserved - const rootNode = db.get_node("root"); + const rootNode = driver.get_node("root"); expect(rootNode).toEqual({ type: CrdtType.OBJECT, data: { b: 2 }, }); - expect(db.has_node("0:0")).toBe(false); - expect(db.has_node("0:1")).toBe(true); - expect(db.get_child_at("root", "list2")).toBe("0:1"); + expect(driver.has_node("0:0")).toBe(false); + expect(driver.has_node("0:1")).toBe(true); + expect(driver.get_child_at("root", "list2")).toBe("0:1"); })); test("set + iter_nodes", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeMap(), - async (entries) => { + (entries) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await write_nodes(db, entries as NodeStream); + write_nodes(driver, entries as NodeStream); // iter_nodes includes all inserted children - for (const [key, value] of db.iter_nodes()) { + for (const [key, value] of driver.iter_nodes()) { if (entries.has(key)) { expect(value).toEqual(entries.get(key)!); } else { @@ -2000,33 +1991,33 @@ export function generateFullTestSuite(config: { )); test("set_child overwrites existing node, delete_node removes it", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.key(), arb.serializedChild(), arb.serializedChild(), - async (key, value1, value2) => { + (key, value1, value2) => { fc.pre(key !== "root"); - const db = await withDefaultDocument(driver); + resetToDefaultNodes(driver); - await db.set_child(key, value1, true); - await db.set_child(key, value2, true); // overwrite same key - expect(db.get_node(key)).toEqual(value2); + driver.set_child(key, value1, true); + driver.set_child(key, value2, true); // overwrite same key + expect(driver.get_node(key)).toEqual(value2); - await delete_nodes(db, [key]); - expect(db.get_node(key)).toEqual(undefined); + delete_nodes(driver, [key]); + expect(driver.get_node(key)).toEqual(undefined); } ) ) )); test("delete_nodes (single key)", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( // Generate 3 non-root pairs where all node IDs and parentIds are unique // (6 unique values total), ensuring no cross-references between entries fc @@ -2047,25 +2038,26 @@ export function generateFullTestSuite(config: { ]).size === 6 ), - async ([entry1, entry2, entry3]) => { - const db = await withDefaultDocument(driver); - await write_nodes(db, [entry1, entry2, entry3]); + ([entry1, entry2, entry3]) => { + resetToDefaultNodes(driver); + + write_nodes(driver, [entry1, entry2, entry3]); const key1 = entry1[0]; const key2 = entry2[0]; const key3 = entry3[0]; - expect(new Set(imap(db.iter_nodes(), ([k]) => k))).toEqual( + expect(new Set(imap(driver.iter_nodes(), ([k]) => k))).toEqual( new Set([...KNOWN_DOC_KEYS, key1, key2, key3]) ); - await delete_nodes(db, [key1]); - await delete_nodes(db, [key1]); // Deleting twice has no effect - expect(new Set(imap(db.iter_nodes(), ([k]) => k))).toEqual( + delete_nodes(driver, [key1]); + delete_nodes(driver, [key1]); // Deleting twice has no effect + expect(new Set(imap(driver.iter_nodes(), ([k]) => k))).toEqual( new Set([...KNOWN_DOC_KEYS, key2, key3]) ); - await delete_nodes(db, [key2]); - await delete_nodes(db, [key2]); // Deleting twice has no effect - expect(new Set(imap(db.iter_nodes(), ([k]) => k))).toEqual( + delete_nodes(driver, [key2]); + delete_nodes(driver, [key2]); // Deleting twice has no effect + expect(new Set(imap(driver.iter_nodes(), ([k]) => k))).toEqual( new Set([...KNOWN_DOC_KEYS, key3]) ); } @@ -2074,41 +2066,41 @@ export function generateFullTestSuite(config: { )); test("deleting the root is a no-op", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_object_data("root", { foo: 42 }); + + driver.set_object_data("root", { foo: 42 }); // Try to delete the root node - await db.delete_node("root"); + driver.delete_node("root"); // ...it should not work - expect(Array.from(db.iter_nodes())).toEqual([ + expect(Array.from(driver.iter_nodes())).toEqual([ ["root", { type: CrdtType.OBJECT, data: { foo: 42 } }], // ^^^^^^^^^^^ Still there ]); })); test("write_nodes + get_node loop", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeMap(), - async (entries) => { + (entries) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(Array.from(db.iter_nodes())).toEqual([ + + expect(Array.from(driver.iter_nodes())).toEqual([ ["root", { type: CrdtType.OBJECT, data: {} }], ]); // Write all the entries (can have dupes) - await write_nodes(db, entries as NodeStream); + write_nodes(driver, entries as NodeStream); // Check that get_node returns expected results const map = new Map(entries); // de-dupe for (const [key, expected] of map) { - expect(db.get_node(key)).toEqual(expected); + expect(driver.get_node(key)).toEqual(expected); } } ) @@ -2116,22 +2108,22 @@ export function generateFullTestSuite(config: { )); test("write_nodes + iter_nodes", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeMap(), - async (entries) => { + (entries) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - expect(Array.from(db.iter_nodes())).toEqual([ + + expect(Array.from(driver.iter_nodes())).toEqual([ ["root", { type: CrdtType.OBJECT, data: {} }], ]); // Write all the entries (can have dupes) - await write_nodes(db, entries as NodeStream); + write_nodes(driver, entries as NodeStream); - for (const [key, value] of db.iter_nodes()) { + for (const [key, value] of driver.iter_nodes()) { if (entries.has(key)) { expect(value).toEqual(entries.get(key)!); } else { @@ -2142,7 +2134,7 @@ export function generateFullTestSuite(config: { // Check that readone will also have the same results const map = new Map(entries); // de-dupe for (const [key, expected] of map) { - expect(db.get_node(key)).toEqual(expected); + expect(driver.get_node(key)).toEqual(expected); } } ) @@ -2150,26 +2142,26 @@ export function generateFullTestSuite(config: { )); test("iter_nodes_optimized agrees with iter_nodes (cross-driver parity)", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeMap(), - async (entries) => { + (entries) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await write_nodes(db, entries as NodeStream); + + write_nodes(driver, entries as NodeStream); // Parse the wire tuples back into CompactNodes and compare // against what the canonical nodeStreamToCompactNodes would // produce from iter_nodes(). Catches any divergence between the // optimized SQL path and the readable JS path (string encoding, // escaping, missing fields, type coercion, etc.). - const fromWire = Array.from(db.iter_nodes_optimized()).map( + const fromWire = Array.from(driver.iter_nodes_optimized()).map( (t) => JSON.parse(t) as unknown ); const fromIter = Array.from( - nodeStreamToCompactNodes(db.iter_nodes()) + nodeStreamToCompactNodes(driver.iter_nodes()) ) as unknown[]; const byId = (a: unknown, b: unknown) => (a as [string])[0].localeCompare((b as [string])[0]); @@ -2181,21 +2173,21 @@ export function generateFullTestSuite(config: { )); test("delete_nodes", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream(), - async (entries) => { + (entries) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await write_nodes(db, entries); - await delete_nodes( - db, + write_nodes(driver, entries); + + delete_nodes( + driver, new Map(entries).keys() ); - for (const [key] of db.iter_nodes()) { + for (const [key] of driver.iter_nodes()) { // Only "root" should remain - all entries should be deleted expect(key).toEqual("root"); } @@ -2205,31 +2197,31 @@ export function generateFullTestSuite(config: { )); test("delete_nodes (with more than 64 keys)", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream({ depthSize: "xlarge", maxKeys: 100, maxLength: 100, }), - async (entries) => { - const db = await withDefaultDocument(driver); - await write_nodes(db, entries); + (entries) => { + resetToDefaultNodes(driver); + write_nodes(driver, entries); - expect(Array.from(db.iter_nodes()).length).toBeGreaterThan(2); // "root" + at least 1 node + expect(Array.from(driver.iter_nodes()).length).toBeGreaterThan(2); // "root" + at least 1 node const originalRootData = ( - db.get_node("root") as SerializedRootObject + driver.get_node("root") as SerializedRootObject ).data; - await delete_nodes( - db, + delete_nodes( + driver, new Map(entries).keys() ); - await delete_nodes(db, KNOWN_DOC_KEYS); // finally also delete the keys from the default document - expect(Array.from(db.iter_nodes())).toEqual([ + delete_nodes(driver, KNOWN_DOC_KEYS); // finally also delete the keys from the default document + expect(Array.from(driver.iter_nodes())).toEqual([ ["root", { type: CrdtType.OBJECT, data: originalRootData }], ]); } @@ -2239,21 +2231,21 @@ export function generateFullTestSuite(config: { )); test("get_snapshot: get_node returns same data as driver (when unchanged)", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream(), - async (entries) => { - const db = await withDefaultDocument(driver); - await write_nodes(db, entries); + (entries) => { + resetToDefaultNodes(driver); + write_nodes(driver, entries); - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); // Snapshot should return the same data as the driver for (const [id] of entries) { expect(snapshot.get_node(id)).toEqual( - db.get_node(id) as SerializedChild + driver.get_node(id) as SerializedChild ); } } @@ -2262,20 +2254,20 @@ export function generateFullTestSuite(config: { )); test("get_snapshot: iter_children returns same data as driver (when unchanged)", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream(), - async (entries) => { - const db = await withDefaultDocument(driver); - await write_nodes(db, entries); + (entries) => { + resetToDefaultNodes(driver); + write_nodes(driver, entries); - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); // Build expected children of root from driver's nodes const expected: [string, string][] = []; - for (const node of db.iter_nodes()) { + for (const node of driver.iter_nodes()) { if (!isRootStorageNode(node)) { const [childId, crdt] = node; if (crdt.parentId === "root") { @@ -2299,11 +2291,11 @@ export function generateFullTestSuite(config: { // This test verifies snapshot isolation. Writes after taking a snapshot // should NOT be visible in the snapshot. test("get_snapshot: new nodes after snapshot are not visible", () => - runTest(async (driver) => { - const db = await withDefaultDocument(driver); + runTest((driver) => { + resetToDefaultNodes(driver); // Take snapshot before adding new node - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); // Add a new node after taking the snapshot const newNode = { @@ -2311,10 +2303,10 @@ export function generateFullTestSuite(config: { parentId: "root", parentKey: "newList", } as const; - await db.set_child("si:new-node-id", newNode); + driver.set_child("si:new-node-id", newNode); // The new node should exist in the driver - expect(db.get_node("si:new-node-id")).toEqual(newNode); + expect(driver.get_node("si:new-node-id")).toEqual(newNode); // But NOT in the snapshot (snapshot isolation) expect(() => snapshot.get_node("si:new-node-id")).toThrow(); @@ -2327,8 +2319,8 @@ export function generateFullTestSuite(config: { // This test verifies snapshot isolation. Deletes after taking a snapshot // should NOT affect the snapshot. test("get_snapshot: deleted nodes after snapshot are still visible", () => - runTest(async (driver) => { - const db = await withDefaultDocument(driver); + runTest((driver) => { + resetToDefaultNodes(driver); // Add a node first const node = { @@ -2336,19 +2328,19 @@ export function generateFullTestSuite(config: { parentId: "root", parentKey: "myMap", } as const; - await db.set_child("si:node-to-delete", node); + driver.set_child("si:node-to-delete", node); // Take snapshot before deleting - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); // Verify node exists in both driver and snapshot - expect(db.get_node("si:node-to-delete")).toEqual(node); + expect(driver.get_node("si:node-to-delete")).toEqual(node); // Delete the node after taking the snapshot - await db.delete_node("si:node-to-delete"); + driver.delete_node("si:node-to-delete"); // The node should NOT exist in the driver anymore - expect(db.get_node("si:node-to-delete")).toBeUndefined(); + expect(driver.get_node("si:node-to-delete")).toBeUndefined(); // But should STILL exist in the snapshot (snapshot isolation) const snapshotChildren = [...snapshot.iter_children("root")]; @@ -2360,19 +2352,19 @@ export function generateFullTestSuite(config: { })); test("get_snapshot: delete_child_key on static data does not affect snapshot", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_object_data("root", { a: 1, b: 2, c: 3 }); + + driver.set_object_data("root", { a: 1, b: 2, c: 3 }); // Take snapshot before deleting a key - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); // Delete a static data key after taking the snapshot - await db.delete_child_key("root", "b"); + driver.delete_child_key("root", "b"); // Driver should reflect the deletion - expect(db.get_node("root")).toEqual({ + expect(driver.get_node("root")).toEqual({ type: CrdtType.OBJECT, data: { a: 1, c: 3 }, }); @@ -2385,19 +2377,19 @@ export function generateFullTestSuite(config: { })); test("get_snapshot: set_object_data after snapshot does not affect snapshot", () => - runTest(async (driver) => { + runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - await db.set_object_data("root", { a: 1, b: 2 }); + + driver.set_object_data("root", { a: 1, b: 2 }); // Take snapshot before mutating - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); // Mutate data after taking the snapshot - await db.set_object_data("root", { a: 1, b: 2, c: 3 }); + driver.set_object_data("root", { a: 1, b: 2, c: 3 }); // Driver should reflect the update - expect(db.get_node("root")).toEqual({ + expect(driver.get_node("root")).toEqual({ type: CrdtType.OBJECT, data: { a: 1, b: 2, c: 3 }, }); @@ -2410,23 +2402,23 @@ export function generateFullTestSuite(config: { })); test("get_snapshot: iter_all returns same nodes as driver", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.nodeStream(), - async (entries) => { - const db = await withDefaultDocument(driver); - await write_nodes(db, entries); + (entries) => { + resetToDefaultNodes(driver); + write_nodes(driver, entries); - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); const snapshotNodes = new Map([ ...snapshot.iter_all(), ]); // Should contain the same node ids as the driver const driverNodes = new Map([ - ...db.iter_nodes(), + ...driver.iter_nodes(), ]); expect(snapshotNodes.size).toBe(driverNodes.size); for (const [id, crdt] of driverNodes) { @@ -2438,8 +2430,8 @@ export function generateFullTestSuite(config: { )); test("get_snapshot: iter_all has snapshot isolation", () => - runTest(async (driver) => { - const db = await withDefaultDocument(driver); + runTest((driver) => { + resetToDefaultNodes(driver); // Add a node const node = { @@ -2447,13 +2439,13 @@ export function generateFullTestSuite(config: { parentId: "root", parentKey: "myList", } as const; - await db.set_child("si:before-snap", node); + driver.set_child("si:before-snap", node); // Take snapshot - const snapshot = db.get_snapshot(); + const snapshot = driver.get_snapshot(); // Add another node after snapshot - await db.set_child("si:after-snap", { + driver.set_child("si:after-snap", { type: CrdtType.MAP, parentId: "root", parentKey: "myMap", @@ -2471,420 +2463,13 @@ export function generateFullTestSuite(config: { test("get_snapshot: iter_all on empty room returns only root", () => runTest((driver) => { driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - const snapshot = db.get_snapshot(); + + const snapshot = driver.get_snapshot(); const nodes = [...snapshot.iter_all()]; expect(nodes).toEqual([["root", { type: CrdtType.OBJECT, data: {} }]]); })); }); - describe("synchronous read-after-write semantics", () => { - // These tests verify that writes are immediately visible to synchronous reads, - // even before the returned Promise is awaited. This is critical behavior for - // the INewNodeStorageDriver API. - - test("set: read sees new node before await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - const node = { - type: CrdtType.LIST, - parentId: "root", - parentKey: "myList", - } as const; - - // Call set but don't await yet - const p$ = db.set_child("0:0", node); - - // Read should see the node immediately (synchronously) - expect(db.get_node("0:0")).toEqual(node); - expect(db.has_node("0:0")).toBe(true); - expect(db.get_child_at("root", "myList")).toBe("0:0"); - expect(db.has_child_at("root", "myList")).toBe(true); - - // After await, should still see the node - await p$; - expect(db.get_node("0:0")).toEqual(node); - expect(db.has_node("0:0")).toBe(true); - expect(db.get_child_at("root", "myList")).toBe("0:0"); - expect(db.has_child_at("root", "myList")).toBe(true); - })); - - test("delete: read sees deletion before await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // First create a node - await db.set_child("0:0", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "myList", - }); - expect(db.has_node("0:0")).toBe(true); - - // Call delete but don't await yet - const p$ = db.delete_node("0:0"); - - // Read should NOT see the node immediately (synchronously) - expect(db.get_node("0:0")).toBe(undefined); - expect(db.has_node("0:0")).toBe(false); - expect(db.get_child_at("root", "myList")).toBe(undefined); - expect(db.has_child_at("root", "myList")).toBe(false); - - // After await, should still NOT see the node - await p$; - expect(db.get_node("0:0")).toBe(undefined); - expect(db.has_node("0:0")).toBe(false); - expect(db.get_child_at("root", "myList")).toBe(undefined); - expect(db.has_child_at("root", "myList")).toBe(false); - })); - - test("move: read sees new position before await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // Create a list with an item - await db.set_child("0:0", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "myList", - }); - await db.set_child("0:1", { - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "item", - }); - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe("0:1"); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe(undefined); - - // Call move but don't await yet - const p$ = db.move_sibling("0:1", SECOND_POSITION); - - // Read should see the new position immediately (synchronously) - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe(undefined); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe("0:1"); - expect(db.get_node("0:1")).toEqual({ - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: SECOND_POSITION, - data: "item", - }); - - // After await, should still see the new position - await p$; - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe(undefined); - expect(db.get_child_at("0:0", SECOND_POSITION)).toBe("0:1"); - expect(db.get_node("0:1")).toEqual({ - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: SECOND_POSITION, - data: "item", - }); - })); - - test("set_object_data: read sees new data before await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // Start with empty root - expect(db.get_node("root")).toEqual({ - type: CrdtType.OBJECT, - data: {}, - }); - - // Call set_object_data but don't await yet - const p$ = db.set_object_data("root", { foo: 42, bar: "hello" }); - - // Read should see the new data immediately (synchronously) - expect(db.get_node("root")).toEqual({ - type: CrdtType.OBJECT, - data: { foo: 42, bar: "hello" }, - }); - - // After await, should still see the new data - await p$; - expect(db.get_node("root")).toEqual({ - type: CrdtType.OBJECT, - data: { foo: 42, bar: "hello" }, - }); - })); - - test("set_object_data: no-op if called on a non-object node", () => - runTest(async (driver) => { - const db = await withDefaultDocument(driver); - - const listBefore = db.get_node("0:dl"); - const mapBefore = db.get_node("0:dm"); - - // set_object_data on a LiveList is a no-op - await db.set_object_data("0:dl", { foo: 42 }); - expect(db.get_node("0:dl")).toEqual(listBefore); - - // set_object_data on a LiveMap is a no-op - await db.set_object_data("0:dm", { foo: 42 }); - expect(db.get_node("0:dm")).toEqual(mapBefore); - - // set_object_data on a LiveObject should work - await db.set_object_data("0:do", { foo: 42 }); - expect(db.get_node("0:do")).toEqual({ - type: CrdtType.OBJECT, - parentId: "root", - parentKey: "o", - data: { foo: 42 }, - }); - })); - - test("delete_child_key: read sees deletion before await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // Set up root with static data and a child node - await db.set_object_data("root", { a: 1, b: 2 }); - await db.set_child("0:0", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "myList", - }); - - expect(db.get_node("root")).toEqual({ - type: CrdtType.OBJECT, - data: { a: 1, b: 2 }, - }); - expect(db.has_node("0:0")).toBe(true); - - // Call delete_child_key for static data but don't await yet - const p1$ = db.delete_child_key("root", "a"); - - // Read should see the deletion immediately (synchronously) - expect(db.get_node("root")).toEqual({ - type: CrdtType.OBJECT, - data: { b: 2 }, - }); - - await p1$; - - // Call delete_child_key for child node but don't await yet - const p2$ = db.delete_child_key("root", "myList"); - - // Read should see the child deletion immediately (synchronously) - expect(db.has_node("0:0")).toBe(false); - expect(db.get_child_at("root", "myList")).toBe(undefined); - expect(db.has_child_at("root", "myList")).toBe(false); - - // After await, should still see the deletion - await p2$; - expect(db.has_node("0:0")).toBe(false); - expect(db.get_child_at("root", "myList")).toBe(undefined); - expect(db.has_child_at("root", "myList")).toBe(false); - })); - - test("multiple writes: all visible before any await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // Perform multiple writes without awaiting any of them - const p1$ = db.set_child("0:0", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "list1", - }); - const p2$ = db.set_child("0:1", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "list2", - }); - const p3$ = db.set_child("0:2", { - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "item", - }); - - // All writes should be visible synchronously - expect(db.has_node("0:0")).toBe(true); - expect(db.has_node("0:1")).toBe(true); - expect(db.has_node("0:2")).toBe(true); - expect(db.get_child_at("root", "list1")).toBe("0:0"); - expect(db.get_child_at("root", "list2")).toBe("0:1"); - expect(db.get_child_at("0:0", FIRST_POSITION)).toBe("0:2"); - - // iter_nodes should see all nodes - const nodes = new Map(db.iter_nodes()); - expect(nodes.size).toBe(4); // root + 3 nodes - expect(nodes.has("root")).toBe(true); - expect(nodes.has("0:0")).toBe(true); - expect(nodes.has("0:1")).toBe(true); - expect(nodes.has("0:2")).toBe(true); - - // After awaiting, should still see all nodes - await Promise.all([p1$, p2$, p3$]); - expect(db.has_node("0:0")).toBe(true); - expect(db.has_node("0:1")).toBe(true); - expect(db.has_node("0:2")).toBe(true); - })); - - test("interleaved write and delete: reads see correct state", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // Create a node - const p1$ = db.set_child("0:0", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "myList", - }); - - // Should be visible immediately - expect(db.has_node("0:0")).toBe(true); - - // Delete it (without awaiting the create) - const p2$ = db.delete_node("0:0"); - - // Should NOT be visible anymore - expect(db.has_node("0:0")).toBe(false); - expect(db.get_child_at("root", "myList")).toBe(undefined); - - // Re-create it (without awaiting the delete) - const p3$ = db.set_child("0:1", { - type: CrdtType.MAP, - parentId: "root", - parentKey: "myList", - }); - - // Should see the new node - expect(db.has_node("0:1")).toBe(true); - expect(db.get_child_at("root", "myList")).toBe("0:1"); - expect(db.get_node("0:1")).toEqual({ - type: CrdtType.MAP, - parentId: "root", - parentKey: "myList", - }); - - // After awaiting all, state should be consistent - await Promise.all([p1$, p2$, p3$]); - expect(db.has_node("0:0")).toBe(false); - expect(db.has_node("0:1")).toBe(true); - expect(db.get_child_at("root", "myList")).toBe("0:1"); - })); - - test("get_next_sibling: updated before await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // Create a list with two items - await db.set_child("0:0", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "myList", - }); - await db.set_child("0:1", { - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "first", - }); - await db.set_child("0:2", { - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: THIRD_POSITION, - data: "third", - }); - - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe(THIRD_POSITION); - - // Add a node in between without awaiting - const p$ = db.set_child("0:3", { - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: SECOND_POSITION, - data: "second", - }); - - // get_next_sibling should see the new node immediately - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe( - SECOND_POSITION - ); - expect(db.get_next_sibling("0:0", SECOND_POSITION)).toBe( - THIRD_POSITION - ); - - // After await, should still work correctly - await p$; - expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe( - SECOND_POSITION - ); - expect(db.get_next_sibling("0:0", SECOND_POSITION)).toBe( - THIRD_POSITION - ); - })); - - test("set with overwrite: read sees update before await", () => - runTest(async (driver) => { - driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); - const db = driver.load_nodes_api(blackHole); - - // Create a list to hold registers - await db.set_child("0:0", { - type: CrdtType.LIST, - parentId: "root", - parentKey: "myList", - }); - - // Create initial register in the list - await db.set_child("0:1", { - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "initial", - }); - - expect(db.get_node("0:1")).toEqual({ - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "initial", - }); - - // Overwrite with allowOverwrite=true but don't await - const p$ = db.set_child( - "0:1", - { - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "updated", - }, - true - ); - - // Should see updated value immediately - expect(db.get_node("0:1")).toEqual({ - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "updated", - }); - - // After await, should still see updated value - await p$; - expect(db.get_node("0:1")).toEqual({ - type: CrdtType.REGISTER, - parentId: "0:0", - parentKey: FIRST_POSITION, - data: "updated", - }); - })); - }); - describe("meta API impl", () => { test("get_meta with empty store is undefined", () => runTest((driver) => @@ -2953,18 +2538,16 @@ export function generateFullTestSuite(config: { )); test("put_meta loop + get_meta loop", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.metaEntries(), - async (entries) => { + (entries) => { // Write all the entries (can have dupes) - await Promise.all( - Array.from(entries).map(([key, value]) => - driver.put_meta(key, value) - ) - ); + for (const [key, value] of entries) { + driver.put_meta(key, value); + } // Check that readone will also have the same results const map = new Map(entries); // de-dupe @@ -3021,23 +2604,21 @@ export function generateFullTestSuite(config: { )); test("delete_meta", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.metaPair(), arb.metaPair(), arb.metaPair(), - async (entry1, entry2, entry3) => { + (entry1, entry2, entry3) => { fc.pre(entry1[0] !== entry2[0]); fc.pre(entry1[0] !== entry3[0]); fc.pre(entry2[0] !== entry3[0]); - await Promise.all([ - driver.put_meta(...entry1), - driver.put_meta(...entry2), - driver.put_meta(...entry3), - ]); + driver.put_meta(...entry1); + driver.put_meta(...entry2); + driver.put_meta(...entry3); const key1 = entry1[0]; const key2 = entry2[0]; @@ -3251,14 +2832,14 @@ export function generateFullTestSuite(config: { )); test("delete specific session leaves others intact", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( arb.leasedSessionPair(), arb.leasedSessionPair(), arb.leasedSessionPair(), - async (entry1, entry2, entry3) => { + (entry1, entry2, entry3) => { const [sessionId1, session1] = entry1; const [sessionId2, session2] = entry2; const [sessionId3, session3] = entry3; @@ -3272,11 +2853,9 @@ export function generateFullTestSuite(config: { session1.sessionId = sessionId1; session2.sessionId = sessionId2; session3.sessionId = sessionId3; - await Promise.all([ - driver.put_leased_session(session1), - driver.put_leased_session(session2), - driver.put_leased_session(session3), - ]); + driver.put_leased_session(session1); + driver.put_leased_session(session2); + driver.put_leased_session(session3); // Verify all exist expect(driver.get_leased_session(sessionId1)).toEqual(session1); @@ -3345,28 +2924,24 @@ export function generateFullTestSuite(config: { )); test("concurrent put_leased_session operations", () => - runTest(async (driver) => + runTest((driver) => fc.assert( - fc.asyncProperty( + fc.property( fc .array(arb.leasedSessionPair(), { minLength: 3, maxLength: 10 }) .map((x) => new Map(x)) .filter((m) => m.size >= 3), - async (entries) => { - // Put all sessions concurrently - await Promise.all( - Array.from(entries).map(([sessionId, session]) => { - session.sessionId = sessionId; - return driver.put_leased_session(session); - }) - ); + (entries) => { + // Put all sessions + for (const [sessionId, session] of entries) { + session.sessionId = sessionId; + driver.put_leased_session(session); + } // Verify all sessions exist - const results = await Promise.all( - Array.from(entries.keys()).map((sessionId) => - driver.get_leased_session(sessionId) - ) + const results = Array.from(entries.keys()).map((sessionId) => + driver.get_leased_session(sessionId) ); let idx = 0; @@ -3376,11 +2951,9 @@ export function generateFullTestSuite(config: { } // Cleanup: delete all sessions added in this iteration - await Promise.all( - Array.from(entries.keys()).map((sessionId) => - driver.delete_leased_session(sessionId) - ) - ); + for (const sessionId of entries.keys()) { + driver.delete_leased_session(sessionId); + } } ) ) @@ -3899,16 +3472,9 @@ export function generateFullTestSuite(config: { // Create the access layer around the plugin const storage = new Storage(driver); - const logger = { - warn: () => {}, - error: () => {}, - } as unknown as Logger; - return (async () => { - await storage.load(logger); - // Self-check after loading - await selfCheck(storage); + selfCheck(storage); return callback({ storage }); })(); @@ -3932,9 +3498,9 @@ export function generateFullTestSuite(config: { * - If it exists → it MUST be empty * - If it doesn't exist → that's fine too */ - async function assert(s: Storage, expectedNodes: StorageNode[]) { - const memory = Array.from(s.loadedDriver.iter_nodes()); - const storage = Array.from(await s.raw_iter_nodes()); + function assert(s: Storage, expectedNodes: StorageNode[]) { + const memory = Array.from(s.driver.iter_nodes()); + const storage = Array.from(s.raw_iter_nodes()); expectedNodes = [...expectedNodes].sort(cmpById); const inMemoryNodes = [...memory].sort(cmpById); @@ -3971,19 +3537,19 @@ export function generateFullTestSuite(config: { } test("loading storage should not persist empty root node in storage", () => - runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await assert(storage, [rootObj()]); - }); - })); + runTest((driver) => + runWithStorage(driver, ({ storage }) => { + assert(storage, [rootObj()]); + }) + )); test("loading storage should persist root node in storage (with data)", () => { const nodesOnDisk: NodeMap = new Map(); nodesOnDisk.set("root", { data: { a: 1 }, type: CrdtType.OBJECT }); return runTest({ initialNodes: nodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await assert(storage, [rootObj({ a: 1 })]); + await runWithStorage(driver, ({ storage }) => { + assert(storage, [rootObj({ a: 1 })]); }); }); }); @@ -3998,11 +3564,8 @@ export function generateFullTestSuite(config: { }); return runTest({ initialNodes: nodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await assert(storage, [ - obj("0:1", { a: 1 }, "root", "child"), - rootObj(), - ]); + await runWithStorage(driver, ({ storage }) => { + assert(storage, [obj("0:1", { a: 1 }, "root", "child"), rootObj()]); }); }); }); @@ -4025,9 +3588,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Child node wins - "static value" should have been removed - await assert(storage, [ + assert(storage, [ rootObj({ otherkey: "kept" }), // mykey removed from data obj("0:0", { nested: 42 }, "root", "mykey"), // child node preserved ]); @@ -4053,9 +3616,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Register under object is dropped (it's a corruption) - await assert(storage, [ + assert(storage, [ rootObj({ existing: "value" }), // register is gone, not converted ]); }); @@ -4085,9 +3648,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Register under object is dropped - await assert(storage, [ + assert(storage, [ rootObj(), obj("0:0", { existing: "value" }, "root", "child"), ]); @@ -4132,9 +3695,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Entire illegal subtree is dropped - await assert(storage, [rootObj()]); + assert(storage, [rootObj()]); }); } ); @@ -4179,9 +3742,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Only root and list remain, the illegal subtree is dropped - await assert(storage, [ + assert(storage, [ rootObj(), list("0:0", "root", "list"), register("0:1", "0:0", "A0:", "valid register"), @@ -4212,9 +3775,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // The "a" key should be removed from root's data, "b" kept - await assert(storage, [ + assert(storage, [ rootObj({ b: "keep" }), obj("0:0", { nested: "value" }, "root", "a"), ]); @@ -4266,8 +3829,8 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await assert(storage, [ + await runWithStorage(driver, ({ storage }) => { + assert(storage, [ rootObj({ keep: "yes" }), obj("0:0", { nested: "value" }, "root", key), ]); @@ -4317,9 +3880,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Highest node id wins (deterministic across all backends) - await assert(storage, [ + assert(storage, [ rootObj(), obj("1:0", { id: "1:0", winner: true }, "root", "slot1"), obj("1:1", { id: "1:1", winner: true }, "root", "slot2"), @@ -4330,10 +3893,12 @@ export function generateFullTestSuite(config: { }); test("loading storage fixes orphaned nodes", () => { - // DOS-SQLite has a FK constraint (parent_id REFERENCES nodes(id)), so orphaned - // nodes can't exist - the database itself prevents inserting nodes with - // non-existent parents. This test only applies to backends without FK constraints. - if (config.name === "dos-sqlite") return Promise.resolve(); + // The SQLite backends (dos-sqlite, bun-sqlite) have a FK constraint + // (parent_id REFERENCES nodes(id)), so orphaned nodes can't exist - the + // database itself prevents inserting nodes with non-existent parents. + // This test only applies to backends without FK constraints. + if (config.name === "dos-sqlite" || config.name === "bun-sqlite") + return Promise.resolve(); const corruptedNodesOnDisk: NodeMap = new Map(); corruptedNodesOnDisk.set("root", { @@ -4363,22 +3928,21 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Orphans are excluded from in-memory view - await assert(storage, [ - rootObj(), - obj("0:0", {}, "root", "connected"), - ]); + assert(storage, [rootObj(), obj("0:0", {}, "root", "connected")]); }); } ); }); test("loading storage removes node cycles as if the entire tree is orphaned", () => { - // DOS-SQLite has a FK constraint (parent_id REFERENCES nodes(id)), so cycles - // can't be created - inserting a node requires its parent to already exist. - // This test only applies to backends without FK constraints. - if (config.name === "dos-sqlite") return Promise.resolve(); + // The SQLite backends (dos-sqlite, bun-sqlite) have a FK constraint + // (parent_id REFERENCES nodes(id)), so cycles can't be created - + // inserting a node requires its parent to already exist. This test only + // applies to backends without FK constraints. + if (config.name === "dos-sqlite" || config.name === "bun-sqlite") + return Promise.resolve(); const corruptedNodesOnDisk: NodeMap = new Map(); corruptedNodesOnDisk.set("root", { @@ -4414,12 +3978,9 @@ export function generateFullTestSuite(config: { return runTest( { initialNodes: corruptedNodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Cycles are unreachable from root, treated as orphans - await assert(storage, [ - rootObj(), - obj("0:0", {}, "root", "connected"), - ]); + assert(storage, [rootObj(), obj("0:0", {}, "root", "connected")]); }); } ); @@ -4428,15 +3989,15 @@ export function generateFullTestSuite(config: { test("UpdateObject is a no-op on non-object nodes", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createObjectOp("0:1", "root", "o", { a: 0 }), createListOp("0:2", "root", "l"), createMapOp("0:3", "root", "m"), createRegisterOp("0:4", "0:3", "r", 42), ]); - await assert(storage, [ + assert(storage, [ rootObj(), obj("0:1", { a: 0 }, "root", "o"), list("0:2", "root", "l"), @@ -4445,12 +4006,12 @@ export function generateFullTestSuite(config: { ]); // Calling updateObjectOp on non-objects is a no-op... - await storage.applyOps([updateObjectOp("0:0", { a: 1337 })]); - await storage.applyOps([updateObjectOp("0:2", { a: 1337 })]); - await storage.applyOps([updateObjectOp("0:3", { a: 1337 })]); - await storage.applyOps([updateObjectOp("0:4", { a: 1337 })]); + storage.applyOps([updateObjectOp("0:0", { a: 1337 })]); + storage.applyOps([updateObjectOp("0:2", { a: 1337 })]); + storage.applyOps([updateObjectOp("0:3", { a: 1337 })]); + storage.applyOps([updateObjectOp("0:4", { a: 1337 })]); - await assert(storage, [ + assert(storage, [ rootObj(), obj("0:1", { a: 0 }, "root", "o"), list("0:2", "root", "l"), @@ -4459,9 +4020,9 @@ export function generateFullTestSuite(config: { ]); // ...but calling it on an object will not be - await storage.applyOps([updateObjectOp("0:1", { a: 1337 })]); + storage.applyOps([updateObjectOp("0:1", { a: 1337 })]); - await assert(storage, [ + assert(storage, [ rootObj(), obj("0:1", { a: 1337 }, "root", "o"), list("0:2", "root", "l"), @@ -4473,74 +4034,72 @@ export function generateFullTestSuite(config: { test("UpdateObject missing object should not create a new object", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([updateObjectOp("xxx", { a: 0 })]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([updateObjectOp("xxx", { a: 0 })]); - await assert(storage, [rootObj()]); + assert(storage, [rootObj()]); }); })); test("UpdateObject single property with native value", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([updateObjectOp("root", { a: 0 })]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([updateObjectOp("root", { a: 0 })]); - await assert(storage, [rootObj({ a: 0 })]); + assert(storage, [rootObj({ a: 0 })]); }); })); test("UpdateObject remove child object recursively", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createObjectOp("0:1", "root", "child", { a: 0 }), createObjectOp("0:2", "0:1", "grandChild", { b: 0 }), ]); - await assert(storage, [ + assert(storage, [ rootObj(), obj("0:1", { a: 0 }, "root", "child"), obj("0:2", { b: 0 }, "0:1", "grandChild"), ]); - await storage.applyOps([updateObjectOp("root", { child: null })]); + storage.applyOps([updateObjectOp("root", { child: null })]); - await assert(storage, [rootObj({ child: null })]); + assert(storage, [rootObj({ child: null })]); }); })); test("UpdateObject can be used to update the root object", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await assert(storage, [rootObj()]); + await runWithStorage(driver, ({ storage }) => { + assert(storage, [rootObj()]); - await storage.applyOps([updateObjectOp("root", { a: 0 })]); - await assert(storage, [rootObj({ a: 0 })]); + storage.applyOps([updateObjectOp("root", { a: 0 })]); + assert(storage, [rootObj({ a: 0 })]); - await storage.applyOps([deleteObjectKeyOp("root", "a")]); - await assert(storage, [rootObj()]); + storage.applyOps([deleteObjectKeyOp("root", "a")]); + assert(storage, [rootObj()]); }); })); test("CreateRegister in map should remove previous one", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createMapOp("0:1", "root", "map"), createRegisterOp("0:2", "0:1", "boolean", true), ]); - await assert(storage, [ + assert(storage, [ rootObj(), map("0:1", "root", "map"), register("0:2", "0:1", "boolean", true), ]); - await storage.applyOps([ - createRegisterOp("0:3", "0:1", "boolean", false), - ]); + storage.applyOps([createRegisterOp("0:3", "0:1", "boolean", false)]); - await assert(storage, [ + assert(storage, [ rootObj(), map("0:1", "root", "map"), register("0:3", "0:1", "boolean", false), @@ -4550,11 +4109,11 @@ export function generateFullTestSuite(config: { test("CreateRegister inside a list", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([createListOp("0:0", "root", "items")]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([createListOp("0:0", "root", "items")]); const op = createRegisterOp("1:0", "0:0", FIRST_POSITION, "A"); - const result = await storage.applyOps([op]); + const result = storage.applyOps([op]); expect(result).toEqual([{ action: "accepted", op }]); @@ -4568,14 +4127,14 @@ export function generateFullTestSuite(config: { test("CreateRegister inside a list with a parent key that already exists should fix operation", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createRegisterOp("0:1", "0:0", FIRST_POSITION, "A"), ]); const inputOp = createRegisterOp("1:0", "0:0", FIRST_POSITION, "B"); - const result = await storage.applyOps([inputOp]); + const result = storage.applyOps([inputOp]); expect(result).toMatchObject([ { @@ -4604,8 +4163,8 @@ export function generateFullTestSuite(config: { test("CreateRegister inside a list with a parent key that already exists and set intent should replace existing child", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createRegisterOp("0:1", "0:0", FIRST_POSITION, "A"), ]); @@ -4618,7 +4177,7 @@ export function generateFullTestSuite(config: { "set", "0:1" ); - const result = await storage.applyOps([op]); + const result = storage.applyOps([op]); expect(result).toEqual([{ action: "accepted", op }]); @@ -4632,16 +4191,16 @@ export function generateFullTestSuite(config: { test("CreateRegister inside a list with set intent should set it as child", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([createListOp("0:0", "root", "items")]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([createListOp("0:0", "root", "items")]); // Should only happen when a client has deleted a child while another client replace it with a set const op = createRegisterOp("1:0", "0:0", FIRST_POSITION, "B", "set"); - const result = await storage.applyOps([op]); + const result = storage.applyOps([op]); expect(result).toEqual([{ action: "accepted", op }]); - await assert(storage, [ + assert(storage, [ rootObj(), list("0:0", "root", "items"), register("1:0", "0:0", FIRST_POSITION, "B"), @@ -4651,17 +4210,15 @@ export function generateFullTestSuite(config: { test("CreateRegister when register already exists should ignore op and not throw error", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createMapOp("0:0", "root", "map"), createRegisterOp("0:1", "0:0", "boolean", true), ]); - await storage.applyOps([ - createRegisterOp("0:1", "0:0", "boolean", false), - ]); + storage.applyOps([createRegisterOp("0:1", "0:0", "boolean", false)]); - await assert(storage, [ + assert(storage, [ rootObj(), map("0:0", "root", "map"), register("0:1", "0:0", "boolean", true), @@ -4684,50 +4241,50 @@ export function generateFullTestSuite(config: { ]); return runTest({ initialNodes: nodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { const expectedNodes = [ list("0:0", "root", "list"), register("0:1", "0:0", "reg", 42), rootObj(), ]; - await assert(storage, expectedNodes); + assert(storage, expectedNodes); - await storage.applyOps([createObjectOp("0:2", "0:1", "obj", {})]); - await assert(storage, expectedNodes); + storage.applyOps([createObjectOp("0:2", "0:1", "obj", {})]); + assert(storage, expectedNodes); - await storage.applyOps([createListOp("0:3", "0:1", "list")]); - await assert(storage, expectedNodes); + storage.applyOps([createListOp("0:3", "0:1", "list")]); + assert(storage, expectedNodes); - await storage.applyOps([createMapOp("0:4", "0:1", "map")]); - await assert(storage, expectedNodes); + storage.applyOps([createMapOp("0:4", "0:1", "map")]); + assert(storage, expectedNodes); - await storage.applyOps([ + storage.applyOps([ createRegisterOp("0:5", "0:1", "anotherreg", 1337), ]); - await assert(storage, expectedNodes); + assert(storage, expectedNodes); }); }); }); test("CreateMap in root", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([createMapOp("0:1", "root", "map")]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([createMapOp("0:1", "root", "map")]); - await assert(storage, [rootObj(), map("0:1", "root", "map")]); + assert(storage, [rootObj(), map("0:1", "root", "map")]); }); })); test("CreateMap inside a list with a parent key that already exists should fix operation", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createMapOp("0:1", "0:0", FIRST_POSITION), ]); const inputOp = createMapOp("1:0", "0:0", FIRST_POSITION); - const result = await storage.applyOps([inputOp]); + const result = storage.applyOps([inputOp]); expect(result).toMatchObject([ { @@ -4756,14 +4313,14 @@ export function generateFullTestSuite(config: { test("CreateMap inside a list with a parent key that already exists and set intent should replace existing child", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createMapOp("0:1", "0:0", FIRST_POSITION), ]); const op = createMapOp("1:0", "0:0", FIRST_POSITION, "set", "0:1"); - const result = await storage.applyOps([op]); + const result = storage.applyOps([op]); expect(result).toEqual([{ action: "accepted", op }]); @@ -4777,18 +4334,18 @@ export function generateFullTestSuite(config: { test("CreateMap when map already exists should ignore op and not throw error", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([createMapOp("0:1", "root", "map")]); - await storage.applyOps([createMapOp("0:1", "root", "map")]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([createMapOp("0:1", "root", "map")]); + storage.applyOps([createMapOp("0:1", "root", "map")]); - await assert(storage, [rootObj(), map("0:1", "root", "map")]); + assert(storage, [rootObj(), map("0:1", "root", "map")]); }); })); test("SetParentKey changes positions of list items", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:1", "root", "items"), createRegisterOp("0:2", "0:1", FIRST_POSITION, "A"), createRegisterOp("0:3", "0:1", SECOND_POSITION, "B"), @@ -4796,7 +4353,7 @@ export function generateFullTestSuite(config: { const op = setParentKeyOp("0:2", THIRD_POSITION); - const ops = await storage.applyOps([op]); + const ops = storage.applyOps([op]); expect(ops).toEqual([{ action: "accepted", op }]); }); @@ -4804,15 +4361,15 @@ export function generateFullTestSuite(config: { test("SetParentKey changes positions of list items (with conflict)", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:1", "root", "items"), createRegisterOp("0:2", "0:1", FIRST_POSITION, "A"), createRegisterOp("0:3", "0:1", SECOND_POSITION, "B"), ]); const inputOp = setParentKeyOp("0:2", SECOND_POSITION); - const ops = await storage.applyOps([inputOp]); + const ops = storage.applyOps([inputOp]); expect(ops).toMatchObject([ { @@ -4834,18 +4391,18 @@ export function generateFullTestSuite(config: { test("SetParentKey persists list item position changes to disk", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:1", "root", "items"), createRegisterOp("0:2", "0:1", FIRST_POSITION, "A"), createRegisterOp("0:3", "0:1", SECOND_POSITION, "B"), ]); // Move item from FIRST_POSITION to THIRD_POSITION - await storage.applyOps([setParentKeyOp("0:2", THIRD_POSITION)]); + storage.applyOps([setParentKeyOp("0:2", THIRD_POSITION)]); // selfCheck verifies disk matches memory - await selfCheck(storage); + selfCheck(storage); }); })); @@ -4864,8 +4421,8 @@ export function generateFullTestSuite(config: { async (parentNode) => { await runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ parentNode, createRegisterOp("0:2", parentNode.id, FIRST_POSITION, "A"), createRegisterOp("0:3", parentNode.id, SECOND_POSITION, "B"), @@ -4876,7 +4433,7 @@ export function generateFullTestSuite(config: { // The actual test: setParentKeyOp should be a no-op for all nodes // that aren't list child items const op = setParentKeyOp("0:2", THIRD_POSITION); - const results = await storage.applyOps([op]); + const results = storage.applyOps([op]); // Nothing should have changed! expect(getAll(storage)).toEqual(nodesBefore); @@ -4899,19 +4456,19 @@ export function generateFullTestSuite(config: { ]); return runTest({ initialNodes: nodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { // Move the map to a new position in the list const op = setParentKeyOp("0:2", SECOND_POSITION); - await storage.applyOps([op]); + storage.applyOps([op]); // Verify the children where not deleted - expect(storage.loadedDriver.get_node("0:3")).toEqual({ + expect(storage.driver.get_node("0:3")).toEqual({ type: CrdtType.REGISTER, parentId: "0:2", parentKey: "a", data: "A", }); - expect(storage.loadedDriver.get_node("0:4")).toEqual({ + expect(storage.driver.get_node("0:4")).toEqual({ type: CrdtType.REGISTER, parentId: "0:2", parentKey: "b", @@ -4923,30 +4480,23 @@ export function generateFullTestSuite(config: { test("CreateObject in root", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ - createObjectOp("0:1", "root", "child", { a: 0 }), - ]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([createObjectOp("0:1", "root", "child", { a: 0 })]); - await assert(storage, [ - rootObj(), - obj("0:1", { a: 0 }, "root", "child"), - ]); + assert(storage, [rootObj(), obj("0:1", { a: 0 }, "root", "child")]); }); })); test("CreateObject in map", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([createMapOp("0:1", "root", "map")]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([createMapOp("0:1", "root", "map")]); - await assert(storage, [rootObj(), map("0:1", "root", "map")]); + assert(storage, [rootObj(), map("0:1", "root", "map")]); - await storage.applyOps([ - createObjectOp("0:2", "0:1", "first", { a: 0 }), - ]); + storage.applyOps([createObjectOp("0:2", "0:1", "first", { a: 0 })]); - await assert(storage, [ + assert(storage, [ rootObj(), map("0:1", "root", "map"), obj("0:2", { a: 0 }, "0:1", "first"), @@ -4956,12 +4506,12 @@ export function generateFullTestSuite(config: { test("CreateObject should ignore op if parent does not exist", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - const result = await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + const result = storage.applyOps([ createObjectOp("1:0", "42:42", "item", {}), ]); - await assert(storage, [rootObj()]); + assert(storage, [rootObj()]); expect(result.filter((r) => r.action !== "ignored")).toEqual([]); }); @@ -4969,14 +4519,14 @@ export function generateFullTestSuite(config: { test("CreateObject inside a list with a parent key that already exists should fix operation", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createObjectOp("0:1", "0:0", FIRST_POSITION, {}), ]); const inputOp = createObjectOp("1:0", "0:0", FIRST_POSITION, {}); - const result = await storage.applyOps([inputOp]); + const result = storage.applyOps([inputOp]); expect(result).toMatchObject([ { @@ -5005,8 +4555,8 @@ export function generateFullTestSuite(config: { test("CreateObject inside a list with a parent key that already exists and set intent should replace existing child", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createObjectOp("0:1", "0:0", FIRST_POSITION, {}), ]); @@ -5019,7 +4569,7 @@ export function generateFullTestSuite(config: { "set", "0:1" ); - const result = await storage.applyOps([op]); + const result = storage.applyOps([op]); expect(result).toEqual([{ action: "accepted", op }]); @@ -5054,15 +4604,15 @@ export function generateFullTestSuite(config: { test("CreateObject inside a list with that matches multiple keys should modify the op with the right subsequent key", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:1", "root", "items"), createObjectOp("0:2", "0:1", FIRST_POSITION, {}), createObjectOp("0:3", "0:1", SECOND_POSITION, {}), ]); const inputOp = createObjectOp("1:0", "0:1", FIRST_POSITION, {}); - const result = await storage.applyOps([inputOp]); + const result = storage.applyOps([inputOp]); // The new expected position is in between the first and second positions const expectedPosition = makePosition( @@ -5089,14 +4639,14 @@ export function generateFullTestSuite(config: { test("DeleteObjectKey from root", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([updateObjectOp("root", { a: 0, b: 0 })]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([updateObjectOp("root", { a: 0, b: 0 })]); - await assert(storage, [rootObj({ a: 0, b: 0 })]); + assert(storage, [rootObj({ a: 0, b: 0 })]); - await storage.applyOps([deleteObjectKeyOp("root", "a")]); + storage.applyOps([deleteObjectKeyOp("root", "a")]); - await assert(storage, [rootObj({ b: 0 })]); + assert(storage, [rootObj({ b: 0 })]); }); })); @@ -5115,18 +4665,18 @@ export function generateFullTestSuite(config: { }); return runTest({ initialNodes: nodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([deleteObjectKeyOp("0:1", "x")]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([deleteObjectKeyOp("0:1", "x")]); - await assert(storage, [obj("0:1", {}, "root", "o"), rootObj()]); + assert(storage, [obj("0:1", {}, "root", "o"), rootObj()]); }); }); }); test("DeleteCrdt with the root node is a no-op (virtual)", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:1", "root", "items"), createRegisterOp("0:2", "0:1", FIRST_POSITION, "A"), ]); @@ -5137,17 +4687,17 @@ export function generateFullTestSuite(config: { register("0:2", "0:1", FIRST_POSITION, "A"), ]; - await assert(storage, expectedNodes); + assert(storage, expectedNodes); - await storage.applyOps([deleteCrdtOp("root")]); - await assert(storage, expectedNodes); + storage.applyOps([deleteCrdtOp("root")]); + assert(storage, expectedNodes); }); })); test("DeleteCrdt with the root node is a no-op (explicit)", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ updateObjectOp("root", { a: 1, b: 2 }), createListOp("0:1", "root", "items"), createRegisterOp("0:2", "0:1", FIRST_POSITION, "A"), @@ -5159,22 +4709,22 @@ export function generateFullTestSuite(config: { register("0:2", "0:1", FIRST_POSITION, "A"), ]; - await assert(storage, expectedNodes); + assert(storage, expectedNodes); - await storage.applyOps([deleteCrdtOp("root")]); - await assert(storage, expectedNodes); + storage.applyOps([deleteCrdtOp("root")]); + assert(storage, expectedNodes); }); })); test("DeleteCrdt should remove link", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:1", "root", "items"), createRegisterOp("0:2", "0:1", FIRST_POSITION, "A"), ]); - await assert(storage, [ + assert(storage, [ rootObj(), list("0:1", "root", "items"), register("0:2", "0:1", FIRST_POSITION, "A"), @@ -5182,23 +4732,23 @@ export function generateFullTestSuite(config: { expect(countChildrenOf(storage, "0:1")).toBe(1); - await storage.applyOps([deleteCrdtOp("0:2")]); + storage.applyOps([deleteCrdtOp("0:2")]); - await assert(storage, [rootObj(), list("0:1", "root", "items")]); + assert(storage, [rootObj(), list("0:1", "root", "items")]); expect(countChildrenOf(storage, "0:1")).toBe(0); }); })); test("DeleteCrdt remove child records recursively with delete record", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:1", "root", "items"), createObjectOp("0:2", "0:1", FIRST_POSITION, {}), createObjectOp("0:3", "0:2", "child", { a: 0 }), ]); - await assert(storage, [ + assert(storage, [ rootObj(), list("0:1", "root", "items"), obj("0:2", {}, "0:1", FIRST_POSITION), @@ -5208,9 +4758,9 @@ export function generateFullTestSuite(config: { expect(countChildrenOf(storage, "0:1")).toBe(1); expect(countChildrenOf(storage, "0:2")).toBe(1); - await storage.applyOps([deleteCrdtOp("0:2")]); + storage.applyOps([deleteCrdtOp("0:2")]); - await assert(storage, [rootObj(), list("0:1", "root", "items")]); + assert(storage, [rootObj(), list("0:1", "root", "items")]); expect(countChildrenOf(storage, "0:1")).toBe(0); expect(countChildrenOf(storage, "0:2")).toBe(0); @@ -5219,7 +4769,7 @@ export function generateFullTestSuite(config: { test("DeleteCrdt remove multiple batch child records recursively with delete record", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { + await runWithStorage(driver, ({ storage }) => { const ops: ClientWireOp[] = [ createListOp("0:1", "root", "items"), createObjectOp("0:2", "0:1", FIRST_POSITION, {}), @@ -5229,35 +4779,35 @@ export function generateFullTestSuite(config: { ops.push(createObjectOp(`0:${i}`, "0:2", `child-${i}`, { a: 0 })); } - await storage.applyOps(ops); + storage.applyOps(ops); - await storage.applyOps([deleteCrdtOp("0:2")]); + storage.applyOps([deleteCrdtOp("0:2")]); - await assert(storage, [rootObj(), list("0:1", "root", "items")]); + assert(storage, [rootObj(), list("0:1", "root", "items")]); }); })); test("CreateList when list already exists should ignore op and not throw error", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([createListOp("0:1", "root", "list")]); + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([createListOp("0:1", "root", "list")]); - await storage.applyOps([createListOp("0:1", "root", "list")]); + storage.applyOps([createListOp("0:1", "root", "list")]); - await assert(storage, [rootObj(), list("0:1", "root", "list")]); + assert(storage, [rootObj(), list("0:1", "root", "list")]); }); })); test("CreateList inside a list with a parent key that already exists should fix operation", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createListOp("0:1", "0:0", FIRST_POSITION), ]); const inputOp = createListOp("1:0", "0:0", FIRST_POSITION); - const result = await storage.applyOps([inputOp]); + const result = storage.applyOps([inputOp]); expect(result).toMatchObject([ { @@ -5286,14 +4836,14 @@ export function generateFullTestSuite(config: { test("CreateList inside a list with a parent key that already exists and set intent should replace existing child", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createListOp("0:1", "0:0", FIRST_POSITION), ]); const op = createListOp("1:0", "0:0", FIRST_POSITION, "set", "0:1"); - const result = await storage.applyOps([op]); + const result = storage.applyOps([op]); expect(result).toEqual([{ action: "accepted", op }]); @@ -5307,8 +4857,8 @@ export function generateFullTestSuite(config: { test("CreateList inside a list with a parent key that already exists and set intent should replace existing child (even if it got moved in the mean time)", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:0", "root", "items"), createListOp("0:1", "0:0", FIRST_POSITION), createListOp("0:2", "0:0", SECOND_POSITION), @@ -5319,14 +4869,14 @@ export function generateFullTestSuite(config: { // - Client B tries to replace the item in first position with a new one // Client A - await storage.applyOps([ + storage.applyOps([ setParentKeyOp("0:1", THIRD_POSITION), setParentKeyOp("0:2", FIRST_POSITION), ]); // Client B const op = createListOp("0:3", "0:0", FIRST_POSITION, "set", "0:1"); - const result = await storage.applyOps([op]); + const result = storage.applyOps([op]); expect(result).toMatchObject([ { @@ -5366,14 +4916,14 @@ export function generateFullTestSuite(config: { test("ensure execution order of Ops is well-defined", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createObjectOp("0:0", "root", "child", { a: 0 }), updateObjectOp("0:0", { a: 1 }), createObjectOp("0:1", "0:0", "a", { b: 0 }), ]); - await assert(storage, [ + assert(storage, [ rootObj(), obj("0:0", {}, "root", "child"), obj("0:1", { b: 0 }, "0:0", "a"), @@ -5385,8 +4935,8 @@ export function generateFullTestSuite(config: { // incoming Ops, ever, get ignored test("ensure no operations get ignored", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - const result = await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + const result = storage.applyOps([ createMapOp("0:0", "root", "map"), createRegisterOp("0:1", "0:0", "first", 0), createRegisterOp("0:2", "0:0", "first", 1), @@ -5404,8 +4954,8 @@ export function generateFullTestSuite(config: { ]); return runTest({ initialNodes: nodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await storage.applyOps([ + await runWithStorage(driver, ({ storage }) => { + storage.applyOps([ createListOp("0:2", "0:1", "b", "set"), createObjectOp("0:3", "0:2", "c", {}, "set", "0:1"), // ^^^^^ 🔑 @@ -5413,9 +4963,9 @@ export function generateFullTestSuite(config: { // should not cause data corruption! ]); - await selfCheck(storage); + selfCheck(storage); - await assert(storage, [ + assert(storage, [ list("0:1", "root", "a"), rootObj(), list("0:2", "0:1", "b"), @@ -5439,20 +4989,20 @@ export function generateFullTestSuite(config: { ]); return runTest({ initialNodes: nodesOnDisk }, async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await assert(storage, [ + await runWithStorage(driver, ({ storage }) => { + assert(storage, [ obj("0:1", { a: 1, b: 2, c: 3, d: 4, e: 5 }, "root", "obj"), rootObj(), ]); - await storage.applyOps([ + storage.applyOps([ createRegisterOp("0:2", "0:1", "a", "stuff"), // Register Op will get ignored because parent is object createObjectOp("0:3", "0:1", "b", {}), createListOp("0:4", "0:1", "c"), createMapOp("0:5", "0:1", "d"), ]); - await assert(storage, [ + assert(storage, [ obj("0:1", { a: 1, e: 5 }, "root", "obj"), // ^^^^^^^^^^^^^^ The overwritten fields should be gone rootObj(), @@ -5462,7 +5012,7 @@ export function generateFullTestSuite(config: { ]); // Which means that if we remove the children again... - await storage.applyOps([ + storage.applyOps([ deleteCrdtOp("0:2"), deleteCrdtOp("0:3"), deleteCrdtOp("0:4"), @@ -5470,7 +5020,7 @@ export function generateFullTestSuite(config: { ]); // ...the previously masked static fields become visible again to clients - await assert(storage, [ + assert(storage, [ obj("0:1", { a: 1, e: 5 }, "root", "obj"), // ^^^^^^^^^^^^^^ Still gone rootObj(), @@ -5481,14 +5031,14 @@ export function generateFullTestSuite(config: { test("creating registers under objects is impossible", () => runTest(async (driver) => { - await runWithStorage(driver, async ({ storage }) => { - await assert(storage, [rootObj()]); - await selfCheck(storage); + await runWithStorage(driver, ({ storage }) => { + assert(storage, [rootObj()]); + selfCheck(storage); - await storage.applyOps([createRegisterOp("0:0", "root", "a", 42)]); // 👈 Op should get ignored - await selfCheck(storage); + storage.applyOps([createRegisterOp("0:0", "root", "a", 42)]); // 👈 Op should get ignored + selfCheck(storage); - await assert(storage, [rootObj()]); + assert(storage, [rootObj()]); }); })); }); @@ -5512,7 +5062,7 @@ export function generateFullTestSuite(config: { const yjsStorage = new YjsStorage(driver); // Load the root doc - await yjsStorage.loadDocByIdIfNotAlreadyLoaded(blackHole, guid); + await yjsStorage.getYDoc(blackHole, guid); return await callback({ yjsStorage }); } @@ -5567,9 +5117,9 @@ export function generateFullTestSuite(config: { await runWithYjsStorage(driver, [], async ({ yjsStorage }) => { const update = getSampleYDocUpdate(); await yjsStorage.addYDocUpdate(blackHole, bytesToB64(update)); - await expect( + expect(() => yjsStorage.addYDocUpdate(blackHole, "obviouslynotvalidbase64update") - ).rejects.toThrow( + ).toThrow( "Bad YDoc update. Data is corrupted, or data does not match the encoding." ); const loadedUpdate = await yjsStorage.getYDocUpdate(blackHole);