diff --git a/.github/scripts/setup-vercel-example.sh b/.github/scripts/setup-vercel-example.sh new file mode 100644 index 00000000000..8e378fcdfb7 --- /dev/null +++ b/.github/scripts/setup-vercel-example.sh @@ -0,0 +1,691 @@ +#!/bin/bash +set -euo pipefail + +# This script sets up an example on Vercel and Liveblocks. +# It's used by the `.github/workflows/setup-vercel-example.yml` GitHub Action. +# It's only meant for new examples so it will abort if an example is already set up. + +vercel_api_url="https://api.vercel.com" +liveblocks_management_api_url="https://api.liveblocks.io/v2/management" +examples_branch="examples" +vercel_deploy_hook_name="Deploy example" +temporary_directory="$(mktemp -d "${TMPDIR:-/tmp}/setup-vercel-example.XXXXXX")" + +example_name="" +framework="" +vercel_project_name="" +vercel_project_id="" +vercel_project_dashboard_url="" +liveblocks_project_name="" +liveblocks_project="" +liveblocks_project_id="" +root_directory="" +domain="" +env_example_path="" +liveblocks_key_env_name="" +liveblocks_key_type="" +vercel_liveblocks_env_name="" +vercel_deploy_hook_url="" +manual_env_names=() + +cleanup() { + rm -rf "${temporary_directory}" +} + +trap cleanup EXIT + +err() { + echo "$@" >&2 +} + +trim() { + local value="$1" + + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + printf "%s" "${value}" +} + +create_response_file() { + mktemp "${temporary_directory}/response.XXXXXX" +} + +check_required_env() { + local name="$1" + + if [[ -z "${!name:-}" ]]; then + err "Missing ${name}" + exit 1 + fi +} + +duplicate_setup_error() { + local reason="$1" + + err "${reason}" + + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + echo "## ❌ Example already exists" + echo + echo "${reason}" + echo + } >>"${GITHUB_STEP_SUMMARY}" + fi + + exit 1 +} + +vercel_url() { + if [[ "$1" == *"?"* ]]; then + printf "%s%s&teamId=%s" "${vercel_api_url}" "$1" "${VERCEL_TEAM_ID}" + else + printf "%s%s?teamId=%s" "${vercel_api_url}" "$1" "${VERCEL_TEAM_ID}" + fi +} + +curl_vercel() { + local method="$1" + local url="$2" + local response_file="$3" + local body="${4:-}" + local data_args=() + + [[ -n "${body}" ]] && data_args=(--data "${body}") + + curl --silent --show-error \ + --request "${method}" \ + --url "${url}" \ + --header "Authorization: Bearer ${VERCEL_API_TOKEN}" \ + --header "Content-Type: application/json" \ + "${data_args[@]}" \ + --output "${response_file}" \ + --write-out "%{http_code}" +} + +curl_liveblocks() { + local method="$1" + local url="$2" + local response_file="$3" + local body="${4:-}" + local data_args=() + + [[ -n "${body}" ]] && data_args=(--data "${body}") + + curl --silent --show-error \ + --request "${method}" \ + --url "${url}" \ + --header "Authorization: Bearer ${LIVEBLOCKS_MANAGEMENT_API_TOKEN}" \ + --header "Content-Type: application/json" \ + "${data_args[@]}" \ + --output "${response_file}" \ + --write-out "%{http_code}" +} + +api_request() { + local service_name="$1" + local description="$2" + local method="$3" + local url="$4" + local body="${5:-}" + local response_file + local status + + response_file="$(create_response_file)" + + echo >&2 + echo "${description}" >&2 + + if [[ "${service_name}" == "Vercel" ]]; then + status="$(curl_vercel "${method}" "${url}" "${response_file}" "${body}")" || { + err "${description} failed before receiving a Vercel response" + exit 1 + } + else + status="$(curl_liveblocks "${method}" "${url}" "${response_file}" "${body}")" || { + err "${description} failed before receiving a Liveblocks response" + exit 1 + } + fi + + if [[ "${status}" -lt 200 || "${status}" -ge 300 ]]; then + err "${description} failed with HTTP ${status}" + cat "${response_file}" >&2 + exit 1 + fi + + cat "${response_file}" +} + +vercel_request() { + api_request "Vercel" "$@" +} + +liveblocks_request() { + api_request "Liveblocks" "$@" +} + +require_inputs() { + check_required_env "EXAMPLE_NAME" + check_required_env "GITHUB_REPOSITORY" + check_required_env "LIVEBLOCKS_MANAGEMENT_API_TOKEN" + check_required_env "VERCEL_TEAM_ID" + check_required_env "VERCEL_API_TOKEN" +} + +read_example_inputs() { + example_name="$(trim "${EXAMPLE_NAME}")" + framework="$(trim "${FRAMEWORK:-}")" + + if [[ -z "${example_name}" ]]; then + err "Missing example name" + exit 1 + fi + + if [[ ! "${example_name}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then + err "Example name must be lowercase letters, numbers, and hyphens only" + exit 1 + fi + + root_directory="examples/${example_name}" + + if [[ ! -d "${root_directory}" ]]; then + err "Could not find ${root_directory}" + exit 1 + fi + + vercel_project_name="examples-${example_name}" + liveblocks_project_name="Example - ${example_name}" + domain="${example_name}.liveblocks.app" + env_example_path="${root_directory}/.env.example" +} + +read_env_example() { + local env_line + local env_name + + [[ ! -f "${env_example_path}" ]] && return + + # `.env.example` files in examples tell us which Liveblocks key Vercel needs. + while IFS= read -r env_line || [[ -n "${env_line}" ]]; do + env_line="$(trim "${env_line}")" + + [[ -z "${env_line}" || "${env_line}" == \#* || "${env_line}" != *=* ]] && continue + + env_name="$(trim "${env_line%%=*}")" + + [[ -z "${env_name}" ]] && continue + + # Look for variables like `LIVEBLOCKS_SECRET_KEY`, `NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY`, etc. + if [[ "${env_name}" == *LIVEBLOCKS* && "${env_name}" == *PUBLIC_KEY ]]; then + set_liveblocks_key_env_name "${env_name}" "public" + elif [[ "${env_name}" == *LIVEBLOCKS* && "${env_name}" == *SECRET_KEY && "${env_name}" != *WEBHOOK* ]]; then + set_liveblocks_key_env_name "${env_name}" "secret" + else + manual_env_names+=("${env_name}") + fi + done <"${env_example_path}" +} + +set_liveblocks_key_env_name() { + local env_name="$1" + local key_type="$2" + + if [[ -n "${liveblocks_key_env_name}" ]]; then + err "Found multiple Liveblocks API key variables in ${env_example_path}: ${liveblocks_key_env_name} and ${env_name}" + exit 1 + fi + + liveblocks_key_env_name="${env_name}" + liveblocks_key_type="${key_type}" +} + + +print_setup_overview() { + echo "Setting up Vercel and Liveblocks example projects" + echo "Example name: ${example_name}" + echo "Vercel project name: ${vercel_project_name}" + echo "Liveblocks project name: ${liveblocks_project_name}" + echo "Root directory: ${root_directory}" + echo "Vercel custom domain: ${domain}" + echo "GitHub repository: ${GITHUB_REPOSITORY}" + echo "Production branch: ${examples_branch}" + echo "Framework: ${framework:-"(none)"}" + echo "Environment template: ${env_example_path}" + echo "Liveblocks API key variable: ${liveblocks_key_env_name:-"(none found)"}" +} + +resolve_vercel_project_dashboard_url() { + local team_response + local team_slug + + # Source: https://vercel.com/docs/rest-api/reference/endpoints/teams/get-a-team + team_response="$( + vercel_request \ + "Fetching Vercel team metadata" \ + "GET" \ + "${vercel_api_url}/v2/teams/${VERCEL_TEAM_ID}" + )" + team_slug="$(jq -r '.slug // empty' <<<"${team_response}")" + + if [[ -n "${team_slug}" ]]; then + vercel_project_dashboard_url="https://vercel.com/${team_slug}/${vercel_project_name}" + else + err "Warning: could not read team slug from GET /v2/teams/; omitting Vercel dashboard project URL" + fi +} + +assert_no_existing_vercel_project() { + local existing_vercel_project_response_file + local existing_vercel_project_status + + # Source: https://vercel.com/docs/rest-api/projects/find-a-project-by-id-or-name + existing_vercel_project_response_file="$(create_response_file)" + if ! existing_vercel_project_status="$( + curl_vercel \ + "GET" \ + "$(vercel_url "/v10/projects/${vercel_project_name}")" \ + "${existing_vercel_project_response_file}" + )"; then + err "Looking up existing Vercel project failed before receiving a Vercel response" + exit 1 + fi + + if [[ "${existing_vercel_project_status}" == "200" ]]; then + duplicate_setup_error "Example '${example_name}' already exists: Vercel project '${vercel_project_name}' is already present." + fi + + if [[ "${existing_vercel_project_status}" != "404" ]]; then + err "Looking up existing Vercel project failed with HTTP ${existing_vercel_project_status}" + cat "${existing_vercel_project_response_file}" >&2 + exit 1 + fi +} + +assert_no_existing_liveblocks_project() { + local liveblocks_cursor="" + local liveblocks_projects_response + local liveblocks_projects_url + + # Source: https://liveblocks.io/docs/api-reference/rest-api-endpoints#Management + while true; do + liveblocks_projects_url="${liveblocks_management_api_url}/projects?limit=100" + + if [[ -n "${liveblocks_cursor}" ]]; then + liveblocks_projects_url="${liveblocks_projects_url}&cursor=$(jq -rn --arg value "${liveblocks_cursor}" '$value | @uri')" + fi + + liveblocks_projects_response="$( + liveblocks_request \ + "Looking up Liveblocks project" \ + "GET" \ + "${liveblocks_projects_url}" + )" + liveblocks_project="$( + jq -c --arg name "${liveblocks_project_name}" \ + '.projects[]? | select(.name == $name)' \ + <<<"${liveblocks_projects_response}" \ + | head -n 1 + )" + + if [[ -n "${liveblocks_project}" ]]; then + duplicate_setup_error "Example '${example_name}' already exists: Liveblocks project '${liveblocks_project_name}' is already present." + fi + + liveblocks_cursor="$(jq -r '.nextCursor // empty' <<<"${liveblocks_projects_response}")" + + [[ -z "${liveblocks_cursor}" ]] && break + done +} + +preflight_setup() { + assert_no_existing_vercel_project + assert_no_existing_liveblocks_project +} + +create_vercel_project() { + local create_vercel_project_body + local framework_json + local vercel_project_response + + if [[ -n "${framework}" ]]; then + framework_json="$(jq -n --arg framework "${framework}" '$framework')" + else + framework_json="null" + fi + + create_vercel_project_body="$( + jq -n \ + --arg name "${vercel_project_name}" \ + --arg root_directory "${root_directory}" \ + --arg repository "${GITHUB_REPOSITORY}" \ + --argjson framework "${framework_json}" \ + '{ + name: $name, + buildCommand: "npm run build", + installCommand: "npm install", + rootDirectory: $root_directory, + framework: $framework, + gitRepository: { + type: "github", + repo: $repository + } + }' + )" + + assert_no_existing_vercel_project + + # Source: https://vercel.com/docs/rest-api/projects/create-a-new-project + vercel_project_response="$( + vercel_request \ + "Creating Vercel project" \ + "POST" \ + "$(vercel_url "/v11/projects")" \ + "${create_vercel_project_body}" + )" + + vercel_project_id="$(jq -r ".id // empty" <<<"${vercel_project_response}")" + + if [[ -z "${vercel_project_id}" ]]; then + err "Vercel project response did not include an ID" + echo "${vercel_project_response}" >&2 + exit 1 + fi +} + +create_liveblocks_project() { + local liveblocks_project_response + + assert_no_existing_liveblocks_project + + # Examples are public deployments, so we create a Liveblocks production project. + # Production secret keys are only accessible once at creation time. + liveblocks_project_response="$( + liveblocks_request \ + "Creating Liveblocks production project" \ + "POST" \ + "${liveblocks_management_api_url}/projects" \ + "$(jq -n --arg name "${liveblocks_project_name}" '{ name: $name, type: "prod", versionCreationTimeout: false }')" + )" + liveblocks_project="$(jq -c '.project' <<<"${liveblocks_project_response}")" + liveblocks_project_id="$(jq -r '.id // empty' <<<"${liveblocks_project}")" + + if [[ -z "${liveblocks_project_id}" ]]; then + err "Liveblocks project response did not include an ID" + echo "${liveblocks_project}" >&2 + exit 1 + fi +} + +liveblocks_key_value() { + if [[ "${liveblocks_key_type}" == "public" ]]; then + ensure_liveblocks_public_key + jq -r '.publicKey.value // empty' <<<"${liveblocks_project}" + else + jq -r '.secretKey.value // empty' <<<"${liveblocks_project}" + fi +} + +ensure_liveblocks_public_key() { + local liveblocks_project_response + local liveblocks_public_key_activated + + liveblocks_public_key_activated="$(jq -r '.publicKey.activated // false' <<<"${liveblocks_project}")" + + [[ "${liveblocks_public_key_activated}" == "true" ]] && return + + liveblocks_request \ + "Activating Liveblocks public key" \ + "POST" \ + "${liveblocks_management_api_url}/projects/${liveblocks_project_id}/api-keys/public/activate" \ + >/dev/null + + liveblocks_project_response="$( + liveblocks_request \ + "Refreshing Liveblocks project" \ + "GET" \ + "${liveblocks_management_api_url}/projects/${liveblocks_project_id}" + )" + liveblocks_project="$(jq -c '.project' <<<"${liveblocks_project_response}")" +} + +add_liveblocks_key_to_vercel() { + local key_value + + [[ -z "${liveblocks_key_env_name}" ]] && return + + key_value="$(liveblocks_key_value)" + + if [[ -z "${key_value}" ]]; then + err "Liveblocks project response did not include a ${liveblocks_key_type} key" + echo "${liveblocks_project}" >&2 + exit 1 + fi + + vercel_request \ + "Adding Liveblocks API key to Vercel environment variables" \ + "POST" \ + "$(vercel_url "/v10/projects/${vercel_project_id}/env")" \ + "$(jq -n \ + --arg key "${liveblocks_key_env_name}" \ + --arg value "${key_value}" \ + '{ + key: $key, + value: $value, + type: "encrypted", + target: ["production"] + }')" \ + >/dev/null + + vercel_liveblocks_env_name="${liveblocks_key_env_name}" +} + +configure_vercel_project() { + local framework_json + local update_vercel_project_body + + if [[ -n "${framework}" ]]; then + framework_json="$(jq -n --arg framework "${framework}" '$framework')" + else + framework_json="null" + fi + + update_vercel_project_body="$( + jq -n \ + --arg root_directory "${root_directory}" \ + --argjson framework "${framework_json}" \ + '{ + buildCommand: "npm run build", + installCommand: "npm install", + rootDirectory: $root_directory, + framework: $framework, + gitComments: { + onCommit: false, + onPullRequest: false + } + }' + )" + + # Source: https://vercel.com/docs/rest-api/projects/update-an-existing-project + vercel_request \ + "Updating Vercel project settings" \ + "PATCH" \ + "$(vercel_url "/v9/projects/${vercel_project_id}")" \ + "${update_vercel_project_body}" \ + >/dev/null + + # Source: https://github.com/vercel/terraform-provider-vercel/blob/main/client/project.go + # It's an undocumented endpoint, more info: https://community.vercel.com/t/rest-api-docs-for-updating-production-git-branch/820 + vercel_request \ + "Setting Vercel production branch" \ + "PATCH" \ + "$(vercel_url "/v9/projects/${vercel_project_id}/branch")" \ + "$(jq -n --arg branch "${examples_branch}" '{ branch: $branch }')" \ + >/dev/null +} + +create_vercel_deploy_hook() { + local vercel_deploy_hook_response + local vercel_project_response + + # Source: https://vercel.com/docs/rest-api/projects/find-a-project-by-id-or-name + vercel_project_response="$( + vercel_request \ + "Refreshing Vercel project" \ + "GET" \ + "$(vercel_url "/v10/projects/${vercel_project_id}")" + )" + vercel_deploy_hook_url="$( + jq -r \ + --arg name "${vercel_deploy_hook_name}" \ + --arg ref "${examples_branch}" \ + '.link.deployHooks[]? | select(.name == $name and .ref == $ref) | .url' \ + <<<"${vercel_project_response}" \ + | head -n 1 + )" + + if [[ -n "${vercel_deploy_hook_url}" ]]; then + duplicate_setup_error "Example '${example_name}' already exists: deploy hook '${vercel_deploy_hook_name}' already exists for '${vercel_project_name}'." + fi + + # Source: https://github.com/vercel/terraform-provider-vercel/blob/main/client/deploy_hooks.go + # It's an undocumented endpoint, more info: https://community.vercel.com/t/rest-api-docs-for-updating-production-git-branch/820 + vercel_deploy_hook_response="$( + vercel_request \ + "Creating Vercel Deploy Hook" \ + "POST" \ + "$(vercel_url "/v2/projects/${vercel_project_id}/deploy-hooks")" \ + "$(jq -n --arg name "${vercel_deploy_hook_name}" --arg ref "${examples_branch}" '{ name: $name, ref: $ref }')" + )" + vercel_deploy_hook_url="$( + jq -r \ + --arg name "${vercel_deploy_hook_name}" \ + --arg ref "${examples_branch}" \ + '.url // (.link.deployHooks[]? | select(.name == $name and .ref == $ref) | .url) // empty' \ + <<<"${vercel_deploy_hook_response}" \ + | head -n 1 + )" + + if [[ -z "${vercel_deploy_hook_url}" ]]; then + err "Vercel Deploy Hook response did not include a URL" + echo "${vercel_deploy_hook_response:-${vercel_project_response}}" >&2 + exit 1 + fi +} + +add_vercel_custom_domain() { + local vercel_domain_exists + local vercel_project_domains_response + + # Source: https://vercel.com/docs/rest-api/projects/retrieve-project-domains-by-project-by-id-or-name + vercel_project_domains_response="$( + vercel_request \ + "Checking Vercel custom domain" \ + "GET" \ + "$(vercel_url "/v9/projects/${vercel_project_id}/domains")" + )" + vercel_domain_exists="$( + jq -r --arg name "${domain}" '.domains[]? | select(.name == $name) | .name' \ + <<<"${vercel_project_domains_response}" \ + | head -n 1 + )" + + if [[ -n "${vercel_domain_exists}" ]]; then + duplicate_setup_error "Example '${example_name}' already exists: custom domain '${domain}' is already attached." + fi + + # Source: https://vercel.com/docs/rest-api/projects/add-a-domain-to-a-project + vercel_request \ + "Adding Vercel custom domain" \ + "POST" \ + "$(vercel_url "/v10/projects/${vercel_project_id}/domains")" \ + "$(jq -n --arg name "${domain}" '{ name: $name, gitBranch: null }')" \ + >/dev/null +} + +print_success_log() { + echo + echo "Setup complete" + echo "Vercel project: ${vercel_project_name}" + echo "Liveblocks project: ${liveblocks_project_name}" + echo "Vercel deployment URL: https://${domain}" + if [[ -n "${vercel_project_dashboard_url}" ]]; then + echo "Vercel project URL: ${vercel_project_dashboard_url}" + fi + if [[ -n "${vercel_liveblocks_env_name}" ]]; then + echo "Vercel Liveblocks env var: ${vercel_liveblocks_env_name}" + fi + echo "Vercel Deploy Hook: ${vercel_deploy_hook_url}" +} + +write_success_summary() { + [[ -z "${GITHUB_STEP_SUMMARY:-}" ]] && return + + { + echo "## ✅ Example deployed to Vercel" + echo + echo "\`${example_name}\` has been configured on Vercel and will deploy from the \`${examples_branch}\` branch." + echo + echo "🔗 Vercel deployment URL: https://${domain}" + if [[ -n "${vercel_project_dashboard_url}" ]]; then + echo "🗂️ Vercel URL: ${vercel_project_dashboard_url}" + fi + echo "🧱 Liveblocks project: \`${liveblocks_project_name}\`" + echo + echo "### Environment variables" + echo + if [[ -n "${vercel_liveblocks_env_name}" ]]; then + echo "Added to Vercel for production:" + echo + echo "| Variable | Source |" + echo "| --- | --- |" + echo "| \`${vercel_liveblocks_env_name}\` | Liveblocks ${liveblocks_key_type} key |" + else + echo "No Liveblocks API key variable was found in \`${env_example_path}\`, so no Liveblocks key was added to Vercel." + fi + echo + if ((${#manual_env_names[@]} > 0)); then + echo "Still needs to be added manually on Vercel:" + echo + for manual_env_name in "${manual_env_names[@]}"; do + echo "- \`${manual_env_name}\`" + done + echo + fi + echo "### Vercel Deploy Hook" + echo + # We store deploy hooks publicly so we can also display them here. + echo '```text' + echo "${vercel_deploy_hook_url}" + echo '```' + echo + echo "⚠️ Add this to \`.github/workflows/deploy-examples.yml\` on the \`${examples_branch}\` branch:" + echo + echo '```sh' + echo "echo \"Triggering deployment for ${example_name}\"" + echo "curl -s -X POST ${vercel_deploy_hook_url}; echo" + echo '```' + } >>"${GITHUB_STEP_SUMMARY}" +} + +main() { + require_inputs + read_example_inputs + read_env_example + print_setup_overview + + resolve_vercel_project_dashboard_url + preflight_setup + create_vercel_project + create_liveblocks_project + add_liveblocks_key_to_vercel + configure_vercel_project + create_vercel_deploy_hook + add_vercel_custom_domain + + print_success_log + write_success_summary +} + +main "$@" diff --git a/.github/workflows/setup-vercel-example.yml b/.github/workflows/setup-vercel-example.yml new file mode 100644 index 00000000000..967f91c8954 --- /dev/null +++ b/.github/workflows/setup-vercel-example.yml @@ -0,0 +1,44 @@ +name: Set up Vercel example + +on: + workflow_dispatch: + inputs: + example_name: + description: + "Name of the example's folder in the `examples/` directory (e.g. + `nextjs-comments`)" + required: true + type: string + framework: + description: "Framework preset (e.g. `nextjs`)" + required: false + type: choice + # Source: https://vercel.com/docs/rest-api/projects/create-a-new-project#framework + options: + # `choice` inputs can't be optional, so `""` acts as `null`. + - "" + - nextjs + - nuxtjs + - svelte + - sveltekit + - vite + - vue + +jobs: + setup-vercel-example: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Create and configure Vercel project + env: + EXAMPLE_NAME: ${{ inputs.example_name }} + FRAMEWORK: ${{ inputs.framework }} + GITHUB_REPOSITORY: ${{ github.repository }} + LIVEBLOCKS_MANAGEMENT_API_TOKEN: + ${{ secrets.LIVEBLOCKS_MANAGEMENT_API_TOKEN }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} + VERCEL_API_TOKEN: ${{ secrets.VERCEL_API_TOKEN }} + run: bash ./.github/scripts/setup-vercel-example.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 891bb034352..147db408288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## vNEXT (not yet released) +## v3.19.2 + +### `@liveblocks/client` + +- Fix: clients that have `backgroundKeepAliveTimeout` enabled will no longer + disconnect before any pending Yjs updates have been synced to the server. + ## v3.19.1 ### `@liveblocks/node` and Python SDK diff --git a/docs/pages/platform/management-api.mdx b/docs/pages/platform/management-api.mdx index 0e6226f523b..476bc8b6e6e 100644 --- a/docs/pages/platform/management-api.mdx +++ b/docs/pages/platform/management-api.mdx @@ -18,8 +18,9 @@ delete projects, webhooks, and more. The Liveblocks Management API is organized around REST. The API has predictable resource-oriented URLs, accepts form-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes, authentication, -and verbs. See the [API reference](api-reference/rest-api-endpoints#Management) -for more information. +and verbs. See the +[API reference](/docs/api-reference/rest-api-endpoints#Management) for more +information. ``` https://api.liveblocks.io/v2/management @@ -221,5 +222,6 @@ for your use case, please contact ## API reference -See the [API reference](api-reference/rest-api-endpoints#Management) for more +See the +[API reference](/docs/api-reference/rest-api-endpoints#Management) for more information. diff --git a/docs/pages/platform/rest-api.mdx b/docs/pages/platform/rest-api.mdx index e3c1a735345..6840704bce4 100644 --- a/docs/pages/platform/rest-api.mdx +++ b/docs/pages/platform/rest-api.mdx @@ -17,8 +17,8 @@ and more. The Liveblocks API is organized around REST. The API has predictable resource-oriented URLs, accepts form-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes, authentication, -and verbs. See the [API reference](api-reference/rest-api-endpoints) for more -information. +and verbs. See the +[API reference](/docs/api-reference/rest-api-endpoints) for more information. ``` https://api.liveblocks.io/v2 diff --git a/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.setup.ts b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.setup.ts index 4c6f818dbc1..9d16c69df79 100644 --- a/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.setup.ts +++ b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.setup.ts @@ -88,10 +88,22 @@ export const THIRD_POSITION = makePosition(SECOND_POSITION); export const FOURTH_POSITION = makePosition(THIRD_POSITION); export const FIFTH_POSITION = makePosition(FOURTH_POSITION); -export function makeSyncSource(): SyncSource { +/** + * A `SyncSource` that throws on every accessor. Used as a placeholder in + * test fixtures: if a test ever exercises one of these methods, it fails + * loudly. + */ +export function fakeSyncSource(): SyncSource { return { - setSyncStatus: () => {}, - destroy: () => {}, + setSyncStatus: () => { + // no-op + }, + getStatus: () => { + throw new Error("fakeSyncSource: not implemented"); + }, + destroy: () => { + // no-op + }, }; } @@ -118,7 +130,7 @@ function makeRoomConfig( currentUserId: new Signal(undefined), }), // Not used in unit tests (yet) - createSyncSource: makeSyncSource, + createSyncSource: fakeSyncSource, }; } diff --git a/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts b/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts index 80e243e720b..8017f487bfa 100644 --- a/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts +++ b/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts @@ -55,8 +55,8 @@ import { createSerializedList, createSerializedRegister, createSerializedRoot, + fakeSyncSource, FIRST_POSITION, - makeSyncSource, prepareIsolatedStorageTest, prepareRoomWithStorage_loadWithDelay, prepareStorageTest, @@ -101,7 +101,7 @@ function createDefaultRoomConfig< currentUserId: new Signal(undefined), }), // Not used in unit tests (yet) - createSyncSource: makeSyncSource, + createSyncSource: fakeSyncSource, }; } diff --git a/packages/liveblocks-core/src/client.ts b/packages/liveblocks-core/src/client.ts index 9342b812c89..ee55ed83b6f 100644 --- a/packages/liveblocks-core/src/client.ts +++ b/packages/liveblocks-core/src/client.ts @@ -979,6 +979,10 @@ export function createClient( source.set(status); } + function getStatus(): InternalSyncStatus { + return source.get(); + } + function destroy() { unsub(); const index = syncStatusSources.findIndex((item) => item === source); @@ -993,7 +997,7 @@ export function createClient( } } - return { setSyncStatus, destroy }; + return { setSyncStatus, getStatus, destroy }; } // ---------------------------------------------------------------- diff --git a/packages/liveblocks-core/src/room.ts b/packages/liveblocks-core/src/room.ts index 1fe344c6fd0..96315044d6c 100644 --- a/packages/liveblocks-core/src/room.ts +++ b/packages/liveblocks-core/src/room.ts @@ -1198,6 +1198,7 @@ export interface IYjsProvider { */ export interface SyncSource { setSyncStatus(status: InternalSyncStatus): void; + getStatus(): InternalSyncStatus; destroy(): void; } @@ -1543,7 +1544,7 @@ export function createRoom< // - The `backgroundKeepAliveTimeout` client option is configured // - The browser window has been in the background for at least // `backgroundKeepAliveTimeout` milliseconds - // - There are no pending changes + // - There are no pending changes scoped to this room (Storage, Yjs) // canZombie() { return ( @@ -1551,7 +1552,8 @@ export function createRoom< inBackgroundSince.current !== null && Date.now() > inBackgroundSince.current + config.backgroundKeepAliveTimeout && - getStorageStatus() !== "synchronizing" + syncSourceForStorage.getStatus() !== "synchronizing" && + syncSourceForYjs.getStatus() !== "synchronizing" ); }, }; diff --git a/packages/liveblocks-server/src/Room.ts b/packages/liveblocks-server/src/Room.ts index bb9fcd4d9ac..22e6df3adc5 100644 --- a/packages/liveblocks-server/src/Room.ts +++ b/packages/liveblocks-server/src/Room.ts @@ -396,6 +396,10 @@ type RoomOptions = { * that can guarantee that no Ops from other clients can get interleaved * between the chunk generation until the last chunk has been sent. * Defaults to true, but is notably NOT safe to use from DOS-KV backends. + * + * @deprecated Only existed to support the DOS-KV backend, which is gone. + * All remaining drivers are streaming-safe; this flag should be removed + * and the streaming path made unconditional. */ allowStreaming?: boolean; diff --git a/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts b/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts index 1dfd6e79269..aa4c7366813 100644 --- a/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts +++ b/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts @@ -544,10 +544,7 @@ function getSampleYDocUpdate(isV2: boolean = false) { return encode(doc); } -export function generateArbitraries(config?: { - nodeKey?: () => fc.Arbitrary; - metaKey?: () => fc.Arbitrary; -}) { +export function generateArbitraries() { const arb = { docId: () => fc.oneof( @@ -566,15 +563,24 @@ export function generateArbitraries(config?: { fc.stringMatching(/^[0-9A-Za-z_-]+$/) ), - nodeKey: () => config?.nodeKey?.() ?? arb.key(), - metaKey: () => config?.metaKey?.() ?? arb.key(), - jsonObject: () => arb .json() .filter( (v): v is JsonObject => v !== null && typeof v === "object" && !Array.isArray(v) + ) + // `set_object_data` drops top-level `__proto__` keys (assigning would + // route through Object.prototype's setter, and rebuilding the object + // via defineProperty on every write is too slow for this hot path). + // Mirror that contract here so the round-trip property test stops + // rediscovering this as a counterexample on every CI run. + .map((obj) => + Object.prototype.hasOwnProperty.call(obj, "__proto__") + ? (Object.fromEntries( + Object.entries(obj).filter(([k]) => k !== "__proto__") + ) as JsonObject) + : obj ), json: () => @@ -585,19 +591,10 @@ export function generateArbitraries(config?: { .jsonValue({ depthSize: "xsmall" }) .filter((v) => { const jsonText = JSON.stringify(v); - return ( - // Avoid generating floating point numbers in scientific notation - // (like 1.2e-237), as they can round differently between Node and - // SQLite. This is making isEqual comparisons annoying. - !/\d+([.]\d{1,})?e[-+]\d{2,}/.test(jsonText) && - // - // Also avoid generating objects with "__proto__" keys because those - // don't survive a serialization/deserialization roundtrip in - // DOS-KV storage. While is is typically a good thing in - // production, it means that we cannot express that "what goes in - // comes out" if inputs are generated this way. - !/"__proto__"/.test(jsonText) - ); + // Avoid generating floating point numbers in scientific notation + // (like 1.2e-237), as they can round differently between Node and + // SQLite. This is making isEqual comparisons annoying. + return !/\d+([.]\d{1,})?e[-+]\d{2,}/.test(jsonText); }) .map( (v) => @@ -614,7 +611,7 @@ export function generateArbitraries(config?: { childNodeTuple: () => { return fc .tuple( - arb.nodeKey().filter((k) => !KNOWN_DOC_KEYS.includes(k)), + arb.key().filter((k) => !KNOWN_DOC_KEYS.includes(k)), arb.serializedChild() ) .map((x) => x as ChildStorageNode); @@ -638,29 +635,29 @@ export function generateArbitraries(config?: { type: fc.constant(CrdtType.OBJECT), data: arb.jsonObject(), parentId, - parentKey: arb.nodeKey(), + parentKey: arb.key(), }), fc.record({ type: fc.constant(CrdtType.LIST), parentId, - parentKey: arb.nodeKey(), + parentKey: arb.key(), }), fc.record({ type: fc.constant(CrdtType.MAP), parentId, - parentKey: arb.nodeKey(), + parentKey: arb.key(), }), fc.record({ type: fc.constant(CrdtType.REGISTER), data: arb.json(), parentId: nonObjectParentId, - parentKey: arb.nodeKey(), + parentKey: arb.key(), }) ) .filter(wouldNotOverwriteDefaultDoc); }, - metaPair: () => fc.tuple(arb.metaKey(), arb.json()), + metaPair: () => fc.tuple(arb.key(), arb.json()), sessionId: () => fc.oneof({ withCrossShrink: true }, fc.constant("session-1"), fc.uuid()), @@ -785,7 +782,7 @@ export function generateArbitraries(config?: { .tuple( arb.plainLsonTree(options), // Filter out "root" since that ID is reserved for the root node - arb.infiniteUniqueStream(arb.nodeKey().filter((k) => k !== "root")), + arb.infiniteUniqueStream(arb.key().filter((k) => k !== "root")), arb.infiniteStream(arb.pos()) ) .map(([plainLsonTree, uniqNodeIds, positions]) => @@ -856,14 +853,14 @@ export function generateArbitraries(config?: { fc.record({ type: fc.constant(OpCode.CREATE_OBJECT), opId: arb.opId(), - id: options?.id ?? arb.nodeKey(), - parentId: options?.parentId ?? arb.nodeKey(), + id: options?.id ?? arb.key(), + parentId: options?.parentId ?? arb.key(), parentKey: options?.parentKey ?? arb.parentKey(), data: options?.data ?? arb.jsonObject(), intent: options?.intent ?? arb.intent(), deletedId: options?.deletedId ?? - fc.option(arb.nodeKey(), { freq: 10, nil: undefined }), + fc.option(arb.key(), { freq: 10, nil: undefined }), }), createListOp: (options?: { @@ -876,13 +873,13 @@ export function generateArbitraries(config?: { fc.record({ type: fc.constant(OpCode.CREATE_LIST), opId: arb.opId(), - id: options?.id ?? arb.nodeKey(), - parentId: options?.parentId ?? arb.nodeKey(), + id: options?.id ?? arb.key(), + parentId: options?.parentId ?? arb.key(), parentKey: options?.parentKey ?? arb.parentKey(), intent: options?.intent ?? arb.intent(), deletedId: options?.deletedId ?? - fc.option(arb.nodeKey(), { freq: 10, nil: undefined }), + fc.option(arb.key(), { freq: 10, nil: undefined }), }), createMapOp: (options?: { @@ -895,13 +892,13 @@ export function generateArbitraries(config?: { fc.record({ type: fc.constant(OpCode.CREATE_MAP), opId: arb.opId(), - id: options?.id ?? arb.nodeKey(), - parentId: options?.parentId ?? arb.nodeKey(), + id: options?.id ?? arb.key(), + parentId: options?.parentId ?? arb.key(), parentKey: options?.parentKey ?? arb.parentKey(), intent: options?.intent ?? arb.intent(), deletedId: options?.deletedId ?? - fc.option(arb.nodeKey(), { freq: 10, nil: undefined }), + fc.option(arb.key(), { freq: 10, nil: undefined }), }), createRegisterOp: (options?: { @@ -915,14 +912,14 @@ export function generateArbitraries(config?: { fc.record({ type: fc.constant(OpCode.CREATE_REGISTER), opId: arb.opId(), - id: options?.id ?? arb.nodeKey(), - parentId: options?.parentId ?? arb.nodeKey(), + id: options?.id ?? arb.key(), + parentId: options?.parentId ?? arb.key(), parentKey: options?.parentKey ?? arb.parentKey(), data: options?.data ?? arb.json(), intent: options?.intent ?? arb.intent(), deletedId: options?.deletedId ?? - fc.option(arb.nodeKey(), { freq: 10, nil: undefined }), + fc.option(arb.key(), { freq: 10, nil: undefined }), }), createOp: (options?: { @@ -943,14 +940,14 @@ export function generateArbitraries(config?: { fc.record({ type: fc.constant(OpCode.DELETE_CRDT), opId: arb.opId(), - id: arb.nodeKey(), + id: arb.key(), }), updateObjectOpArb: () => fc.record({ type: fc.constant(OpCode.UPDATE_OBJECT), opId: arb.opId(), - id: arb.nodeKey(), + id: arb.key(), data: arb.jsonObject(), }), @@ -958,7 +955,7 @@ export function generateArbitraries(config?: { fc.record({ type: fc.constant(OpCode.SET_PARENT_KEY), opId: arb.opId(), - id: arb.nodeKey(), + id: arb.key(), parentKey: arb.pos(), }), @@ -966,7 +963,7 @@ export function generateArbitraries(config?: { fc.record({ type: fc.constant(OpCode.DELETE_OBJECT_KEY), opId: arb.opId(), - id: arb.nodeKey(), + id: arb.key(), key: arb.parentKey(), }), @@ -1034,15 +1031,8 @@ export function generateFullTestSuite(config: { name: string; /** Runs a test with a driver, optionally pre-populating raw storage data */ runTest: (options: RunTestOptions, testFn: TestFn) => Promise; - customArbitraries?: { - nodeKey?: () => fc.Arbitrary; - metaKey?: () => fc.Arbitrary; - }; }) { - const arb = generateArbitraries({ - nodeKey: config.customArbitraries?.nodeKey, - metaKey: config.customArbitraries?.metaKey, - }); + const arb = generateArbitraries(); // Wrapper that allows calling runTest with or without options function runTest(testFn: TestFn): Promise; @@ -1098,7 +1088,7 @@ export function generateFullTestSuite(config: { runTest(async (driver) => fc.assert( fc.asyncProperty( - arb.nodeKey(), + arb.key(), async (key) => { fc.pre(key !== "root"); @@ -1210,8 +1200,8 @@ export function generateFullTestSuite(config: { runTest(async (driver) => fc.assert( fc.asyncProperty( - arb.nodeKey(), - arb.nodeKey(), + arb.key(), + arb.key(), async (parentId, parentKey) => { await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); @@ -1428,8 +1418,8 @@ export function generateFullTestSuite(config: { runTest(async (driver) => fc.assert( fc.asyncProperty( - arb.nodeKey(), - arb.nodeKey(), + arb.key(), + arb.key(), async (parentId, parentKey) => { await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); @@ -1465,8 +1455,8 @@ export function generateFullTestSuite(config: { fc.assert( fc.asyncProperty( arb.nodeStream(), - arb.nodeKey(), - arb.nodeKey(), + arb.key(), + arb.key(), async (entries, parentId, parentKey) => { await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); @@ -1933,7 +1923,7 @@ export function generateFullTestSuite(config: { runTest(async (driver) => fc.assert( fc.asyncProperty( - arb.nodeKey(), + arb.key(), arb.serializedChild(), arb.serializedChild(), @@ -2789,7 +2779,7 @@ export function generateFullTestSuite(config: { runTest(async (driver) => fc.assert( fc.asyncProperty( - arb.metaKey(), + arb.key(), async (key) => { expect(await driver.get_meta(key)).toEqual(undefined); @@ -2835,7 +2825,7 @@ export function generateFullTestSuite(config: { runTest(async (driver) => fc.assert( fc.asyncProperty( - arb.metaKey(), + arb.key(), arb.json(), arb.json(), @@ -3333,7 +3323,6 @@ export function generateFullTestSuite(config: { describe("feed API impl", () => { test("list_feeds on empty store is empty", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); const result = await driver.list_feeds(); expect(result.feeds).toEqual([]); expect(result.nextCursor).toBeUndefined(); @@ -3341,7 +3330,6 @@ export function generateFullTestSuite(config: { test("get_feed on empty store is undefined", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty(arb.feedId(), async (feedId) => { expect(await driver.get_feed(feedId)).toEqual(undefined); @@ -3351,7 +3339,6 @@ export function generateFullTestSuite(config: { test("create_feed + get_feed", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty(arb.feed(), async (feed) => { await driver.create_feed(feed); @@ -3365,7 +3352,6 @@ export function generateFullTestSuite(config: { test("update_feed_metadata", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty( arb.feed(), @@ -3391,7 +3377,6 @@ export function generateFullTestSuite(config: { test("delete_feed", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty(arb.feed(), async (feed) => { // Ensure feed doesn't already exist @@ -3408,7 +3393,6 @@ export function generateFullTestSuite(config: { test("delete_feed on non-existent feed is no-op", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty(arb.feedId(), async (feedId) => { await driver.delete_feed(feedId); @@ -3419,7 +3403,6 @@ export function generateFullTestSuite(config: { test("list_feeds returns all feeds", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty( fc @@ -3459,7 +3442,6 @@ export function generateFullTestSuite(config: { test("list_feeds with pagination", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); // Create 3 feeds with different createdAt timestamps const feeds: Feed[] = [ { @@ -3511,7 +3493,6 @@ export function generateFullTestSuite(config: { test("list_feeds with since filter", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); const feeds: Feed[] = [ { feedId: "feed-old", @@ -3544,7 +3525,6 @@ export function generateFullTestSuite(config: { test("add_feed_message", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty( arb.feed(), @@ -3573,7 +3553,6 @@ export function generateFullTestSuite(config: { test("list_feed_messages", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty(arb.feed(), async (feed) => { // Ensure feed doesn't already exist @@ -3592,7 +3571,6 @@ export function generateFullTestSuite(config: { test("list_feed_messages with pagination", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); const messages: FeedMessage[] = [ { id: "msg-1", @@ -3653,7 +3631,6 @@ export function generateFullTestSuite(config: { test("update_feed_message", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty( arb.feed(), @@ -3689,7 +3666,6 @@ export function generateFullTestSuite(config: { test("update_feed_message ignores stale timestamped updates for same message", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); const feed: Feed = { feedId: "test-feed", metadata: {}, @@ -3735,7 +3711,6 @@ export function generateFullTestSuite(config: { test("list_feed_messages order remains by createdAt after update", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); const feed: Feed = { feedId: "test-feed", metadata: {}, @@ -3787,7 +3762,6 @@ export function generateFullTestSuite(config: { test("delete_feed_message", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty( arb.feed(), @@ -3818,7 +3792,6 @@ export function generateFullTestSuite(config: { test("delete_feed_message on non-existent message is no-op", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty( arb.feed(), @@ -3849,7 +3822,6 @@ export function generateFullTestSuite(config: { test("delete_feed deletes all messages", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty( arb.feed(), diff --git a/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts b/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts index b5739edb9fc..ef634e821ad 100644 --- a/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts +++ b/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts @@ -550,10 +550,7 @@ function getSampleYDocUpdate(isV2: boolean = false) { return encode(doc); } -export function generateArbitraries(config?: { - nodeKey?: () => fc.Arbitrary; - metaKey?: () => fc.Arbitrary; -}) { +export function generateArbitraries() { const arb = { docId: () => fc.oneof( @@ -572,15 +569,24 @@ export function generateArbitraries(config?: { fc.stringMatching(/^[0-9A-Za-z_-]+$/) ), - nodeKey: () => config?.nodeKey?.() ?? arb.key(), - metaKey: () => config?.metaKey?.() ?? arb.key(), - jsonObject: () => arb .json() .filter( (v): v is JsonObject => v !== null && typeof v === "object" && !Array.isArray(v) + ) + // `set_object_data` drops top-level `__proto__` keys (assigning would + // route through Object.prototype's setter, and rebuilding the object + // via defineProperty on every write is too slow for this hot path). + // Mirror that contract here so the round-trip property test stops + // rediscovering this as a counterexample on every CI run. + .map((obj) => + Object.prototype.hasOwnProperty.call(obj, "__proto__") + ? (Object.fromEntries( + Object.entries(obj).filter(([k]) => k !== "__proto__") + ) as JsonObject) + : obj ), json: () => @@ -591,19 +597,10 @@ export function generateArbitraries(config?: { .jsonValue({ depthSize: "xsmall" }) .filter((v) => { const jsonText = JSON.stringify(v); - return ( - // Avoid generating floating point numbers in scientific notation - // (like 1.2e-237), as they can round differently between Node and - // SQLite. This is making isEqual comparisons annoying. - !/\d+([.]\d{1,})?e[-+]\d{2,}/.test(jsonText) && - // - // Also avoid generating objects with "__proto__" keys because those - // don't survive a serialization/deserialization roundtrip in - // DOS-KV storage. While is is typically a good thing in - // production, it means that we cannot express that "what goes in - // comes out" if inputs are generated this way. - !/"__proto__"/.test(jsonText) - ); + // Avoid generating floating point numbers in scientific notation + // (like 1.2e-237), as they can round differently between Node and + // SQLite. This is making isEqual comparisons annoying. + return !/\d+([.]\d{1,})?e[-+]\d{2,}/.test(jsonText); }) .map( (v) => @@ -620,7 +617,7 @@ export function generateArbitraries(config?: { childNodeTuple: () => { return fc .tuple( - arb.nodeKey().filter((k) => !KNOWN_DOC_KEYS.includes(k)), + arb.key().filter((k) => !KNOWN_DOC_KEYS.includes(k)), arb.serializedChild() ) .map((x) => x as ChildStorageNode); @@ -644,29 +641,29 @@ export function generateArbitraries(config?: { type: fc.constant(CrdtType.OBJECT), data: arb.jsonObject(), parentId, - parentKey: arb.nodeKey(), + parentKey: arb.key(), }), fc.record({ type: fc.constant(CrdtType.LIST), parentId, - parentKey: arb.nodeKey(), + parentKey: arb.key(), }), fc.record({ type: fc.constant(CrdtType.MAP), parentId, - parentKey: arb.nodeKey(), + parentKey: arb.key(), }), fc.record({ type: fc.constant(CrdtType.REGISTER), data: arb.json(), parentId: nonObjectParentId, - parentKey: arb.nodeKey(), + parentKey: arb.key(), }) ) .filter(wouldNotOverwriteDefaultDoc); }, - metaPair: () => fc.tuple(arb.metaKey(), arb.json()), + metaPair: () => fc.tuple(arb.key(), arb.json()), sessionId: () => fc.oneof({ withCrossShrink: true }, fc.constant("session-1"), fc.uuid()), @@ -791,7 +788,7 @@ export function generateArbitraries(config?: { .tuple( arb.plainLsonTree(options), // Filter out "root" since that ID is reserved for the root node - arb.infiniteUniqueStream(arb.nodeKey().filter((k) => k !== "root")), + arb.infiniteUniqueStream(arb.key().filter((k) => k !== "root")), arb.infiniteStream(arb.pos()) ) .map(([plainLsonTree, uniqNodeIds, positions]) => @@ -862,14 +859,14 @@ export function generateArbitraries(config?: { fc.record({ type: fc.constant(OpCode.CREATE_OBJECT), opId: arb.opId(), - id: options?.id ?? arb.nodeKey(), - parentId: options?.parentId ?? arb.nodeKey(), + id: options?.id ?? arb.key(), + parentId: options?.parentId ?? arb.key(), parentKey: options?.parentKey ?? arb.parentKey(), data: options?.data ?? arb.jsonObject(), intent: options?.intent ?? arb.intent(), deletedId: options?.deletedId ?? - fc.option(arb.nodeKey(), { freq: 10, nil: undefined }), + fc.option(arb.key(), { freq: 10, nil: undefined }), }), createListOp: (options?: { @@ -882,13 +879,13 @@ export function generateArbitraries(config?: { fc.record({ type: fc.constant(OpCode.CREATE_LIST), opId: arb.opId(), - id: options?.id ?? arb.nodeKey(), - parentId: options?.parentId ?? arb.nodeKey(), + id: options?.id ?? arb.key(), + parentId: options?.parentId ?? arb.key(), parentKey: options?.parentKey ?? arb.parentKey(), intent: options?.intent ?? arb.intent(), deletedId: options?.deletedId ?? - fc.option(arb.nodeKey(), { freq: 10, nil: undefined }), + fc.option(arb.key(), { freq: 10, nil: undefined }), }), createMapOp: (options?: { @@ -901,13 +898,13 @@ export function generateArbitraries(config?: { fc.record({ type: fc.constant(OpCode.CREATE_MAP), opId: arb.opId(), - id: options?.id ?? arb.nodeKey(), - parentId: options?.parentId ?? arb.nodeKey(), + id: options?.id ?? arb.key(), + parentId: options?.parentId ?? arb.key(), parentKey: options?.parentKey ?? arb.parentKey(), intent: options?.intent ?? arb.intent(), deletedId: options?.deletedId ?? - fc.option(arb.nodeKey(), { freq: 10, nil: undefined }), + fc.option(arb.key(), { freq: 10, nil: undefined }), }), createRegisterOp: (options?: { @@ -921,14 +918,14 @@ export function generateArbitraries(config?: { fc.record({ type: fc.constant(OpCode.CREATE_REGISTER), opId: arb.opId(), - id: options?.id ?? arb.nodeKey(), - parentId: options?.parentId ?? arb.nodeKey(), + id: options?.id ?? arb.key(), + parentId: options?.parentId ?? arb.key(), parentKey: options?.parentKey ?? arb.parentKey(), data: options?.data ?? arb.json(), intent: options?.intent ?? arb.intent(), deletedId: options?.deletedId ?? - fc.option(arb.nodeKey(), { freq: 10, nil: undefined }), + fc.option(arb.key(), { freq: 10, nil: undefined }), }), createOp: (options?: { @@ -949,14 +946,14 @@ export function generateArbitraries(config?: { fc.record({ type: fc.constant(OpCode.DELETE_CRDT), opId: arb.opId(), - id: arb.nodeKey(), + id: arb.key(), }), updateObjectOpArb: () => fc.record({ type: fc.constant(OpCode.UPDATE_OBJECT), opId: arb.opId(), - id: arb.nodeKey(), + id: arb.key(), data: arb.jsonObject(), }), @@ -964,7 +961,7 @@ export function generateArbitraries(config?: { fc.record({ type: fc.constant(OpCode.SET_PARENT_KEY), opId: arb.opId(), - id: arb.nodeKey(), + id: arb.key(), parentKey: arb.pos(), }), @@ -972,7 +969,7 @@ export function generateArbitraries(config?: { fc.record({ type: fc.constant(OpCode.DELETE_OBJECT_KEY), opId: arb.opId(), - id: arb.nodeKey(), + id: arb.key(), key: arb.parentKey(), }), @@ -1040,15 +1037,8 @@ export function generateFullTestSuite(config: { name: string; /** Runs a test with a driver, optionally pre-populating raw storage data */ runTest: (options: RunTestOptions, testFn: TestFn) => Promise; - customArbitraries?: { - nodeKey?: () => fc.Arbitrary; - metaKey?: () => fc.Arbitrary; - }; }) { - const arb = generateArbitraries({ - nodeKey: config.customArbitraries?.nodeKey, - metaKey: config.customArbitraries?.metaKey, - }); + const arb = generateArbitraries(); // Wrapper that allows calling runTest with or without options function runTest(testFn: TestFn): Promise; @@ -1104,7 +1094,7 @@ export function generateFullTestSuite(config: { runTest(async (driver) => fc.assert( fc.asyncProperty( - arb.nodeKey(), + arb.key(), async (key) => { fc.pre(key !== "root"); @@ -1216,8 +1206,8 @@ export function generateFullTestSuite(config: { runTest(async (driver) => fc.assert( fc.asyncProperty( - arb.nodeKey(), - arb.nodeKey(), + arb.key(), + arb.key(), async (parentId, parentKey) => { await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); @@ -1434,8 +1424,8 @@ export function generateFullTestSuite(config: { runTest(async (driver) => fc.assert( fc.asyncProperty( - arb.nodeKey(), - arb.nodeKey(), + arb.key(), + arb.key(), async (parentId, parentKey) => { await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); @@ -1471,8 +1461,8 @@ export function generateFullTestSuite(config: { fc.assert( fc.asyncProperty( arb.nodeStream(), - arb.nodeKey(), - arb.nodeKey(), + arb.key(), + arb.key(), async (entries, parentId, parentKey) => { await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); @@ -1939,7 +1929,7 @@ export function generateFullTestSuite(config: { runTest(async (driver) => fc.assert( fc.asyncProperty( - arb.nodeKey(), + arb.key(), arb.serializedChild(), arb.serializedChild(), @@ -2795,7 +2785,7 @@ export function generateFullTestSuite(config: { runTest(async (driver) => fc.assert( fc.asyncProperty( - arb.metaKey(), + arb.key(), async (key) => { expect(await driver.get_meta(key)).toEqual(undefined); @@ -2841,7 +2831,7 @@ export function generateFullTestSuite(config: { runTest(async (driver) => fc.assert( fc.asyncProperty( - arb.metaKey(), + arb.key(), arb.json(), arb.json(), @@ -3339,7 +3329,6 @@ export function generateFullTestSuite(config: { describe("feed API impl", () => { test("list_feeds on empty store is empty", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); const result = await driver.list_feeds(); expect(result.feeds).toEqual([]); expect(result.nextCursor).toBeUndefined(); @@ -3347,7 +3336,6 @@ export function generateFullTestSuite(config: { test("get_feed on empty store is undefined", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty(arb.feedId(), async (feedId) => { expect(await driver.get_feed(feedId)).toEqual(undefined); @@ -3357,7 +3345,6 @@ export function generateFullTestSuite(config: { test("create_feed + get_feed", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty(arb.feed(), async (feed) => { await driver.create_feed(feed); @@ -3371,7 +3358,6 @@ export function generateFullTestSuite(config: { test("update_feed_metadata", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty( arb.feed(), @@ -3397,7 +3383,6 @@ export function generateFullTestSuite(config: { test("delete_feed", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty(arb.feed(), async (feed) => { // Ensure feed doesn't already exist @@ -3414,7 +3399,6 @@ export function generateFullTestSuite(config: { test("delete_feed on non-existent feed is no-op", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty(arb.feedId(), async (feedId) => { await driver.delete_feed(feedId); @@ -3425,7 +3409,6 @@ export function generateFullTestSuite(config: { test("list_feeds returns all feeds", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty( fc @@ -3465,7 +3448,6 @@ export function generateFullTestSuite(config: { test("list_feeds with pagination", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); // Create 3 feeds with different createdAt timestamps const feeds: Feed[] = [ { @@ -3517,7 +3499,6 @@ export function generateFullTestSuite(config: { test("list_feeds with since filter", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); const feeds: Feed[] = [ { feedId: "feed-old", @@ -3550,7 +3531,6 @@ export function generateFullTestSuite(config: { test("add_feed_message", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty( arb.feed(), @@ -3579,7 +3559,6 @@ export function generateFullTestSuite(config: { test("list_feed_messages", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty(arb.feed(), async (feed) => { // Ensure feed doesn't already exist @@ -3598,7 +3577,6 @@ export function generateFullTestSuite(config: { test("list_feed_messages with pagination", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); const messages: FeedMessage[] = [ { id: "msg-1", @@ -3659,7 +3637,6 @@ export function generateFullTestSuite(config: { test("update_feed_message", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty( arb.feed(), @@ -3695,7 +3672,6 @@ export function generateFullTestSuite(config: { test("update_feed_message ignores stale timestamped updates for same message", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); const feed: Feed = { feedId: "test-feed", metadata: {}, @@ -3741,7 +3717,6 @@ export function generateFullTestSuite(config: { test("list_feed_messages order remains by createdAt after update", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); const feed: Feed = { feedId: "test-feed", metadata: {}, @@ -3793,7 +3768,6 @@ export function generateFullTestSuite(config: { test("delete_feed_message", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty( arb.feed(), @@ -3824,7 +3798,6 @@ export function generateFullTestSuite(config: { test("delete_feed_message on non-existent message is no-op", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty( arb.feed(), @@ -3855,7 +3828,6 @@ export function generateFullTestSuite(config: { test("delete_feed deletes all messages", () => runTest(async (driver) => { - if (config.name === "dos-kv") return Promise.resolve(); return fc.assert( fc.asyncProperty( arb.feed(),