diff --git a/docs/operations/relay-postgres-to-vitess-migration.md b/docs/operations/relay-postgres-to-vitess-migration.md new file mode 100644 index 00000000000..4485be2cb2d --- /dev/null +++ b/docs/operations/relay-postgres-to-vitess-migration.md @@ -0,0 +1,170 @@ +# Relay: PlanetScale Postgres → PlanetScale Vitess (MySQL) data migration + +This runbook covers the one-time migration of the production relay database from PlanetScale +Postgres (`t3coderelay`, `us-west`, PS_20) to PlanetScale Vitess/MySQL (`t3coderelay-vitess`). + +Everything infrastructural lives in IaC across three PRs; only the data replication itself is an +operational task: + +| Phase | PR | What it does | +| ------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Provision | `relay/provision-vitess-db` | Adds `RelayMysqlDatabase` to the prod stack. Deploying it creates the empty Vitess database and applies `migrations/mysql/…_baseline` via the alchemy migration runner (bookkeeping in `relay_migrations` included). The worker keeps running on Postgres. | +| 2. Replicate | — (operational) | One AWS DMS task copies data Postgres → PlanetScale directly and keeps CDC running. | +| 3. Cutover | the migration PR | Ports the worker to mysql (schema, driver, repos) and flips Hyperdrive's origin to the Vitess runtime password. The Postgres database + role **stay in the stack** for rollback. | +| 4. Teardown | follow-up PR after soak | Removes `RelayPostgresDatabase`/`RelayPostgresRuntimeRole` and `migrations/postgres` from the stack (the retained database is orphaned, then deleted in the console). | + +The relay dataset is small, so the data phase follows PlanetScale's +[small-database Postgres → PlanetScale guide](https://planetscale.com/docs/vitess/imports/postgres-planetscale-migration-guide): +a single DMS task, no Aurora intermediate, no import tool. (The +[larger-database guide](https://planetscale.com/docs/vitess/imports/postgres-mysql-planetscale-migration-guide) +adds those hops only for fast imports of big datasets.) If even a DMS task feels like overkill, the +"dump-and-load" appendix trades it for a ~15-minute write freeze and no AWS at all. + +## 0. Preconditions + +- [ ] AWS account with DMS permissions and the AWS CLI authenticated (only needed for the DMS + path). A host with `psql` and `mysql` clients for verification. +- [ ] Source Postgres must expose logical replication for DMS: `logical_replication = 1` and + `pglogical` in `shared_preload_libraries`. These are managed settings on PlanetScale + Postgres — confirm/enable them in the console (Settings → Parameters) or via support before + scheduling anything. +- [ ] Verify the narrowed column bounds hold in production data. The MySQL schema turns several + indexed `text` columns into `varchar(255)` (InnoDB cannot uniquely index unbounded TEXT): + + ```sql + -- run against the Postgres prod branch; every max must be <= 255 + select max(length(push_token)), max(length(push_to_start_token)) from relay_mobile_devices; + select max(length(activity_push_token)) from relay_live_activities; + select max(length(hostname)), max(length(tunnel_name)) from relay_managed_endpoint_allocations; + select max(length(environment_public_key)) from relay_environment_links; + select max(length(environment_public_key)) from relay_environment_credentials; + select max(length(environment_public_key)) from relay_agent_activity_rows; + ``` + + If anything exceeds 255, widen the column in `infra/relay/src/persistence/schema.ts` and + regenerate the baseline migration first. + +- [ ] No relay schema changes land while the migration is in flight. + +## 1. Land and deploy the provisioning PR + +Merge `relay/provision-vitess-db` (or deploy it to `prod` manually). The deploy creates +`t3coderelay-vitess` (`us-west`, PS_20), applies the checked-in baseline schema, and records it in +`relay_migrations` — nothing manual, no `pscale` involved. The worker is untouched and still runs +on Postgres. + +Afterwards, confirm in the console that **safe migrations is OFF** on `main` (it is off by default +for non-imported databases): alchemy applies DDL over a direct connection, not deploy requests, and +future `migrations/mysql/*` files fail to apply while it is on. + +The only non-IaC credential in this whole flow: DMS needs to authenticate against the target, so +mint a short-lived password for it (this is a migration-time secret, not infrastructure — it +expires on its own and never enters the stack): + +```sh +pscale password create t3coderelay-vitess main dms-import --role readwriter --ttl 604800 +``` + +## 2. Replicate with a single DMS task + +Use PlanetScale's [postgres-planetscale scripts](https://github.com/planetscale/postgres-planetscale) +(review before running): + +```sh +sh import.sh --identifier "T3RelayPgToVitess" \ + --source "${PG_USER}:${PG_PASSWORD}@${PG_HOST}/${PG_DB}/public" \ + --target "${PS_USER}:${PS_PASSWORD}@${PS_HOST}/t3coderelay-vitess" +``` + +Two things to check in the task configuration before starting it: + +- **Target prep mode must be `DO_NOTHING`** (or at most `TRUNCATE_BEFORE_LOAD`). DMS's default + `DROP_AND_CREATE` would replace the deploy-created tables with DMS's own inferred types and drop + the indexes/varchar bounds the relay depends on. +- **Exclude `relay_migrations` from the table mappings.** The source table tracks the _Postgres_ + migration files; copying it would clobber the MySQL bookkeeping written by the provisioning + deploy. + +DMS handles the type conversions (`jsonb → json`, `boolean → tinyint(1)` as `t/f → 1/0`, +`integer → int`; all timestamps are ISO-8601 varchars and pass through). Resource provisioning +takes ~20 minutes; the copy itself is quick at our size. After the full load, CDC keeps the target +in sync. Verify row counts per table: + +```sql +select 'relay_mobile_devices', count(*) from relay_mobile_devices +union all select 'relay_live_activities', count(*) from relay_live_activities +union all select 'relay_environment_links', count(*) from relay_environment_links +union all select 'relay_environment_credentials', count(*) from relay_environment_credentials +union all select 'relay_managed_endpoint_allocations', count(*) from relay_managed_endpoint_allocations +union all select 'relay_managed_tunnel_limits', count(*) from relay_managed_tunnel_limits +union all select 'relay_agent_activity_rows', count(*) from relay_agent_activity_rows +union all select 'relay_delivery_attempts', count(*) from relay_delivery_attempts +union all select 'relay_dpop_proofs', count(*) from relay_dpop_proofs; +``` + +Watch the task's CloudWatch logs for conversion errors. Leave CDC running until cutover. + +## 3. Land the cutover PR + +1. Pick a low-traffic window. The relay's writes are retry-friendly (device registrations, + activity upserts, APNs bookkeeping), but anything written to Postgres between "stop DMS" and + "new worker live" is lost — at our volume that window is seconds to a couple of minutes. +2. Stop the DMS task (CDC drained first: task statistics show no pending changes). +3. Merge the migration PR (CI deploys `prod` on push to `main`), or deploy manually: + + ```sh + vp run --filter t3code-relay deploy -- --stage prod + ``` + + The deploy: + - keeps owning the Postgres database + runtime role (unchanged, still in the stack); + - updates `RelayMysqlDatabase` in place (same resource id as the provisioning PR; only + `migrationsDir` moves to the mysql schema resource) and applies nothing — the baseline is + already recorded in `relay_migrations`; + - creates the runtime password (`RelayMysqlRuntimePassword`, role `readwriter`) and points the + existing Hyperdrive config at the MySQL origin — this is the actual cutover. + +4. Smoke-test: link an environment, register a device from the mobile app, confirm agent activity + rows appear, and watch worker traces (Axiom `relay.*` spans) for + `SqlError`/`EffectDrizzleQueryError`. + +## 4. Rollback + +Revert the cutover PR (or redeploy the previous `main` commit). Because the Postgres database and +role never left the stack, that deploy only flips Hyperdrive back to the Postgres origin — no +re-provisioning, no data steps. Writes made while Vitess was primary are lost (nothing replicates +back to Postgres); decide based on how long the new stack was live. The DMS task can be restarted +afterwards to re-sync for another attempt (with `TRUNCATE_BEFORE_LOAD`, since the target now has +stale rows). + +## 5. Soak + +Run on Vitess for an agreed period (suggest ≥1 week) before destroying anything. The Postgres +database keeps costing its cluster size during soak — that's the price of the rollback path. + +## 6. Land the teardown PR + +1. Tear down the DMS resources: `sh cleanup.sh --identifier "T3RelayPgToVitess"` (the `dms-import` + password has expired on its own). +2. Take a final PlanetScale Postgres backup. +3. The teardown PR removes the `RelayPostgresDatabase`/`RelayPostgresRuntimeRole` block from + `infra/relay/src/db.ts` and deletes `infra/relay/migrations/postgres/`. Deploying it orphans the + retained database (Alchemy stops managing it; nothing is deleted). +4. Delete the `t3coderelay` Postgres database in the PlanetScale console. +5. Ask developers to redeploy personal stages: the next `deploy` replaces their Postgres + branch/role resources with Vitess branch/password resources automatically. + +## Appendix: dump-and-load (no DMS at all) + +For the current data volume the DMS task can be replaced by a short write freeze: + +1. Land + deploy the provisioning PR (step 1; skip the DMS password). +2. Freeze relay writes (announce a maintenance window; seconds-to-minutes of 5xx on + registration/link endpoints is acceptable — clients retry). +3. Copy data table-by-table: `pg_dump --data-only --column-inserts` per table, transform to + MySQL-compatible inserts (`t/f → 1/0` for booleans; JSON and ISO-timestamp varchars pass + through; adjust identifier quoting), and load with `mysql` using a short-TTL `pscale password`. + At these sizes a small script or `pgloader` both work. +4. Verify row counts (query in step 2 above), then land the cutover PR (step 3) and smoke-test. + +Same rollback caveat: once traffic lands on Vitess, writes are not mirrored back to Postgres. diff --git a/infra/relay/README.md b/infra/relay/README.md index 0085c9c5b6b..069566d9843 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -40,7 +40,9 @@ credential, or authorization behavior. APNs delivery, and queue processing. - [`src/auth`](./src/auth) contains relay token and DPoP proof handling. - [`src/persistence/schema.ts`](./src/persistence/schema.ts) defines persisted relay state. Keep - schema and migration changes together. + schema and migration changes together: after editing the schema, run + `pnpm exec drizzle-kit generate` (see [`drizzle.config.ts`](./drizzle.config.ts)) and commit the + new `migrations/mysql/` directory — MySQL migrations are generated manually, not by the deploy. Shared request and response schemas live in [`packages/contracts/src/relay.ts`](../../packages/contracts/src/relay.ts). Shared client-side relay @@ -89,10 +91,10 @@ file from the relay directory. Runtime secrets include Clerk and APNs credential the configured API and tunnel DNS zones as retained Cloudflare resources. Personal stages reference the production-owned zones. -The `prod` Alchemy stage owns the retained PlanetScale database and is the shared hosted relay for -stable and nightly clients. Every other stage references that database and provisions an isolated -PlanetScale branch and runtime role for local development, so deploy `prod` before creating -developer stages: +The `prod` Alchemy stage owns the retained PlanetScale database (Vitess/MySQL) and is the shared +hosted relay for stable and nightly clients. Every other stage references that database and +provisions an isolated PlanetScale branch and runtime password for local development, so deploy +`prod` before creating developer stages: ```sh vp run --filter t3code-relay deploy -- --stage prod diff --git a/infra/relay/drizzle.config.ts b/infra/relay/drizzle.config.ts new file mode 100644 index 00000000000..8405e03f047 --- /dev/null +++ b/infra/relay/drizzle.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "drizzle-kit"; + +// MySQL migrations are generated manually and checked in (alchemy's +// Drizzle.Schema auto-generation is Postgres-only). After changing +// src/persistence/schema.ts run `pnpm exec drizzle-kit generate` and commit +// the new migrations/mysql/_*/ directory; deploys apply it. +export default defineConfig({ + schema: "./src/persistence/schema.ts", + out: "./migrations/mysql", + dialect: "mysql", +}); diff --git a/infra/relay/package.json b/infra/relay/package.json index 4e9519671f2..b358cd9c7ea 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@clerk/backend": "catalog:", + "@effect/sql-mysql2": "catalog:", "@effect/sql-pg": "catalog:", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", diff --git a/infra/relay/src/agentActivity/AgentActivityRows.test.ts b/infra/relay/src/agentActivity/AgentActivityRows.test.ts index 65b0c2a0a90..97f66238be0 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.test.ts @@ -24,7 +24,7 @@ describe("AgentActivityRows", () => { const failingDb = { insert: () => ({ values: () => ({ - onConflictDoUpdate: () => Effect.fail(cause), + onDuplicateKeyUpdate: () => Effect.fail(cause), }), }), delete: () => ({ diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index 14245075e10..603c922a116 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -134,12 +134,7 @@ export const make = Effect.gen(function* () { updatedAt: input.state.updatedAt, createdAt: DateTime.formatIso(now), }) - .onConflictDoUpdate({ - target: [ - relayAgentActivityRows.environmentId, - relayAgentActivityRows.environmentPublicKey, - relayAgentActivityRows.threadId, - ], + .onDuplicateKeyUpdate({ set: { stateJson, updatedAt: input.state.updatedAt, @@ -191,7 +186,7 @@ export const make = Effect.gen(function* () { .delete(relayAgentActivityRows) .where( and( - sql`${relayAgentActivityRows.stateJson} ->> 'phase' IN ('completed', 'failed')`, + sql`${relayAgentActivityRows.stateJson} ->> '$.phase' IN ('completed', 'failed')`, lt(relayAgentActivityRows.updatedAt, input.updatedBefore), ), ) diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.test.ts b/infra/relay/src/agentActivity/DeliveryAttempts.test.ts index 2f3d948983d..906910273ff 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.test.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.test.ts @@ -60,22 +60,16 @@ describe("DeliveryAttempts", () => { it.effect("claims signed queue source jobs before APNs delivery", () => { const insertedValues: Array> = []; - const conflictTargets: Array = []; const fakeDb = { insert: (table: unknown) => { expect(table).toBe(relayDeliveryAttempts); return { - values: (values: Record) => { - insertedValues.push(values); - return { - onConflictDoNothing: (config: { readonly target: unknown }) => { - conflictTargets.push(config.target); - return { - returning: () => Effect.succeed([{ id: values.id }]), - }; - }, - }; - }, + ignore: () => ({ + values: (values: Record) => { + insertedValues.push(values); + return Effect.succeed({ affectedRows: 1 }); + }, + }), }; }, } as unknown as RelayDb.RelayDb["Service"]; @@ -93,7 +87,6 @@ describe("DeliveryAttempts", () => { }); expect(claimed).toBe("claimed"); - expect(conflictTargets).toEqual([relayDeliveryAttempts.sourceJobId]); expect(insertedValues[0]).toMatchObject({ kind: "push_notification", sourceJobId: "job-1", @@ -113,10 +106,8 @@ describe("DeliveryAttempts", () => { it.effect("reports completed source jobs when the durable claim already exists", () => { const fakeDb = { insert: () => ({ - values: () => ({ - onConflictDoNothing: () => ({ - returning: () => Effect.succeed([]), - }), + ignore: () => ({ + values: () => Effect.succeed({ affectedRows: 0 }), }), }), select: () => ({ @@ -163,10 +154,8 @@ describe("DeliveryAttempts", () => { it.effect("reports in-flight source jobs while an active claim lease exists", () => { const fakeDb = { insert: () => ({ - values: () => ({ - onConflictDoNothing: () => ({ - returning: () => Effect.succeed([]), - }), + ignore: () => ({ + values: () => Effect.succeed({ affectedRows: 0 }), }), }), select: () => ({ @@ -214,10 +203,8 @@ describe("DeliveryAttempts", () => { const updatedValues: Array> = []; const fakeDb = { insert: () => ({ - values: () => ({ - onConflictDoNothing: () => ({ - returning: () => Effect.succeed([]), - }), + ignore: () => ({ + values: () => Effect.succeed({ affectedRows: 0 }), }), }), select: () => ({ @@ -240,9 +227,7 @@ describe("DeliveryAttempts", () => { set: (values: Record) => { updatedValues.push(values); return { - where: () => ({ - returning: () => Effect.succeed([{ id: "attempt-1" }]), - }), + where: () => Effect.succeed({ affectedRows: 1 }), }; }, }), @@ -325,14 +310,10 @@ describe("DeliveryAttempts", () => { const cause = new Error("database unavailable"); const fakeDb = { insert: () => ({ - values: (values: Record) => - values.kind === "record" - ? Effect.fail(cause) - : { - onConflictDoNothing: () => ({ - returning: () => Effect.fail(cause), - }), - }, + values: () => Effect.fail(cause), + ignore: () => ({ + values: () => Effect.fail(cause), + }), }), update: () => ({ set: () => ({ diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.ts b/infra/relay/src/agentActivity/DeliveryAttempts.ts index 843415abfe8..928ab212972 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.ts @@ -8,6 +8,7 @@ import * as Crypto from "effect/Crypto"; import * as Schema from "effect/Schema"; import * as RelayDb from "../db.ts"; +import { affectedRows } from "../persistence/mysqlResult.ts"; import { relayDeliveryAttempts } from "../persistence/schema.ts"; export class DeliveryAttemptRecordPersistenceError extends Schema.TaggedErrorClass()( @@ -148,12 +149,14 @@ export const make = Effect.gen(function* () { const id = yield* crypto.randomUUIDv4; const now = yield* DateTime.now; const createdAt = DateTime.formatIso(now); + // INSERT IGNORE: the only unique constraint a fresh UUID row can hit + // is idx_relay_delivery_attempts_source_job, so an ignored insert + // means the source job is already claimed. const inserted = yield* db .insert(relayDeliveryAttempts) - .values(insertValues(input, id, createdAt)) - .onConflictDoNothing({ target: relayDeliveryAttempts.sourceJobId }) - .returning({ id: relayDeliveryAttempts.id }); - if (inserted.length > 0) { + .ignore() + .values(insertValues(input, id, createdAt)); + if (affectedRows(inserted) > 0) { return "claimed"; } @@ -198,9 +201,8 @@ export const make = Effect.gen(function* () { isNull(relayDeliveryAttempts.apnsId), isNull(relayDeliveryAttempts.transportError), ), - ) - .returning({ id: relayDeliveryAttempts.id }); - return reclaimed.length > 0 ? "claimed" : "in_flight"; + ); + return affectedRows(reclaimed) > 0 ? "claimed" : "in_flight"; }).pipe( Effect.mapError( (cause) => diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts index 5a37b1f20fd..af0ad084431 100644 --- a/infra/relay/src/agentActivity/Devices.test.ts +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -1,7 +1,7 @@ import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; import { describe, expect, it } from "@effect/vitest"; import type { SQL } from "drizzle-orm"; -import { PgDialect } from "drizzle-orm/pg-core"; +import { MySqlDialect } from "drizzle-orm/mysql-core"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -35,7 +35,7 @@ describe("Devices", () => { const updateSets: Array> = []; const updateConditions: Array = []; const insertedValues: Array> = []; - const dialect = new PgDialect(); + const dialect = new MySqlDialect(); const fakeDb = { update: (table: unknown) => { @@ -64,9 +64,9 @@ describe("Devices", () => { insertedValues.push(values); calls.push("insert.values"); return { - onConflictDoUpdate: (config: unknown) => { + onDuplicateKeyUpdate: (config: unknown) => { expect(config).toBeDefined(); - calls.push("insert.onConflictDoUpdate"); + calls.push("insert.onDuplicateKeyUpdate"); return Effect.void; }, }; @@ -88,7 +88,7 @@ describe("Devices", () => { "update.where", "insert", "insert.values", - "insert.onConflictDoUpdate", + "insert.onDuplicateKeyUpdate", ]); expect(updateSets).toEqual([ expect.objectContaining({ pushToken: null }), @@ -96,11 +96,11 @@ describe("Devices", () => { ]); expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([ { - sql: '"relay_mobile_devices"."push_token" = $1', + sql: "`relay_mobile_devices`.`push_token` = ?", params: ["apns-device-token"], }, { - sql: '"relay_mobile_devices"."push_to_start_token" = $1', + sql: "`relay_mobile_devices`.`push_to_start_token` = ?", params: ["push-to-start-token"], }, ]); @@ -122,7 +122,7 @@ describe("Devices", () => { it.effect("unregisters APNs state only for the current user device", () => { const calls: Array = []; const deleteConditions: Array = []; - const dialect = new PgDialect(); + const dialect = new MySqlDialect(); const fakeDb = { delete: (table: unknown) => { @@ -151,14 +151,14 @@ describe("Devices", () => { expect(deleteConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([ { sql: - '(("relay_live_activities"."user_id" = $1) and ' + - '("relay_live_activities"."device_id" = $2))', + "((`relay_live_activities`.`user_id` = ?) and " + + "(`relay_live_activities`.`device_id` = ?))", params: ["user-2", "device-1"], }, { sql: - '(("relay_mobile_devices"."user_id" = $1) and ' + - '("relay_mobile_devices"."device_id" = $2))', + "((`relay_mobile_devices`.`user_id` = ?) and " + + "(`relay_mobile_devices`.`device_id` = ?))", params: ["user-2", "device-1"], }, ]); @@ -168,7 +168,7 @@ describe("Devices", () => { }); it.effect("lists safe notification state without exposing APNs tokens", () => { - const dialect = new PgDialect(); + const dialect = new MySqlDialect(); let condition: SQL | null = null; const fakeDb = { select: () => ({ @@ -200,7 +200,7 @@ describe("Devices", () => { expect(condition).not.toBeNull(); expect(dialect.sqlToQuery(condition!)).toEqual({ - sql: '"relay_mobile_devices"."user_id" = $1', + sql: "`relay_mobile_devices`.`user_id` = ?", params: ["user-2"], }); expect(listed).toEqual([ diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index 42087128c33..63669dbb400 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -138,23 +138,23 @@ export const make = Effect.gen(function* () { createdAt: updatedAt, updatedAt, }) - .onConflictDoUpdate({ - target: [relayMobileDevices.userId, relayMobileDevices.deviceId], + .onDuplicateKeyUpdate({ set: { platform: registration.platform, label: registration.label, iosMajorVersion: registration.iosMajorVersion, appVersion: registration.appVersion ?? null, // Preserve routing from newer app builds when an older build - // re-registers without these fields. - bundleId: sql`coalesce(excluded.bundle_id, ${relayMobileDevices.bundleId})`, + // re-registers without these fields. `values(col)` is MySQL's + // spelling of Postgres' `excluded.col`. + bundleId: sql`coalesce(values(bundle_id), ${relayMobileDevices.bundleId})`, apsEnvironment: sql`coalesce( - excluded.aps_environment, + values(aps_environment), ${relayMobileDevices.apsEnvironment} )`, - pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`, + pushToken: sql`coalesce(values(push_token), ${relayMobileDevices.pushToken})`, pushToStartToken: sql`coalesce( - excluded.push_to_start_token, + values(push_to_start_token), ${relayMobileDevices.pushToStartToken} )`, preferencesJson: registration.preferences, diff --git a/infra/relay/src/agentActivity/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts index 7f2bce87431..95b4fd9cebf 100644 --- a/infra/relay/src/agentActivity/LiveActivities.test.ts +++ b/infra/relay/src/agentActivity/LiveActivities.test.ts @@ -4,7 +4,7 @@ import type { } from "@t3tools/contracts/relay"; import { describe, expect, it } from "@effect/vitest"; import type { SQL } from "drizzle-orm"; -import { PgDialect } from "drizzle-orm/pg-core"; +import { MySqlDialect } from "drizzle-orm/mysql-core"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -49,7 +49,7 @@ describe("LiveActivities", () => { const conflictConfigs: Array<{ readonly set?: Record; }> = []; - const dialect = new PgDialect(); + const dialect = new MySqlDialect(); const fakeDb = { update: (table: unknown) => { @@ -78,10 +78,10 @@ describe("LiveActivities", () => { insertedValues.push(values); calls.push("insert.values"); return { - onConflictDoUpdate: (config: { readonly set?: Record }) => { + onDuplicateKeyUpdate: (config: { readonly set?: Record }) => { expect(config).toBeDefined(); conflictConfigs.push(config); - calls.push("insert.onConflictDoUpdate"); + calls.push("insert.onDuplicateKeyUpdate"); return Effect.void; }, }; @@ -100,7 +100,7 @@ describe("LiveActivities", () => { "update.where", "insert", "insert.values", - "insert.onConflictDoUpdate", + "insert.onDuplicateKeyUpdate", ]); expect(updateSets).toEqual([ expect.objectContaining({ @@ -111,7 +111,7 @@ describe("LiveActivities", () => { ]); expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([ { - sql: '"relay_live_activities"."activity_push_token" = $1', + sql: "`relay_live_activities`.`activity_push_token` = ?", params: ["activity-push-token"], }, ]); @@ -150,6 +150,7 @@ describe("LiveActivities", () => { const conflictConfigs: Array<{ readonly set?: Record; }> = []; + const dialect = new MySqlDialect(); const fakeDb = { insert: (table: unknown) => { @@ -158,7 +159,7 @@ describe("LiveActivities", () => { values: (values: Record) => { insertedValues.push(values); return { - onConflictDoUpdate: (config: { readonly set?: Record }) => { + onDuplicateKeyUpdate: (config: { readonly set?: Record }) => { conflictConfigs.push(config); return Effect.void; }, @@ -187,10 +188,13 @@ describe("LiveActivities", () => { ]); expect(conflictConfigs[0]?.set).toEqual( expect.objectContaining({ - endedAt: relayLiveActivities.endedAt, lastLiveActivityDeliveryAt: "2026-05-25T00:00:10.000Z", }), ); + expect(dialect.sqlToQuery(conflictConfigs[0]?.set?.endedAt as SQL)).toEqual({ + sql: "`relay_live_activities`.`ended_at`", + params: [], + }); }).pipe( Effect.provide( LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), @@ -203,7 +207,7 @@ describe("LiveActivities", () => { const fakeDb = { insert: () => ({ values: () => ({ - onConflictDoUpdate: (config: { readonly set?: Record }) => { + onDuplicateKeyUpdate: (config: { readonly set?: Record }) => { conflictConfigs.push(config); return Effect.void; }, @@ -261,7 +265,7 @@ describe("LiveActivities", () => { set: () => ({ where: () => Effect.fail(cause) }), }), insert: () => ({ - values: () => ({ onConflictDoUpdate: () => Effect.fail(cause) }), + values: () => ({ onDuplicateKeyUpdate: () => Effect.fail(cause) }), }), select: () => ({ from: () => ({ diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index 97e87a65f2c..bbf6b11687b 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -166,8 +166,7 @@ export const make = Effect.gen(function* () { createdAt: updatedAt, updatedAt, }) - .onConflictDoUpdate({ - target: [relayLiveActivities.userId, relayLiveActivities.deviceId], + .onDuplicateKeyUpdate({ set: { activityPushToken: registration.activityPushToken, remoteStartQueuedAt: null, @@ -277,8 +276,7 @@ export const make = Effect.gen(function* () { createdAt: input.deliveredAt, updatedAt: input.deliveredAt, }) - .onConflictDoUpdate({ - target: [relayLiveActivities.userId, relayLiveActivities.deviceId], + .onDuplicateKeyUpdate({ set: { // A delivered start begins a NEW activity generation: the stored // update token belongs to the previous activity (dead once a new @@ -298,7 +296,7 @@ export const make = Effect.gen(function* () { ? input.deliveredAt : sql`coalesce( ${relayLiveActivities.remoteStartedAt}, - excluded.remote_started_at + values(remote_started_at) )`, remoteStartQueuedAt: null, endedAt: @@ -306,7 +304,7 @@ export const make = Effect.gen(function* () { ? null : input.kind === "live_activity_end" ? input.deliveredAt - : relayLiveActivities.endedAt, + : sql`${relayLiveActivities.endedAt}`, lastAggregateJson: aggregateJson, lastLiveActivityDeliveryAt: input.deliveredAt, updatedAt: input.deliveredAt, @@ -341,12 +339,11 @@ export const make = Effect.gen(function* () { createdAt: input.queuedAt, updatedAt: input.queuedAt, }) - .onConflictDoUpdate({ - target: [relayLiveActivities.userId, relayLiveActivities.deviceId], + .onDuplicateKeyUpdate({ set: { remoteStartQueuedAt: sql`coalesce( ${relayLiveActivities.remoteStartQueuedAt}, - excluded.remote_start_queued_at + values(remote_start_queued_at) )`, endedAt: null, updatedAt: input.queuedAt, diff --git a/infra/relay/src/auth/DpopProofs.test.ts b/infra/relay/src/auth/DpopProofs.test.ts index fba64586e28..76b8137d6b4 100644 --- a/infra/relay/src/auth/DpopProofs.test.ts +++ b/infra/relay/src/auth/DpopProofs.test.ts @@ -23,19 +23,13 @@ describe("DpopProofReplay", () => { expect(table).toBe(relayDpopProofs); calls.push("insert"); return { - values: (values: (typeof insertedValues)[number]) => { - insertedValues.push(values); - calls.push("insert.values"); + ignore: () => { + calls.push("insert.ignore"); return { - onConflictDoNothing: () => { - calls.push("insert.onConflictDoNothing"); - return { - returning: (selection: unknown) => { - expect(selection).toBeDefined(); - calls.push("insert.returning"); - return Effect.succeed([{ jti: values.jti }]); - }, - }; + values: (values: (typeof insertedValues)[number]) => { + insertedValues.push(values); + calls.push("insert.values"); + return Effect.succeed({ affectedRows: 1 }); }, }; }, @@ -53,12 +47,7 @@ describe("DpopProofReplay", () => { }); expect(consumed).toBe(true); - expect(calls).toEqual([ - "insert", - "insert.values", - "insert.onConflictDoNothing", - "insert.returning", - ]); + expect(calls).toEqual(["insert", "insert.ignore", "insert.values"]); expect(insertedValues).toMatchObject([ { thumbprint: "thumbprint", diff --git a/infra/relay/src/auth/DpopProofs.ts b/infra/relay/src/auth/DpopProofs.ts index fa784eb639b..4913f94f0bc 100644 --- a/infra/relay/src/auth/DpopProofs.ts +++ b/infra/relay/src/auth/DpopProofs.ts @@ -8,6 +8,7 @@ import { lt } from "drizzle-orm"; import { verifyDpopProof } from "@t3tools/shared/dpop"; import * as RelayDb from "../db.ts"; +import { affectedRows } from "../persistence/mysqlResult.ts"; import { relayDpopProofs } from "../persistence/schema.ts"; export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass()( @@ -55,6 +56,7 @@ const make = Effect.gen(function* () { const createdAt = DateTime.formatIso(yield* DateTime.now); const inserted = yield* db .insert(relayDpopProofs) + .ignore() .values({ thumbprint: input.thumbprint, jti: input.jti, @@ -62,8 +64,6 @@ const make = Effect.gen(function* () { expiresAt: DateTime.formatIso(input.expiresAt), createdAt, }) - .onConflictDoNothing() - .returning({ jti: relayDpopProofs.jti }) .pipe( Effect.mapError( (cause) => @@ -76,7 +76,7 @@ const make = Effect.gen(function* () { }), ), ); - return inserted.length > 0; + return affectedRows(inserted) > 0; }, ); diff --git a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts index 7663e874879..2fa99f7b2af 100644 --- a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts +++ b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts @@ -62,19 +62,14 @@ function makeDpopProof(input: { function layer( insert: ( values: DpopProofInsertValues, - ) => Effect.Effect, { _tag: string }>, + ) => Effect.Effect<{ readonly affectedRows: number }, { _tag: string }>, ) { const fakeDb = { insert: (table: unknown) => { expect(table).toBe(relayDpopProofs); return { - values: (values: DpopProofInsertValues) => ({ - onConflictDoNothing: () => ({ - returning: (selection: unknown) => { - expect(selection).toBeDefined(); - return insert(values); - }, - }), + ignore: () => ({ + values: (values: DpopProofInsertValues) => insert(values), }), }; }, @@ -88,10 +83,10 @@ function consumeEachProofOnce() { Effect.sync(() => { const key = `${values.thumbprint}:${values.jti}`; if (consumed.has(key)) { - return []; + return { affectedRows: 0 }; } consumed.add(key); - return [{ jti: values.jti }]; + return { affectedRows: 1 }; }); } diff --git a/infra/relay/src/db.ts b/infra/relay/src/db.ts index 569ad6cfd67..085ac68f4f7 100644 --- a/infra/relay/src/db.ts +++ b/infra/relay/src/db.ts @@ -1,10 +1,9 @@ -import type { PgClient } from "@effect/sql-pg/PgClient"; +import type { MysqlClient } from "@effect/sql-mysql2/MysqlClient"; import * as Cloudflare from "alchemy/Cloudflare"; -import * as Drizzle from "alchemy/Drizzle"; import * as Planetscale from "alchemy/Planetscale"; import * as Alchemy from "alchemy"; import * as RemovalPolicy from "alchemy/RemovalPolicy"; -import type { EffectPgDatabase } from "drizzle-orm/effect-postgres"; +import type { EffectMysql2Database } from "drizzle-orm/effect-mysql2"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -13,8 +12,8 @@ import { relayDatabaseMode } from "./dbConfig.ts"; export class RelayDb extends Context.Service< RelayDb, - EffectPgDatabase & { - readonly $client: PgClient; + EffectMysql2Database & { + readonly $client: MysqlClient; } >()("t3code-relay/db/RelayDb") {} @@ -35,70 +34,79 @@ export class RelayTransactions extends Context.Service< ); } +// Unlike Postgres, alchemy has no automatic Drizzle.Schema generation for +// MySQL (drizzle-kit exposes no programmatic mysql API): migrations are +// generated manually with `pnpm exec drizzle-kit generate` (drizzle.config.ts) +// and checked in; deploys apply whatever is committed here. +const mysqlMigrationsDir = "migrations/mysql"; + export const PlanetscaleDatabase = Effect.gen(function* () { const { stage } = yield* Alchemy.Stack; - const schema = yield* Drizzle.Schema("RelaySchema", { - schema: "./src/persistence/schema.ts", - out: "./migrations/postgres", - dialect: "postgres", - }); - const mode = relayDatabaseMode(stage); - // Phase 1 of the Vitess migration - // (docs/operations/relay-postgres-to-vitess-migration.md): provision the - // MySQL target and apply its checked-in baseline schema while the worker - // still runs on Postgres, so DMS can replicate data into it ahead of the - // cutover deploy. Deliberately prod-only: nothing speaks MySQL until the - // cutover PR, which takes over this resource id and adds the per-stage - // MySQLBranch/MySQLPassword mirror of the Postgres branch-per-stage - // setup below for developer stages. + // The retired Postgres database stays in the stack (prod only) until the + // Vitess cutover has soaked: prod must own BOTH databases while DMS + // replicates data across and while rollback (redeploying the previous + // commit, which flips Hyperdrive back to this role's origin) is still on + // the table. The migrations dir is the frozen checked-in history — every + // file is already recorded in relay_migrations, so deploys no-op against + // it. Remove this block together with migrations/postgres once the old + // database is decommissioned + // (docs/operations/relay-postgres-to-vitess-migration.md). if (mode === "shared-database") { - yield* Planetscale.MySQLDatabase("RelayMysqlDatabase", { - name: "t3coderelay-vitess", + const postgresDatabase = yield* Planetscale.PostgresDatabase("RelayPostgresDatabase", { + name: "t3coderelay", region: { slug: "us-west" }, clusterSize: "PS_20", - migrationsDir: "migrations/mysql", + migrationsDir: "migrations/postgres", migrationsTable: "relay_migrations", replicas: 2, }).pipe(RemovalPolicy.retain()); + yield* Planetscale.PostgresRole("RelayPostgresRuntimeRole", { + database: postgresDatabase, + inheritedRoles: ["pg_read_all_data", "pg_write_all_data"], + }); } const database = mode === "shared-database" - ? yield* Planetscale.PostgresDatabase("RelayPostgresDatabase", { - name: "t3coderelay", + ? // Same resource + props as the provisioning PR that created this + // database (with its baseline already applied) ahead of the cutover, + // so this deploy no-ops on the database itself. + yield* Planetscale.MySQLDatabase("RelayMysqlDatabase", { + name: "t3coderelay-vitess", region: { slug: "us-west" }, clusterSize: "PS_20", - migrationsDir: schema.out, + migrationsDir: mysqlMigrationsDir, migrationsTable: "relay_migrations", replicas: 2, }).pipe(RemovalPolicy.retain()) - : yield* Planetscale.PostgresDatabase.ref("RelayPostgresDatabase", { + : yield* Planetscale.MySQLDatabase.ref("RelayMysqlDatabase", { stage: "prod", }); const branch = mode === "stage-branch" - ? yield* Planetscale.PostgresBranch("RelayPostgresBranch", { + ? yield* Planetscale.MySQLBranch("RelayMysqlBranch", { database, - migrationsDir: schema.out, + isProduction: false, + migrationsDir: mysqlMigrationsDir, migrationsTable: "relay_migrations", }) : undefined; - const runtimeRole = yield* Planetscale.PostgresRole("RelayPostgresRuntimeRole", { + const runtimePassword = yield* Planetscale.MySQLPassword("RelayMysqlRuntimePassword", { database, ...(branch ? { branch } : {}), - inheritedRoles: ["pg_read_all_data", "pg_write_all_data"], + role: "readwriter", }); - return { branch, database, runtimeRole }; + return { branch, database, runtimePassword }; }); export const RelayHyperdrive = Effect.gen(function* () { - const { runtimeRole } = yield* PlanetscaleDatabase; + const { runtimePassword } = yield* PlanetscaleDatabase; return yield* Cloudflare.Hyperdrive.Connection("RelayHyperdrive", { - origin: runtimeRole.origin, + origin: runtimePassword.origin, caching: { disabled: true, }, diff --git a/infra/relay/src/environments/EnvironmentCredentials.test.ts b/infra/relay/src/environments/EnvironmentCredentials.test.ts index 4a2e07c6b6d..dcb5af7924f 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.test.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.test.ts @@ -1,6 +1,6 @@ import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; -import { PgDialect } from "drizzle-orm/pg-core"; +import { MySqlDialect } from "drizzle-orm/mysql-core"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -87,11 +87,11 @@ describe("EnvironmentCredentials", () => { expect(error).not.toHaveProperty("token"); expect(whereConditions).toHaveLength(1); - const query = new PgDialect().sqlToQuery(whereConditions[0] as never); + const query = new MySqlDialect().sqlToQuery(whereConditions[0] as never); expect(query.sql).toContain("exists"); - expect(query.sql).toContain('"relay_environment_links"."environment_id"'); - expect(query.sql).toContain('"relay_environment_links"."environment_public_key"'); - expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); + expect(query.sql).toContain("`relay_environment_links`.`environment_id`"); + expect(query.sql).toContain("`relay_environment_links`.`environment_public_key`"); + expect(query.sql).toContain("`relay_environment_links`.`revoked_at` is null"); }).pipe( Effect.provide( EnvironmentCredentials.layer.pipe( @@ -168,13 +168,13 @@ describe("EnvironmentCredentials", () => { staleCredentialRevocations[0]?.values.updatedAt, ); - const query = new PgDialect().sqlToQuery(staleCredentialRevocations[0]?.condition as never); - expect(query.sql).toContain('"relay_environment_credentials"."environment_id" = $1'); - expect(query.sql).toContain( - '"relay_environment_credentials"."environment_public_key" = $2', + const query = new MySqlDialect().sqlToQuery( + staleCredentialRevocations[0]?.condition as never, ); - expect(query.sql).toContain('"relay_environment_credentials"."credential_id" <> $3'); - expect(query.sql).toContain('"relay_environment_credentials"."revoked_at" is null'); + expect(query.sql).toContain("`relay_environment_credentials`.`environment_id` = ?"); + expect(query.sql).toContain("`relay_environment_credentials`.`environment_public_key` = ?"); + expect(query.sql).toContain("`relay_environment_credentials`.`credential_id` <> ?"); + expect(query.sql).toContain("`relay_environment_credentials`.`revoked_at` is null"); expect(query.params).toEqual(["env_test", "environment-public-key", credentialId]); }).pipe( Effect.provide( @@ -199,12 +199,7 @@ describe("EnvironmentCredentials", () => { return { where: (condition: unknown) => { whereConditions.push(condition); - return { - returning: (selection: unknown) => { - expect(selection).toBeDefined(); - return Effect.succeed([{ credentialId: "credential-1" }]); - }, - }; + return Effect.succeed({ affectedRows: 1 }); }, }; }, @@ -224,14 +219,14 @@ describe("EnvironmentCredentials", () => { expect(updateValues[0]?.revokedAt).toEqual(updateValues[0]?.updatedAt); expect(whereConditions).toHaveLength(1); - const query = new PgDialect().sqlToQuery(whereConditions[0] as never); - expect(query.sql).toContain('"relay_environment_credentials"."environment_id" = $1'); - expect(query.sql).toContain('"relay_environment_credentials"."environment_public_key" = $2'); - expect(query.sql).toContain('"relay_environment_credentials"."revoked_at" is null'); + const query = new MySqlDialect().sqlToQuery(whereConditions[0] as never); + expect(query.sql).toContain("`relay_environment_credentials`.`environment_id` = ?"); + expect(query.sql).toContain("`relay_environment_credentials`.`environment_public_key` = ?"); + expect(query.sql).toContain("`relay_environment_credentials`.`revoked_at` is null"); expect(query.sql).toContain("not exists"); - expect(query.sql).toContain('"relay_environment_links"."environment_id" = $3'); - expect(query.sql).toContain('"relay_environment_links"."environment_public_key" = $4'); - expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); + expect(query.sql).toContain("`relay_environment_links`.`environment_id` = ?"); + expect(query.sql).toContain("`relay_environment_links`.`environment_public_key` = ?"); + expect(query.sql).toContain("`relay_environment_links`.`revoked_at` is null"); expect(query.params).toEqual([ "env_test", "environment-public-key", diff --git a/infra/relay/src/environments/EnvironmentCredentials.ts b/infra/relay/src/environments/EnvironmentCredentials.ts index 373f894ee16..2ee810e1fc3 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.ts @@ -7,9 +7,10 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { and, eq, exists, isNull, ne, notExists } from "drizzle-orm"; -import { QueryBuilder } from "drizzle-orm/pg-core"; +import { QueryBuilder } from "drizzle-orm/mysql-core"; import * as RelayDb from "../db.ts"; +import { affectedRows } from "../persistence/mysqlResult.ts"; import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persistence/schema.ts"; export class EnvironmentCredentialCreatePersistenceError extends Schema.TaggedErrorClass()( @@ -245,7 +246,7 @@ const make = Effect.gen(function* () { )(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); const revokedAt = DateTime.formatIso(yield* DateTime.now); - const rows = yield* db + const revoked = yield* db .update(relayEnvironmentCredentials) .set({ revokedAt, @@ -270,9 +271,6 @@ const make = Effect.gen(function* () { ), ), ) - .returning({ - credentialId: relayEnvironmentCredentials.credentialId, - }) .pipe( Effect.mapError( (cause) => @@ -282,7 +280,7 @@ const make = Effect.gen(function* () { }), ), ); - return rows.length > 0; + return affectedRows(revoked) > 0; }), }); }); diff --git a/infra/relay/src/environments/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts index dccb9e39f60..ead017e9e3f 100644 --- a/infra/relay/src/environments/EnvironmentLinks.test.ts +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { PgDialect } from "drizzle-orm/pg-core"; +import { MySqlDialect } from "drizzle-orm/mysql-core"; import * as RelayDb from "../db.ts"; import { relayEnvironmentLinks } from "../persistence/schema.ts"; @@ -102,11 +102,11 @@ describe("EnvironmentLinks", () => { expect(yield* links.listUsersForEnvironment({ environmentId: "env-1" })).toEqual([]); expect(whereConditions).toHaveLength(1); - const query = new PgDialect().sqlToQuery(whereConditions[0] as never); - expect(query.sql).toContain('"relay_environment_links"."environment_id" = $1'); - expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); - expect(query.sql).toContain('"relay_environment_links"."notifications_enabled" = $2'); - expect(query.sql).toContain('"relay_environment_links"."live_activities_enabled" = $3'); + const query = new MySqlDialect().sqlToQuery(whereConditions[0] as never); + expect(query.sql).toContain("`relay_environment_links`.`environment_id` = ?"); + expect(query.sql).toContain("`relay_environment_links`.`revoked_at` is null"); + expect(query.sql).toContain("`relay_environment_links`.`notifications_enabled` = ?"); + expect(query.sql).toContain("`relay_environment_links`.`live_activities_enabled` = ?"); expect(query.sql).toContain(" or "); expect(query.params).toEqual(["env-1", true, true]); }).pipe( @@ -128,12 +128,7 @@ describe("EnvironmentLinks", () => { return { where: (condition: unknown) => { whereConditions.push(condition); - return { - returning: (selection: unknown) => { - expect(selection).toBeDefined(); - return Effect.succeed([{ environmentId: "env-1" }]); - }, - }; + return Effect.succeed({ affectedRows: 1 }); }, }; }, @@ -154,11 +149,11 @@ describe("EnvironmentLinks", () => { expect(typeof updateValues[0]?.revokedAt).toBe("string"); expect(whereConditions).toHaveLength(1); - const dialect = new PgDialect(); + const dialect = new MySqlDialect(); const query = dialect.sqlToQuery(whereConditions[0] as never); - expect(query.sql).toContain('"relay_environment_links"."user_id" = $1'); - expect(query.sql).toContain('"relay_environment_links"."environment_id" = $2'); - expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); + expect(query.sql).toContain("`relay_environment_links`.`user_id` = ?"); + expect(query.sql).toContain("`relay_environment_links`.`environment_id` = ?"); + expect(query.sql).toContain("`relay_environment_links`.`revoked_at` is null"); expect(query.params).toEqual(["user-1", "env-1"]); }).pipe( Effect.provide( diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts index 6630af0a11b..33b83df6c93 100644 --- a/infra/relay/src/environments/EnvironmentLinks.ts +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -12,6 +12,7 @@ import * as Schema from "effect/Schema"; import { and, eq, isNull, or } from "drizzle-orm"; import * as RelayDb from "../db.ts"; +import { affectedRows } from "../persistence/mysqlResult.ts"; import { relayEnvironmentLinks } from "../persistence/schema.ts"; export interface RelayLinkedEnvironmentRecord extends RelayClientEnvironmentRecord { @@ -191,8 +192,7 @@ const make = Effect.gen(function* () { createdAt: now, updatedAt: now, }) - .onConflictDoUpdate({ - target: [relayEnvironmentLinks.userId, relayEnvironmentLinks.environmentId], + .onDuplicateKeyUpdate({ set: { environmentPublicKey: proof.environmentPublicKey, environmentLabel: proof.descriptor.label, @@ -401,7 +401,7 @@ const make = Effect.gen(function* () { "relay.environment_id": input.environmentId, }); const revokedAt = DateTime.formatIso(yield* DateTime.now); - const rows = yield* db + const revoked = yield* db .update(relayEnvironmentLinks) .set({ revokedAt, @@ -414,7 +414,6 @@ const make = Effect.gen(function* () { isNull(relayEnvironmentLinks.revokedAt), ), ) - .returning({ environmentId: relayEnvironmentLinks.environmentId }) .pipe( Effect.mapError( (cause) => @@ -425,7 +424,7 @@ const make = Effect.gen(function* () { }), ), ); - return rows.length > 0; + return affectedRows(revoked) > 0; }), }); }); diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.test.ts b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts index ebf51de100c..ab75303cf2c 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.test.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts @@ -19,9 +19,7 @@ describe("ManagedEndpointAllocations", () => { set: (values: { readonly updatedAt: string }) => { claimedAt = values.updatedAt; return { - where: () => ({ - returning: () => Effect.succeed([{ userId: "user-1" }]), - }), + where: () => Effect.succeed({ affectedRows: 1 }), }; }, }; @@ -46,9 +44,7 @@ describe("ManagedEndpointAllocations", () => { delete: (table: unknown) => { expect(table).toBe(relayManagedEndpointAllocations); return { - where: () => ({ - returning: () => Effect.succeed([]), - }), + where: () => Effect.succeed({ affectedRows: 0 }), }; }, } as unknown as RelayDb.RelayDb["Service"]; @@ -102,10 +98,8 @@ describe("ManagedEndpointAllocations", () => { insert: (table: unknown) => { expect(table).toBe(relayManagedEndpointAllocations); return { - values: () => ({ - onConflictDoNothing: () => ({ - returning: () => Effect.succeed([]), - }), + ignore: () => ({ + values: () => Effect.succeed({ affectedRows: 0 }), }), }; }, diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts index 4320eeea3b7..f74d1a997e3 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -8,6 +8,7 @@ import * as Schema from "effect/Schema"; import * as RelayDb from "../db.ts"; import { isManagedEndpointHostname, managedEndpointForHostname } from "../deploymentConfig.ts"; +import { affectedRows } from "../persistence/mysqlResult.ts"; import { relayManagedEndpointAllocations } from "../persistence/schema.ts"; export interface ManagedEndpointAllocation { @@ -194,15 +195,17 @@ export const make = Effect.gen(function* () { input: ReserveManagedEndpointAllocationInput, ) { const now = DateTime.formatIso(yield* DateTime.now); - const inserted = yield* db + // INSERT IGNORE swallows any unique-index conflict (allocation key, + // hostname, tunnel name), like the untargeted ON CONFLICT DO NOTHING + // did on Postgres; the read-back below resolves whichever row won. + yield* db .insert(relayManagedEndpointAllocations) + .ignore() .values({ ...input, createdAt: now, updatedAt: now, }) - .onConflictDoNothing() - .returning(allocationSelection) .pipe( Effect.mapError( (cause) => @@ -215,25 +218,23 @@ export const make = Effect.gen(function* () { ), ); - const allocation = - inserted[0] ?? - (yield* db - .select(allocationSelection) - .from(relayManagedEndpointAllocations) - .where(whereAllocation(input)) - .limit(1) - .pipe( - Effect.map((rows) => rows[0]), - Effect.mapError( - (cause) => - new ManagedEndpointAllocationPersistenceError({ - operation: "reserve", - stage: "database-request", - ...input, - cause, - }), - ), - )); + const allocation = yield* db + .select(allocationSelection) + .from(relayManagedEndpointAllocations) + .where(whereAllocation(input)) + .limit(1) + .pipe( + Effect.map((rows) => rows[0]), + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "reserve", + stage: "database-request", + ...input, + cause, + }), + ), + ); if (allocation === undefined) { return yield* new ManagedEndpointAllocationPersistenceError({ @@ -327,9 +328,8 @@ export const make = Effect.gen(function* () { eq(relayManagedEndpointAllocations.updatedAt, input.updatedAt), ), ) - .returning({ userId: relayManagedEndpointAllocations.userId }) .pipe( - Effect.map((rows) => rows.length > 0), + Effect.map((result) => affectedRows(result) > 0), Effect.mapError( (cause) => new ManagedEndpointAllocationPersistenceError({ @@ -357,9 +357,8 @@ export const make = Effect.gen(function* () { eq(relayManagedEndpointAllocations.updatedAt, input.updatedAt), ), ) - .returning({ userId: relayManagedEndpointAllocations.userId }) .pipe( - Effect.map((rows) => rows.length > 0), + Effect.map((result) => affectedRows(result) > 0), Effect.mapError( (cause) => new ManagedEndpointAllocationPersistenceError({ @@ -402,9 +401,8 @@ export const make = Effect.gen(function* () { eq(relayManagedEndpointAllocations.updatedAt, input.updatedAt), ), ) - .returning({ userId: relayManagedEndpointAllocations.userId }) .pipe( - Effect.map((rows) => rows.length > 0), + Effect.map((result) => affectedRows(result) > 0), Effect.mapError( (cause) => new ManagedEndpointAllocationPersistenceError({ diff --git a/infra/relay/src/persistence/drizzleMysql.ts b/infra/relay/src/persistence/drizzleMysql.ts new file mode 100644 index 00000000000..63f6774f53e --- /dev/null +++ b/infra/relay/src/persistence/drizzleMysql.ts @@ -0,0 +1,146 @@ +// @effect-diagnostics anyUnknownInErrorContext:off unsafeEffectTypeAssertion:off preferSchemaOverJson:off globalErrorInEffectFailure:off globalErrorInEffectCatch:off - vendored alchemy code, kept verbatim; alchemy owns these idioms. +// Vendored from alchemy-run/alchemy#1063 (src/SQL/MySQL.ts + src/Drizzle/MySQL.ts, +// merge commit 5ae3df20df): Drizzle.MySQL landed upstream after alchemy +// 2.0.0-beta.67 was cut, so no published release carries it yet. Delete this +// file and import `Drizzle.MySQL` from "alchemy/Drizzle" once the workspace +// moves to a release that contains it. +import * as MysqlClient from "@effect/sql-mysql2/MysqlClient"; +import { makeExecutionMemo } from "alchemy/Runtime/ExecutionMemo"; +import { proxyChain } from "alchemy/Util/proxy-chain"; +import type { AnyRelations, EmptyRelations } from "drizzle-orm"; +import type { EffectMysql2Database } from "drizzle-orm/effect-mysql2"; +import * as MySqlDrizzle from "drizzle-orm/effect-mysql2"; +import type { EffectDrizzleMySqlConfig } from "drizzle-orm/mysql-core/effect/utils"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; + +/** + * Options for the MySQL client: `@effect/sql-mysql2`'s client configuration, + * with `url` widened to also accept an Effect (e.g. a Hyperdrive connection + * string, which resolves from the Worker environment at runtime). + */ +export type MySQLConfig = Omit & { + readonly url: Redacted.Redacted | Effect.Effect, E, R>; +}; + +const isWorkerd = () => + (globalThis as { navigator?: { userAgent?: string } }).navigator?.userAgent === + "Cloudflare-Workers" || "WebSocketPair" in globalThis; + +// Query-string params are JSON-parsed into poolConfig entries (mysql2's own +// URI convention), so `mysql://...?ssl={"rejectUnauthorized":true}` works. +const parseMySQLUrl = (url: Redacted.Redacted) => + Effect.try({ + try: () => { + const u = new URL(Redacted.value(url)); + const poolConfig: Record = {}; + for (const [key, value] of u.searchParams) { + try { + poolConfig[key] = JSON.parse(value); + } catch { + poolConfig[key] = value; + } + } + const database = decodeURIComponent(u.pathname.replace(/^\//, "")); + return { + host: u.hostname, + port: u.port === "" ? 3306 : Number(u.port), + database: database === "" ? undefined : database, + username: u.username === "" ? undefined : decodeURIComponent(u.username), + password: u.password === "" ? undefined : Redacted.make(decodeURIComponent(u.password)), + poolConfig: poolConfig as MysqlClient.MysqlClientConfig["poolConfig"], + }; + }, + catch: (cause) => new Error(`SQL.MySQL: failed to parse connection url: ${cause}`), + }).pipe(Effect.orDie); + +/** + * Resolve a {@link MySQLConfig} into the `MysqlClientConfig` handed to + * `@effect/sql-mysql2`. The `url` is parsed into discrete connection fields + * (mysql2's URI code path ignores `poolConfig`), and on workerd the defaults + * flip to `poolConfig.disableEval` (no runtime codegen in the isolate) and + * `disablePreparedStatements` (Hyperdrive's MySQL proxy has no + * `COM_STMT_PREPARE`). Explicit config fields always win over parsed / + * detected values. + */ +export const resolveMySQLConfig = ( + config: MySQLConfig, +): Effect.Effect => + Effect.gen(function* () { + const { url, ...overrides } = config; + const resolved = Effect.isEffect(url) ? yield* url : url; + const parsed = yield* parseMySQLUrl(resolved); + const workerd = yield* Effect.sync(isWorkerd); + return { + ...overrides, + host: overrides.host ?? parsed.host, + port: overrides.port ?? parsed.port, + database: overrides.database ?? parsed.database, + username: overrides.username ?? parsed.username, + password: overrides.password ?? parsed.password, + poolConfig: { + ...(workerd ? { disableEval: true } : {}), + ...parsed.poolConfig, + ...overrides.poolConfig, + }, + disablePreparedStatements: overrides.disablePreparedStatements ?? workerd, + } satisfies MysqlClient.MysqlClientConfig; + }); + +/** + * Open a Drizzle/MySQL database from a connection URL using the + * `drizzle-orm/effect-mysql2` integration. + * + * ```typescript + * const conn = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive); + * const db = yield* DrizzleMysql.MySQL(conn.connectionString, { relations }); + * + * fetch: Effect.gen(function* () { + * const rows = yield* db.select().from(users); + * }); + * ``` + * + * The pool opens on the first query of an execution, is reused for every + * query in it, and closes when the event settles (see + * {@link makeExecutionMemo}); plan/deploy never connect. Workers defaults + * ({@link resolveMySQLConfig}) are overridden via `config.client`. + * + * @binding + */ +export const MySQL = ( + connectionString: Effect.Effect, E, R>, + config?: EffectDrizzleMySqlConfig & { + /** + * Overrides for the underlying `@effect/sql-mysql2` client — pool + * options (e.g. `poolConfig.ssl` for a direct TLS connection), + * `disablePreparedStatements`, `maxConnections`, and friends. + */ + readonly client?: Omit; + }, +) => + Effect.map( + makeExecutionMemo( + Effect.gen(function* () { + const { client, ...drizzleConfig } = config ?? {}; + const mysqlCtx = yield* Layer.build( + MysqlClient.layer(yield* resolveMySQLConfig({ ...client, url: connectionString })), + ); + return yield* MySqlDrizzle.makeWithDefaults( + drizzleConfig as EffectDrizzleMySqlConfig, + ).pipe(Effect.provideContext(mysqlCtx)); + }), + ), + (db) => + proxyChain< + EffectMysql2Database & { + $client: MysqlClient.MysqlClient; + } + >( + db as Effect.Effect< + EffectMysql2Database & { + $client: MysqlClient.MysqlClient; + } + >, + ), + ); diff --git a/infra/relay/src/persistence/mysqlResult.ts b/infra/relay/src/persistence/mysqlResult.ts new file mode 100644 index 00000000000..53d19824c2b --- /dev/null +++ b/infra/relay/src/persistence/mysqlResult.ts @@ -0,0 +1,17 @@ +/** + * drizzle's effect-mysql2 driver yields the mysql2 `ResultSetHeader` for + * INSERT/UPDATE/DELETE statements, but types the result as a row array. + * Normalize to the affected-row count; MySQL has no `RETURNING`, so this + * replaces the Postgres `RETURNING`-based "did this statement match" checks. + */ +export const affectedRows = (result: unknown): number => { + if ( + typeof result === "object" && + result !== null && + "affectedRows" in result && + typeof (result as { affectedRows: unknown }).affectedRows === "number" + ) { + return (result as { affectedRows: number }).affectedRows; + } + return 0; +}; diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index 61b72f2df86..af4b79dd432 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -6,29 +6,33 @@ import type { import { boolean, index, - integer, - jsonb, - pgTable, + int, + json, + mysqlTable, primaryKey, text, uniqueIndex, varchar, -} from "drizzle-orm/pg-core"; +} from "drizzle-orm/mysql-core"; -export const relayMobileDevices = pgTable( +// InnoDB cannot index TEXT columns without a prefix length, so every column +// that participates in an index or primary key is a bounded VARCHAR here +// (push/activity tokens, hostnames, tunnel names, environment public keys). + +export const relayMobileDevices = mysqlTable( "relay_mobile_devices", { userId: varchar("user_id", { length: 255 }).notNull(), deviceId: varchar("device_id", { length: 255 }).notNull(), label: text("label").notNull().default("iOS device"), platform: varchar("platform", { length: 16 }).notNull().$type<"ios">(), - iosMajorVersion: integer("ios_major_version").notNull(), + iosMajorVersion: int("ios_major_version").notNull(), appVersion: varchar("app_version", { length: 64 }), bundleId: varchar("bundle_id", { length: 255 }), apsEnvironment: varchar("aps_environment", { length: 16 }).$type<"sandbox" | "production">(), - pushToken: text("push_token"), - pushToStartToken: text("push_to_start_token"), - preferencesJson: jsonb("preferences_json").notNull().$type(), + pushToken: varchar("push_token", { length: 255 }), + pushToStartToken: varchar("push_to_start_token", { length: 255 }), + preferencesJson: json("preferences_json").notNull().$type(), createdAt: varchar("created_at", { length: 64 }).notNull(), updatedAt: varchar("updated_at", { length: 64 }).notNull(), }, @@ -39,16 +43,16 @@ export const relayMobileDevices = pgTable( ], ); -export const relayLiveActivities = pgTable( +export const relayLiveActivities = mysqlTable( "relay_live_activities", { userId: varchar("user_id", { length: 255 }).notNull(), deviceId: varchar("device_id", { length: 255 }).notNull(), - activityPushToken: text("activity_push_token"), + activityPushToken: varchar("activity_push_token", { length: 255 }), remoteStartQueuedAt: varchar("remote_start_queued_at", { length: 64 }), remoteStartedAt: varchar("remote_started_at", { length: 64 }), endedAt: varchar("ended_at", { length: 64 }), - lastAggregateJson: jsonb("last_aggregate_json").$type(), + lastAggregateJson: json("last_aggregate_json").$type(), lastLiveActivityDeliveryAt: varchar("last_live_activity_delivery_at", { length: 64 }), createdAt: varchar("created_at", { length: 64 }).notNull(), updatedAt: varchar("updated_at", { length: 64 }).notNull(), @@ -59,13 +63,13 @@ export const relayLiveActivities = pgTable( ], ); -export const relayEnvironmentLinks = pgTable( +export const relayEnvironmentLinks = mysqlTable( "relay_environment_links", { userId: varchar("user_id", { length: 191 }).notNull(), environmentId: varchar("environment_id", { length: 191 }).notNull(), environmentLabel: text("environment_label").notNull().default("T3 Environment"), - environmentPublicKey: text("environment_public_key").notNull(), + environmentPublicKey: varchar("environment_public_key", { length: 255 }).notNull(), endpointHttpBaseUrl: text("endpoint_http_base_url").notNull(), endpointWsBaseUrl: text("endpoint_ws_base_url").notNull(), endpointProviderKind: varchar("endpoint_provider_kind", { length: 32 }).notNull(), @@ -83,14 +87,14 @@ export const relayEnvironmentLinks = pgTable( ], ); -export const relayManagedEndpointAllocations = pgTable( +export const relayManagedEndpointAllocations = mysqlTable( "relay_managed_endpoint_allocations", { userId: varchar("user_id", { length: 191 }).notNull(), environmentId: varchar("environment_id", { length: 191 }).notNull(), - hostname: text("hostname").notNull(), + hostname: varchar("hostname", { length: 255 }).notNull(), tunnelId: varchar("tunnel_id", { length: 191 }), - tunnelName: text("tunnel_name").notNull(), + tunnelName: varchar("tunnel_name", { length: 255 }).notNull(), dnsRecordId: varchar("dns_record_id", { length: 191 }), readyAt: varchar("ready_at", { length: 64 }), createdAt: varchar("created_at", { length: 64 }).notNull(), @@ -103,19 +107,19 @@ export const relayManagedEndpointAllocations = pgTable( ], ); -export const relayManagedTunnelLimits = pgTable("relay_managed_tunnel_limits", { +export const relayManagedTunnelLimits = mysqlTable("relay_managed_tunnel_limits", { userId: varchar("user_id", { length: 191 }).primaryKey(), - maxTunnels: integer("max_tunnels").notNull(), + maxTunnels: int("max_tunnels").notNull(), createdAt: varchar("created_at", { length: 64 }).notNull(), updatedAt: varchar("updated_at", { length: 64 }).notNull(), }); -export const relayEnvironmentCredentials = pgTable( +export const relayEnvironmentCredentials = mysqlTable( "relay_environment_credentials", { credentialId: varchar("credential_id", { length: 64 }).primaryKey(), environmentId: varchar("environment_id", { length: 191 }).notNull(), - environmentPublicKey: text("environment_public_key").notNull(), + environmentPublicKey: varchar("environment_public_key", { length: 255 }).notNull(), credentialHash: varchar("credential_hash", { length: 191 }).notNull(), revokedAt: varchar("revoked_at", { length: 64 }), createdAt: varchar("created_at", { length: 64 }).notNull(), @@ -132,13 +136,13 @@ export const relayEnvironmentCredentials = pgTable( ], ); -export const relayAgentActivityRows = pgTable( +export const relayAgentActivityRows = mysqlTable( "relay_agent_activity_rows", { environmentId: varchar("environment_id", { length: 191 }).notNull(), - environmentPublicKey: text("environment_public_key").notNull(), + environmentPublicKey: varchar("environment_public_key", { length: 255 }).notNull(), threadId: varchar("thread_id", { length: 191 }).notNull(), - stateJson: jsonb("state_json").notNull().$type(), + stateJson: json("state_json").notNull().$type(), updatedAt: varchar("updated_at", { length: 64 }).notNull(), createdAt: varchar("created_at", { length: 64 }).notNull(), }, @@ -148,7 +152,7 @@ export const relayAgentActivityRows = pgTable( ], ); -export const relayDeliveryAttempts = pgTable( +export const relayDeliveryAttempts = mysqlTable( "relay_delivery_attempts", { id: varchar("id", { length: 36 }).primaryKey(), @@ -160,7 +164,7 @@ export const relayDeliveryAttempts = pgTable( kind: varchar("kind", { length: 64 }).notNull(), sourceJobId: varchar("source_job_id", { length: 64 }), tokenSuffix: varchar("token_suffix", { length: 16 }), - apnsStatus: integer("apns_status"), + apnsStatus: int("apns_status"), apnsReason: text("apns_reason"), apnsId: varchar("apns_id", { length: 128 }), transportError: text("transport_error"), @@ -175,12 +179,12 @@ export const relayDeliveryAttempts = pgTable( ], ); -export const relayDpopProofs = pgTable( +export const relayDpopProofs = mysqlTable( "relay_dpop_proofs", { thumbprint: varchar("thumbprint", { length: 128 }).notNull(), jti: varchar("jti", { length: 255 }).notNull(), - iat: integer("iat").notNull(), + iat: int("iat").notNull(), expiresAt: varchar("expires_at", { length: 64 }).notNull(), createdAt: varchar("created_at", { length: 64 }).notNull(), }, diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index bcc53f3ff05..33ca75c5b7a 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -1,6 +1,5 @@ import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; -import * as Drizzle from "alchemy/Drizzle"; import * as Config from "effect/Config"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -44,6 +43,7 @@ import * as EnvironmentLinks from "./environments/EnvironmentLinks.ts"; import * as ManagedEndpointAllocations from "./environments/ManagedEndpointAllocations.ts"; import * as LiveActivities from "./agentActivity/LiveActivities.ts"; import * as RelayDb from "./db.ts"; +import * as DrizzleMysql from "./persistence/drizzleMysql.ts"; import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queues.ts"; import * as RelayConfiguration from "./Config.ts"; import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts"; @@ -143,7 +143,7 @@ export const ApiLive = Api.make( const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey; const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey; const hyperdrive = yield* Cloudflare.Hyperdrive.Connect(yield* RelayDb.RelayHyperdrive); - const db = yield* Drizzle.Postgres(hyperdrive.connectionString); + const db = yield* DrizzleMysql.MySQL(hyperdrive.connectionString); const managedEndpointTunnelBinding = yield* Cloudflare.Tunnel.ReadWriteTunnel(); // Keep Worker custom-domain reconciliation ordered after API zone provisioning. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85afdd75d7a..9d58fa9c1e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,7 @@ overrides: '@effect/platform-bun': 4.0.0-beta.102 '@effect/platform-node': 4.0.0-beta.102 '@effect/platform-node-shared': 4.0.0-beta.102 + '@effect/sql-mysql2': 4.0.0-beta.102 '@effect/sql-pg': 4.0.0-beta.102 '@effect/sql-sqlite-bun': 4.0.0-beta.102 '@effect/vitest': 4.0.0-beta.102 @@ -664,6 +665,9 @@ importers: '@clerk/backend': specifier: 3.14.0 version: 3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@effect/sql-mysql2': + specifier: 4.0.0-beta.102 + version: 4.0.0-beta.102(@types/node@24.12.4)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@effect/sql-pg': specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) @@ -684,10 +688,10 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.65 - version: 2.0.0-beta.65(249551d75ad3792c0b22cd2754b25266) + version: 2.0.0-beta.65(eead4ef14d62793c7a40bd378b7ab471) drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-pg@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-sqlite-bun@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + version: 1.0.0-rc.4(6d7372add232cae10a536adca115d034) effect: specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) @@ -1978,6 +1982,11 @@ packages: peerDependencies: effect: 4.0.0-beta.102 + '@effect/sql-mysql2@4.0.0-beta.102': + resolution: {integrity: sha512-dLt7NG0pBHvWvEvwjACEdZfLt32VHg7wZU2qOsS8HiYZcm+NZrv2Bkdqm+GRwWqcGxV1Q125wld8seXC+YN7CQ==} + peerDependencies: + effect: 4.0.0-beta.102 + '@effect/sql-pg@4.0.0-beta.102': resolution: {integrity: sha512-02a+fNfECWCZIZcgHl7OLQN2jZj42gq90EdwdnvR3KA6GLGypY02swABS4zFtaW9H80rTa0ukqVnTdcO/DT5GQ==} peerDependencies: @@ -6159,7 +6168,7 @@ packages: '@cloudflare/workers-types': '>=4' '@effect/sql-d1': '>=4.0.0-beta.83 || >=4.0.0' '@effect/sql-libsql': '>=4.0.0-beta.83 || >=4.0.0' - '@effect/sql-mysql2': '>=4.0.0-beta.83 || >=4.0.0' + '@effect/sql-mysql2': 4.0.0-beta.102 '@effect/sql-pg': 4.0.0-beta.102 '@effect/sql-pglite': '>=4.0.0-beta.83 || >=4.0.0' '@effect/sql-sqlite-bun': 4.0.0-beta.102 @@ -8270,6 +8279,12 @@ packages: peerDependencies: '@types/node': 24.12.4 + mysql2@3.23.2: + resolution: {integrity: sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': 24.12.4 + named-placeholders@1.1.6: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} @@ -9525,6 +9540,10 @@ packages: resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + sql-escaper@1.5.1: + resolution: {integrity: sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -11817,6 +11836,13 @@ snapshots: '@cloudflare/workers-types': 5.20260726.1 effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) + '@effect/sql-mysql2@4.0.0-beta.102(@types/node@24.12.4)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))': + dependencies: + effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) + mysql2: 3.23.2(@types/node@24.12.4) + transitivePeerDependencies: + - '@types/node' + '@effect/sql-pg@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))': dependencies: effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) @@ -15371,7 +15397,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.65(249551d75ad3792c0b22cd2754b25266): + alchemy@2.0.0-beta.65(eead4ef14d62793c7a40bd378b7ab471): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1062.0 @@ -15416,7 +15442,7 @@ snapshots: '@effect/platform-node': 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/sql-pg': 4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) drizzle-kit: 1.0.0-rc.4 - drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-pg@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-sqlite-bun@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + drizzle-orm: 1.0.0-rc.4(6d7372add232cae10a536adca115d034) vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -16439,17 +16465,18 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-pg@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-sqlite-bun@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): + drizzle-orm@1.0.0-rc.4(6d7372add232cae10a536adca115d034): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) + '@effect/sql-mysql2': 4.0.0-beta.102(@types/node@24.12.4)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@effect/sql-pg': 4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@effect/sql-sqlite-bun': 4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) bun-types: 1.3.14 effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) expo-sqlite: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - mysql2: 3.22.4(@types/node@24.12.4) + mysql2: 3.23.2(@types/node@24.12.4) pg: 8.21.0 zod: 4.4.3 @@ -19123,6 +19150,18 @@ snapshots: named-placeholders: 1.1.6 sql-escaper: 1.3.3 + mysql2@3.23.2(@types/node@24.12.4): + dependencies: + '@types/node': 24.12.4 + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + sql-escaper: 1.5.1 + named-placeholders@1.1.6: dependencies: lru.min: 1.1.4 @@ -20781,6 +20820,8 @@ snapshots: sql-escaper@1.3.3: {} + sql-escaper@1.5.1: {} + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 79736c894e1..97285e6a2a1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -35,6 +35,7 @@ catalog: "@effect/platform-bun": 4.0.0-beta.102 "@effect/platform-node": 4.0.0-beta.102 "@effect/platform-node-shared": 4.0.0-beta.102 + "@effect/sql-mysql2": 4.0.0-beta.102 "@effect/sql-pg": 4.0.0-beta.102 "@effect/sql-sqlite-bun": 4.0.0-beta.102 "@effect/tsgo": 0.13.2 @@ -69,6 +70,7 @@ minimumReleaseAgeExclude: - "@effect/platform-bun@4.0.0-beta.102" - "@effect/platform-node-shared@4.0.0-beta.102" - "@effect/platform-node@4.0.0-beta.102" + - "@effect/sql-mysql2@4.0.0-beta.102" - "@effect/sql-pg@4.0.0-beta.102" - "@effect/sql-sqlite-bun@4.0.0-beta.102" - "@effect/vitest@4.0.0-beta.102" @@ -93,6 +95,7 @@ overrides: "@effect/platform-bun": "catalog:" "@effect/platform-node": "catalog:" "@effect/platform-node-shared": "catalog:" + "@effect/sql-mysql2": "catalog:" "@effect/sql-pg": "catalog:" "@effect/sql-sqlite-bun": "catalog:" "@effect/vitest": "catalog:"