The org-slug column was nullable and the runtime self-healed it: every resolveOrganization (and the SSR gate) lazily minted a slug for any row that lacked one. That left a permanent nullable state and a heal-on-read cost. Move slug generation to the single moment a row is inserted — upsertOrganization mints the slug at INSERT (ON CONFLICT DO NOTHING absorbs the id/slug races, retrying with a fresh candidate) and keeps the existing slug on conflict. The three insert sites (createOrganization, acceptInvitation, the WorkOS-fallback upsert in resolveOrganization) all get a slug for free; resolveOrganization and the SSR gate's organizationDisplay no longer heal. The schema column is now NOT NULL (migration 0004), backfilled once for legacy rows. Deploy order matters and is documented in the backfill script: backfill → deploy this code → apply 0004. The old code inserts a NULL slug then heals, so NOT NULL cannot land while it still serves. Also adds e2e/cloud/auth-routing-flow.test.ts: one browser session walking the whole arc — signed out → /login → hosted-AuthKit sign-in → org-less onboarding → create org → canonical /<slug> dashboard — through the real dev-server SSR gate. The hops are covered piecewise elsewhere; this is the narrative regression guard and the only test pinning the bare-/ → /<slug>/ dashboard canonicalization a fresh login lands on.
Unified · Split
| @@ -0,0 +1 @@ | |||
| 1 | ALTER TABLE "organizations" ALTER COLUMN "slug" SET NOT NULL; | ||
| @@ -0,0 +1,1242 @@ | |||
| 1 | { | ||
| 2 | "id": "1187b7aa-fdd6-412a-bcfb-20d6ea8f8644", | ||
| 3 | "prevId": "80591016-4fb7-47d8-ab60-9a5c8eaea9c8", | ||
| 4 | "version": "7", | ||
| 5 | "dialect": "postgresql", | ||
| 6 | "tables": { | ||
| 7 | "public.accounts": { | ||
| 8 | "name": "accounts", | ||
| 9 | "schema": "", | ||
| 10 | "columns": { | ||
| 11 | "id": { | ||
| 12 | "name": "id", | ||
| 13 | "type": "text", | ||
| 14 | "primaryKey": true, | ||
| 15 | "notNull": true | ||
| 16 | }, | ||
| 17 | "created_at": { | ||
| 18 | "name": "created_at", | ||
| 19 | "type": "timestamp with time zone", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "default": "now()" | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | "indexes": {}, | ||
| 26 | "foreignKeys": {}, | ||
| 27 | "compositePrimaryKeys": {}, | ||
| 28 | "uniqueConstraints": {}, | ||
| 29 | "policies": {}, | ||
| 30 | "checkConstraints": {}, | ||
| 31 | "isRLSEnabled": false | ||
| 32 | }, | ||
| 33 | "public.memberships": { | ||
| 34 | "name": "memberships", | ||
| 35 | "schema": "", | ||
| 36 | "columns": { | ||
| 37 | "account_id": { | ||
| 38 | "name": "account_id", | ||
| 39 | "type": "text", | ||
| 40 | "primaryKey": false, | ||
| 41 | "notNull": true | ||
| 42 | }, | ||
| 43 | "organization_id": { | ||
| 44 | "name": "organization_id", | ||
| 45 | "type": "text", | ||
| 46 | "primaryKey": false, | ||
| 47 | "notNull": true | ||
| 48 | }, | ||
| 49 | "created_at": { | ||
| 50 | "name": "created_at", | ||
| 51 | "type": "timestamp with time zone", | ||
| 52 | "primaryKey": false, | ||
| 53 | "notNull": true, | ||
| 54 | "default": "now()" | ||
| 55 | } | ||
| 56 | }, | ||
| 57 | "indexes": {}, | ||
| 58 | "foreignKeys": { | ||
| 59 | "memberships_account_id_accounts_id_fk": { | ||
| 60 | "name": "memberships_account_id_accounts_id_fk", | ||
| 61 | "tableFrom": "memberships", | ||
| 62 | "tableTo": "accounts", | ||
| 63 | "columnsFrom": ["account_id"], | ||
| 64 | "columnsTo": ["id"], | ||
| 65 | "onDelete": "cascade", | ||
| 66 | "onUpdate": "no action" | ||
| 67 | }, | ||
| 68 | "memberships_organization_id_organizations_id_fk": { | ||
| 69 | "name": "memberships_organization_id_organizations_id_fk", | ||
| 70 | "tableFrom": "memberships", | ||
| 71 | "tableTo": "organizations", | ||
| 72 | "columnsFrom": ["organization_id"], | ||
| 73 | "columnsTo": ["id"], | ||
| 74 | "onDelete": "cascade", | ||
| 75 | "onUpdate": "no action" | ||
| 76 | } | ||
| 77 | }, | ||
| 78 | "compositePrimaryKeys": { | ||
| 79 | "memberships_account_id_organization_id_pk": { | ||
| 80 | "name": "memberships_account_id_organization_id_pk", | ||
| 81 | "columns": ["account_id", "organization_id"] | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | "uniqueConstraints": {}, | ||
| 85 | "policies": {}, | ||
| 86 | "checkConstraints": {}, | ||
| 87 | "isRLSEnabled": false | ||
| 88 | }, | ||
| 89 | "public.organizations": { | ||
| 90 | "name": "organizations", | ||
| 91 | "schema": "", | ||
| 92 | "columns": { | ||
| 93 | "id": { | ||
| 94 | "name": "id", | ||
| 95 | "type": "text", | ||
| 96 | "primaryKey": true, | ||
| 97 | "notNull": true | ||
| 98 | }, | ||
| 99 | "name": { | ||
| 100 | "name": "name", | ||
| 101 | "type": "text", | ||
| 102 | "primaryKey": false, | ||
| 103 | "notNull": true | ||
| 104 | }, | ||
| 105 | "slug": { | ||
| 106 | "name": "slug", | ||
| 107 | "type": "text", | ||
| 108 | "primaryKey": false, | ||
| 109 | "notNull": true | ||
| 110 | }, | ||
| 111 | "created_at": { | ||
| 112 | "name": "created_at", | ||
| 113 | "type": "timestamp with time zone", | ||
| 114 | "primaryKey": false, | ||
| 115 | "notNull": true, | ||
| 116 | "default": "now()" | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | "indexes": { | ||
| 120 | "organizations_slug_unique": { | ||
| 121 | "name": "organizations_slug_unique", | ||
| 122 | "columns": [ | ||
| 123 | { | ||
| 124 | "expression": "slug", | ||
| 125 | "isExpression": false, | ||
| 126 | "asc": true, | ||
| 127 | "nulls": "last" | ||
| 128 | } | ||
| 129 | ], | ||
| 130 | "isUnique": true, | ||
| 131 | "concurrently": false, | ||
| 132 | "method": "btree", | ||
| 133 | "with": {} | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | "foreignKeys": {}, | ||
| 137 | "compositePrimaryKeys": {}, | ||
| 138 | "uniqueConstraints": {}, | ||
| 139 | "policies": {}, | ||
| 140 | "checkConstraints": {}, | ||
| 141 | "isRLSEnabled": false | ||
| 142 | }, | ||
| 143 | "public.blob": { | ||
| 144 | "name": "blob", | ||
| 145 | "schema": "", | ||
| 146 | "columns": { | ||
| 147 | "namespace": { | ||
| 148 | "name": "namespace", | ||
| 149 | "type": "varchar(255)", | ||
| 150 | "primaryKey": false, | ||
| 151 | "notNull": true | ||
| 152 | }, | ||
| 153 | "key": { | ||
| 154 | "name": "key", | ||
| 155 | "type": "varchar(255)", | ||
| 156 | "primaryKey": false, | ||
| 157 | "notNull": true | ||
| 158 | }, | ||
| 159 | "value": { | ||
| 160 | "name": "value", | ||
| 161 | "type": "text", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": true | ||
| 164 | }, | ||
| 165 | "row_id": { | ||
| 166 | "name": "row_id", | ||
| 167 | "type": "varchar(255)", | ||
| 168 | "primaryKey": true, | ||
| 169 | "notNull": true | ||
| 170 | }, | ||
| 171 | "id": { | ||
| 172 | "name": "id", | ||
| 173 | "type": "varchar(255)", | ||
| 174 | "primaryKey": false, | ||
| 175 | "notNull": true | ||
| 176 | } | ||
| 177 | }, | ||
| 178 | "indexes": { | ||
| 179 | "blob_id_uidx": { | ||
| 180 | "name": "blob_id_uidx", | ||
| 181 | "columns": [ | ||
| 182 | { | ||
| 183 | "expression": "id", | ||
| 184 | "isExpression": false, | ||
| 185 | "asc": true, | ||
| 186 | "nulls": "last" | ||
| 187 | } | ||
| 188 | ], | ||
| 189 | "isUnique": true, | ||
| 190 | "concurrently": false, | ||
| 191 | "method": "btree", | ||
| 192 | "with": {} | ||
| 193 | } | ||
| 194 | }, | ||
| 195 | "foreignKeys": {}, | ||
| 196 | "compositePrimaryKeys": {}, | ||
| 197 | "uniqueConstraints": {}, | ||
| 198 | "policies": {}, | ||
| 199 | "checkConstraints": {}, | ||
| 200 | "isRLSEnabled": false | ||
| 201 | }, | ||
| 202 | "public.connection": { | ||
| 203 | "name": "connection", | ||
| 204 | "schema": "", | ||
| 205 | "columns": { | ||
| 206 | "integration": { | ||
| 207 | "name": "integration", | ||
| 208 | "type": "varchar(255)", | ||
| 209 | "primaryKey": false, | ||
| 210 | "notNull": true | ||
| 211 | }, | ||
| 212 | "name": { | ||
| 213 | "name": "name", | ||
| 214 | "type": "varchar(255)", | ||
| 215 | "primaryKey": false, | ||
| 216 | "notNull": true | ||
| 217 | }, | ||
| 218 | "template": { | ||
| 219 | "name": "template", | ||
| 220 | "type": "text", | ||
| 221 | "primaryKey": false, | ||
| 222 | "notNull": true | ||
| 223 | }, | ||
| 224 | "provider": { | ||
| 225 | "name": "provider", | ||
| 226 | "type": "text", | ||
| 227 | "primaryKey": false, | ||
| 228 | "notNull": true | ||
| 229 | }, | ||
| 230 | "item_ids": { | ||
| 231 | "name": "item_ids", | ||
| 232 | "type": "json", | ||
| 233 | "primaryKey": false, | ||
| 234 | "notNull": true | ||
| 235 | }, | ||
| 236 | "identity_label": { | ||
| 237 | "name": "identity_label", | ||
| 238 | "type": "text", | ||
| 239 | "primaryKey": false, | ||
| 240 | "notNull": false | ||
| 241 | }, | ||
| 242 | "oauth_client": { | ||
| 243 | "name": "oauth_client", | ||
| 244 | "type": "text", | ||
| 245 | "primaryKey": false, | ||
| 246 | "notNull": false | ||
| 247 | }, | ||
| 248 | "oauth_client_owner": { | ||
| 249 | "name": "oauth_client_owner", | ||
| 250 | "type": "text", | ||
| 251 | "primaryKey": false, | ||
| 252 | "notNull": false | ||
| 253 | }, | ||
| 254 | "refresh_item_id": { | ||
| 255 | "name": "refresh_item_id", | ||
| 256 | "type": "text", | ||
| 257 | "primaryKey": false, | ||
| 258 | "notNull": false | ||
| 259 | }, | ||
| 260 | "expires_at": { | ||
| 261 | "name": "expires_at", | ||
| 262 | "type": "bigint", | ||
| 263 | "primaryKey": false, | ||
| 264 | "notNull": false | ||
| 265 | }, | ||
| 266 | "oauth_scope": { | ||
| 267 | "name": "oauth_scope", | ||
| 268 | "type": "text", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": false | ||
| 271 | }, | ||
| 272 | "provider_state": { | ||
| 273 | "name": "provider_state", | ||
| 274 | "type": "json", | ||
| 275 | "primaryKey": false, | ||
| 276 | "notNull": false | ||
| 277 | }, | ||
| 278 | "created_at": { | ||
| 279 | "name": "created_at", | ||
| 280 | "type": "timestamp", | ||
| 281 | "primaryKey": false, | ||
| 282 | "notNull": true | ||
| 283 | }, | ||
| 284 | "updated_at": { | ||
| 285 | "name": "updated_at", | ||
| 286 | "type": "timestamp", | ||
| 287 | "primaryKey": false, | ||
| 288 | "notNull": true | ||
| 289 | }, | ||
| 290 | "row_id": { | ||
| 291 | "name": "row_id", | ||
| 292 | "type": "varchar(255)", | ||
| 293 | "primaryKey": true, | ||
| 294 | "notNull": true | ||
| 295 | }, | ||
| 296 | "tenant": { | ||
| 297 | "name": "tenant", | ||
| 298 | "type": "varchar(255)", | ||
| 299 | "primaryKey": false, | ||
| 300 | "notNull": true | ||
| 301 | }, | ||
| 302 | "owner": { | ||
| 303 | "name": "owner", | ||
| 304 | "type": "varchar(255)", | ||
| 305 | "primaryKey": false, | ||
| 306 | "notNull": true | ||
| 307 | }, | ||
| 308 | "subject": { | ||
| 309 | "name": "subject", | ||
| 310 | "type": "varchar(255)", | ||
| 311 | "primaryKey": false, | ||
| 312 | "notNull": true | ||
| 313 | } | ||
| 314 | }, | ||
| 315 | "indexes": { | ||
| 316 | "connection_uidx": { | ||
| 317 | "name": "connection_uidx", | ||
| 318 | "columns": [ | ||
| 319 | { | ||
| 320 | "expression": "tenant", | ||
| 321 | "isExpression": false, | ||
| 322 | "asc": true, | ||
| 323 | "nulls": "last" | ||
| 324 | }, | ||
| 325 | { | ||
| 326 | "expression": "owner", | ||
| 327 | "isExpression": false, | ||
| 328 | "asc": true, | ||
| 329 | "nulls": "last" | ||
| 330 | }, | ||
| 331 | { | ||
| 332 | "expression": "subject", | ||
| 333 | "isExpression": false, | ||
| 334 | "asc": true, | ||
| 335 | "nulls": "last" | ||
| 336 | }, | ||
| 337 | { | ||
| 338 | "expression": "integration", | ||
| 339 | "isExpression": false, | ||
| 340 | "asc": true, | ||
| 341 | "nulls": "last" | ||
| 342 | }, | ||
| 343 | { | ||
| 344 | "expression": "name", | ||
| 345 | "isExpression": false, | ||
| 346 | "asc": true, | ||
| 347 | "nulls": "last" | ||
| 348 | } | ||
| 349 | ], | ||
| 350 | "isUnique": true, | ||
| 351 | "concurrently": false, | ||
| 352 | "method": "btree", | ||
| 353 | "with": {} | ||
| 354 | } | ||
| 355 | }, | ||
| 356 | "foreignKeys": {}, | ||
| 357 | "compositePrimaryKeys": {}, | ||
| 358 | "uniqueConstraints": {}, | ||
| 359 | "policies": {}, | ||
| 360 | "checkConstraints": {}, | ||
| 361 | "isRLSEnabled": false | ||
| 362 | }, | ||
| 363 | "public.definition": { | ||
| 364 | "name": "definition", | ||
| 365 | "schema": "", | ||
| 366 | "columns": { | ||
| 367 | "integration": { | ||
| 368 | "name": "integration", | ||
| 369 | "type": "varchar(255)", | ||
| 370 | "primaryKey": false, | ||
| 371 | "notNull": true | ||
| 372 | }, | ||
| 373 | "connection": { | ||
| 374 | "name": "connection", | ||
| 375 | "type": "varchar(255)", | ||
| 376 | "primaryKey": false, | ||
| 377 | "notNull": true | ||
| 378 | }, | ||
| 379 | "plugin_id": { | ||
| 380 | "name": "plugin_id", | ||
| 381 | "type": "text", | ||
| 382 | "primaryKey": false, | ||
| 383 | "notNull": true | ||
| 384 | }, | ||
| 385 | "name": { | ||
| 386 | "name": "name", | ||
| 387 | "type": "text", | ||
| 388 | "primaryKey": false, | ||
| 389 | "notNull": true | ||
| 390 | }, | ||
| 391 | "schema": { | ||
| 392 | "name": "schema", | ||
| 393 | "type": "json", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": true | ||
| 396 | }, | ||
| 397 | "created_at": { | ||
| 398 | "name": "created_at", | ||
| 399 | "type": "timestamp", | ||
| 400 | "primaryKey": false, | ||
| 401 | "notNull": true | ||
| 402 | }, | ||
| 403 | "row_id": { | ||
| 404 | "name": "row_id", | ||
| 405 | "type": "varchar(255)", | ||
| 406 | "primaryKey": true, | ||
| 407 | "notNull": true | ||
| 408 | }, | ||
| 409 | "tenant": { | ||
| 410 | "name": "tenant", | ||
| 411 | "type": "varchar(255)", | ||
| 412 | "primaryKey": false, | ||
| 413 | "notNull": true | ||
| 414 | }, | ||
| 415 | "owner": { | ||
| 416 | "name": "owner", | ||
| 417 | "type": "varchar(255)", | ||
| 418 | "primaryKey": false, | ||
| 419 | "notNull": true | ||
| 420 | }, | ||
| 421 | "subject": { | ||
| 422 | "name": "subject", | ||
| 423 | "type": "varchar(255)", | ||
| 424 | "primaryKey": false, | ||
| 425 | "notNull": true | ||
| 426 | } | ||
| 427 | }, | ||
| 428 | "indexes": { | ||
| 429 | "definition_uidx": { | ||
| 430 | "name": "definition_uidx", | ||
| 431 | "columns": [ | ||
| 432 | { | ||
| 433 | "expression": "tenant", | ||
| 434 | "isExpression": false, | ||
| 435 | "asc": true, | ||
| 436 | "nulls": "last" | ||
| 437 | }, | ||
| 438 | { | ||
| 439 | "expression": "owner", | ||
| 440 | "isExpression": false, | ||
| 441 | "asc": true, | ||
| 442 | "nulls": "last" | ||
| 443 | }, | ||
| 444 | { | ||
| 445 | "expression": "subject", | ||
| 446 | "isExpression": false, | ||
| 447 | "asc": true, | ||
| 448 | "nulls": "last" | ||
| 449 | }, | ||
| 450 | { | ||
| 451 | "expression": "integration", | ||
| 452 | "isExpression": false, | ||
| 453 | "asc": true, | ||
| 454 | "nulls": "last" | ||
| 455 | }, | ||
| 456 | { | ||
| 457 | "expression": "connection", | ||
| 458 | "isExpression": false, | ||
| 459 | "asc": true, | ||
| 460 | "nulls": "last" | ||
| 461 | }, | ||
| 462 | { | ||
| 463 | "expression": "name", | ||
| 464 | "isExpression": false, | ||
| 465 | "asc": true, | ||
| 466 | "nulls": "last" | ||
| 467 | } | ||
| 468 | ], | ||
| 469 | "isUnique": true, | ||
| 470 | "concurrently": false, | ||
| 471 | "method": "btree", | ||
| 472 | "with": {} | ||
| 473 | } | ||
| 474 | }, | ||
| 475 | "foreignKeys": {}, | ||
| 476 | "compositePrimaryKeys": {}, | ||
| 477 | "uniqueConstraints": {}, | ||
| 478 | "policies": {}, | ||
| 479 | "checkConstraints": {}, | ||
| 480 | "isRLSEnabled": false | ||
| 481 | }, | ||
| 482 | "public.integration": { | ||
| 483 | "name": "integration", | ||
| 484 | "schema": "", | ||
| 485 | "columns": { | ||
| 486 | "slug": { | ||
| 487 | "name": "slug", | ||
| 488 | "type": "varchar(255)", | ||
| 489 | "primaryKey": false, | ||
| 490 | "notNull": true | ||
| 491 | }, | ||
| 492 | "plugin_id": { | ||
| 493 | "name": "plugin_id", | ||
| 494 | "type": "text", | ||
| 495 | "primaryKey": false, | ||
| 496 | "notNull": true | ||
| 497 | }, | ||
| 498 | "description": { | ||
| 499 | "name": "description", | ||
| 500 | "type": "text", | ||
| 501 | "primaryKey": false, | ||
| 502 | "notNull": true | ||
| 503 | }, | ||
| 504 | "config": { | ||
| 505 | "name": "config", | ||
| 506 | "type": "json", | ||
| 507 | "primaryKey": false, | ||
| 508 | "notNull": false | ||
| 509 | }, | ||
| 510 | "can_remove": { | ||
| 511 | "name": "can_remove", | ||
| 512 | "type": "boolean", | ||
| 513 | "primaryKey": false, | ||
| 514 | "notNull": true, | ||
| 515 | "default": true | ||
| 516 | }, | ||
| 517 | "can_refresh": { | ||
| 518 | "name": "can_refresh", | ||
| 519 | "type": "boolean", | ||
| 520 | "primaryKey": false, | ||
| 521 | "notNull": true, | ||
| 522 | "default": false | ||
| 523 | }, | ||
| 524 | "created_at": { | ||
| 525 | "name": "created_at", | ||
| 526 | "type": "timestamp", | ||
| 527 | "primaryKey": false, | ||
| 528 | "notNull": true | ||
| 529 | }, | ||
| 530 | "updated_at": { | ||
| 531 | "name": "updated_at", | ||
| 532 | "type": "timestamp", | ||
| 533 | "primaryKey": false, | ||
| 534 | "notNull": true | ||
| 535 | }, | ||
| 536 | "row_id": { | ||
| 537 | "name": "row_id", | ||
| 538 | "type": "varchar(255)", | ||
| 539 | "primaryKey": true, | ||
| 540 | "notNull": true | ||
| 541 | }, | ||
| 542 | "tenant": { | ||
| 543 | "name": "tenant", | ||
| 544 | "type": "varchar(255)", | ||
| 545 | "primaryKey": false, | ||
| 546 | "notNull": true | ||
| 547 | } | ||
| 548 | }, | ||
| 549 | "indexes": { | ||
| 550 | "integration_uidx": { | ||
| 551 | "name": "integration_uidx", | ||
| 552 | "columns": [ | ||
| 553 | { | ||
| 554 | "expression": "tenant", | ||
| 555 | "isExpression": false, | ||
| 556 | "asc": true, | ||
| 557 | "nulls": "last" | ||
| 558 | }, | ||
| 559 | { | ||
| 560 | "expression": "slug", | ||
| 561 | "isExpression": false, | ||
| 562 | "asc": true, | ||
| 563 | "nulls": "last" | ||
| 564 | } | ||
| 565 | ], | ||
| 566 | "isUnique": true, | ||
| 567 | "concurrently": false, | ||
| 568 | "method": "btree", | ||
| 569 | "with": {} | ||
| 570 | } | ||
| 571 | }, | ||
| 572 | "foreignKeys": {}, | ||
| 573 | "compositePrimaryKeys": {}, | ||
| 574 | "uniqueConstraints": {}, | ||
| 575 | "policies": {}, | ||
| 576 | "checkConstraints": {}, | ||
| 577 | "isRLSEnabled": false | ||
| 578 | }, | ||
| 579 | "public.oauth_client": { | ||
| 580 | "name": "oauth_client", | ||
| 581 | "schema": "", | ||
| 582 | "columns": { | ||
| 583 | "slug": { | ||
| 584 | "name": "slug", | ||
| 585 | "type": "varchar(255)", | ||
| 586 | "primaryKey": false, | ||
| 587 | "notNull": true | ||
| 588 | }, | ||
| 589 | "authorization_url": { | ||
| 590 | "name": "authorization_url", | ||
| 591 | "type": "text", | ||
| 592 | "primaryKey": false, | ||
| 593 | "notNull": true | ||
| 594 | }, | ||
| 595 | "token_url": { | ||
| 596 | "name": "token_url", | ||
| 597 | "type": "text", | ||
| 598 | "primaryKey": false, | ||
| 599 | "notNull": true | ||
| 600 | }, | ||
| 601 | "grant": { | ||
| 602 | "name": "grant", | ||
| 603 | "type": "text", | ||
| 604 | "primaryKey": false, | ||
| 605 | "notNull": true | ||
| 606 | }, | ||
| 607 | "client_id": { | ||
| 608 | "name": "client_id", | ||
| 609 | "type": "text", | ||
| 610 | "primaryKey": false, | ||
| 611 | "notNull": true | ||
| 612 | }, | ||
| 613 | "client_secret_item_id": { | ||
| 614 | "name": "client_secret_item_id", | ||
| 615 | "type": "text", | ||
| 616 | "primaryKey": false, | ||
| 617 | "notNull": false | ||
| 618 | }, | ||
| 619 | "resource": { | ||
| 620 | "name": "resource", | ||
| 621 | "type": "text", | ||
| 622 | "primaryKey": false, | ||
| 623 | "notNull": false | ||
| 624 | }, | ||
| 625 | "origin_kind": { | ||
| 626 | "name": "origin_kind", | ||
| 627 | "type": "text", | ||
| 628 | "primaryKey": false, | ||
| 629 | "notNull": false | ||
| 630 | }, | ||
| 631 | "origin_integration": { | ||
| 632 | "name": "origin_integration", | ||
| 633 | "type": "text", | ||
| 634 | "primaryKey": false, | ||
| 635 | "notNull": false | ||
| 636 | }, | ||
| 637 | "created_at": { | ||
| 638 | "name": "created_at", | ||
| 639 | "type": "timestamp", | ||
| 640 | "primaryKey": false, | ||
| 641 | "notNull": true | ||
| 642 | }, | ||
| 643 | "row_id": { | ||
| 644 | "name": "row_id", | ||
| 645 | "type": "varchar(255)", | ||
| 646 | "primaryKey": true, | ||
| 647 | "notNull": true | ||
| 648 | }, | ||
| 649 | "tenant": { | ||
| 650 | "name": "tenant", | ||
| 651 | "type": "varchar(255)", | ||
| 652 | "primaryKey": false, | ||
| 653 | "notNull": true | ||
| 654 | }, | ||
| 655 | "owner": { | ||
| 656 | "name": "owner", | ||
| 657 | "type": "varchar(255)", | ||
| 658 | "primaryKey": false, | ||
| 659 | "notNull": true | ||
| 660 | }, | ||
| 661 | "subject": { | ||
| 662 | "name": "subject", | ||
| 663 | "type": "varchar(255)", | ||
| 664 | "primaryKey": false, | ||
| 665 | "notNull": true | ||
| 666 | } | ||
| 667 | }, | ||
| 668 | "indexes": { | ||
| 669 | "oauth_client_uidx": { | ||
| 670 | "name": "oauth_client_uidx", | ||
| 671 | "columns": [ | ||
| 672 | { | ||
| 673 | "expression": "tenant", | ||
| 674 | "isExpression": false, | ||
| 675 | "asc": true, | ||
| 676 | "nulls": "last" | ||
| 677 | }, | ||
| 678 | { | ||
| 679 | "expression": "owner", | ||
| 680 | "isExpression": false, | ||
| 681 | "asc": true, | ||
| 682 | "nulls": "last" | ||
| 683 | }, | ||
| 684 | { | ||
| 685 | "expression": "subject", | ||
| 686 | "isExpression": false, | ||
| 687 | "asc": true, | ||
| 688 | "nulls": "last" | ||
| 689 | }, | ||
| 690 | { | ||
| 691 | "expression": "slug", | ||
| 692 | "isExpression": false, | ||
| 693 | "asc": true, | ||
| 694 | "nulls": "last" | ||
| 695 | } | ||
| 696 | ], | ||
| 697 | "isUnique": true, | ||
| 698 | "concurrently": false, | ||
| 699 | "method": "btree", | ||
| 700 | "with": {} | ||
| 701 | } | ||
| 702 | }, | ||
| 703 | "foreignKeys": {}, | ||
| 704 | "compositePrimaryKeys": {}, | ||
| 705 | "uniqueConstraints": {}, | ||
| 706 | "policies": {}, | ||
| 707 | "checkConstraints": {}, | ||
| 708 | "isRLSEnabled": false | ||
| 709 | }, | ||
| 710 | "public.oauth_session": { | ||
| 711 | "name": "oauth_session", | ||
| 712 | "schema": "", | ||
| 713 | "columns": { | ||
| 714 | "state": { | ||
| 715 | "name": "state", | ||
| 716 | "type": "varchar(255)", | ||
| 717 | "primaryKey": false, | ||
| 718 | "notNull": true | ||
| 719 | }, | ||
| 720 | "client_slug": { | ||
| 721 | "name": "client_slug", | ||
| 722 | "type": "text", | ||
| 723 | "primaryKey": false, | ||
| 724 | "notNull": true | ||
| 725 | }, | ||
| 726 | "integration": { | ||
| 727 | "name": "integration", | ||
| 728 | "type": "text", | ||
| 729 | "primaryKey": false, | ||
| 730 | "notNull": true | ||
| 731 | }, | ||
| 732 | "name": { | ||
| 733 | "name": "name", | ||
| 734 | "type": "text", | ||
| 735 | "primaryKey": false, | ||
| 736 | "notNull": true | ||
| 737 | }, | ||
| 738 | "template": { | ||
| 739 | "name": "template", | ||
| 740 | "type": "text", | ||
| 741 | "primaryKey": false, | ||
| 742 | "notNull": true | ||
| 743 | }, | ||
| 744 | "redirect_url": { | ||
| 745 | "name": "redirect_url", | ||
| 746 | "type": "text", | ||
| 747 | "primaryKey": false, | ||
| 748 | "notNull": true | ||
| 749 | }, | ||
| 750 | "pkce_verifier": { | ||
| 751 | "name": "pkce_verifier", | ||
| 752 | "type": "text", | ||
| 753 | "primaryKey": false, | ||
| 754 | "notNull": false | ||
| 755 | }, | ||
| 756 | "identity_label": { | ||
| 757 | "name": "identity_label", | ||
| 758 | "type": "text", | ||
| 759 | "primaryKey": false, | ||
| 760 | "notNull": false | ||
| 761 | }, | ||
| 762 | "payload": { | ||
| 763 | "name": "payload", | ||
| 764 | "type": "json", | ||
| 765 | "primaryKey": false, | ||
| 766 | "notNull": true | ||
| 767 | }, | ||
| 768 | "expires_at": { | ||
| 769 | "name": "expires_at", | ||
| 770 | "type": "bigint", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": true | ||
| 773 | }, | ||
| 774 | "created_at": { | ||
| 775 | "name": "created_at", | ||
| 776 | "type": "timestamp", | ||
| 777 | "primaryKey": false, | ||
| 778 | "notNull": true | ||
| 779 | }, | ||
| 780 | "row_id": { | ||
| 781 | "name": "row_id", | ||
| 782 | "type": "varchar(255)", | ||
| 783 | "primaryKey": true, | ||
| 784 | "notNull": true | ||
| 785 | }, | ||
| 786 | "tenant": { | ||
| 787 | "name": "tenant", | ||
| 788 | "type": "varchar(255)", | ||
| 789 | "primaryKey": false, | ||
| 790 | "notNull": true | ||
| 791 | }, | ||
| 792 | "owner": { | ||
| 793 | "name": "owner", | ||
| 794 | "type": "varchar(255)", | ||
| 795 | "primaryKey": false, | ||
| 796 | "notNull": true | ||
| 797 | }, | ||
| 798 | "subject": { | ||
| 799 | "name": "subject", | ||
| 800 | "type": "varchar(255)", | ||
| 801 | "primaryKey": false, | ||
| 802 | "notNull": true | ||
| 803 | } | ||
| 804 | }, | ||
| 805 | "indexes": { | ||
| 806 | "oauth_session_uidx": { | ||
| 807 | "name": "oauth_session_uidx", | ||
| 808 | "columns": [ | ||
| 809 | { | ||
| 810 | "expression": "tenant", | ||
| 811 | "isExpression": false, | ||
| 812 | "asc": true, | ||
| 813 | "nulls": "last" | ||
| 814 | }, | ||
| 815 | { | ||
| 816 | "expression": "state", | ||
| 817 | "isExpression": false, | ||
| 818 | "asc": true, | ||
| 819 | "nulls": "last" | ||
| 820 | } | ||
| 821 | ], | ||
| 822 | "isUnique": true, | ||
| 823 | "concurrently": false, | ||
| 824 | "method": "btree", | ||
| 825 | "with": {} | ||
| 826 | } | ||
| 827 | }, | ||
| 828 | "foreignKeys": {}, | ||
| 829 | "compositePrimaryKeys": {}, | ||
| 830 | "uniqueConstraints": {}, | ||
| 831 | "policies": {}, | ||
| 832 | "checkConstraints": {}, | ||
| 833 | "isRLSEnabled": false | ||
| 834 | }, | ||
| 835 | "public.plugin_storage": { | ||
| 836 | "name": "plugin_storage", | ||
| 837 | "schema": "", | ||
| 838 | "columns": { | ||
| 839 | "plugin_id": { | ||
| 840 | "name": "plugin_id", | ||
| 841 | "type": "varchar(255)", | ||
| 842 | "primaryKey": false, | ||
| 843 | "notNull": true | ||
| 844 | }, | ||
| 845 | "collection": { | ||
| 846 | "name": "collection", | ||
| 847 | "type": "varchar(255)", | ||
| 848 | "primaryKey": false, | ||
| 849 | "notNull": true | ||
| 850 | }, | ||
| 851 | "key": { | ||
| 852 | "name": "key", | ||
| 853 | "type": "varchar(255)", | ||
| 854 | "primaryKey": false, | ||
| 855 | "notNull": true | ||
| 856 | }, | ||
| 857 | "data": { | ||
| 858 | "name": "data", | ||
| 859 | "type": "json", | ||
| 860 | "primaryKey": false, | ||
| 861 | "notNull": true | ||
| 862 | }, | ||
| 863 | "created_at": { | ||
| 864 | "name": "created_at", | ||
| 865 | "type": "timestamp", | ||
| 866 | "primaryKey": false, | ||
| 867 | "notNull": true | ||
| 868 | }, | ||
| 869 | "updated_at": { | ||
| 870 | "name": "updated_at", | ||
| 871 | "type": "timestamp", | ||
| 872 | "primaryKey": false, | ||
| 873 | "notNull": true | ||
| 874 | }, | ||
| 875 | "row_id": { | ||
| 876 | "name": "row_id", | ||
| 877 | "type": "varchar(255)", | ||
| 878 | "primaryKey": true, | ||
| 879 | "notNull": true | ||
| 880 | }, | ||
| 881 | "tenant": { | ||
| 882 | "name": "tenant", | ||
| 883 | "type": "varchar(255)", | ||
| 884 | "primaryKey": false, | ||
| 885 | "notNull": true | ||
| 886 | }, | ||
| 887 | "owner": { | ||
| 888 | "name": "owner", | ||
| 889 | "type": "varchar(255)", | ||
| 890 | "primaryKey": false, | ||
| 891 | "notNull": true | ||
| 892 | }, | ||
| 893 | "subject": { | ||
| 894 | "name": "subject", | ||
| 895 | "type": "varchar(255)", | ||
| 896 | "primaryKey": false, | ||
| 897 | "notNull": true | ||
| 898 | } | ||
| 899 | }, | ||
| 900 | "indexes": { | ||
| 901 | "plugin_storage_uidx": { | ||
| 902 | "name": "plugin_storage_uidx", | ||
| 903 | "columns": [ | ||
| 904 | { | ||
| 905 | "expression": "tenant", | ||
| 906 | "isExpression": false, | ||
| 907 | "asc": true, | ||
| 908 | "nulls": "last" | ||
| 909 | }, | ||
| 910 | { | ||
| 911 | "expression": "owner", | ||
| 912 | "isExpression": false, | ||
| 913 | "asc": true, | ||
| 914 | "nulls": "last" | ||
| 915 | }, | ||
| 916 | { | ||
| 917 | "expression": "subject", | ||
| 918 | "isExpression": false, | ||
| 919 | "asc": true, | ||
| 920 | "nulls": "last" | ||
| 921 | }, | ||
| 922 | { | ||
| 923 | "expression": "plugin_id", | ||
| 924 | "isExpression": false, | ||
| 925 | "asc": true, | ||
| 926 | "nulls": "last" | ||
| 927 | }, | ||
| 928 | { | ||
| 929 | "expression": "collection", | ||
| 930 | "isExpression": false, | ||
| 931 | "asc": true, | ||
| 932 | "nulls": "last" | ||
| 933 | }, | ||
| 934 | { | ||
| 935 | "expression": "key", | ||
| 936 | "isExpression": false, | ||
| 937 | "asc": true, | ||
| 938 | "nulls": "last" | ||
| 939 | } | ||
| 940 | ], | ||
| 941 | "isUnique": true, | ||
| 942 | "concurrently": false, | ||
| 943 | "method": "btree", | ||
| 944 | "with": {} | ||
| 945 | } | ||
| 946 | }, | ||
| 947 | "foreignKeys": {}, | ||
| 948 | "compositePrimaryKeys": {}, | ||
| 949 | "uniqueConstraints": {}, | ||
| 950 | "policies": {}, | ||
| 951 | "checkConstraints": {}, | ||
| 952 | "isRLSEnabled": false | ||
| 953 | }, | ||
| 954 | "public.private_executor_cloud_settings": { | ||
| 955 | "name": "private_executor_cloud_settings", | ||
| 956 | "schema": "", | ||
| 957 | "columns": { | ||
| 958 | "id": { | ||
| 959 | "name": "id", | ||
| 960 | "type": "varchar(255)", | ||
| 961 | "primaryKey": true, | ||
| 962 | "notNull": true | ||
| 963 | }, | ||
| 964 | "version": { | ||
| 965 | "name": "version", | ||
| 966 | "type": "varchar(255)", | ||
| 967 | "primaryKey": false, | ||
| 968 | "notNull": true, | ||
| 969 | "default": "'1.0.0'" | ||
| 970 | } | ||
| 971 | }, | ||
| 972 | "indexes": {}, | ||
| 973 | "foreignKeys": {}, | ||
| 974 | "compositePrimaryKeys": {}, | ||
| 975 | "uniqueConstraints": {}, | ||
| 976 | "policies": {}, | ||
| 977 | "checkConstraints": {}, | ||
| 978 | "isRLSEnabled": false | ||
| 979 | }, | ||
| 980 | "public.tool": { | ||
| 981 | "name": "tool", | ||
| 982 | "schema": "", | ||
| 983 | "columns": { | ||
| 984 | "integration": { | ||
| 985 | "name": "integration", | ||
| 986 | "type": "varchar(255)", | ||
| 987 | "primaryKey": false, | ||
| 988 | "notNull": true | ||
| 989 | }, | ||
| 990 | "connection": { | ||
| 991 | "name": "connection", | ||
| 992 | "type": "varchar(255)", | ||
| 993 | "primaryKey": false, | ||
| 994 | "notNull": true | ||
| 995 | }, | ||
| 996 | "plugin_id": { | ||
| 997 | "name": "plugin_id", | ||
| 998 | "type": "text", | ||
| 999 | "primaryKey": false, | ||
| 1000 | "notNull": true | ||
| 1001 | }, | ||
| 1002 | "name": { | ||
| 1003 | "name": "name", | ||
| 1004 | "type": "varchar(255)", | ||
| 1005 | "primaryKey": false, | ||
| 1006 | "notNull": true | ||
| 1007 | }, | ||
| 1008 | "description": { | ||
| 1009 | "name": "description", | ||
| 1010 | "type": "text", | ||
| 1011 | "primaryKey": false, | ||
| 1012 | "notNull": true | ||
| 1013 | }, | ||
| 1014 | "input_schema": { | ||
| 1015 | "name": "input_schema", | ||
| 1016 | "type": "json", | ||
| 1017 | "primaryKey": false, | ||
| 1018 | "notNull": false | ||
| 1019 | }, | ||
| 1020 | "output_schema": { | ||
| 1021 | "name": "output_schema", | ||
| 1022 | "type": "json", | ||
| 1023 | "primaryKey": false, | ||
| 1024 | "notNull": false | ||
| 1025 | }, | ||
| 1026 | "annotations": { | ||
| 1027 | "name": "annotations", | ||
| 1028 | "type": "json", | ||
| 1029 | "primaryKey": false, | ||
| 1030 | "notNull": false | ||
| 1031 | }, | ||
| 1032 | "created_at": { | ||
| 1033 | "name": "created_at", | ||
| 1034 | "type": "timestamp", | ||
| 1035 | "primaryKey": false, | ||
| 1036 | "notNull": true | ||
| 1037 | }, | ||
| 1038 | "updated_at": { | ||
| 1039 | "name": "updated_at", | ||
| 1040 | "type": "timestamp", | ||
| 1041 | "primaryKey": false, | ||
| 1042 | "notNull": true | ||
| 1043 | }, | ||
| 1044 | "row_id": { | ||
| 1045 | "name": "row_id", | ||
| 1046 | "type": "varchar(255)", | ||
| 1047 | "primaryKey": true, | ||
| 1048 | "notNull": true | ||
| 1049 | }, | ||
| 1050 | "tenant": { | ||
| 1051 | "name": "tenant", | ||
| 1052 | "type": "varchar(255)", | ||
| 1053 | "primaryKey": false, | ||
| 1054 | "notNull": true | ||
| 1055 | }, | ||
| 1056 | "owner": { | ||
| 1057 | "name": "owner", | ||
| 1058 | "type": "varchar(255)", | ||
| 1059 | "primaryKey": false, | ||
| 1060 | "notNull": true | ||
| 1061 | }, | ||
| 1062 | "subject": { | ||
| 1063 | "name": "subject", | ||
| 1064 | "type": "varchar(255)", | ||
| 1065 | "primaryKey": false, | ||
| 1066 | "notNull": true | ||
| 1067 | } | ||
| 1068 | }, | ||
| 1069 | "indexes": { | ||
| 1070 | "tool_uidx": { | ||
| 1071 | "name": "tool_uidx", | ||
| 1072 | "columns": [ | ||
| 1073 | { | ||
| 1074 | "expression": "tenant", | ||
| 1075 | "isExpression": false, | ||
| 1076 | "asc": true, | ||
| 1077 | "nulls": "last" | ||
| 1078 | }, | ||
| 1079 | { | ||
| 1080 | "expression": "owner", | ||
| 1081 | "isExpression": false, | ||
| 1082 | "asc": true, | ||
| 1083 | "nulls": "last" | ||
| 1084 | }, | ||
| 1085 | { | ||
| 1086 | "expression": "subject", | ||
| 1087 | "isExpression": false, | ||
| 1088 | "asc": true, | ||
| 1089 | "nulls": "last" | ||
| 1090 | }, | ||
| 1091 | { | ||
| 1092 | "expression": "integration", | ||
| 1093 | "isExpression": false, | ||
| 1094 | "asc": true, | ||
| 1095 | "nulls": "last" | ||
| 1096 | }, | ||
| 1097 | { | ||
| 1098 | "expression": "connection", | ||
| 1099 | "isExpression": false, | ||
| 1100 | "asc": true, | ||
| 1101 | "nulls": "last" | ||
| 1102 | }, | ||
| 1103 | { | ||
| 1104 | "expression": "name", | ||
| 1105 | "isExpression": false, | ||
| 1106 | "asc": true, | ||
| 1107 | "nulls": "last" | ||
| 1108 | } | ||
| 1109 | ], | ||
| 1110 | "isUnique": true, | ||
| 1111 | "concurrently": false, | ||
| 1112 | "method": "btree", | ||
| 1113 | "with": {} | ||
| 1114 | } | ||
| 1115 | }, | ||
| 1116 | "foreignKeys": {}, | ||
| 1117 | "compositePrimaryKeys": {}, | ||
| 1118 | "uniqueConstraints": {}, | ||
| 1119 | "policies": {}, | ||
| 1120 | "checkConstraints": {}, | ||
| 1121 | "isRLSEnabled": false | ||
| 1122 | }, | ||
| 1123 | "public.tool_policy": { | ||
| 1124 | "name": "tool_policy", | ||
| 1125 | "schema": "", | ||
| 1126 | "columns": { | ||
| 1127 | "id": { | ||
| 1128 | "name": "id", | ||
| 1129 | "type": "varchar(255)", | ||
| 1130 | "primaryKey": false, | ||
| 1131 | "notNull": true | ||
| 1132 | }, | ||
| 1133 | "pattern": { | ||
| 1134 | "name": "pattern", | ||
| 1135 | "type": "text", | ||
| 1136 | "primaryKey": false, | ||
| 1137 | "notNull": true | ||
| 1138 | }, | ||
| 1139 | "action": { | ||
| 1140 | "name": "action", | ||
| 1141 | "type": "text", | ||
| 1142 | "primaryKey": false, | ||
| 1143 | "notNull": true | ||
| 1144 | }, | ||
| 1145 | "position": { | ||
| 1146 | "name": "position", | ||
| 1147 | "type": "text", | ||
| 1148 | "primaryKey": false, | ||
| 1149 | "notNull": true | ||
| 1150 | }, | ||
| 1151 | "created_at": { | ||
| 1152 | "name": "created_at", | ||
| 1153 | "type": "timestamp", | ||
| 1154 | "primaryKey": false, | ||
| 1155 | "notNull": true | ||
| 1156 | }, | ||
| 1157 | "updated_at": { | ||
| 1158 | "name": "updated_at", | ||
| 1159 | "type": "timestamp", | ||
| 1160 | "primaryKey": false, | ||
| 1161 | "notNull": true | ||
| 1162 | }, | ||
| 1163 | "row_id": { | ||
| 1164 | "name": "row_id", | ||
| 1165 | "type": "varchar(255)", | ||
| 1166 | "primaryKey": true, | ||
| 1167 | "notNull": true | ||
| 1168 | }, | ||
| 1169 | "tenant": { | ||
| 1170 | "name": "tenant", | ||
| 1171 | "type": "varchar(255)", | ||
| 1172 | "primaryKey": false, | ||
| 1173 | "notNull": true | ||
| 1174 | }, | ||
| 1175 | "owner": { | ||
| 1176 | "name": "owner", | ||
| 1177 | "type": "varchar(255)", | ||
| 1178 | "primaryKey": false, | ||
| 1179 | "notNull": true | ||
| 1180 | }, | ||
| 1181 | "subject": { | ||
| 1182 | "name": "subject", | ||
| 1183 | "type": "varchar(255)", | ||
| 1184 | "primaryKey": false, | ||
| 1185 | "notNull": true | ||
| 1186 | } | ||
| 1187 | }, | ||
| 1188 | "indexes": { | ||
| 1189 | "tool_policy_uidx": { | ||
| 1190 | "name": "tool_policy_uidx", | ||
| 1191 | "columns": [ | ||
| 1192 | { | ||
| 1193 | "expression": "tenant", | ||
| 1194 | "isExpression": false, | ||
| 1195 | "asc": true, | ||
| 1196 | "nulls": "last" | ||
| 1197 | }, | ||
| 1198 | { | ||
| 1199 | "expression": "owner", | ||
| 1200 | "isExpression": false, | ||
| 1201 | "asc": true, | ||
| 1202 | "nulls": "last" | ||
| 1203 | }, | ||
| 1204 | { | ||
| 1205 | "expression": "subject", | ||
| 1206 | "isExpression": false, | ||
| 1207 | "asc": true, | ||
| 1208 | "nulls": "last" | ||
| 1209 | }, | ||
| 1210 | { | ||
| 1211 | "expression": "id", | ||
| 1212 | "isExpression": false, | ||
| 1213 | "asc": true, | ||
| 1214 | "nulls": "last" | ||
| 1215 | } | ||
| 1216 | ], | ||
| 1217 | "isUnique": true, | ||
| 1218 | "concurrently": false, | ||
| 1219 | "method": "btree", | ||
| 1220 | "with": {} | ||
| 1221 | } | ||
| 1222 | }, | ||
| 1223 | "foreignKeys": {}, | ||
| 1224 | "compositePrimaryKeys": {}, | ||
| 1225 | "uniqueConstraints": {}, | ||
| 1226 | "policies": {}, | ||
| 1227 | "checkConstraints": {}, | ||
| 1228 | "isRLSEnabled": false | ||
| 1229 | } | ||
| 1230 | }, | ||
| 1231 | "enums": {}, | ||
| 1232 | "schemas": {}, | ||
| 1233 | "sequences": {}, | ||
| 1234 | "roles": {}, | ||
| 1235 | "policies": {}, | ||
| 1236 | "views": {}, | ||
| 1237 | "_meta": { | ||
| 1238 | "columns": {}, | ||
| 1239 | "schemas": {}, | ||
| 1240 | "tables": {} | ||
| 1241 | } | ||
| 1242 | } | ||
| @@ -29,6 +29,13 @@ · 28 unmodified lines | |||
| 29 | "when": 1781283644753, | 29 | "when": 1781283644753, |
| 30 | "tag": "0003_friendly_monster_badoon", | 30 | "tag": "0003_friendly_monster_badoon", |
| 31 | "breakpoints": true | 31 | "breakpoints": true |
| 32 | }, | ||
| 33 | { | ||
| 34 | "idx": 4, | ||
| 35 | "version": "7", | ||
| 36 | "when": 1781375837041, | ||
| 37 | "tag": "0004_nervous_quasar", | ||
| 38 | "breakpoints": true | ||
| 32 | } | 39 | } |
| 33 | ] | 40 | ] |
| 34 | } | 41 | } |
| @@ -1,16 +1,19 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band migration script over a raw postgres connection */ | 1 | /* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band migration script over a raw postgres connection */ |
| 2 | // --------------------------------------------------------------------------- | 2 | // --------------------------------------------------------------------------- |
| 3 | // One-off data backfill: mint URL slugs for organizations that predate the | 3 | // One-off data backfill: mint URL slugs for organizations that predate the |
| 4 | // slug column. Run OUT-OF-BAND against the database after applying the | 4 | // slug column (added by migration 0003, nullable). |
| 5 | // schema migration that adds `organizations.slug` (0002): | ||
| 6 | // | 5 | // |
| 7 | // bun run db:backfill-org-slugs:prod # op run --env-file=.env.production | 6 | // bun run db:backfill-org-slugs:prod # op run --env-file=.env.production |
| 8 | // bun run db:backfill-org-slugs:dev # against the local PGlite dev db | 7 | // bun run db:backfill-org-slugs:dev # against the local PGlite dev db |
| 9 | // | 8 | // |
| 10 | // Idempotent — already-slugged rows are skipped, so re-running is safe. The | 9 | // Idempotent — already-slugged rows are skipped, so re-running is safe. |
| 11 | // runtime also lazily mints slugs on first resolution (see | 10 | // |
| 12 | // auth/organization.ts), so this backfill is belt-and-braces for bulk | 11 | // Ordering matters: the runtime now mints a slug at the moment a row is |
| 13 | // pre-warming rather than a deploy gate. | 12 | // inserted and NO LONGER self-heals null slugs, and migration 0004 makes the |
| 13 | // column NOT NULL. So this backfill is the SOLE way pre-existing null-slug rows | ||
| 14 | // get a slug, and it MUST run BEFORE 0004 is applied (a null row would fail the | ||
| 15 | // NOT NULL alter). The correct sequence is: backfill (this script) → deploy the | ||
| 16 | // mint-at-insert code → apply 0004. | ||
| 14 | // Pass --dry-run to print the plan without writing. | 17 | // Pass --dry-run to print the plan without writing. |
| 15 | // --------------------------------------------------------------------------- | 18 | // --------------------------------------------------------------------------- |
| 16 | 19 | ||
| @@ -47,8 +50,8 @@ for (const row of rows) { · 30 unmodified lines | |||
| 47 | planned.add(slug); | 50 | planned.add(slug); |
| 48 | console.log(`${row.id} ${JSON.stringify(row.name)} -> ${slug}`); | 51 | console.log(`${row.id} ${JSON.stringify(row.name)} -> ${slug}`); |
| 49 | if (dryRun) continue; | 52 | if (dryRun) continue; |
| 50 | // Guard WHERE slug IS NULL: a concurrently-deployed runtime may have lazily | 53 | // Guard WHERE slug IS NULL keeps re-runs idempotent (and tolerates a row that |
| 51 | // minted this org's slug between our read and this write. | 54 | // gained a slug between our read and this write). |
| 52 | await sql`UPDATE organizations SET slug = ${slug} WHERE id = ${row.id} AND slug IS NULL`; | 55 | await sql`UPDATE organizations SET slug = ${slug} WHERE id = ${row.id} AND slug IS NULL`; |
| 53 | updated += 1; | 56 | updated += 1; |
| 54 | } | 57 | } |
| @@ -50,7 +50,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 49 unmodified lines | |||
| 50 | getAccount: async (id: string) => ({ id, createdAt }), | 50 | getAccount: async (id: string) => ({ id, createdAt }), |
| 51 | upsertOrganization: async (org: { id: string; name: string }) => ({ | 51 | upsertOrganization: async (org: { id: string; name: string }) => ({ |
| 52 | ...org, | 52 | ...org, |
| 53 | slug: null, | 53 | slug: `org-slug-${org.id}`, |
| 54 | createdAt, | 54 | createdAt, |
| 55 | }), | 55 | }), |
| 56 | getOrganization: async (id: string) => ({ | 56 | getOrganization: async (id: string) => ({ |
| @@ -65,11 +65,6 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 8 unmodified lines | |||
| 65 | slug, | 65 | slug, |
| 66 | createdAt, | 66 | createdAt, |
| 67 | }), | 67 | }), |
| 68 | ensureOrganizationSlug: async (org: { id: string; name: string; slug: string | null }) => ({ | ||
| 69 | ...org, | ||
| 70 | slug: org.slug ?? `org-slug-${org.id}`, | ||
| 71 | createdAt, | ||
| 72 | }), | ||
| 73 | }), | 68 | }), |
| 74 | ), | 69 | ), |
| 75 | }); | 70 | }); |
| @@ -342,10 +342,10 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 341 unmodified lines | |||
| 342 | 342 | ||
| 343 | const org = yield* workos.createOrganization(name); | 343 | const org = yield* workos.createOrganization(name); |
| 344 | yield* workos.createMembership(org.id, session.accountId, "admin"); | 344 | yield* workos.createMembership(org.id, session.accountId, "admin"); |
| 345 | // `upsertOrganization` mints the slug at insert — no separate heal step. | ||
| 345 | const mirrored = yield* users.use((s) => | 346 | const mirrored = yield* users.use((s) => |
| 346 | s.upsertOrganization({ id: org.id, name: org.name }), | 347 | s.upsertOrganization({ id: org.id, name: org.name }), |
| 347 | ); | 348 | ); |
| 348 | const slugged = yield* users.use((s) => s.ensureOrganizationSlug(mirrored)); | ||
| 349 | 349 | ||
| 350 | // Try to attach the new org to the current session. This can fail | 350 | // Try to attach the new org to the current session. This can fail |
| 351 | // (or silently return a session still scoped to the old org) when | 351 | // (or silently return a session still scoped to the old org) when |
| @@ -372,7 +372,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 20 unmodified lines | |||
| 372 | } | 372 | } |
| 373 | 373 | ||
| 374 | (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS); | 374 | (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS); |
| 375 | return { id: org.id, name: org.name, slug: slugged.slug }; | 375 | return { id: org.id, name: org.name, slug: mirrored.slug }; |
| 376 | }), | 376 | }), |
| 377 | ) | 377 | ) |
| 378 | .handle("pendingInvitations", () => | 378 | .handle("pendingInvitations", () => |
| @@ -441,12 +441,12 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 62 unmodified lines | |||
| 441 | return yield* new WorkOSError(); | 441 | return yield* new WorkOSError(); |
| 442 | } | 442 | } |
| 443 | 443 | ||
| 444 | // Mirror the org locally so domain tables can FK against it. | 444 | // Mirror the org locally so domain tables can FK against it; the |
| 445 | // upsert mints the slug at insert — no separate heal step. | ||
| 445 | const org = yield* workos.getOrganization(invitation.organizationId); | 446 | const org = yield* workos.getOrganization(invitation.organizationId); |
| 446 | const mirrored = yield* users.use((s) => | 447 | const mirrored = yield* users.use((s) => |
| 447 | s.upsertOrganization({ id: org.id, name: org.name }), | 448 | s.upsertOrganization({ id: org.id, name: org.name }), |
| 448 | ); | 449 | ); |
| 449 | const slugged = yield* users.use((s) => s.ensureOrganizationSlug(mirrored)); | ||
| 450 | 450 | ||
| 451 | // Attach the just-accepted org to the current session. Same shape | 451 | // Attach the just-accepted org to the current session. Same shape |
| 452 | // as createOrganization: refresh + verify; if we can't pin the | 452 | // as createOrganization: refresh + verify; if we can't pin the |
| @@ -468,7 +468,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 15 unmodified lines | |||
| 468 | } | 468 | } |
| 469 | 469 | ||
| 470 | (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS); | 470 | (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS); |
| 471 | return { id: org.id, name: org.name, slug: slugged.slug }; | 471 | return { id: org.id, name: org.name, slug: mirrored.slug }; |
| 472 | }), | 472 | }), |
| 473 | ) | 473 | ) |
| 474 | .handle("getMcpPaused", ({ params }) => | 474 | .handle("getMcpPaused", ({ params }) => |
| @@ -60,9 +60,10 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 59 unmodified lines | |||
| 60 | fn({ | 60 | fn({ |
| 61 | ensureAccount: async (id: string) => ({ id, createdAt }), | 61 | ensureAccount: async (id: string) => ({ id, createdAt }), |
| 62 | getAccount: async (id: string) => ({ id, createdAt }), | 62 | getAccount: async (id: string) => ({ id, createdAt }), |
| 63 | // Slug is minted at insert now — the stub returns a slugged row. | ||
| 63 | upsertOrganization: async (org: { id: string; name: string }) => ({ | 64 | upsertOrganization: async (org: { id: string; name: string }) => ({ |
| 64 | ...org, | 65 | ...org, |
| 65 | slug: null, | 66 | slug: org.id, |
| 66 | createdAt, | 67 | createdAt, |
| 67 | }), | 68 | }), |
| 68 | getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: id, createdAt }), | 69 | getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: id, createdAt }), |
| @@ -74,11 +75,6 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 5 unmodified lines | |||
| 74 | slug, | 75 | slug, |
| 75 | createdAt, | 76 | createdAt, |
| 76 | }), | 77 | }), |
| 77 | ensureOrganizationSlug: async (org: { id: string; name: string; slug: string | null }) => ({ | ||
| 78 | ...org, | ||
| 79 | slug: org.slug ?? org.id, | ||
| 80 | createdAt, | ||
| 81 | }), | ||
| 82 | }), | 78 | }), |
| 83 | ), | 79 | ), |
| 84 | }); | 80 | }); |
| @@ -29,22 +29,20 @@ import { WorkOSClient } from "./workos"; · 28 unmodified lines | |||
| 29 | // unknown org is read. All other callers just do `getOrganization` and get | 29 | // unknown org is read. All other callers just do `getOrganization` and get |
| 30 | // a self-healing lookup for free. | 30 | // a self-healing lookup for free. |
| 31 | // | 31 | // |
| 32 | // URL slugs are OURS (WorkOS orgs have none): resolution also lazily mints a | 32 | // URL slugs are OURS (WorkOS orgs have none) and are minted at the moment a |
| 33 | // slug for any org that predates them, so every resolved org is routable at | 33 | // row is inserted — `upsertOrganization` is the single mint point, so the |
| 34 | // `/<slug>/…` without a separate backfill dependency. | 34 | // mirror-on-first-read below produces a slugged, routable org without any |
| 35 | // read-path healing. | ||
| 35 | 36 | ||
| 36 | export const resolveOrganization = (organizationId: string) => | 37 | export const resolveOrganization = (organizationId: string) => |
| 37 | Effect.gen(function* () { | 38 | Effect.gen(function* () { |
| 38 | const users = yield* UserStoreService; | 39 | const users = yield* UserStoreService; |
| 39 | const existing = yield* users.use((s) => s.getOrganization(organizationId)); | 40 | const existing = yield* users.use((s) => s.getOrganization(organizationId)); |
| 40 | if (existing) return yield* users.use((s) => s.ensureOrganizationSlug(existing)); | 41 | if (existing) return existing; |
| 41 | 42 | ||
| 42 | const workos = yield* WorkOSClient; | 43 | const workos = yield* WorkOSClient; |
| 43 | const fresh = yield* workos.getOrganization(organizationId); | 44 | const fresh = yield* workos.getOrganization(organizationId); |
| 44 | const mirrored = yield* users.use((s) => | 45 | return yield* users.use((s) => s.upsertOrganization({ id: fresh.id, name: fresh.name })); |
| 45 | s.upsertOrganization({ id: fresh.id, name: fresh.name }), | ||
| 46 | ); | ||
| 47 | return yield* users.use((s) => s.ensureOrganizationSlug(mirrored)); | ||
| 48 | }); | 46 | }); |
| 49 | 47 | ||
| 50 | // --------------------------------------------------------------------------- | 48 | // --------------------------------------------------------------------------- |
| @@ -139,22 +139,18 @@ const resolveAuthHint = async ( · 138 unmodified lines | |||
| 139 | }; | 139 | }; |
| 140 | 140 | ||
| 141 | // The sealed session carries the org ID but not its name/slug; the local | 141 | // The sealed session carries the org ID but not its name/slug; the local |
| 142 | // mirror has both (the slug minted lazily for orgs that predate slugs). Only | 142 | // mirror has both (every org row is born with a slug — see |
| 143 | // consulted when minting (absent/mismatched hint) — never on the steady-state | 143 | // `upsertOrganization`). Only consulted when minting the hint (absent/mismatched |
| 144 | // path — and over per-request layers, because a connection cached in the | 144 | // cookie) — never on the steady-state path — and over per-request layers, |
| 145 | // shared runtime would be reused across requests, which Cloudflare forbids. A | 145 | // because a connection cached in the shared runtime would be reused across |
| 146 | // miss or failure reads as empty strings — display-only, corrected by the | 146 | // requests, which Cloudflare forbids. A miss or failure reads as empty strings |
| 147 | // client's /account/me write. | 147 | // — display-only, corrected by the client's /account/me write. |
| 148 | const organizationDisplay = async ( | 148 | const organizationDisplay = async ( |
| 149 | organizationId: string, | 149 | organizationId: string, |
| 150 | ): Promise<{ name: string; slug: string }> => { | 150 | ): Promise<{ name: string; slug: string }> => { |
| 151 | const exit = await getRuntime().runPromiseExit( | 151 | const exit = await getRuntime().runPromiseExit( |
| 152 | Effect.flatMap(UserStoreService.asEffect(), (users) => | 152 | Effect.flatMap(UserStoreService.asEffect(), (users) => |
| 153 | users.use(async (store) => { | 153 | users.use((store) => store.getOrganization(organizationId)), |
| 154 | const org = await store.getOrganization(organizationId); | ||
| 155 | if (!org) return null; | ||
| 156 | return store.ensureOrganizationSlug(org); | ||
| 157 | }), | ||
| 158 | ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))), | 154 | ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))), |
| 159 | ); | 155 | ); |
| 160 | return Exit.isSuccess(exit) | 156 | return Exit.isSuccess(exit) |
| @@ -17,9 +17,6 @@ import type { DrizzleDb } from "../db/db"; · 16 unmodified lines | |||
| 17 | export type Account = typeof accounts.$inferSelect; | 17 | export type Account = typeof accounts.$inferSelect; |
| 18 | export type Organization = typeof organizations.$inferSelect; | 18 | export type Organization = typeof organizations.$inferSelect; |
| 19 | 19 | ||
| 20 | /** An organization row whose slug has been minted (see `ensureOrganizationSlug`). */ | ||
| 21 | export type SluggedOrganization = Organization & { readonly slug: string }; | ||
| 22 | |||
| 23 | export const makeUserStore = (db: DrizzleDb) => { | 20 | export const makeUserStore = (db: DrizzleDb) => { |
| 24 | const getOrganization = async (id: string) => { | 21 | const getOrganization = async (id: string) => { |
| 25 | const rows = await db.select().from(organizations).where(eq(organizations.id, id)); | 22 | const rows = await db.select().from(organizations).where(eq(organizations.id, id)); |
| @@ -34,38 +31,45 @@ export const makeUserStore = (db: DrizzleDb) => { · 8 unmodified lines | |||
| 34 | return rows.length > 0; | 31 | return rows.length > 0; |
| 35 | }; | 32 | }; |
| 36 | 33 | ||
| 37 | // The unique index can reject a candidate when two requests race to mint | 34 | // Insert a brand-new org row carrying a freshly-minted slug. `ON CONFLICT DO |
| 38 | // slugs (for the same org or colliding candidates); surface that as null | 35 | // NOTHING` (no target) absorbs BOTH unique violations without throwing: an |
| 39 | // and let the caller re-read or retry. | 36 | // id collision (the org was mirrored concurrently) and a slug collision (the |
| 40 | const trySetSlug = async (organizationId: string, slug: string) => { | 37 | // candidate was claimed by a different org). Returns the inserted row, or |
| 41 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a unique-index violation from a concurrent mint is an expected race, not a failure | 38 | // null when either conflict swallowed the insert — the caller decides whether |
| 42 | try { | 39 | // to re-read (id race) or retry with a new candidate (slug race). |
| 40 | const tryInsertOrg = async (id: string, name: string, slug: string) => { | ||
| 41 | const [row] = await db | ||
| 42 | .insert(organizations) | ||
| 43 | .values({ id, name, slug }) | ||
| 44 | .onConflictDoNothing() | ||
| 45 | .returning(); | ||
| 46 | return row ?? null; | ||
| 47 | }; | ||
| 48 | |||
| 49 | // Every new org row is born with a slug — there is no nullable window and no | ||
| 50 | // self-healing. Existing rows keep their slug (stable across renames, so org | ||
| 51 | // URLs survive) and only refresh their name. | ||
| 52 | const upsertOrganization = async (org: { id: string; name: string }) => { | ||
| 53 | const existing = await getOrganization(org.id); | ||
| 54 | if (existing) { | ||
| 43 | const [updated] = await db | 55 | const [updated] = await db |
| 44 | .update(organizations) | 56 | .update(organizations) |
| 45 | .set({ slug }) | 57 | .set({ name: org.name }) |
| 46 | .where(eq(organizations.id, organizationId)) | 58 | .where(eq(organizations.id, org.id)) |
| 47 | .returning(); | 59 | .returning(); |
| 48 | return updated ?? null; | 60 | return updated ?? existing; |
| 49 | } catch { | ||
| 50 | return null; | ||
| 51 | } | 61 | } |
| 52 | }; | 62 | for (let attempt = 0; attempt < 4; attempt++) { |
| 53 | |||
| 54 | // Mint and persist a slug for an org that predates slugs (or was mirrored | ||
| 55 | // before its name was known). Slugs are stable once set — renames do NOT | ||
| 56 | // regenerate them, so org URLs survive. | ||
| 57 | const ensureOrganizationSlug = async (org: Organization): Promise<SluggedOrganization> => { | ||
| 58 | if (org.slug) return org as SluggedOrganization; | ||
| 59 | for (let attempt = 0; attempt < 3; attempt++) { | ||
| 60 | const slug = await generateOrgSlug(org.name, slugTaken); | 63 | const slug = await generateOrgSlug(org.name, slugTaken); |
| 61 | const updated = await trySetSlug(org.id, slug); | 64 | const inserted = await tryInsertOrg(org.id, org.name, slug); |
| 62 | if (updated?.slug) return updated as SluggedOrganization; | 65 | if (inserted) return inserted; |
| 63 | // Lost a race: either another request minted this org's slug (re-read | 66 | // The insert was swallowed by a conflict. If the id now exists, a |
| 64 | // and return), or the candidate got claimed by a different org (retry). | 67 | // concurrent request mirrored it — return that row. Otherwise the slug |
| 68 | // candidate collided; loop and mint a fresh one. | ||
| 65 | const fresh = await getOrganization(org.id); | 69 | const fresh = await getOrganization(org.id); |
| 66 | if (fresh?.slug) return fresh as SluggedOrganization; | 70 | if (fresh) return fresh; |
| 67 | } | 71 | } |
| 68 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: slug minting exhausted retries; surfacing loudly beats silently unslugged orgs | 72 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: slug minting exhausted retries; surfacing loudly beats a silently unslugged org |
| 69 | throw new Error(`unable to mint a slug for organization ${org.id}`); | 73 | throw new Error(`unable to mint a slug for organization ${org.id}`); |
| 70 | }; | 74 | }; |
| 71 | 75 | ||
| @@ -84,17 +88,7 @@ export const makeUserStore = (db: DrizzleDb) => { · 12 unmodified lines | |||
| 84 | 88 | ||
| 85 | // --- Organizations --- | 89 | // --- Organizations --- |
| 86 | 90 | ||
| 87 | upsertOrganization: async (org: { id: string; name: string }) => { | 91 | upsertOrganization, |
| 88 | const [result] = await db | ||
| 89 | .insert(organizations) | ||
| 90 | .values(org) | ||
| 91 | .onConflictDoUpdate({ | ||
| 92 | target: organizations.id, | ||
| 93 | set: { name: org.name }, | ||
| 94 | }) | ||
| 95 | .returning(); | ||
| 96 | return result!; | ||
| 97 | }, | ||
| 98 | 92 | ||
| 99 | getOrganization, | 93 | getOrganization, |
| 100 | 94 | ||
| @@ -102,7 +96,5 @@ export const makeUserStore = (db: DrizzleDb) => { · 1 unmodified line | |||
| 102 | const rows = await db.select().from(organizations).where(eq(organizations.slug, slug)); | 96 | const rows = await db.select().from(organizations).where(eq(organizations.slug, slug)); |
| 103 | return rows[0] ?? null; | 97 | return rows[0] ?? null; |
| 104 | }, | 98 | }, |
| 105 | |||
| 106 | ensureOrganizationSlug, | ||
| 107 | }; | 99 | }; |
| 108 | }; | 100 | }; |
| @@ -22,6 +22,7 @@ · 21 unmodified lines | |||
| 22 | 22 | ||
| 23 | import { describe, it, expect } from "@effect/vitest"; | 23 | import { describe, it, expect } from "@effect/vitest"; |
| 24 | import { Effect, Layer } from "effect"; | 24 | import { Effect, Layer } from "effect"; |
| 25 | import { isValidOrgSlug } from "@executor-js/api"; | ||
| 25 | 26 | ||
| 26 | import { DbService } from "./db"; | 27 | import { DbService } from "./db"; |
| 27 | import { makeUserStore } from "../auth/user-store"; | 28 | import { makeUserStore } from "../auth/user-store"; |
| @@ -118,3 +119,37 @@ describe("DbService", () => { · 90 unmodified lines | |||
| 118 | expect(result?.name).toBe("Acme"); | 119 | expect(result?.name).toBe("Acme"); |
| 119 | }, 15_000); | 120 | }, 15_000); |
| 120 | }); | 121 | }); |
| 122 | |||
| 123 | describe("upsertOrganization · slug is minted at insert", () => { | ||
| 124 | const upsert = (org: { id: string; name: string }) => | ||
| 125 | program( | ||
| 126 | Effect.gen(function* () { | ||
| 127 | const { db } = yield* DbService; | ||
| 128 | return yield* Effect.promise(() => makeUserStore(db).upsertOrganization(org)); | ||
| 129 | }), | ||
| 130 | ); | ||
| 131 | |||
| 132 | it("mints a valid slug on insert", async () => { | ||
| 133 | const org = await upsert({ id: `org_${crypto.randomUUID()}`, name: "Slug Mint Co" }); | ||
| 134 | expect(org.slug, "a new org row is born with a slug").toBeTruthy(); | ||
| 135 | expect(isValidOrgSlug(org.slug), "the minted slug fits the URL grammar").toBe(true); | ||
| 136 | }); | ||
| 137 | |||
| 138 | it("keeps the slug stable across renames (conflict updates name only)", async () => { | ||
| 139 | const id = `org_${crypto.randomUUID()}`; | ||
| 140 | const created = await upsert({ id, name: "Original Name" }); | ||
| 141 | const renamed = await upsert({ id, name: "Renamed Org" }); | ||
| 142 | expect(renamed.slug, "the slug survives a rename").toBe(created.slug); | ||
| 143 | expect(renamed.name, "the name is refreshed on conflict").toBe("Renamed Org"); | ||
| 144 | }); | ||
| 145 | |||
| 146 | it("discriminates same-name collisions into distinct slugs", async () => { | ||
| 147 | // Same name → same slug base; the second insert collides on the unique | ||
| 148 | // index and gets a discriminated slug. | ||
| 149 | const name = `Collide ${crypto.randomUUID().slice(0, 8)}`; | ||
| 150 | const a = await upsert({ id: `org_${crypto.randomUUID()}`, name }); | ||
| 151 | const b = await upsert({ id: `org_${crypto.randomUUID()}`, name }); | ||
| 152 | expect(a.slug).not.toBe(b.slug); | ||
| 153 | expect(isValidOrgSlug(a.slug) && isValidOrgSlug(b.slug), "both slugs are valid").toBe(true); | ||
| 154 | }); | ||
| 155 | }); | ||
| @@ -22,15 +22,17 @@ export const accounts = pgTable("accounts", { · 21 unmodified lines | |||
| 22 | /** | 22 | /** |
| 23 | * Organization (billing entity, scoping root). The `id` is the WorkOS | 23 | * Organization (billing entity, scoping root). The `id` is the WorkOS |
| 24 | * organization ID. The `slug` is OURS, not WorkOS's (their org object has no | 24 | * organization ID. The `slug` is OURS, not WorkOS's (their org object has no |
| 25 | * slug): minted at create time, lazily backfilled for orgs first seen via the | 25 | * slug): minted at the moment a row is inserted (the single mint point is |
| 26 | * mirror's self-heal path, and stable across renames so org URLs don't break. | 26 | * `upsertOrganization`) and stable across renames so org URLs don't break. |
| 27 | * NOT NULL — there is no nullable window: legacy rows were backfilled once and | ||
| 28 | * every insert since carries a slug. | ||
| 27 | */ | 29 | */ |
| 28 | export const organizations = pgTable( | 30 | export const organizations = pgTable( |
| 29 | "organizations", | 31 | "organizations", |
| 30 | { | 32 | { |
| 31 | id: text("id").primaryKey(), | 33 | id: text("id").primaryKey(), |
| 32 | name: text("name").notNull(), | 34 | name: text("name").notNull(), |
| 33 | slug: text("slug"), | 35 | slug: text("slug").notNull(), |
| 34 | createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), | 36 | createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), |
| 35 | }, | 37 | }, |
| 36 | (t) => ({ | 38 | (t) => ({ |
| @@ -0,0 +1,74 @@ | |||
| 1 | // Cloud-only (browser): the whole auth-routing arc in ONE session, end to end | ||
| 2 | // through the real dev server (the e2e cloud target boots `vite dev` with the | ||
| 3 | // SSR gate active, so this is the same routing that runs in prod): | ||
| 4 | // | ||
| 5 | // signed out → /login | ||
| 6 | // click Sign in → WorkOS (emulator) callback → org-less → /create-org | ||
| 7 | // create the first org → canonical dashboard at /<slug> | ||
| 8 | // | ||
| 9 | // The individual hops are also covered piecewise (cloud/unauthenticated-skeleton, | ||
| 10 | // cloud/session-gate, scenarios/org-slug-routing) — those pin edge cases this | ||
| 11 | // narrative doesn't (invalid-cookie clearing, token refresh, returnTo | ||
| 12 | // open-redirect, unknown-slug 404). This one is the regression guard for the | ||
| 13 | // happy path as a single continuous flow, and is the only test that pins the | ||
| 14 | // bare-`/` → `/<slug>/` dashboard canonicalization a fresh login lands on. | ||
| 15 | // | ||
| 16 | // The headless helpers sign in via a `login_hint` query param, but the | ||
| 17 | // emulator's hosted AuthKit also serves a real form ("continue as a new user"), | ||
| 18 | // so the browser flow just fills it — genuinely end-to-end through the UI. | ||
| 19 | import { randomUUID } from "node:crypto"; | ||
| 20 | |||
| 21 | import { expect } from "@effect/vitest"; | ||
| 22 | import { Effect } from "effect"; | ||
| 23 | |||
| 24 | import { scenario } from "../src/scenario"; | ||
| 25 | import { Browser, Target } from "../src/services"; | ||
| 26 | |||
| 27 | scenario( | ||
| 28 | "Auth routing · signed out → login → onboarding → dashboard, one browser session", | ||
| 29 | {}, | ||
| 30 | Effect.gen(function* () { | ||
| 31 | const browser = yield* Browser; | ||
| 32 | yield* Target; | ||
| 33 | |||
| 34 | // A brand-new user the emulator provisions on first login_hint — no org, so | ||
| 35 | // the post-callback document is gated to onboarding. | ||
| 36 | const email = `flow-${randomUUID().slice(0, 8)}@e2e.test`; | ||
| 37 | |||
| 38 | // No cookies → a clean, signed-out browser. | ||
| 39 | const anonymous = { label: "anonymous" }; | ||
| 40 | |||
| 41 | yield* browser.session(anonymous, async ({ page, step }) => { | ||
| 42 | await step("Signed out, the root redirects to /login", async () => { | ||
| 43 | await page.goto("/", { waitUntil: "commit" }); | ||
| 44 | await page.getByText("Sign in to manage your tools and sources").waitFor(); | ||
| 45 | }); | ||
| 46 | expect(new URL(page.url()).pathname, "the signed-out root lands on /login").toBe("/login"); | ||
| 47 | |||
| 48 | await step("Sign in through the UI → org-less session lands on onboarding", async () => { | ||
| 49 | await page.getByRole("link", { name: "Sign in" }).click(); | ||
| 50 | // The emulator's hosted AuthKit form: provision a brand-new user. | ||
| 51 | await page.getByPlaceholder("new-user@example.com").fill(email); | ||
| 52 | await page.getByRole("button", { name: /Continue/ }).click(); | ||
| 53 | // callback mints the session → SSR gate sees no org → /create-org. | ||
| 54 | await page.waitForURL((url) => url.pathname === "/create-org", { timeout: 30_000 }); | ||
| 55 | await page.getByPlaceholder("Northwind Labs").waitFor({ timeout: 30_000 }); | ||
| 56 | }); | ||
| 57 | |||
| 58 | await step("Create the first org → canonical dashboard at /<slug>", async () => { | ||
| 59 | await page.getByPlaceholder("Northwind Labs").fill("Flow Test Org"); | ||
| 60 | await page.getByRole("button", { name: "Create organization" }).click(); | ||
| 61 | await page.getByText("Connect your MCP client").waitFor({ timeout: 30_000 }); | ||
| 62 | await page.getByRole("button", { name: "Continue to app" }).click(); | ||
| 63 | // The bare landing canonicalizes onto the new org's slug. | ||
| 64 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000 }); | ||
| 65 | await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); | ||
| 66 | }); | ||
| 67 | |||
| 68 | expect( | ||
| 69 | /^\/[a-z0-9-]+\/?$/.test(new URL(page.url()).pathname), | ||
| 70 | "the signed-in user ends on their org-slug dashboard", | ||
| 71 | ).toBe(true); | ||
| 72 | }); | ||
| 73 | }), | ||
| 74 | ); | ||