From eb65403b0b3a55506018b9a235dd359558f8a93d Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Mon, 9 Dec 2024 13:31:15 +0000 Subject: [PATCH] Yjs Electric provider example (#1508) A codemirror example with Yjs using Electric for sync Contains an Y-Electric connection provider that we will extract and ship as a separate package. --- .dockerignore | 3 + .../nextjs-example/app/shape-proxy/route.ts | 2 +- examples/yjs/.eslintignore | 2 + examples/yjs/.eslintrc.cjs | 44 ++ examples/yjs/.gitignore | 10 + examples/yjs/.prettierrc | 5 + examples/yjs/Dockerfile | 31 ++ examples/yjs/README.md | 28 ++ examples/yjs/app/api/operation/route.ts | 73 ++++ examples/yjs/app/electric-editor.tsx | 115 +++++ examples/yjs/app/layout.tsx | 16 + examples/yjs/app/page.tsx | 8 + .../yjs/app/shape-proxy/[...table]/route.ts | 44 ++ examples/yjs/app/utils.ts | 42 ++ examples/yjs/app/y-electric.ts | 409 ++++++++++++++++++ .../db/migrations/01-create_yjs_tables.sql | 27 ++ examples/yjs/index.html | 12 + examples/yjs/next.config.mjs | 7 + examples/yjs/package.json | 50 +++ examples/yjs/public/favicon.ico | Bin 0 -> 1659 bytes examples/yjs/public/robots.txt | 3 + examples/yjs/sst-env.d.ts | 19 + examples/yjs/sst.config.ts | 172 ++++++++ examples/yjs/tsconfig.json | 28 ++ pnpm-lock.yaml | 392 ++++++++++++++++- 25 files changed, 1540 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 examples/yjs/.eslintignore create mode 100644 examples/yjs/.eslintrc.cjs create mode 100644 examples/yjs/.gitignore create mode 100644 examples/yjs/.prettierrc create mode 100644 examples/yjs/Dockerfile create mode 100644 examples/yjs/README.md create mode 100644 examples/yjs/app/api/operation/route.ts create mode 100644 examples/yjs/app/electric-editor.tsx create mode 100644 examples/yjs/app/layout.tsx create mode 100644 examples/yjs/app/page.tsx create mode 100644 examples/yjs/app/shape-proxy/[...table]/route.ts create mode 100644 examples/yjs/app/utils.ts create mode 100644 examples/yjs/app/y-electric.ts create mode 100644 examples/yjs/db/migrations/01-create_yjs_tables.sql create mode 100644 examples/yjs/index.html create mode 100644 examples/yjs/next.config.mjs create mode 100644 examples/yjs/package.json create mode 100644 examples/yjs/public/favicon.ico create mode 100644 examples/yjs/public/robots.txt create mode 100644 examples/yjs/sst-env.d.ts create mode 100644 examples/yjs/sst.config.ts create mode 100644 examples/yjs/tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..d98fe17111 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ + +# sst +.sst \ No newline at end of file diff --git a/examples/nextjs-example/app/shape-proxy/route.ts b/examples/nextjs-example/app/shape-proxy/route.ts index aadfae4d3a..0e66e0506f 100644 --- a/examples/nextjs-example/app/shape-proxy/route.ts +++ b/examples/nextjs-example/app/shape-proxy/route.ts @@ -41,4 +41,4 @@ export async function GET(request: Request) { }) } return resp -} +} \ No newline at end of file diff --git a/examples/yjs/.eslintignore b/examples/yjs/.eslintignore new file mode 100644 index 0000000000..362a28a121 --- /dev/null +++ b/examples/yjs/.eslintignore @@ -0,0 +1,2 @@ +/build/** +sst.config.ts diff --git a/examples/yjs/.eslintrc.cjs b/examples/yjs/.eslintrc.cjs new file mode 100644 index 0000000000..1e2f4aa287 --- /dev/null +++ b/examples/yjs/.eslintrc.cjs @@ -0,0 +1,44 @@ +module.exports = { + env: { + browser: true, + es2021: true, + node: true, + }, + extends: [ + `eslint:recommended`, + `plugin:@typescript-eslint/recommended`, + `plugin:prettier/recommended`, + 'plugin:@next/next/recommended', + ], + parserOptions: { + ecmaVersion: 2022, + requireConfigFile: false, + sourceType: `module`, + ecmaFeatures: { + jsx: true, + }, + }, + parser: `@typescript-eslint/parser`, + plugins: [`prettier`], + rules: { + quotes: [`error`, `backtick`], + "no-unused-vars": `off`, + "@typescript-eslint/no-unused-vars": [ + `error`, + { + argsIgnorePattern: `^_`, + varsIgnorePattern: `^_`, + caughtErrorsIgnorePattern: `^_`, + }, + ], + "@next/next/no-img-element": "off" + }, + ignorePatterns: [ + `**/node_modules/**`, + `**/dist/**`, + `tsup.config.ts`, + `vitest.config.ts`, + `.eslintrc.js`, + `**/*.css`, + ], +}; diff --git a/examples/yjs/.gitignore b/examples/yjs/.gitignore new file mode 100644 index 0000000000..7a06c3b2bd --- /dev/null +++ b/examples/yjs/.gitignore @@ -0,0 +1,10 @@ +dist +.env.local + +# Turborepo +.turbo + +# next.js +/.next/ +/out/ +next-env.d.ts diff --git a/examples/yjs/.prettierrc b/examples/yjs/.prettierrc new file mode 100644 index 0000000000..eaff0359ca --- /dev/null +++ b/examples/yjs/.prettierrc @@ -0,0 +1,5 @@ +{ + "trailingComma": "es5", + "semi": false, + "tabWidth": 2 +} diff --git a/examples/yjs/Dockerfile b/examples/yjs/Dockerfile new file mode 100644 index 0000000000..a1f6b8a52e --- /dev/null +++ b/examples/yjs/Dockerfile @@ -0,0 +1,31 @@ +FROM node:lts-alpine AS base + +# Stage 1: Install dependencies +FROM base AS deps +WORKDIR /app + +RUN npm install -g pnpm + +COPY pnpm-*.yaml ./ +COPY package.json ./ +COPY tsconfig.build.json ./ +COPY packages/typescript-client packages/typescript-client/ +COPY packages/react-hooks packages/react-hooks/ +COPY examples/yjs-provider/ examples/yjs-provider/ + +# Install dependencies +RUN pnpm install --frozen-lockfile +RUN pnpm run -r build + + +# Need to make production image more clean +FROM node:lts-alpine AS prod +WORKDIR /app + +ENV NODE_ENV=production +COPY --from=deps /app/ ./ + +WORKDIR /app/examples/yjs-provider/ + +EXPOSE 3000 +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/examples/yjs/README.md b/examples/yjs/README.md new file mode 100644 index 0000000000..4c943b7ac2 --- /dev/null +++ b/examples/yjs/README.md @@ -0,0 +1,28 @@ +# Yjs Electric provider + +This example showcases a multiplayer [Codemirror](https://codemirror.net/) editor with [YJS](https://github.com/yjs/yjs) and [ElectricSQL](https://electric-sql.com/). All data is synchronized through [Postgres](https://www.postgresql.org/), eliminating the need for additional real-time infrastructure. + +Y-Electric is a [YJS connection provider](https://docs.yjs.dev/ecosystem/connection-provider) that comes with offline support, integrates with [database providers](https://docs.yjs.dev/ecosystem/database-provider) and also handles [Presence/Awareness](https://docs.yjs.dev/api/about-awareness) data. It works with the entire YJS ecosystem and with you existing apps too! + +> We're releasing The Y-Electric backend as a package soon! + +## How to run + +Make sure you've installed all dependencies for the monorepo and built the packages (from the monorepo root directory): + +```shell +pnpm install +pnpm run -r build +``` + +Start the docker containers (in this directory): + +```shell +pnpm backend:up +``` + +Start the dev server: + +```shell +pnpm dev +``` \ No newline at end of file diff --git a/examples/yjs/app/api/operation/route.ts b/examples/yjs/app/api/operation/route.ts new file mode 100644 index 0000000000..3d562538e2 --- /dev/null +++ b/examples/yjs/app/api/operation/route.ts @@ -0,0 +1,73 @@ +import { Pool } from "pg" +import { NextResponse } from "next/server" +import { neon } from "@neondatabase/serverless" + +// hybrid implementation for connection pool and serverless with neon + +const connectionString = + process.env.NEON_DATABASE_URL || + process.env.DATABASE_URL || + `postgresql://postgres:password@localhost:54321/electric` + +const sql = process.env.NEON_DATABASE_URL ? neon(connectionString) : undefined + +const pool = !process.env.NEON_DATABASE_URL + ? new Pool({ connectionString }) + : undefined + +export async function POST(request: Request) { + try { + const { room, op, clientId } = await getRequestParams(request) + if (!clientId) { + await saveOperation(room, op) + } else { + await saveAwarenessOperation(room, op, clientId) + } + return NextResponse.json({}) + } catch (e) { + const resp = e instanceof Error ? e.message : e + return NextResponse.json(resp, { status: 400 }) + } +} + +async function saveOperation(room: string, op: string) { + const q = `INSERT INTO ydoc_operations (room, op) VALUES ($1, decode($2, 'base64'))` + const params = [room, op] + await runQuery(q, params) +} + +async function saveAwarenessOperation( + room: string, + op: string, + clientId: string +) { + const q = `INSERT INTO ydoc_awareness (room, clientId, op) VALUES ($1, $2, decode($3, 'base64')) + ON CONFLICT (clientId, room) + DO UPDATE SET op = decode($3, 'base64'), updated = now()` + const params = [room, clientId, op] + await runQuery(q, params) +} + +async function getRequestParams( + request: Request +): Promise<{ room: string; op: string; clientId?: string }> { + const { room, op, clientId } = await request.json() + if (!room) { + throw new Error(`'room' is required`) + } + if (!op) { + throw new Error(`'op' is required`) + } + + return { room, op, clientId } +} + +async function runQuery(q: string, params: string[]) { + if (pool) { + await pool.query(q, params) + } else if (sql) { + await sql(q, params) + } else { + throw new Error(`No database driver provided`) + } +} diff --git a/examples/yjs/app/electric-editor.tsx b/examples/yjs/app/electric-editor.tsx new file mode 100644 index 0000000000..f280e1a8c8 --- /dev/null +++ b/examples/yjs/app/electric-editor.tsx @@ -0,0 +1,115 @@ +"use client" + +import { useEffect, useRef, useState } from "react" + +import * as Y from "yjs" +import { yCollab, yUndoManagerKeymap } from "y-codemirror.next" +import { ElectricProvider } from "./y-electric" +import { Awareness } from "y-protocols/awareness" + +import { EditorState } from "@codemirror/state" +import { EditorView, basicSetup } from "codemirror" +import { keymap } from "@codemirror/view" +import { javascript } from "@codemirror/lang-javascript" + +import * as random from "lib0/random" +import { IndexeddbPersistence } from "y-indexeddb" + +const room = `electric-demo` + +const usercolors = [ + { color: `#30bced`, light: `#30bced33` }, + { color: `#6eeb83`, light: `#6eeb8333` }, + { color: `#ffbc42`, light: `#ffbc4233` }, + { color: `#ecd444`, light: `#ecd44433` }, + { color: `#ee6352`, light: `#ee635233` }, + { color: `#9ac2c9`, light: `#9ac2c933` }, +] + +const userColor = usercolors[random.uint32() % usercolors.length] +const ydoc = new Y.Doc() + +const isServer = typeof window === `undefined` + +const awareness = !isServer ? new Awareness(ydoc) : undefined +awareness?.setLocalStateField(`user`, { + name: userColor.color, + color: userColor.color, + colorLight: userColor.light, +}) + +const network = !isServer + ? new ElectricProvider( + new URL(`/shape-proxy`, window?.location.origin).href, + room, + ydoc, + { + connect: true, + awareness, + persistence: new IndexeddbPersistence(room, ydoc), + } + ) + : undefined + +export default function ElectricEditor() { + const editor = useRef(null) + + const [connectivityStatus, setConnectivityStatus] = useState< + `connected` | `disconnected` + >(`connected`) + + const toggle = () => { + if (!network) { + return + } + const toggleStatus = + connectivityStatus === `connected` ? `disconnected` : `connected` + setConnectivityStatus(toggleStatus) + toggleStatus === `connected` ? network.connect() : network.disconnect() + } + + useEffect(() => { + if (typeof window === `undefined`) { + return + } + + const ytext = ydoc.getText(room) + + const state = EditorState.create({ + doc: ytext.toString(), + extensions: [ + keymap.of([...yUndoManagerKeymap]), + basicSetup, + javascript(), + EditorView.lineWrapping, + yCollab(ytext, awareness), + ], + }) + + const view = new EditorView({ state, parent: editor.current ?? undefined }) + + return () => view.destroy() + }) + + return ( +
+
toggle()}> + +
+

+ This is a demo of Yjs using + {` `} + {` `} + Electric for + syncing. +

+

+ The content of this editor is shared with every client that visits this + domain. +

+
+
+ ) +} diff --git a/examples/yjs/app/layout.tsx b/examples/yjs/app/layout.tsx new file mode 100644 index 0000000000..ddc3e573cc --- /dev/null +++ b/examples/yjs/app/layout.tsx @@ -0,0 +1,16 @@ +export const metadata = { + title: `Yjs <> Electric`, + description: `Yjs synching with Electric`, +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/examples/yjs/app/page.tsx b/examples/yjs/app/page.tsx new file mode 100644 index 0000000000..b31810d8f9 --- /dev/null +++ b/examples/yjs/app/page.tsx @@ -0,0 +1,8 @@ +"use server" + +import React from "react" +import ElectricEditor from "./electric-editor" + +const Page = async () => + +export default Page diff --git a/examples/yjs/app/shape-proxy/[...table]/route.ts b/examples/yjs/app/shape-proxy/[...table]/route.ts new file mode 100644 index 0000000000..d389389091 --- /dev/null +++ b/examples/yjs/app/shape-proxy/[...table]/route.ts @@ -0,0 +1,44 @@ +export async function GET(request: Request) { + const url = new URL(request.url) + const originUrl = new URL( + process.env.ELECTRIC_URL + ? `${process.env.ELECTRIC_URL}/v1/shape/` + : `http://localhost:3000/v1/shape/` + ) + + url.searchParams.forEach((value, key) => { + originUrl.searchParams.set(key, value) + }) + + if (process.env.DATABASE_ID) { + originUrl.searchParams.set(`database_id`, process.env.DATABASE_ID) + } + + const headers = new Headers() + if (process.env.ELECTRIC_TOKEN) { + originUrl.searchParams.set(`token`, process.env.ELECTRIC_TOKEN) + } + + const newRequest = new Request(originUrl.toString(), { + method: `GET`, + headers, + }) + + // When proxying long-polling requests, content-encoding & content-length are added + // erroneously (saying the body is gzipped when it's not) so we'll just remove + // them to avoid content decoding errors in the browser. + // + // Similar-ish problem to https://github.com/wintercg/fetch/issues/23 + let resp = await fetch(newRequest) + if (resp.headers.get(`content-encoding`)) { + const headers = new Headers(resp.headers) + headers.delete(`content-encoding`) + headers.delete(`content-length`) + resp = new Response(resp.body, { + status: resp.status, + statusText: resp.statusText, + headers, + }) + } + return resp +} diff --git a/examples/yjs/app/utils.ts b/examples/yjs/app/utils.ts new file mode 100644 index 0000000000..780dd50921 --- /dev/null +++ b/examples/yjs/app/utils.ts @@ -0,0 +1,42 @@ +import { toBase64 } from "lib0/buffer" +import * as decoding from "lib0/decoding" + +const hexStringToUint8Array = (hexString: string) => { + const cleanHexString = hexString.startsWith(`\\x`) + ? hexString.slice(2) + : hexString + return new Uint8Array( + cleanHexString.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)) + ) +} + +export const parseToUint8Array = { + bytea: hexStringToUint8Array, +} + +export const parseToBase64 = { + bytea: (hexString: string) => { + const uint8Array = hexStringToUint8Array(hexString) + return toBase64(uint8Array) + }, +} + +export const parseToDecoder = { + bytea: (hexString: string) => { + const uint8Array = hexStringToUint8Array(hexString) + return decoding.createDecoder(uint8Array) + }, +} + +export const parseToDecoderLazy = { + bytea: (hexString: string) => () => { + const uint8Array = hexStringToUint8Array(hexString) + return decoding.createDecoder(uint8Array) + }, +} + +export const paserToTimestamptz = { + timestamptz: (timestamp: string) => { + return new Date(timestamp) + }, +} diff --git a/examples/yjs/app/y-electric.ts b/examples/yjs/app/y-electric.ts new file mode 100644 index 0000000000..50ec608ad4 --- /dev/null +++ b/examples/yjs/app/y-electric.ts @@ -0,0 +1,409 @@ +import { toBase64 } from "lib0/buffer" +import * as encoding from "lib0/encoding" +import * as decoding from "lib0/decoding" +import * as syncProtocol from "y-protocols/sync" +import * as awarenessProtocol from "y-protocols/awareness" +import { ObservableV2 } from "lib0/observable" +import * as env from "lib0/environment" +import * as Y from "yjs" +import { + isChangeMessage, + isControlMessage, + Message, + Offset, + ShapeStream, +} from "@electric-sql/client" +import { parseToDecoder, parseToDecoderLazy, paserToTimestamptz } from "./utils" +import { IndexeddbPersistence } from "y-indexeddb" + +type OperationMessage = { + op: decoding.Decoder +} + +type AwarenessMessage = { + op: () => decoding.Decoder + clientId: string + room: string + updated: Date +} + +type ObservableProvider = { + sync: (state: boolean) => void + synced: (state: boolean) => void + status: (status: { + status: `connecting` | `connected` | `disconnected` + }) => void + // eslint-disable-next-line quotes + "connection-close": () => void +} + +// from yjs docs, need to check if is configurable +const awarenessPingPeriod = 30000 //ms + +const messageSync = 0 + +export class ElectricProvider extends ObservableV2 { + private baseUrl: string + private roomName: string + private doc: Y.Doc + public awareness?: awarenessProtocol.Awareness + + private operationsStream?: ShapeStream + private awarenessStream?: ShapeStream + + private shouldConnect: boolean + private connected: boolean + private _synced: boolean + + private modifiedWhileOffline: boolean + private lastSyncedStateVector?: Uint8Array + + private updateHandler: (update: Uint8Array, origin: unknown) => void + private awarenessUpdateHandler?: ( + changed: { added: number[]; updated: number[]; removed: number[] }, + origin: string + ) => void + private disconnectShapeHandler?: () => void + private exitHandler?: () => void + + private persistence?: IndexeddbPersistence + private loaded: boolean + private resume: { + operations?: { offset: Offset; handle: string } + awareness?: { offset: Offset; handle: string } + } = {} + + private awarenessState: Record | null = null + + constructor( + serverUrl: string, + roomName: string, + doc: Y.Doc, + options: { + awareness?: awarenessProtocol.Awareness + connect?: boolean + persistence?: IndexeddbPersistence + } // TODO: make it generic, we can load it outside the provider + ) { + super() + + this.baseUrl = serverUrl + `/v1/shape` + this.roomName = roomName + + this.doc = doc + this.awareness = options.awareness + + this.connected = false + this._synced = false + this.shouldConnect = options.connect ?? false + + this.modifiedWhileOffline = false + + this.persistence = options.persistence + this.loaded = this.persistence === undefined + + this.updateHandler = (update: Uint8Array, origin: unknown) => { + if (origin !== this) { + this.sendOperation(update) + } + } + this.doc.on(`update`, this.updateHandler) + + if (this.awareness) { + this.awarenessUpdateHandler = ({ added, updated, removed }, origin) => { + if (origin === `local`) { + const changedClients = added.concat(updated).concat(removed) + this.sendAwareness(changedClients) + } + } + this.awareness.on(`update`, this.awarenessUpdateHandler) + } + + if (env.isNode && typeof process !== `undefined`) { + this.exitHandler = () => { + process.on(`exit`, () => this.destroy()) + } + } + + if (!this.loaded) { + this.loadSyncState() + } else if (options.connect) { + this.connect() + } + } + + get synced() { + return this._synced + } + + set synced(state) { + if (this._synced !== state) { + this._synced = state + this.emit(`synced`, [state]) + this.emit(`sync`, [state]) + } + } + + async loadSyncState() { + if (!this.persistence) { + throw Error(`Can't load sync state without persistence backend`) + } + const operationsHandle = await this.persistence.get(`operations_handle`) + const operationsOffset = await this.persistence.get(`operations_offset`) + + const awarenessHandle = await this.persistence.get(`awareness_handle`) + const awarenessOffset = await this.persistence.get(`awareness_offset`) + + const lastSyncedStateVector = await this.persistence.get( + `last_synced_state_vector` + ) + + this.lastSyncedStateVector = lastSyncedStateVector + this.modifiedWhileOffline = this.lastSyncedStateVector !== undefined + + this.resume = { + operations: { + handle: operationsHandle, + offset: operationsOffset, + }, + + // TODO: we might miss some awareness updates since last pings + awareness: { + handle: awarenessHandle, + offset: awarenessOffset, + }, + } + + this.loaded = true + if (this.shouldConnect) { + this.connect() + } + } + + destroy() { + this.disconnect() + this.doc.off(`update`, this.updateHandler) + this.awareness?.off(`update`, this.awarenessUpdateHandler!) + if (env.isNode && typeof process !== `undefined`) { + process.off(`exit`, this.exitHandler!) + } + super.destroy() + } + + disconnect() { + this.shouldConnect = false + + if (this.awareness && this.connected) { + this.awarenessState = this.awareness.getLocalState() + + awarenessProtocol.removeAwarenessStates( + this.awareness, + Array.from(this.awareness.getStates().keys()).filter( + (client) => client !== this.doc.clientID + ), + this + ) + + // try to notify other clients that we are disconnected + awarenessProtocol.removeAwarenessStates( + this.awareness, + [this.doc.clientID], + `local` + ) + } + + if (this.disconnectShapeHandler) { + this.disconnectShapeHandler() + } + } + + connect() { + this.shouldConnect = true + if (!this.connected && !this.operationsStream) { + this.setupShapeStream() + } + + if (this.awareness && this.awarenessState !== null) { + this.awareness.setLocalState(this.awarenessState) + this.awarenessState = null + } + } + + private sendOperation(update: Uint8Array) { + if (!this.connected) { + this.modifiedWhileOffline = true + return Promise.resolve() + } + + const encoder = encoding.createEncoder() + syncProtocol.writeUpdate(encoder, update) + const op = toBase64(encoding.toUint8Array(encoder)) + const room = this.roomName + + return fetch(`/api/operation`, { + method: `POST`, + body: JSON.stringify({ room, op }), + }) + } + + private sendAwareness(changedClients: number[]) { + const encoder = encoding.createEncoder() + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(this.awareness!, changedClients) + ) + const op = toBase64(encoding.toUint8Array(encoder)) + + if (this.connected) { + const room = this.roomName + const clientId = `${this.doc.clientID}` + + return fetch(`/api/operation`, { + method: `POST`, + body: JSON.stringify({ clientId, room, op }), + }) + } + } + + private setupShapeStream() { + if (this.shouldConnect && !this.operationsStream) { + this.connected = false + this.synced = false + + console.log(`Setting up shape stream ${JSON.stringify(this.resume)}`) + + this.operationsStream = new ShapeStream({ + url: this.baseUrl, + params: { + table: `ydoc_operations`, + where: `room = '${this.roomName}'`, + }, + parser: parseToDecoder, + subscribe: true, + ...this.resume.operations, + }) + + this.awarenessStream = new ShapeStream({ + url: this.baseUrl, + params: { + where: `room = '${this.roomName}'`, + table: `ydoc_awareness`, + }, + parser: { ...parseToDecoderLazy, ...paserToTimestamptz }, + ...this.resume.awareness, + }) + + const updateShapeState = ( + name: `operations` | `awareness`, + offset: Offset, + handle: string + ) => { + this.resume[name] = { offset, handle } + this.persistence?.set(`${name}_offset`, offset) + this.persistence?.set(`${name}_handle`, handle) + } + + const handleSyncMessage = (messages: Message[]) => { + messages.forEach((message) => { + if (isChangeMessage(message) && message.value.op) { + const decoder = message.value.op + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, messageSync) + syncProtocol.readSyncMessage(decoder, encoder, this.doc, this) + } else if ( + isControlMessage(message) && + message.headers.control === `up-to-date` + ) { + this.synced = true + + updateShapeState( + `operations`, + this.operationsStream!.lastOffset, + this.operationsStream!.shapeHandle + ) + } + }) + } + + const unsubscribeSyncHandler = + this.operationsStream.subscribe(handleSyncMessage) + + const handleAwarenessMessage = ( + messages: Message[] + ) => { + const minTime = new Date(Date.now() - awarenessPingPeriod) + messages.forEach((message) => { + if (isChangeMessage(message) && message.value.op) { + if (message.value.updated < minTime) { + return + } + + const decoder = message.value.op() + awarenessProtocol.applyAwarenessUpdate( + this.awareness!, + decoding.readVarUint8Array(decoder), + this + ) + } + }) + + updateShapeState( + `awareness`, + this.awarenessStream!.lastOffset, + this.awarenessStream!.shapeHandle + ) + } + + const unsubscribeAwarenessHandler = this.awarenessStream.subscribe( + handleAwarenessMessage + ) + + this.disconnectShapeHandler = () => { + this.operationsStream = undefined + this.awarenessStream = undefined + + if (this.connected) { + this.lastSyncedStateVector = Y.encodeStateVector(this.doc) + this.persistence?.set( + `last_synced_state_vector`, + this.lastSyncedStateVector + ) + + this.connected = false + this.synced = false + this.emit(`status`, [{ status: `disconnected` }]) + } + + unsubscribeSyncHandler() + unsubscribeAwarenessHandler() + this.disconnectShapeHandler = undefined + this.emit(`connection-close`, []) + } + + const pushLocalChangesUnsubscribe = this.operationsStream!.subscribe( + () => { + this.connected = true + + if (this.modifiedWhileOffline) { + const pendingUpdates = Y.encodeStateAsUpdate( + this.doc, + this.lastSyncedStateVector + ) + const encoderState = encoding.createEncoder() + syncProtocol.writeUpdate(encoderState, pendingUpdates) + + this.sendOperation(pendingUpdates).then(() => { + this.lastSyncedStateVector = undefined + this.modifiedWhileOffline = false + this.persistence?.del(`last_synced_state_vector`) + this.emit(`status`, [{ status: `connected` }]) + }) + } + pushLocalChangesUnsubscribe() + } + ) + + this.emit(`status`, [{ status: `connecting` }]) + } + } +} diff --git a/examples/yjs/db/migrations/01-create_yjs_tables.sql b/examples/yjs/db/migrations/01-create_yjs_tables.sql new file mode 100644 index 0000000000..1e9d43e165 --- /dev/null +++ b/examples/yjs/db/migrations/01-create_yjs_tables.sql @@ -0,0 +1,27 @@ +CREATE TABLE ydoc_operations( + id SERIAL PRIMARY KEY, + room TEXT, + op BYTEA NOT NULL +); + +CREATE TABLE ydoc_awareness( + clientId TEXT, + room TEXT, + op BYTEA NOT NULL, + updated TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (clientId, room) +); + +CREATE OR REPLACE FUNCTION delete_old_rows() +RETURNS TRIGGER AS $$ +BEGIN + DELETE FROM ydoc_awareness + WHERE updated < NOW() - INTERVAL '2 minutes'; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER delete_old_rows_trigger +AFTER INSERT OR UPDATE ON ydoc_awareness +FOR EACH STATEMENT +EXECUTE FUNCTION delete_old_rows(); diff --git a/examples/yjs/index.html b/examples/yjs/index.html new file mode 100644 index 0000000000..1c8f62ecc9 --- /dev/null +++ b/examples/yjs/index.html @@ -0,0 +1,12 @@ + + + + + + + + Yjs Electric provider + + + + \ No newline at end of file diff --git a/examples/yjs/next.config.mjs b/examples/yjs/next.config.mjs new file mode 100644 index 0000000000..e559ceb6ca --- /dev/null +++ b/examples/yjs/next.config.mjs @@ -0,0 +1,7 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { +} + +export default nextConfig \ No newline at end of file diff --git a/examples/yjs/package.json b/examples/yjs/package.json new file mode 100644 index 0000000000..3475e8b5d2 --- /dev/null +++ b/examples/yjs/package.json @@ -0,0 +1,50 @@ +{ + "name": "@electric-examples/yjs-provider", + "private": true, + "version": "0.0.1", + "author": "ElectricSQL", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "backend:up": "PROJECT_NAME=yjs-provider pnpm -C ../../ run example-backend:up && pnpm db:migrate", + "backend:down": "PROJECT_NAME=yjs-provider pnpm -C ../../ run example-backend:down", + "db:migrate": "dotenv -e ../../.env.dev -- pnpm exec pg-migrations apply --directory ./db/migrations", + "dev": "next dev --turbo -p 5173", + "build": "next build", + "start": "next start", + "lint": "eslint . --ext js,ts,tsx --report-unused-disable-directives --max-warnings 0", + "stylecheck": "eslint . --quiet", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@codemirror/lang-javascript": "^6.2.2", + "@codemirror/state": "^6.4.1", + "@codemirror/view": "^6.32.0", + "@electric-sql/client": "workspace:*", + "@electric-sql/react": "workspace:*", + "@neondatabase/serverless": "^0.10.4", + "codemirror": "^6.0.1", + "lib0": "^0.2.96", + "next": "^14.2.9", + "pg": "^8.13.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "sst": "^3.3.35", + "y-codemirror.next": "0.3.5", + "y-indexeddb": "^9.0.12", + "y-protocols": "1.0.6", + "yjs": "^13.6.18" + }, + "devDependencies": { + "@databases/pg-migrations": "^5.0.3", + "@next/eslint-plugin-next": "^14.2.5", + "@types/pg": "^8.11.6", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "dotenv": "^16.4.5", + "eslint": "^8.57.0", + "typescript": "^5.5.3", + "vite": "^5.3.4" + } +} diff --git a/examples/yjs/public/favicon.ico b/examples/yjs/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e55095b98323d3dd569e75945b47d612e02b620b GIT binary patch literal 1659 zcmXw32~<;88on=!frKpNJxJIR8d(Y=fdLIrlxPvehQ%o=ZNV5Ug31u2NIV1Py}AHu z6;q@rq6DNv1&^p$gSDC*sdaTbL*lx`(xzXgWV;9&!JbO!v|NNO!( ziI=jLg_BzLfq@3lUk@Ir!9YFfLK$oM65_>j=Eo7lO9xm{e!CW0p{uf5PR>4SK zLH-^i{Ct!x3?w%y8L6@C#Q~HP3i^gPgu`VA`cbNPvIG1m-xh%TYJ#qkzCMo7Sw;V1 z4Sk~o;kqF%Tte4(Y~O{HihSsH4Q*>G!gYsz7djlu1%^7%ucqa$M?5^h{W|DQ4J~&A z%;!7o%Yp8mggrfBKF_fv6XE(!iNy|%v7YZmx z@(_-jQ)V*Mdx9O{OV5zdH^dWeRyln04~8U$c=;gIbDYpoNv_}RBu#|9`7BWgGwvf+ zLh(8^1k|mQHl#srbx-)~mE{?W+ox|QO zu&;n}yol7imlY98I$z3M8A1H<5cA_mMv|D3vVyTbj{ezd$K9C@<=fc)K2zag+4DCk zFGxy-`Wh590FX8(#I8u)_3z1{><#c*WdA61rXh(Gq|>iUQ3ZPMDffvx$|@(nOl6RN+E$g#S4A=ZP zo@U9gy_u*Tq<3p}^l#zy#R#=x@-N9_V_6}labTV9WX9N)yjz>ql8efm{{n=1?coFH zM=CTvl&MAi4{IBb^}LNi-KvK7^Ocs-GQM&LUf_oxT;q<;2J`UdZL6NOK3P0y)V-s# zE8g$FPN^naTZR`6^07Z#Ec8D-W^d1mrZ`h_YUzlbyzI8kC9-Rq-#I9O1=U%TXi ztFK9;XpgprzDrw1tDh@6WAV=TU6u;o8Ixj*SN5p!)zsQ>e4`(}{Lh*)>2a+F6<>*I zf#0**@!@hbrClq}NxP!GM*9mj|A6l6=IbLnI*lIRn|cKPktM(|nIrd`Dck?{i}}ec zqnsD=&Hpjqw-CP$b;S*qTRw-iI6^}&oK^Kod%9ZR7nMnCQ0wI!?K8=#`sqc%xan*u zAxn96Z@=WU?a5b_V|wGwu1~^(mV+shkR$!z@x$$T(P*#UTY70|XdF3Y+uYtM%6lq! zs&7^+hDC!nK2>VIE;3KWOy>g4lkO9oIi*1>kAo3!!%Fq$&`Iv@gMg zwUjvh`XpUvkcWeK?QIY=w}3ePzmqiGfIJZU=i0md@(;1*^O>(iFm`cxHMpmQp|rPj zlA+4wo4J=1LzeO$ewmnWZt}pc$LN7|?j_kEl)g?U(1fYURVP5#K)EwbY_axAZ{13eVd)m2Gf<&Sm54VIA3slCitWUGh%Yq8 zpx6E!arMs%uMVg%VQa6m=cY&Uq1VZN*eU!L@x=^t=^L1#Zi|UJG6JHmb*nse8_`TC zXx1OmZ9xUlxmkafF3&at1(}5&x*}Tvbj~b<#Uf>a55;gevE%3nU|T;Svqq%?j6Eui z#%gN0oDpd_wk^+m^~fM-!x_%_DnhTRhw0tR7h$hFe@`&=(9}_FaknhbNFy_S)n^F~ zuWc^k8dL^$;AH5(e~eLBFX}kr1~d<-@JMJt9c!xt&GzT2wkg0;zSxKA{m8ZmOSkob zZ6j@|whD0nakbl7+z6!xv0caO=<2lH;6Vvi^hD|3mK7EB#Mq9vHq5n=( + +import { execSync } from "child_process" + +const isProduction = () => $app.stage.toLocaleLowerCase() === `production` + +export default $config({ + app(input) { + return { + name: `yjs`, + removal: + input?.stage.toLocaleLowerCase() === `production` ? `retain` : `remove`, + home: `aws`, + providers: { + cloudflare: `5.42.0`, + aws: { + version: `6.57.0`, + }, + neon: `0.6.3`, + }, + } + }, + async run() { + try { + const project = neon.getProjectOutput({ + id: process.env.NEON_PROJECT_ID!, + }) + const base = { + projectId: project.id, + branchId: project.defaultBranchId, + } + + const db = new neon.Database(`yjs-db`, { + ...base, + ownerName: `neondb_owner`, + name: isProduction() ? `yjs` : `yjs-${$app.stage}`, + }) + + const databaseUri = getNeonDbUri(project, db, false) + const pooledUri = getNeonDbUri(project, db, true) + databaseUri.apply(applyMigrations) + + const electricInfo = databaseUri.apply((uri) => + addDatabaseToElectric(uri) + ) + + // const serverless = deployServerlessApp(electricInfo, pooledUri) + const website = deployAppServer(electricInfo, databaseUri) + + return { + // serverless_url: serverless.url, + server_url: website.url, + databaseUri, + databasePooledUri: pooledUri, + } + } catch (e) { + console.error(e) + } + }, +}) + +function applyMigrations(uri: string) { + execSync(`pnpm exec pg-migrations apply --directory ./db/migrations`, { + env: { + ...process.env, + DATABASE_URL: uri, + }, + }) +} + +function deployAppServer( + { id, token }: $util.Output<{ id: string; token: string }>, + uri: $util.Output +) { + const vpc = new sst.aws.Vpc(`yjs-vpc-${$app.stage}`, { bastion: true }) + const cluster = new sst.aws.Cluster(`yjs-cluster-${$app.stage}`, { vpc }) + const service = cluster.addService(`yjs-service-${$app.stage}`, { + loadBalancer: { + ports: [{ listen: "443/https", forward: "3000/http" }], + domain: { + name: `yjs${isProduction() ? `` : `-${$app.stage}`}.examples.electric-sql.com`, + dns: sst.cloudflare.dns(), + }, + }, + environment: { + ELECTRIC_URL: process.env.ELECTRIC_API!, + DATABASE_URL: uri, + DATABASE_ID: id, + ELECTRIC_TOKEN: token, + }, + image: { + context: "../..", + dockerfile: "Dockerfile", + }, + dev: { + command: "npm run dev", + }, + }) + + return service +} + +function deployServerlessApp( + electricInfo: $util.Output<{ id: string; token: string }>, + uri: $util.Output +) { + return new sst.aws.Nextjs(`yjs`, { + environment: { + ELECTRIC_URL: process.env.ELECTRIC_API!, + ELECTRIC_TOKEN: electricInfo.token, + DATABASE_ID: electricInfo.id, + NEON_DATABASE_URL: uri, + }, + domain: { + name: `yjs${isProduction() ? `` : `-stage-${$app.stage}`}.examples.electric-sql.com`, + dns: sst.cloudflare.dns(), + }, + }) +} + +function getNeonDbUri( + project: $util.Output, + db: neon.Database, + pooled: boolean +) { + const passwordOutput = neon.getBranchRolePasswordOutput({ + projectId: project.id, + branchId: project.defaultBranchId, + roleName: db.ownerName, + }) + + const endpoint = neon.getBranchEndpointsOutput({ + projectId: project.id, + branchId: project.defaultBranchId, + }) + + const databaseHost = pooled + ? endpoint.endpoints?.apply((endpoints) => + endpoints![0].host.replace( + endpoints![0].id, + endpoints![0].id + "-pooler" + ) + ) + : project.databaseHost + + const url = $interpolate`postgresql://${passwordOutput.roleName}:${passwordOutput.password}@${databaseHost}/${db.name}?sslmode=require` + return url +} + +async function addDatabaseToElectric( + uri: string +): Promise<{ id: string; token: string }> { + const adminApi = process.env.ELECTRIC_ADMIN_API + + const result = await fetch(`${adminApi}/v1/databases`, { + method: `PUT`, + headers: { "Content-Type": `application/json` }, + body: JSON.stringify({ + database_url: uri, + region: `us-east-1`, + }), + }) + + if (!result.ok) { + throw new Error( + `Could not add database to Electric (${result.status}): ${await result.text()}` + ) + } + + return await result.json() +} diff --git a/examples/yjs/tsconfig.json b/examples/yjs/tsconfig.json new file mode 100644 index 0000000000..01460806e7 --- /dev/null +++ b/examples/yjs/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2015", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules", "sst.config.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed408527b0..954f8eed74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -815,6 +815,91 @@ importers: specifier: ^0.21.0 version: 0.21.0(vite@5.4.11(@types/node@20.17.6)(terser@5.36.0))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0) + examples/yjs: + dependencies: + '@codemirror/lang-javascript': + specifier: ^6.2.2 + version: 6.2.2 + '@codemirror/state': + specifier: ^6.4.1 + version: 6.5.0 + '@codemirror/view': + specifier: ^6.32.0 + version: 6.35.2 + '@electric-sql/client': + specifier: workspace:* + version: link:../../packages/typescript-client + '@electric-sql/react': + specifier: workspace:* + version: link:../../packages/react-hooks + '@neondatabase/serverless': + specifier: ^0.10.4 + version: 0.10.4 + codemirror: + specifier: ^6.0.1 + version: 6.0.1(@lezer/common@1.2.3) + lib0: + specifier: ^0.2.96 + version: 0.2.99 + next: + specifier: ^14.2.9 + version: 14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + pg: + specifier: ^8.13.1 + version: 8.13.1 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + sst: + specifier: ^3.3.35 + version: 3.3.59 + y-codemirror.next: + specifier: 0.3.5 + version: 0.3.5(@codemirror/state@6.5.0)(@codemirror/view@6.35.2)(yjs@13.6.20) + y-indexeddb: + specifier: ^9.0.12 + version: 9.0.12(yjs@13.6.20) + y-protocols: + specifier: 1.0.6 + version: 1.0.6(yjs@13.6.20) + yjs: + specifier: ^13.6.18 + version: 13.6.20 + devDependencies: + '@databases/pg-migrations': + specifier: ^5.0.3 + version: 5.0.3(typescript@5.7.2) + '@next/eslint-plugin-next': + specifier: ^14.2.5 + version: 14.2.20 + '@types/pg': + specifier: ^8.11.6 + version: 8.11.10 + '@types/react': + specifier: ^18.3.3 + version: 18.3.12 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.1 + '@vitejs/plugin-react': + specifier: ^4.3.1 + version: 4.3.3(vite@5.4.11(@types/node@20.17.6)(terser@5.36.0)) + dotenv: + specifier: ^16.4.5 + version: 16.4.7 + eslint: + specifier: ^8.57.0 + version: 8.57.1 + typescript: + specifier: ^5.5.3 + version: 5.7.2 + vite: + specifier: ^5.3.4 + version: 5.4.11(@types/node@20.17.6)(terser@5.36.0) + packages/elixir-client: {} packages/experimental: @@ -1727,6 +1812,35 @@ packages: '@changesets/write@0.3.2': resolution: {integrity: sha512-kDxDrPNpUgsjDbWBvUo27PzKX4gqeKOlhibaOXDJA6kuBisGqNHv/HwGJrAu8U/dSf8ZEFIeHIPtvSlZI1kULw==} + '@codemirror/autocomplete@6.18.3': + resolution: {integrity: sha512-1dNIOmiM0z4BIBwxmxEfA1yoxh1MF/6KPBbh20a5vphGV0ictKlgQsbJs6D6SkR6iJpGbpwRsa6PFMNlg9T9pQ==} + peerDependencies: + '@codemirror/language': ^6.0.0 + '@codemirror/state': ^6.0.0 + '@codemirror/view': ^6.0.0 + '@lezer/common': ^1.0.0 + + '@codemirror/commands@6.7.1': + resolution: {integrity: sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==} + + '@codemirror/lang-javascript@6.2.2': + resolution: {integrity: sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==} + + '@codemirror/language@6.10.6': + resolution: {integrity: sha512-KrsbdCnxEztLVbB5PycWXFxas4EOyk/fPAfruSOnDDppevQgid2XZ+KbJ9u+fDikP/e7MW7HPBTvTb8JlZK9vA==} + + '@codemirror/lint@6.8.4': + resolution: {integrity: sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A==} + + '@codemirror/search@6.5.8': + resolution: {integrity: sha512-PoWtZvo7c1XFeZWmmyaOp2G0XVbOnm+fJzvghqGAktBW3cufwJUWvSCcNG0ppXiBEM05mZu6RhMtXPv2hpllig==} + + '@codemirror/state@6.5.0': + resolution: {integrity: sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==} + + '@codemirror/view@6.35.2': + resolution: {integrity: sha512-u04R04XFCYCNaHoNRr37WUUAfnxKPwPdqV+370NiO6i85qB1J/qCD/WbbMJsyJfRWhXIJXAe2BG/oTzAggqv4A==} + '@databases/connection-pool@1.1.0': resolution: {integrity: sha512-/12/SNgl0V77mJTo5SX3yGPz4c9XGQwAlCfA0vlfs/0HcaErNpYXpmhj0StET07w6TmTJTnaUgX2EPcQK9ez5A==} @@ -2646,18 +2760,39 @@ packages: '@jspm/core@2.0.1': resolution: {integrity: sha512-Lg3PnLp0QXpxwLIAuuJboLeRaIhrgJjeuh797QADg3xz8wGLugQOS5DpsE8A6i6Adgzf+bacllkKZG3J0tGfDw==} + '@lezer/common@1.2.3': + resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==} + + '@lezer/highlight@1.2.1': + resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==} + + '@lezer/javascript@1.4.21': + resolution: {integrity: sha512-lL+1fcuxWYPURMM/oFZLEDm0XuLN128QPV+VuGtKpeaOGdcl9F2LYC3nh1S9LkPqx9M0mndZFdXCipNAZpzIkQ==} + + '@lezer/lr@1.4.2': + resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@marijn/find-cluster-break@1.0.0': + resolution: {integrity: sha512-0YSzy7M9mBiK+h1m33rD8vZOfaO8leG6CY3+Q+1Lig86snkc8OAHQVAdndmnXMWJlVIH6S7fSZVVcjLcq6OH1A==} + '@mdx-js/mdx@2.3.0': resolution: {integrity: sha512-jLuwRlz8DQfQNiUCJR50Y09CGPq3fLtmtUQfVrj79E0JWu3dvsVcxVIcfhR5h0iXu+/z++zDrYeiJqifRynJkA==} + '@neondatabase/serverless@0.10.4': + resolution: {integrity: sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA==} + '@next/env@14.2.20': resolution: {integrity: sha512-JfDpuOCB0UBKlEgEy/H6qcBSzHimn/YWjUHzKl1jMeUO+QVRdzmTTl8gFJaNO87c8DXmVKhFCtwxQ9acqB3+Pw==} + '@next/eslint-plugin-next@14.2.20': + resolution: {integrity: sha512-T0JRi706KLbvR1Uc46t56VtawbhR/igdBagzOrA7G+vv4rvjwnlu/Y4/Iq6X9TDVj5UZjyot4lUdkNd3V2kLhw==} + '@next/swc-darwin-arm64@14.2.20': resolution: {integrity: sha512-WDfq7bmROa5cIlk6ZNonNdVhKmbCv38XteVFYsxea1vDJt3SnYGgxLGMTXQNfs5OkFvAhmfKKrwe7Y0Hs+rWOg==} engines: {node: '>= 10'} @@ -4132,6 +4267,9 @@ packages: '@types/pg@8.11.10': resolution: {integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==} + '@types/pg@8.11.6': + resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==} + '@types/prop-types@15.7.13': resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} @@ -4862,6 +5000,9 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + codemirror@6.0.1: + resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -5828,6 +5969,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.3.10: + resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true @@ -6242,6 +6388,13 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + + jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -6381,6 +6534,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib0@0.2.99: + resolution: {integrity: sha512-vwztYuUf1uf/1zQxfzRfO5yzfNKhTtgOByCruuiQQxWQXnPb8Itaube5ylofcV0oM0aKal9Mv+S1s1Ky0UYP1w==} + engines: {node: '>=16'} + hasBin: true + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -8182,31 +8340,68 @@ packages: resolution: {integrity: sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + sst-darwin-arm64@3.3.59: + resolution: {integrity: sha512-v8E3pg3JK7LDFOD0ot5N+HMNOA6MDHczx1M1SsJnMcNZPLTFH/Dn1B3roUaTP55+8jLKtqSJhnp9kLtTPHR1GQ==} + cpu: [arm64] + os: [darwin] + sst-darwin-arm64@3.3.7: resolution: {integrity: sha512-2CQh78YIdvrRpO8enZ/Jx51JsUSFtk564u9w4ldcu5SsMMDY1ocdw5p/XIwBy1eKeRtrXLizd35sYbtSfSy6sw==} cpu: [arm64] os: [darwin] + sst-darwin-x64@3.3.59: + resolution: {integrity: sha512-SOOvHPxdxXwcMEwfdDXFiaMqZTdy1UcTL74omKGAn3i0pdoDN+FK2KPa865gNd1dzyYg0P6QXcUu9hI92g9Cyg==} + cpu: [x64] + os: [darwin] + sst-darwin-x64@3.3.7: resolution: {integrity: sha512-+hiDT3+am+CBO3xBy8yl3bmFeTjGXUT/+7V6NFOV2yxlRP3A8J65nEjWdzPTU/u7hRl+leE8EBu14j0grt/7/A==} cpu: [x64] os: [darwin] + sst-linux-arm64@3.3.59: + resolution: {integrity: sha512-C7RjmuO0RR6xhmz0EZDcL6FhPCInwj5odehJZkbrvW0SObdoBLZLeDF0Or/7DKKh9VuSes/dpN1UvuUtMVE5Cg==} + cpu: [arm64] + os: [linux] + sst-linux-arm64@3.3.7: resolution: {integrity: sha512-dYolpXAjq0S8QjL8sTKzcRpPNgZDeMcJ9PHnt/8GpdqxNxEpGlNF9gMl2cB7mleJyJYBNMPvi4YEeCGtcazmeQ==} cpu: [arm64] os: [linux] + sst-linux-x64@3.3.59: + resolution: {integrity: sha512-7XZaYHl7Uun9q6koMEIICvw/+BX18hqiVmBjaC2p6J6x5tw+z7kfpK3x+yD26HLHoFNhpIBLb+6Nqj7XDyCa/Q==} + cpu: [x64] + os: [linux] + sst-linux-x64@3.3.7: resolution: {integrity: sha512-K2vPOZ5DS8mJmE4QtffgZN5Nem1MIBhoVozNtZ0NoufeKHbFz0Hyw9wbqxYSbs2MOoVNKvG8qwcX99ojVXTFKw==} cpu: [x64] os: [linux] + sst-linux-x86@3.3.59: + resolution: {integrity: sha512-GA8dZw6ty12ZwvpdVXkKBtdNwNuGF8vIcOKSzAQ6eC2UmywHlvQNgz2fiWMPVz94QLnNHCHAZlL6XAR7fz7n4Q==} + cpu: [x86] + os: [linux] + sst-linux-x86@3.3.7: resolution: {integrity: sha512-4rXj54+UJd+HLmrhCHQ0k9AOkugHZhhh6sCUnkUNChJr5ei62pRscUQ7ge8/jywvfzHZGZw3eXXJWCCsjilXFA==} cpu: [x86] os: [linux] + sst@3.3.59: + resolution: {integrity: sha512-WzKYWMf41n/TMEVp54tEITvmZQ0iZHf2whevpqqS85dAGP6w74NbEkwPNAB5oU2RlA2sj8GgMF76NdctsYQ0JA==} + hasBin: true + peerDependencies: + hono: 4.x + valibot: 0.30.x + peerDependenciesMeta: + hono: + optional: true + valibot: + optional: true + sst@3.3.7: resolution: {integrity: sha512-qIJPQnGeIHarWZoUvphwi6R1nu6Pccd3Q2Qy9ltBLs4Z47TkSdwBNeqCBhgAzWA0eLDwStTXliexyQCcNM6gDQ==} hasBin: true @@ -8316,6 +8511,9 @@ packages: strnum@1.0.5: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} + style-mod@4.1.2: + resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==} + style-to-object@0.4.4: resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} @@ -9182,6 +9380,25 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y-codemirror.next@0.3.5: + resolution: {integrity: sha512-VluNu3e5HfEXybnypnsGwKAj+fKLd4iAnR7JuX1Sfyydmn1jCBS5wwEL/uS04Ch2ib0DnMAOF6ZRR/8kK3wyGw==} + peerDependencies: + '@codemirror/state': ^6.0.0 + '@codemirror/view': ^6.0.0 + yjs: ^13.5.6 + + y-indexeddb@9.0.12: + resolution: {integrity: sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + + y-protocols@1.0.6: + resolution: {integrity: sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -9223,6 +9440,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yjs@13.6.20: + resolution: {integrity: sha512-Z2YZI+SYqK7XdWlloI3lhMiKnCdFCVC4PchpdO+mCYwtiTwncjUbnRK9R1JmkNfdmHyDXuWN3ibJAt0wsqTbLQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -10223,6 +10444,61 @@ snapshots: human-id: 1.0.2 prettier: 2.8.8 + '@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.35.2)(@lezer/common@1.2.3)': + dependencies: + '@codemirror/language': 6.10.6 + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.2 + '@lezer/common': 1.2.3 + + '@codemirror/commands@6.7.1': + dependencies: + '@codemirror/language': 6.10.6 + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.2 + '@lezer/common': 1.2.3 + + '@codemirror/lang-javascript@6.2.2': + dependencies: + '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.35.2)(@lezer/common@1.2.3) + '@codemirror/language': 6.10.6 + '@codemirror/lint': 6.8.4 + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.2 + '@lezer/common': 1.2.3 + '@lezer/javascript': 1.4.21 + + '@codemirror/language@6.10.6': + dependencies: + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.2 + '@lezer/common': 1.2.3 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 + style-mod: 4.1.2 + + '@codemirror/lint@6.8.4': + dependencies: + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.2 + crelt: 1.0.6 + + '@codemirror/search@6.5.8': + dependencies: + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.2 + crelt: 1.0.6 + + '@codemirror/state@6.5.0': + dependencies: + '@marijn/find-cluster-break': 1.0.0 + + '@codemirror/view@6.35.2': + dependencies: + '@codemirror/state': 6.5.0 + style-mod: 4.1.2 + w3c-keyname: 2.2.8 + '@databases/connection-pool@1.1.0': dependencies: '@databases/queue': 1.0.1 @@ -10848,6 +11124,22 @@ snapshots: '@jspm/core@2.0.1': {} + '@lezer/common@1.2.3': {} + + '@lezer/highlight@1.2.1': + dependencies: + '@lezer/common': 1.2.3 + + '@lezer/javascript@1.4.21': + dependencies: + '@lezer/common': 1.2.3 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 + + '@lezer/lr@1.4.2': + dependencies: + '@lezer/common': 1.2.3 + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.26.0 @@ -10864,6 +11156,8 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@marijn/find-cluster-break@1.0.0': {} + '@mdx-js/mdx@2.3.0': dependencies: '@types/estree-jsx': 1.0.5 @@ -10886,8 +11180,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@neondatabase/serverless@0.10.4': + dependencies: + '@types/pg': 8.11.6 + '@next/env@14.2.20': {} + '@next/eslint-plugin-next@14.2.20': + dependencies: + glob: 10.3.10 + '@next/swc-darwin-arm64@14.2.20': optional: true @@ -12543,6 +12845,12 @@ snapshots: pg-protocol: 1.7.0 pg-types: 4.0.2 + '@types/pg@8.11.6': + dependencies: + '@types/node': 20.17.6 + pg-protocol: 1.7.0 + pg-types: 4.0.2 + '@types/prop-types@15.7.13': {} '@types/react-beautiful-dnd@13.1.8': @@ -12821,7 +13129,7 @@ snapshots: '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.0) '@vanilla-extract/babel-plugin-debug-ids': 1.1.0 '@vanilla-extract/css': 1.16.0 - esbuild: 0.17.6 + esbuild: 0.18.20 eval: 0.1.8 find-up: 5.0.0 javascript-stringify: 2.1.0 @@ -13454,6 +13762,18 @@ snapshots: cluster-key-slot@1.1.2: {} + codemirror@6.0.1(@lezer/common@1.2.3): + dependencies: + '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.35.2)(@lezer/common@1.2.3) + '@codemirror/commands': 6.7.1 + '@codemirror/language': 6.10.6 + '@codemirror/lint': 6.8.4 + '@codemirror/search': 6.5.8 + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.2 + transitivePeerDependencies: + - '@lezer/common' + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -14611,6 +14931,14 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.3.10: + dependencies: + foreground-child: 3.3.0 + jackspeak: 2.3.6 + minimatch: 9.0.5 + minipass: 7.1.2 + path-scurry: 1.11.1 + glob@10.4.5: dependencies: foreground-child: 3.3.0 @@ -15036,6 +15364,14 @@ snapshots: isexe@2.0.0: {} + isomorphic.js@0.2.5: {} + + jackspeak@2.3.6: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -15208,6 +15544,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib0@0.2.99: + dependencies: + isomorphic.js: 0.2.5 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -17306,21 +17646,48 @@ snapshots: dependencies: minipass: 7.1.2 + sst-darwin-arm64@3.3.59: + optional: true + sst-darwin-arm64@3.3.7: optional: true + sst-darwin-x64@3.3.59: + optional: true + sst-darwin-x64@3.3.7: optional: true + sst-linux-arm64@3.3.59: + optional: true + sst-linux-arm64@3.3.7: optional: true + sst-linux-x64@3.3.59: + optional: true + sst-linux-x64@3.3.7: optional: true + sst-linux-x86@3.3.59: + optional: true + sst-linux-x86@3.3.7: optional: true + sst@3.3.59: + dependencies: + aws4fetch: 1.0.20 + jose: 5.2.3 + openid-client: 5.6.4 + optionalDependencies: + sst-darwin-arm64: 3.3.59 + sst-darwin-x64: 3.3.59 + sst-linux-arm64: 3.3.59 + sst-linux-x64: 3.3.59 + sst-linux-x86: 3.3.59 + sst@3.3.7: dependencies: aws4fetch: 1.0.20 @@ -17440,6 +17807,8 @@ snapshots: strnum@1.0.5: {} + style-mod@4.1.2: {} + style-to-object@0.4.4: dependencies: inline-style-parser: 0.1.1 @@ -18460,6 +18829,23 @@ snapshots: xtend@4.0.2: {} + y-codemirror.next@0.3.5(@codemirror/state@6.5.0)(@codemirror/view@6.35.2)(yjs@13.6.20): + dependencies: + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.2 + lib0: 0.2.99 + yjs: 13.6.20 + + y-indexeddb@9.0.12(yjs@13.6.20): + dependencies: + lib0: 0.2.99 + yjs: 13.6.20 + + y-protocols@1.0.6(yjs@13.6.20): + dependencies: + lib0: 0.2.99 + yjs: 13.6.20 + y18n@5.0.8: {} yallist@2.1.2: {} @@ -18498,6 +18884,10 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yjs@13.6.20: + dependencies: + lib0: 0.2.99 + yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.2: {}