From 74590de3151cfb8599a04ae4a6e7c3bfabb0e185 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sun, 17 May 2026 01:15:31 -0600 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20db:=20add=20client=5Fuuid=20col?= =?UTF-8?q?umn=20to=207=20offline-first=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the client/server ID split per docs/design/client-uuid-split.md §3 Option C. Each affected table gains a `client_uuid text UNIQUE NOT NULL` column, backfilled from the existing `id`, with a format CHECK constraint enforcing the URL-safe nanoid charset (≤64 chars). Tables touched: packs, pack_items, weight_history, pack_templates, pack_template_items, trips, trail_condition_reports. Also restores packages/api/src/db/schema.ts as a 1-line re-export shim (load-bearing for drizzle-kit per the #2414 plan §"Migration Infra"). The shim was deleted in 0154b8711 along with all other re-export shims, which silently broke drizzle-kit generate since 2026-05-14. Refs: docs/plans/2026-05-17-001-feat-client-uuid-split-phase1-plan.md U1 Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/drizzle/0047_add_client_uuid.sql | 53 + packages/api/drizzle/meta/0047_snapshot.json | 2341 +++++++++++++++++ packages/api/drizzle/meta/_journal.json | 7 + packages/api/src/db/schema.ts | 5 + packages/db/src/schema.ts | 7 + 5 files changed, 2413 insertions(+) create mode 100644 packages/api/drizzle/0047_add_client_uuid.sql create mode 100644 packages/api/drizzle/meta/0047_snapshot.json create mode 100644 packages/api/src/db/schema.ts diff --git a/packages/api/drizzle/0047_add_client_uuid.sql b/packages/api/drizzle/0047_add_client_uuid.sql new file mode 100644 index 0000000000..35dd7b90af --- /dev/null +++ b/packages/api/drizzle/0047_add_client_uuid.sql @@ -0,0 +1,53 @@ +-- Phase 1 of the client/server ID split (docs/design/client-uuid-split.md §3 Option C). +-- Add `client_uuid` as an idempotency token alongside the existing `id` PK. +-- Backfilled from existing `id` so old rows continue to round-trip. +-- Format CHECK enforces URL-safe nanoid charset, ≤64 chars. + +-- pack_items +ALTER TABLE "pack_items" ADD COLUMN "client_uuid" text;--> statement-breakpoint +UPDATE "pack_items" SET "client_uuid" = "id" WHERE "client_uuid" IS NULL;--> statement-breakpoint +ALTER TABLE "pack_items" ALTER COLUMN "client_uuid" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "pack_items" ADD CONSTRAINT "pack_items_client_uuid_unique" UNIQUE("client_uuid");--> statement-breakpoint +ALTER TABLE "pack_items" ADD CONSTRAINT "pack_items_client_uuid_format" CHECK ("client_uuid" ~ '^[A-Za-z0-9_-]{1,64}$');--> statement-breakpoint + +-- pack_template_items +ALTER TABLE "pack_template_items" ADD COLUMN "client_uuid" text;--> statement-breakpoint +UPDATE "pack_template_items" SET "client_uuid" = "id" WHERE "client_uuid" IS NULL;--> statement-breakpoint +ALTER TABLE "pack_template_items" ALTER COLUMN "client_uuid" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "pack_template_items" ADD CONSTRAINT "pack_template_items_client_uuid_unique" UNIQUE("client_uuid");--> statement-breakpoint +ALTER TABLE "pack_template_items" ADD CONSTRAINT "pack_template_items_client_uuid_format" CHECK ("client_uuid" ~ '^[A-Za-z0-9_-]{1,64}$');--> statement-breakpoint + +-- pack_templates +ALTER TABLE "pack_templates" ADD COLUMN "client_uuid" text;--> statement-breakpoint +UPDATE "pack_templates" SET "client_uuid" = "id" WHERE "client_uuid" IS NULL;--> statement-breakpoint +ALTER TABLE "pack_templates" ALTER COLUMN "client_uuid" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "pack_templates" ADD CONSTRAINT "pack_templates_client_uuid_unique" UNIQUE("client_uuid");--> statement-breakpoint +ALTER TABLE "pack_templates" ADD CONSTRAINT "pack_templates_client_uuid_format" CHECK ("client_uuid" ~ '^[A-Za-z0-9_-]{1,64}$');--> statement-breakpoint + +-- weight_history +ALTER TABLE "weight_history" ADD COLUMN "client_uuid" text;--> statement-breakpoint +UPDATE "weight_history" SET "client_uuid" = "id" WHERE "client_uuid" IS NULL;--> statement-breakpoint +ALTER TABLE "weight_history" ALTER COLUMN "client_uuid" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "weight_history" ADD CONSTRAINT "weight_history_client_uuid_unique" UNIQUE("client_uuid");--> statement-breakpoint +ALTER TABLE "weight_history" ADD CONSTRAINT "weight_history_client_uuid_format" CHECK ("client_uuid" ~ '^[A-Za-z0-9_-]{1,64}$');--> statement-breakpoint + +-- packs +ALTER TABLE "packs" ADD COLUMN "client_uuid" text;--> statement-breakpoint +UPDATE "packs" SET "client_uuid" = "id" WHERE "client_uuid" IS NULL;--> statement-breakpoint +ALTER TABLE "packs" ALTER COLUMN "client_uuid" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "packs" ADD CONSTRAINT "packs_client_uuid_unique" UNIQUE("client_uuid");--> statement-breakpoint +ALTER TABLE "packs" ADD CONSTRAINT "packs_client_uuid_format" CHECK ("client_uuid" ~ '^[A-Za-z0-9_-]{1,64}$');--> statement-breakpoint + +-- trail_condition_reports +ALTER TABLE "trail_condition_reports" ADD COLUMN "client_uuid" text;--> statement-breakpoint +UPDATE "trail_condition_reports" SET "client_uuid" = "id" WHERE "client_uuid" IS NULL;--> statement-breakpoint +ALTER TABLE "trail_condition_reports" ALTER COLUMN "client_uuid" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "trail_condition_reports" ADD CONSTRAINT "trail_condition_reports_client_uuid_unique" UNIQUE("client_uuid");--> statement-breakpoint +ALTER TABLE "trail_condition_reports" ADD CONSTRAINT "trail_condition_reports_client_uuid_format" CHECK ("client_uuid" ~ '^[A-Za-z0-9_-]{1,64}$');--> statement-breakpoint + +-- trips +ALTER TABLE "trips" ADD COLUMN "client_uuid" text;--> statement-breakpoint +UPDATE "trips" SET "client_uuid" = "id" WHERE "client_uuid" IS NULL;--> statement-breakpoint +ALTER TABLE "trips" ALTER COLUMN "client_uuid" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "trips" ADD CONSTRAINT "trips_client_uuid_unique" UNIQUE("client_uuid");--> statement-breakpoint +ALTER TABLE "trips" ADD CONSTRAINT "trips_client_uuid_format" CHECK ("client_uuid" ~ '^[A-Za-z0-9_-]{1,64}$'); diff --git a/packages/api/drizzle/meta/0047_snapshot.json b/packages/api/drizzle/meta/0047_snapshot.json new file mode 100644 index 0000000000..4ecd48dd63 --- /dev/null +++ b/packages/api/drizzle/meta/0047_snapshot.json @@ -0,0 +1,2341 @@ +{ + "id": "f35f795a-b106-4cee-bc65-1167c83a1cc7", + "prevId": "1f086d6d-055d-4b37-a5d6-32b1141d2043", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_users_id_fk": { + "name": "account_user_id_users_id_fk", + "tableFrom": "account", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "account_provider_account_idx": { + "name": "account_provider_account_idx", + "nullsNotDistinct": false, + "columns": ["provider_id", "account_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_item_etl_jobs": { + "name": "catalog_item_etl_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "etl_job_id": { + "name": "etl_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "catalog_item_etl_jobs_catalog_item_id_catalog_items_id_fk": { + "name": "catalog_item_etl_jobs_catalog_item_id_catalog_items_id_fk", + "tableFrom": "catalog_item_etl_jobs", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_item_etl_jobs_etl_job_id_etl_jobs_id_fk": { + "name": "catalog_item_etl_jobs_etl_job_id_etl_jobs_id_fk", + "tableFrom": "catalog_item_etl_jobs", + "tableTo": "etl_jobs", + "columnsFrom": ["etl_job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_items": { + "name": "catalog_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rating_value": { + "name": "rating_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "availability": { + "name": "availability", + "type": "availability", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "seller": { + "name": "seller", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_sku": { + "name": "product_sku", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "material": { + "name": "material", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "techs": { + "name": "techs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "links": { + "name": "links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reviews": { + "name": "reviews", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "qas": { + "name": "qas", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "faqs": { + "name": "faqs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "embedding_idx": { + "name": "embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "catalog_items_sku_unique": { + "name": "catalog_items_sku_unique", + "nullsNotDistinct": false, + "columns": ["sku"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment_likes": { + "name": "comment_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "comment_likes_comment_id_post_comments_id_fk": { + "name": "comment_likes_comment_id_post_comments_id_fk", + "tableFrom": "comment_likes", + "tableTo": "post_comments", + "columnsFrom": ["comment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_likes_user_id_users_id_fk": { + "name": "comment_likes_user_id_users_id_fk", + "tableFrom": "comment_likes", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "comment_likes_comment_id_user_id_unique": { + "name": "comment_likes_comment_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["comment_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.etl_jobs": { + "name": "etl_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "etl_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_processed": { + "name": "total_processed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_valid": { + "name": "total_valid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_invalid": { + "name": "total_invalid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scraper_revision": { + "name": "scraper_revision", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "etl_jobs_scraper_revision_idx": { + "name": "etl_jobs_scraper_revision_idx", + "columns": [ + { + "expression": "scraper_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invalid_item_logs": { + "name": "invalid_item_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "raw_data": { + "name": "raw_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "row_index": { + "name": "row_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invalid_item_logs_job_id_etl_jobs_id_fk": { + "name": "invalid_item_logs_job_id_etl_jobs_id_fk", + "tableFrom": "invalid_item_logs", + "tableTo": "etl_jobs", + "columnsFrom": ["job_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_items": { + "name": "pack_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_uuid": { + "name": "client_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumable": { + "name": "consumable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "worn": { + "name": "worn", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_ai_generated": { + "name": "is_ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "template_item_id": { + "name": "template_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pack_items_embedding_idx": { + "name": "pack_items_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "pack_items_pack_id_packs_id_fk": { + "name": "pack_items_pack_id_packs_id_fk", + "tableFrom": "pack_items", + "tableTo": "packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pack_items_catalog_item_id_catalog_items_id_fk": { + "name": "pack_items_catalog_item_id_catalog_items_id_fk", + "tableFrom": "pack_items", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_items_user_id_users_id_fk": { + "name": "pack_items_user_id_users_id_fk", + "tableFrom": "pack_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_items_template_item_id_pack_template_items_id_fk": { + "name": "pack_items_template_item_id_pack_template_items_id_fk", + "tableFrom": "pack_items", + "tableTo": "pack_template_items", + "columnsFrom": ["template_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pack_items_client_uuid_unique": { + "name": "pack_items_client_uuid_unique", + "nullsNotDistinct": false, + "columns": ["client_uuid"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_template_items": { + "name": "pack_template_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_uuid": { + "name": "client_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumable": { + "name": "consumable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "worn": { + "name": "worn", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pack_template_id": { + "name": "pack_template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pack_template_items_pack_template_id_pack_templates_id_fk": { + "name": "pack_template_items_pack_template_id_pack_templates_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "pack_templates", + "columnsFrom": ["pack_template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pack_template_items_catalog_item_id_catalog_items_id_fk": { + "name": "pack_template_items_catalog_item_id_catalog_items_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_template_items_user_id_users_id_fk": { + "name": "pack_template_items_user_id_users_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pack_template_items_client_uuid_unique": { + "name": "pack_template_items_client_uuid_unique", + "nullsNotDistinct": false, + "columns": ["client_uuid"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_templates": { + "name": "pack_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_uuid": { + "name": "client_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_app_template": { + "name": "is_app_template", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "content_source": { + "name": "content_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pack_templates_user_id_users_id_fk": { + "name": "pack_templates_user_id_users_id_fk", + "tableFrom": "pack_templates", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pack_templates_client_uuid_unique": { + "name": "pack_templates_client_uuid_unique", + "nullsNotDistinct": false, + "columns": ["client_uuid"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.weight_history": { + "name": "weight_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_uuid": { + "name": "client_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "weight_history_user_id_users_id_fk": { + "name": "weight_history_user_id_users_id_fk", + "tableFrom": "weight_history", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "weight_history_pack_id_packs_id_fk": { + "name": "weight_history_pack_id_packs_id_fk", + "tableFrom": "weight_history", + "tableTo": "packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "weight_history_client_uuid_unique": { + "name": "weight_history_client_uuid_unique", + "nullsNotDistinct": false, + "columns": ["client_uuid"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.packs": { + "name": "packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_uuid": { + "name": "client_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_ai_generated": { + "name": "is_ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "packs_user_id_users_id_fk": { + "name": "packs_user_id_users_id_fk", + "tableFrom": "packs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "packs_template_id_pack_templates_id_fk": { + "name": "packs_template_id_pack_templates_id_fk", + "tableFrom": "packs", + "tableTo": "pack_templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "packs_client_uuid_unique": { + "name": "packs_client_uuid_unique", + "nullsNotDistinct": false, + "columns": ["client_uuid"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_comments": { + "name": "post_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "post_comments_post_id_posts_id_fk": { + "name": "post_comments_post_id_posts_id_fk", + "tableFrom": "post_comments", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_comments_user_id_users_id_fk": { + "name": "post_comments_user_id_users_id_fk", + "tableFrom": "post_comments", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_comments_parent_comment_id_post_comments_id_fk": { + "name": "post_comments_parent_comment_id_post_comments_id_fk", + "tableFrom": "post_comments", + "tableTo": "post_comments", + "columnsFrom": ["parent_comment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_likes": { + "name": "post_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "post_likes_post_id_posts_id_fk": { + "name": "post_likes_post_id_posts_id_fk", + "tableFrom": "post_likes", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_likes_user_id_users_id_fk": { + "name": "post_likes_user_id_users_id_fk", + "tableFrom": "post_likes", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_likes_post_id_user_id_unique": { + "name": "post_likes_post_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["post_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "posts_user_id_users_id_fk": { + "name": "posts_user_id_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reported_content": { + "name": "reported_content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ai_response": { + "name": "ai_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_comment": { + "name": "user_comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed": { + "name": "reviewed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reported_content_user_id_users_id_fk": { + "name": "reported_content_user_id_users_id_fk", + "tableFrom": "reported_content", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reported_content_reviewed_by_users_id_fk": { + "name": "reported_content_reviewed_by_users_id_fk", + "tableFrom": "reported_content", + "tableTo": "users", + "columnsFrom": ["reviewed_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_users_id_fk": { + "name": "session_user_id_users_id_fk", + "tableFrom": "session", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trail_condition_reports": { + "name": "trail_condition_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_uuid": { + "name": "client_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trail_name": { + "name": "trail_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trail_region": { + "name": "trail_region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "overall_condition": { + "name": "overall_condition", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hazards": { + "name": "hazards", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "water_crossings": { + "name": "water_crossings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "water_crossing_difficulty": { + "name": "water_crossing_difficulty", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trip_id": { + "name": "trip_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trail_condition_reports_user_id_idx": { + "name": "trail_condition_reports_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trail_condition_reports_active_created_idx": { + "name": "trail_condition_reports_active_created_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trail_condition_reports_trail_name_idx": { + "name": "trail_condition_reports_trail_name_idx", + "columns": [ + { + "expression": "trail_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trail_condition_reports_trip_id_idx": { + "name": "trail_condition_reports_trip_id_idx", + "columns": [ + { + "expression": "trip_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trail_condition_reports\".\"trip_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trail_condition_reports_user_id_users_id_fk": { + "name": "trail_condition_reports_user_id_users_id_fk", + "tableFrom": "trail_condition_reports", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trail_condition_reports_trip_id_trips_id_fk": { + "name": "trail_condition_reports_trip_id_trips_id_fk", + "tableFrom": "trail_condition_reports", + "tableTo": "trips", + "columnsFrom": ["trip_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "trail_condition_reports_client_uuid_unique": { + "name": "trail_condition_reports_client_uuid_unique", + "nullsNotDistinct": false, + "columns": ["client_uuid"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trips": { + "name": "trips", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_uuid": { + "name": "client_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trail_osm_id": { + "name": "trail_osm_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "trips_user_id_users_id_fk": { + "name": "trips_user_id_users_id_fk", + "tableFrom": "trips", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "trips_pack_id_packs_id_fk": { + "name": "trips_pack_id_packs_id_fk", + "tableFrom": "trips", + "tableTo": "packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "trips_client_uuid_unique": { + "name": "trips_client_uuid_unique", + "nullsNotDistinct": false, + "columns": ["client_uuid"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json index ca463a5058..740f5f9b07 100644 --- a/packages/api/drizzle/meta/_journal.json +++ b/packages/api/drizzle/meta/_journal.json @@ -337,6 +337,13 @@ "when": 1778594728740, "tag": "0047_cute_bloodscream", "breakpoints": true + }, + { + "idx": 47, + "version": "7", + "when": 1779001951684, + "tag": "0047_add_client_uuid", + "breakpoints": true } ] } diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts new file mode 100644 index 0000000000..f937c4cd7f --- /dev/null +++ b/packages/api/src/db/schema.ts @@ -0,0 +1,5 @@ +// Re-export shim — load-bearing for drizzle-kit. +// drizzle.config.ts points at this path; drizzle-kit follows the re-export +// to discover tables defined in @packrat/db. The canonical schema lives in +// packages/db/src/schema.ts. +export * from '@packrat/db/schema'; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 6f8b9d807a..8725974054 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -109,6 +109,7 @@ export const jwks = pgTable('jwks', { // Packs table export const packs = pgTable('packs', { id: text('id').primaryKey(), + clientUuid: text('client_uuid').unique().notNull(), name: text('name').notNull(), description: text('description'), category: text('category').notNull().$type(), @@ -226,6 +227,7 @@ export const packItems = pgTable( 'pack_items', { id: text('id').primaryKey(), + clientUuid: text('client_uuid').unique().notNull(), name: text('name').notNull(), description: text('description'), weight: real('weight').notNull(), @@ -260,6 +262,7 @@ export const packItems = pgTable( export const packWeightHistory = pgTable('weight_history', { id: text('id').primaryKey(), + clientUuid: text('client_uuid').unique().notNull(), userId: text('user_id') .references(() => users.id, { onDelete: 'cascade' }) .notNull(), @@ -273,6 +276,7 @@ export const packWeightHistory = pgTable('weight_history', { export const packTemplates = pgTable('pack_templates', { id: text('id').primaryKey(), + clientUuid: text('client_uuid').unique().notNull(), name: text('name').notNull(), description: text('description'), category: text('category').notNull(), @@ -295,6 +299,7 @@ export const packTemplates = pgTable('pack_templates', { export const packTemplateItems = pgTable('pack_template_items', { id: text('id').primaryKey(), + clientUuid: text('client_uuid').unique().notNull(), name: text('name').notNull(), description: text('description'), weight: real('weight').notNull(), @@ -323,6 +328,7 @@ export const trailConditionReports = pgTable( 'trail_condition_reports', { id: text('id').primaryKey(), + clientUuid: text('client_uuid').unique().notNull(), trailName: text('trail_name').notNull(), trailRegion: text('trail_region'), surface: text('surface').notNull(), @@ -357,6 +363,7 @@ export const trailConditionReports = pgTable( export const trips = pgTable('trips', { id: text('id').primaryKey(), + clientUuid: text('client_uuid').unique().notNull(), name: text('name').notNull(), description: text('description'), startDate: timestamp('start_date'), From 56645effaeded3865ac2da272fefeffb6562a3c8 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sun, 17 May 2026 01:16:02 -0600 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=A8=20api:=20re-introduce=20mintId=20?= =?UTF-8?q?helper=20for=20server-side=20ID=20minting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-adds packages/api/src/utils/ids.ts (deleted in 67e6afea T9 revert). Now serves two purposes during Phase 1: 1. Fills the text `id` PK (until Phase 2 swaps to bigserial). 2. Fills `client_uuid` for lean callers (MCP/CLI/web) — they don't have offline-first concerns so the server is the right owner of the idempotency token. The format (`_<12hex>`) is URL-safe and within the 64-char limit the migration 0047 CHECK constraint enforces. Refs: docs/plans/2026-05-17-001-feat-client-uuid-split-phase1-plan.md U2 Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/utils/ids.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 packages/api/src/utils/ids.ts diff --git a/packages/api/src/utils/ids.ts b/packages/api/src/utils/ids.ts new file mode 100644 index 0000000000..bdcb202e84 --- /dev/null +++ b/packages/api/src/utils/ids.ts @@ -0,0 +1,18 @@ +/** + * Server-side ID minting for the client/server ID split. + * + * Used in two positions during Phase 1 (docs/design/client-uuid-split.md): + * 1. Fills the `id` text PK for inserts (legacy field, dropped in Phase 2). + * 2. Fills `client_uuid` as the lean-caller default — MCP, CLI, and web + * callers don't have offline-first concerns and shouldn't have to mint. + * + * Distinct from any client-side ID generation (mobile uses nanoid, + * CLI uses `uuid` v7). The format here happens to match nanoid charset + * (URL-safe, ≤ 64 chars) so the DB CHECK constraint accepts both. + */ + +const STRIP_HYPHENS = /-/g; + +export function mintId(prefix: string): string { + return `${prefix}_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, 12)}`; +} From 372b362498b5572614d71af691a2bac159af7f0b Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 18 May 2026 04:08:28 -0600 Subject: [PATCH 3/8] =?UTF-8?q?=E2=9C=A8=20api:=20accept=20and=20return=20?= =?UTF-8?q?clientUuid=20on=20offline-first=20POST=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 PR 2 of the client/server ID split (docs/design/client-uuid-split.md §5). Each affected POST route now: - Accepts optional `clientUuid` alongside legacy `id` (compat shim). - Computes `id` and `clientUuid` per the three caller shapes: * Legacy mobile sends `id` only → id round-trips, clientUuid = id. * New mobile/web sends `clientUuid` → server mints fresh id. * Lean callers (MCP/CLI) send neither → server mints both. - Returns `clientUuid` on the response (now present on every row in DB). - Idempotent retry via onConflictDoNothing on (client_uuid) + user-scoped re-fetch (guards against cross-user namespace probing). Routes updated: POST /packs, POST /packs/:packId/items, POST /packs/:packId/weight-history, POST /trips, POST /pack-templates, POST /pack-templates/:templateId/items, POST /trail-conditions/reports. Also extends Zod request + response schemas in @packrat/schemas for packs, trips, packTemplates, trailConditions. New shared `ClientUuidSchema` in packs.ts enforces `^[A-Za-z0-9_-]{1,64}$` (matches the DB CHECK). Seed data, test fixtures, packService.generatePacks all now populate clientUuid (= id for synthetic rows, since they don't have a separate client identity to preserve). Refs: docs/plans/2026-05-17-001-feat-client-uuid-split-phase1-plan.md U3 U4 U5 Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/db/seed.ts | 2 + .../api/src/routes/packTemplates/index.ts | 41 ++++++++++-- packages/api/src/routes/packs/index.ts | 67 ++++++++++++++++--- .../api/src/routes/trailConditions/reports.ts | 33 +++++---- packages/api/src/routes/trips/index.ts | 18 ++++- packages/api/src/services/packService.ts | 19 ++++-- .../src/utils/__tests__/compute-pack.test.ts | 2 + packages/api/test/fixtures/pack-fixtures.ts | 8 ++- .../test/fixtures/pack-template-fixtures.ts | 8 ++- packages/api/test/packs.test.ts | 4 +- packages/schemas/src/packTemplates.ts | 11 ++- packages/schemas/src/packs.ts | 21 +++++- packages/schemas/src/trailConditions.ts | 8 ++- packages/schemas/src/trips.ts | 7 +- 14 files changed, 199 insertions(+), 50 deletions(-) diff --git a/packages/api/src/db/seed.ts b/packages/api/src/db/seed.ts index feed0a1b45..8701f255bb 100644 --- a/packages/api/src/db/seed.ts +++ b/packages/api/src/db/seed.ts @@ -1897,6 +1897,7 @@ async function seed() { // Insert template await seedDb.insert(schema.packTemplates).values({ id: templateDef.id, + clientUuid: templateDef.id, name: templateDef.name, description: templateDef.description, category: templateDef.category, @@ -1914,6 +1915,7 @@ async function seed() { for (const item of templateDef.items) { await seedDb.insert(schema.packTemplateItems).values({ id: item.id, + clientUuid: item.id, name: item.name, description: item.description, weight: item.weight, diff --git a/packages/api/src/routes/packTemplates/index.ts b/packages/api/src/routes/packTemplates/index.ts index 2494f6d6f2..72344530e6 100644 --- a/packages/api/src/routes/packTemplates/index.ts +++ b/packages/api/src/routes/packTemplates/index.ts @@ -4,6 +4,7 @@ import { createDb } from '@packrat/api/db'; import { adminAuthPlugin, authPlugin } from '@packrat/api/middleware/auth'; import { CatalogService } from '@packrat/api/services/catalogService'; import { getEnv } from '@packrat/api/utils/env-validation'; +import { mintId } from '@packrat/api/utils/ids'; import { type PackTemplate, packTemplateItems, packTemplates } from '@packrat/db'; import { assertDefined } from '@packrat/guards'; import { @@ -146,10 +147,15 @@ export const packTemplatesRoutes = new Elysia({ prefix: '/pack-templates' }) const isAppTemplate = user.role === 'ADMIN' ? data.isAppTemplate : false; - const [newTemplate] = await db + // Phase 1 ID split (docs/design/client-uuid-split.md §5.4). + const clientUuid = data.clientUuid ?? data.id ?? mintId('pt'); + const templateId = data.clientUuid || !data.id ? mintId('pt') : data.id; + + const [inserted] = await db .insert(packTemplates) .values({ - id: data.id, + id: templateId, + clientUuid, userId: user.userId, name: data.name, description: data.description, @@ -160,8 +166,18 @@ export const packTemplatesRoutes = new Elysia({ prefix: '/pack-templates' }) localCreatedAt: new Date(data.localCreatedAt), localUpdatedAt: new Date(data.localUpdatedAt), }) + .onConflictDoNothing({ target: packTemplates.clientUuid }) .returning(); + const newTemplate = + inserted ?? + (await db.query.packTemplates.findFirst({ + where: and( + eq(packTemplates.clientUuid, clientUuid), + eq(packTemplates.userId, user.userId), + ), + })); + assertDefined(newTemplate, 'Failed to create pack template'); const templateWithItems = await db.query.packTemplates.findFirst({ @@ -658,10 +674,15 @@ export const packTemplatesRoutes = new Elysia({ prefix: '/pack-templates' }) return status(403, { error: 'Not allowed' }); } - const [newItem] = await db + // Phase 1 ID split (docs/design/client-uuid-split.md §5.4). + const clientUuid = data.clientUuid ?? data.id ?? mintId('pti'); + const itemId = data.clientUuid || !data.id ? mintId('pti') : data.id; + + const [inserted] = await db .insert(packTemplateItems) .values({ - id: data.id, + id: itemId, + clientUuid, packTemplateId: templateId, name: data.name, description: data.description, @@ -675,8 +696,20 @@ export const packTemplatesRoutes = new Elysia({ prefix: '/pack-templates' }) notes: data.notes, userId: user.userId, }) + .onConflictDoNothing({ target: packTemplateItems.clientUuid }) .returning(); + const newItem = + inserted ?? + (await db.query.packTemplateItems.findFirst({ + where: and( + eq(packTemplateItems.clientUuid, clientUuid), + eq(packTemplateItems.userId, user.userId), + ), + })); + + if (!newItem) return status(500, { error: 'Failed to create template item' }); + await db .update(packTemplates) .set({ updatedAt: new Date() }) diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index 4fe6e10e3f..e336c0fb9f 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -12,6 +12,7 @@ import { getPackDetails } from '@packrat/api/utils/DbUtils'; import { getEmbeddingText } from '@packrat/api/utils/embeddingHelper'; import { getEnv } from '@packrat/api/utils/env-validation'; import { getPresignedUrl } from '@packrat/api/utils/getPresignedUrl'; +import { mintId } from '@packrat/api/utils/ids'; import { catalogItems, type NewPack, @@ -90,12 +91,23 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) const db = createDb(); const data = body; + // Phase 1 of the client/server ID split (docs/design/client-uuid-split.md). + // Three caller shapes coexist on this route: + // - Legacy mobile sends `id` only → id round-trips (clientUuid = id). + // Existing builds keep working unchanged; their reconciliation layer + // keys local rows by `id`. + // - New mobile / web (post-PR 3) sends `clientUuid` → server owns id. + // - Lean callers (MCP / CLI) send neither → server mints both. + const clientUuid = data.clientUuid ?? data.id ?? mintId('p'); + const id = data.clientUuid || !data.id ? mintId('p') : data.id; + // Zod validates all fields at runtime; cast through the Standard Schema // inference gap so drizzle's insert accepts the values. - const [newPack] = await db + const [inserted] = await db .insert(packs) .values({ - id: data.id, + id, + clientUuid, userId: user.userId, name: data.name, description: data.description, @@ -106,8 +118,17 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) localCreatedAt: new Date(data.localCreatedAt as string), localUpdatedAt: new Date(data.localUpdatedAt as string), } as typeof packs.$inferInsert) + .onConflictDoNothing({ target: packs.clientUuid }) .returning(); + // Idempotent retry: if conflict on clientUuid, fetch the existing row + // (user-scoped to prevent cross-user namespace probing). + const newPack = + inserted ?? + (await db.query.packs.findFirst({ + where: and(eq(packs.clientUuid, clientUuid), eq(packs.userId, user.userId)), + })); + if (!newPack) return status(500, { error: 'Failed to create pack' }); const packWithItems: PackWithItems = { ...newPack, items: [] }; @@ -413,21 +434,34 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) const db = createDb(); try { const data = body; - const packWeightHistoryEntry = await db + // Phase 1 ID split (see POST /packs comment for caller-shape breakdown). + const clientUuid = data.clientUuid ?? data.id ?? mintId('w'); + const id = data.clientUuid || !data.id ? mintId('w') : data.id; + const [inserted] = await db .insert(packWeightHistory) .values({ - id: data.id, + id, + clientUuid, packId: params.packId, userId: user.userId, weight: data.weight, localCreatedAt: new Date(data.localCreatedAt), }) + .onConflictDoNothing({ target: packWeightHistory.clientUuid }) .returning(); - return packWeightHistoryEntry.map((entry) => ({ - ...entry, - updatedAt: entry.createdAt, - })); + const entry = + inserted ?? + (await db.query.packWeightHistory.findFirst({ + where: and( + eq(packWeightHistory.clientUuid, clientUuid), + eq(packWeightHistory.userId, user.userId), + ), + })); + + if (!entry) return status(500, { error: 'Failed to create weight history entry' }); + + return { ...entry, updatedAt: entry.createdAt }; } catch (error) { console.error('Pack weight history API error:', error); return status(500, { error: 'Failed to create weight history entry' }); @@ -631,7 +665,10 @@ Limit to maximum 6 recommendations, prioritizing the most important gaps. Only s getEnv(); if (!OPENAI_API_KEY) return status(400, { error: 'OpenAI API key not configured' }); - const itemId = data.id; + + // Phase 1 ID split (see POST /packs comment for caller-shape breakdown). + const clientUuid = data.clientUuid ?? data.id ?? mintId('i'); + const itemId = data.clientUuid || !data.id ? mintId('i') : data.id; const embeddingText = getEmbeddingText(data); const embedding = await generateEmbedding({ @@ -643,10 +680,11 @@ Limit to maximum 6 recommendations, prioritizing the most important gaps. Only s cloudflareAiBinding: AI, }); - const [newItem] = await db + const [inserted] = await db .insert(packItems) .values({ id: itemId, + clientUuid, packId, catalogItemId: data.catalogItemId ? Number(data.catalogItemId) : null, name: data.name, @@ -662,12 +700,19 @@ Limit to maximum 6 recommendations, prioritizing the most important gaps. Only s userId: user.userId, embedding, } as NewPackItem) // safe-cast: object literal matches NewPackItem shape; cast required because embedding field type is narrower in the inferred type + .onConflictDoNothing({ target: packItems.clientUuid }) .returning(); - await db.update(packs).set({ updatedAt: new Date() }).where(eq(packs.id, packId)); + const newItem = + inserted ?? + (await db.query.packItems.findFirst({ + where: and(eq(packItems.clientUuid, clientUuid), eq(packItems.userId, user.userId)), + })); if (!newItem) return status(400, { error: 'Failed to create item' }); + await db.update(packs).set({ updatedAt: new Date() }).where(eq(packs.id, packId)); + return status(201, { ...newItem, consumable: newItem.consumable ?? false, diff --git a/packages/api/src/routes/trailConditions/reports.ts b/packages/api/src/routes/trailConditions/reports.ts index 22b8722d1e..14e95785a8 100644 --- a/packages/api/src/routes/trailConditions/reports.ts +++ b/packages/api/src/routes/trailConditions/reports.ts @@ -1,5 +1,6 @@ import { createDb } from '@packrat/api/db'; import { authPlugin } from '@packrat/api/middleware/auth'; +import { mintId } from '@packrat/api/utils/ids'; import type { NewTrailConditionReport } from '@packrat/db'; import { trailConditionReports } from '@packrat/db'; import { @@ -85,11 +86,16 @@ export const trailConditionRoutes = new Elysia() const db = createDb(); const data = body; + // Phase 1 ID split (docs/design/client-uuid-split.md §5.4). + const clientUuid = data.clientUuid ?? data.id ?? mintId('tcr'); + const reportId = data.clientUuid || !data.id ? mintId('tcr') : data.id; + try { - const [newReport] = await db + const [inserted] = await db .insert(trailConditionReports) .values({ - id: data.id, + id: reportId, + clientUuid, trailName: data.trailName, trailRegion: data.trailRegion ?? null, surface: data.surface, @@ -105,23 +111,22 @@ export const trailConditionRoutes = new Elysia() localCreatedAt: new Date(data.localCreatedAt), localUpdatedAt: new Date(data.localUpdatedAt), }) + .onConflictDoNothing({ target: trailConditionReports.clientUuid }) .returning(); - if (!newReport) return status(400, { error: 'Failed to submit report' }); - - return toReportResponse(newReport); - } catch (error) { - const pgCode = (error as { code?: string })?.code; - if (pgCode === '23505') { - const existing = await db.query.trailConditionReports.findFirst({ + const newReport = + inserted ?? + (await db.query.trailConditionReports.findFirst({ where: and( - eq(trailConditionReports.id, data.id), + eq(trailConditionReports.clientUuid, clientUuid), eq(trailConditionReports.userId, user.userId), ), - }); - if (existing) return toReportResponse(existing); - return status(409, { error: 'Report ID already in use by another user' }); - } + })); + + if (!newReport) return status(500, { error: 'Failed to submit report' }); + + return toReportResponse(newReport); + } catch (error) { console.error('Error creating trail condition report:', error); return status(500, { error: 'Failed to submit trail condition report' }); } diff --git a/packages/api/src/routes/trips/index.ts b/packages/api/src/routes/trips/index.ts index 7ddd6c66c3..e85bbd3d32 100644 --- a/packages/api/src/routes/trips/index.ts +++ b/packages/api/src/routes/trips/index.ts @@ -1,5 +1,6 @@ import { createDb } from '@packrat/api/db'; import { authPlugin } from '@packrat/api/middleware/auth'; +import { mintId } from '@packrat/api/utils/ids'; import { trips } from '@packrat/db'; import { CreateTripBodySchema, TripSchema, UpdateTripBodySchema } from '@packrat/schemas/trips'; import { and, eq } from 'drizzle-orm'; @@ -45,11 +46,17 @@ export const tripsRoutes = new Elysia({ prefix: '/trips' }) const db = createDb(); const data = body; + // Phase 1 ID split — see packages/api/src/routes/packs/index.ts POST handler + // for the caller-shape breakdown. + const clientUuid = data.clientUuid ?? data.id ?? mintId('t'); + const tripId = data.clientUuid || !data.id ? mintId('t') : data.id; + try { - const [newTrip] = await db + const [inserted] = await db .insert(trips) .values({ - id: data.id, + id: tripId, + clientUuid, userId: user.userId, name: data.name, description: data.description ?? null, @@ -62,8 +69,15 @@ export const tripsRoutes = new Elysia({ prefix: '/trips' }) localCreatedAt: new Date(data.localCreatedAt), localUpdatedAt: new Date(data.localUpdatedAt), }) + .onConflictDoNothing({ target: trips.clientUuid }) .returning(); + const newTrip = + inserted ?? + (await db.query.trips.findFirst({ + where: and(eq(trips.clientUuid, clientUuid), eq(trips.userId, user.userId)), + })); + if (!newTrip) throw new Error('Failed to create trip'); return TripSchema.parse(newTrip); diff --git a/packages/api/src/services/packService.ts b/packages/api/src/services/packService.ts index f7c9cd260f..e9c36d1d39 100644 --- a/packages/api/src/services/packService.ts +++ b/packages/api/src/services/packService.ts @@ -79,6 +79,7 @@ export class PackService { const packId = crypto.randomUUID(); packsToInsert.push({ id: packId, + clientUuid: packId, userId: this.userId, name: pack.name, description: pack.description, @@ -91,13 +92,17 @@ export class PackService { }); itemsToInsert.push( - ...pack.items.map((item) => ({ - ...item, - id: crypto.randomUUID(), - packId, - isAIGenerated: true, - userId: this.userId, - })), + ...pack.items.map((item) => { + const itemId = crypto.randomUUID(); + return { + ...item, + id: itemId, + clientUuid: itemId, + packId, + isAIGenerated: true, + userId: this.userId, + }; + }), ); } diff --git a/packages/api/src/utils/__tests__/compute-pack.test.ts b/packages/api/src/utils/__tests__/compute-pack.test.ts index b3b379fc94..e08b76912e 100644 --- a/packages/api/src/utils/__tests__/compute-pack.test.ts +++ b/packages/api/src/utils/__tests__/compute-pack.test.ts @@ -8,6 +8,7 @@ import { computePacksWeights, computePackWeights } from '../compute-pack'; function makePack(overrides: Partial = {}): PackWithItems { return { id: 'pack-1', + clientUuid: 'pack-1', name: 'Test Pack', description: null, category: 'hiking', @@ -32,6 +33,7 @@ function makePackItem( ): PackItem { return { id: 'item-1', + clientUuid: 'item-1', name: 'Test Item', quantity: overrides.quantity ?? 1, consumable: overrides.consumable ?? false, diff --git a/packages/api/test/fixtures/pack-fixtures.ts b/packages/api/test/fixtures/pack-fixtures.ts index ea1ae72c50..45a04b709f 100644 --- a/packages/api/test/fixtures/pack-fixtures.ts +++ b/packages/api/test/fixtures/pack-fixtures.ts @@ -10,8 +10,10 @@ type PackItemOverrides = Partial> & { userId: */ export const createTestPack = (overrides: PackOverrides): InferInsertModel => { const now = new Date(); + const id = overrides.id ?? `pack_test_${Date.now()}_${Math.random().toString(36).substring(7)}`; return { - id: overrides.id ?? `pack_test_${Date.now()}_${Math.random().toString(36).substring(7)}`, + id, + clientUuid: overrides.clientUuid ?? id, name: overrides.name ?? 'Test Backpacking Pack', description: overrides.description ?? 'A test pack for backpacking trips', category: overrides.category ?? 'backpacking', @@ -31,8 +33,10 @@ export const createTestPackItem = ( packId: string, overrides: PackItemOverrides, ): InferInsertModel => { + const id = overrides.id ?? `item_test_${Date.now()}_${Math.random().toString(36).substring(7)}`; return { - id: overrides.id ?? `item_test_${Date.now()}_${Math.random().toString(36).substring(7)}`, + id, + clientUuid: overrides.clientUuid ?? id, name: overrides.name ?? 'Test Backpack', description: overrides.description ?? 'A test item for the pack', weight: overrides.weight ?? 1200, diff --git a/packages/api/test/fixtures/pack-template-fixtures.ts b/packages/api/test/fixtures/pack-template-fixtures.ts index 43c8c64eab..045f5fe125 100644 --- a/packages/api/test/fixtures/pack-template-fixtures.ts +++ b/packages/api/test/fixtures/pack-template-fixtures.ts @@ -11,8 +11,10 @@ export const createTestPackTemplate = ( overrides: PackTemplateOverrides, ): InferInsertModel => { const now = new Date(); + const id = overrides.id ?? `pt_test_${Date.now()}_${Math.random().toString(36).substring(7)}`; return { - id: overrides.id ?? `pt_test_${Date.now()}_${Math.random().toString(36).substring(7)}`, + id, + clientUuid: overrides.clientUuid ?? id, name: overrides.name ?? 'Test Backpacking Template', description: overrides.description ?? 'A test template for backpacking trips', category: overrides.category ?? 'backpacking', @@ -38,8 +40,10 @@ export const createTestPackTemplateItem = ( packTemplateId: string, overrides: PackTemplateItemOverrides, ): InferInsertModel => { + const id = overrides.id ?? `pti_test_${Date.now()}_${Math.random().toString(36).substring(7)}`; return { - id: overrides.id ?? `pti_test_${Date.now()}_${Math.random().toString(36).substring(7)}`, + id, + clientUuid: overrides.clientUuid ?? id, name: overrides.name ?? 'Test Backpack', description: overrides.description ?? 'A test item for the pack template', weight: overrides.weight ?? 1200, diff --git a/packages/api/test/packs.test.ts b/packages/api/test/packs.test.ts index 5ecfb3382e..93044d5e0c 100644 --- a/packages/api/test/packs.test.ts +++ b/packages/api/test/packs.test.ts @@ -38,8 +38,10 @@ vi.mock('@packrat/api/services/packService', async () => { async generatePacks(count: number) { const mockPacks: Pack[] = []; for (let i = 0; i < count; i++) { + const id = `generated-pack-${i}-${Date.now()}`; mockPacks.push({ - id: `generated-pack-${i}-${Date.now()}`, + id, + clientUuid: id, userId: this._userId, name: `Generated Test Pack ${i + 1}`, description: `AI-generated pack for testing purposes ${i + 1}`, diff --git a/packages/schemas/src/packTemplates.ts b/packages/schemas/src/packTemplates.ts index 445deaa590..fa4e4eb990 100644 --- a/packages/schemas/src/packTemplates.ts +++ b/packages/schemas/src/packTemplates.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { ClientUuidSchema } from './packs'; import { datetimeString } from './utils'; export const PackTemplateErrorResponseSchema = z.object({ @@ -9,6 +10,7 @@ export const PackTemplateErrorResponseSchema = z.object({ export const PackTemplateSchema = z.object({ id: z.string(), + clientUuid: z.string(), name: z.string(), description: z.string().nullable(), category: z.string(), @@ -27,6 +29,7 @@ export const PackTemplateSchema = z.object({ export const PackTemplateItemSchema = z.object({ id: z.string(), + clientUuid: z.string(), name: z.string(), description: z.string().nullable(), weight: z.number(), @@ -49,8 +52,11 @@ export const PackTemplateWithItemsSchema = PackTemplateSchema.extend({ items: z.array(PackTemplateItemSchema), }); +// `id` is legacy (Phase 1 compat shim — docs/design/client-uuid-split.md §5.4). +// `clientUuid` is the new idempotency token. Both optional; server mints. export const CreatePackTemplateRequestSchema = z.object({ - id: z.string(), + id: z.string().optional(), + clientUuid: ClientUuidSchema.optional(), name: z.string().min(1).max(255), description: z.string().optional(), category: z.string().min(1), @@ -73,7 +79,8 @@ export const UpdatePackTemplateRequestSchema = z.object({ }); export const CreatePackTemplateItemRequestSchema = z.object({ - id: z.string(), + id: z.string().optional(), + clientUuid: ClientUuidSchema.optional(), name: z.string().min(1).max(255), description: z.string().optional(), weight: z.number().min(0), diff --git a/packages/schemas/src/packs.ts b/packages/schemas/src/packs.ts index f9495c95ad..7183ecac88 100644 --- a/packages/schemas/src/packs.ts +++ b/packages/schemas/src/packs.ts @@ -2,8 +2,12 @@ import { PACK_CATEGORIES, WEIGHT_UNITS } from '@packrat/constants'; import { z } from 'zod'; import { datetimeString } from './utils'; +// URL-safe nanoid charset, ≤ 64 chars. Matches the DB CHECK on `client_uuid`. +export const ClientUuidSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/); + export const PackItemSchema = z.object({ id: z.string(), + clientUuid: z.string(), name: z.string(), description: z.string().nullable(), weight: z.number(), @@ -26,6 +30,7 @@ export const PackItemSchema = z.object({ export const PackSchema = z.object({ id: z.string(), + clientUuid: z.string(), userId: z.string(), name: z.string(), description: z.string().nullable(), @@ -168,14 +173,22 @@ export const GapAnalysisResponseSchema = z.object({ // Body schemas mirroring the inline route schemas (exported so stores/clients // can use ApiBody<> or direct z.infer<> without importing from route files). +// +// `id` is the legacy client-supplied identifier and is accepted as a +// compatibility shim during Phase 1 of the client/server ID split +// (docs/design/client-uuid-split.md §5.4). New callers should send +// `clientUuid` instead. Both are optional — the server mints when neither +// is supplied. export const CreatePackBodySchema = CreatePackRequestSchema.extend({ - id: z.string(), + id: z.string().optional(), + clientUuid: ClientUuidSchema.optional(), localCreatedAt: z.string().datetime(), localUpdatedAt: z.string().datetime(), }); export const AddPackItemBodySchema = CreatePackItemRequestSchema.extend({ - id: z.string(), + id: z.string().optional(), + clientUuid: ClientUuidSchema.optional(), }); export const UpdatePackBodySchema = UpdatePackRequestSchema.extend({ @@ -184,6 +197,7 @@ export const UpdatePackBodySchema = UpdatePackRequestSchema.extend({ export const PackWeightHistoryResponseSchema = z.object({ id: z.string(), + clientUuid: z.string(), packId: z.string(), userId: z.string(), weight: z.number(), @@ -193,7 +207,8 @@ export const PackWeightHistoryResponseSchema = z.object({ }); export const CreatePackWeightHistoryBodySchema = z.object({ - id: z.string(), + id: z.string().optional(), + clientUuid: ClientUuidSchema.optional(), weight: z.number(), localCreatedAt: z.string().datetime(), }); diff --git a/packages/schemas/src/trailConditions.ts b/packages/schemas/src/trailConditions.ts index e36f13a759..f99842551c 100644 --- a/packages/schemas/src/trailConditions.ts +++ b/packages/schemas/src/trailConditions.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { ClientUuidSchema } from './packs'; const datetimeString = z.preprocess( (v) => (v instanceof Date ? v.toISOString() : v), @@ -11,6 +12,7 @@ export const WaterCrossingDifficultySchema = z.enum(['easy', 'moderate', 'diffic export const TrailConditionReportSchema = z.object({ id: z.string(), + clientUuid: z.string(), trailName: z.string(), trailRegion: z.string().nullable().optional(), surface: TrailSurfaceSchema, @@ -31,8 +33,11 @@ export const TrailConditionReportSchema = z.object({ export type TrailConditionReport = z.infer; +// `id` is legacy (Phase 1 compat shim — docs/design/client-uuid-split.md §5.4). +// `clientUuid` is the new idempotency token. Both optional; server mints. export const CreateTrailConditionReportRequestSchema = z.object({ - id: z.string().describe('Client-generated report ID'), + id: z.string().optional().describe('Legacy client-supplied report ID'), + clientUuid: ClientUuidSchema.optional(), trailName: z.string().min(1), trailRegion: z.string().optional().nullable(), surface: TrailSurfaceSchema, @@ -50,6 +55,7 @@ export const CreateTrailConditionReportRequestSchema = z.object({ export const UpdateTrailConditionReportRequestSchema = CreateTrailConditionReportRequestSchema.omit( { id: true, + clientUuid: true, localCreatedAt: true, }, ).partial(); diff --git a/packages/schemas/src/trips.ts b/packages/schemas/src/trips.ts index 3e63266c05..0b3ca64ce9 100644 --- a/packages/schemas/src/trips.ts +++ b/packages/schemas/src/trips.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { ClientUuidSchema } from './packs'; import { datetimeString } from './utils'; const nullableDateString = z.preprocess( @@ -14,6 +15,7 @@ export const TripLocationSchema = z.object({ export const TripSchema = z.object({ id: z.string(), + clientUuid: z.string(), name: z.string(), description: z.string().nullable().optional(), notes: z.string().nullable().optional(), @@ -31,8 +33,11 @@ export const TripSchema = z.object({ export type Trip = z.infer; +// `id` is legacy (Phase 1 compat shim — see docs/design/client-uuid-split.md). +// `clientUuid` is the new idempotency token. Both optional; server mints. export const CreateTripBodySchema = z.object({ - id: z.string(), + id: z.string().optional(), + clientUuid: ClientUuidSchema.optional(), name: z.string().min(1).max(255), description: z.string().nullable().optional(), notes: z.string().nullable().optional(), From 74ab574b6a996cfc46391be71e079965438f72fc Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 18 May 2026 04:11:04 -0600 Subject: [PATCH 4/8] =?UTF-8?q?=E2=9C=A8=20cli:=20stop=20minting=20client?= =?UTF-8?q?=20ids=20=E2=80=94=20let=20the=20server=20fill=20clientUuid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the client/server ID split design (docs/design/client-uuid-split.md §8 Q4), lean callers (this CLI, MCP, web) have no offline-first concerns. They should omit `clientUuid` and let the server mint both `id` and `clientUuid`. Drops `id: shortId(prefix)` from `packs create`, `trips create`, and `templates create` request bodies. The `shortId` helper stays in api/ids.ts — reserved for a future `--client-uuid` flag that enables explicit idempotent retries from the shell. Refs: docs/plans/2026-05-17-001-feat-client-uuid-split-phase1-plan.md U7 Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/api/ids.ts | 15 ++++++++++----- packages/cli/src/commands/packs/create.ts | 5 +++-- packages/cli/src/commands/templates/index.ts | 4 ++-- packages/cli/src/commands/trips/index.ts | 4 ++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/api/ids.ts b/packages/cli/src/api/ids.ts index d1a6b801e9..4c387349c6 100644 --- a/packages/cli/src/api/ids.ts +++ b/packages/cli/src/api/ids.ts @@ -1,9 +1,14 @@ /** - * ID helpers for client-side creation. The API expects the client to supply - * IDs (so offline-first stores can write before sync). UUIDv7 is time-ordered - * for good B-tree locality if/when the id becomes the actual PK on disk. - * Using the `uuid` npm package (not Bun.randomUUIDv7) so the same helper - * works in any JS runtime — useful if this ever moves to MCP / Workers. + * Client-side ID helpers. Reserved for callers that want to supply their own + * `clientUuid` for idempotent retries (e.g., a future `--client-uuid` flag). + * + * After Phase 1 of the client/server ID split, lean callers (this CLI, MCP, + * web) generally let the server mint both `id` and `clientUuid` — see + * docs/design/client-uuid-split.md §8 Q4. So `shortId` is intentionally + * unused by current create commands; keep it available rather than reinvent. + * + * UUIDv7 is time-ordered for B-tree locality. The `uuid` npm package works + * in any JS runtime — useful if this helper ever moves to MCP / Workers. */ import { v7 as uuidv7 } from 'uuid'; diff --git a/packages/cli/src/commands/packs/create.ts b/packages/cli/src/commands/packs/create.ts index 7c80733469..968dd0edc0 100644 --- a/packages/cli/src/commands/packs/create.ts +++ b/packages/cli/src/commands/packs/create.ts @@ -2,7 +2,7 @@ import { toRecord } from '@packrat/guards'; import { defineCommand } from 'citty'; import consola from 'consola'; import { getUserClient } from '../../api/client'; -import { nowIso, shortId } from '../../api/ids'; +import { nowIso } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; export default defineCommand({ @@ -31,7 +31,8 @@ export default defineCommand({ : undefined; const pack = await runApi( client.packs.post({ - id: shortId('p'), + // No clientUuid: lean callers let the server mint per Phase 1 of the + // client/server ID split (docs/design/client-uuid-split.md §8 Q4). name: args.name, description: args.description, category: args.category, diff --git a/packages/cli/src/commands/templates/index.ts b/packages/cli/src/commands/templates/index.ts index 83a5647c1d..968f9012d9 100644 --- a/packages/cli/src/commands/templates/index.ts +++ b/packages/cli/src/commands/templates/index.ts @@ -1,7 +1,7 @@ import { toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; -import { nowIso, shortId } from '../../api/ids'; +import { nowIso } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -60,7 +60,7 @@ const createCmd = defineCommand({ const now = nowIso(); const data = await runApi( client['pack-templates'].post({ - id: shortId('pt'), + // No clientUuid: lean callers let the server mint. name: args.name, description: args.description, category: args.category, diff --git a/packages/cli/src/commands/trips/index.ts b/packages/cli/src/commands/trips/index.ts index 38a8e3f2cd..8627ee25b3 100644 --- a/packages/cli/src/commands/trips/index.ts +++ b/packages/cli/src/commands/trips/index.ts @@ -1,7 +1,7 @@ import { toRecord, toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; -import { nowIso, shortId } from '../../api/ids'; +import { nowIso } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; import { printSummary, printTable } from '../../shared'; @@ -87,7 +87,7 @@ const createCmd = defineCommand({ : null; const trip = await runApi( client.trips.post({ - id: shortId('t'), + // No clientUuid: lean callers let the server mint. name: args.name, description: args.description, location, From 847fca09347a0c7f9ef014c7fd887067e9431ee5 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 18 May 2026 04:11:19 -0600 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=93=9D=20docs:=20client-uuid-split=20?= =?UTF-8?q?plan=20+=20design=20doc=20status=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Phase 1 plan and updates the design doc: - PR 1 + PR 2: shipping in plan 2026-05-17-001 - PR 3, 4, 5: deferred to follow-up plans - PR 6 (Phase 2): separate design doc when ready Also extends the mobile compute-pack test fixture with clientUuid so the PackWithItems / PackItem types continue to typecheck after the schema change. (Mobile sync work proper happens in the deferred PR 3.) Refs: docs/plans/2026-05-17-001-feat-client-uuid-split-phase1-plan.md U8 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/utils/__tests__/compute-pack.test.ts | 2 + docs/design/client-uuid-split.md | 13 +- ...-001-feat-client-uuid-split-phase1-plan.md | 509 ++++++++++++++++++ 3 files changed, 517 insertions(+), 7 deletions(-) create mode 100644 docs/plans/2026-05-17-001-feat-client-uuid-split-phase1-plan.md diff --git a/apps/expo/lib/utils/__tests__/compute-pack.test.ts b/apps/expo/lib/utils/__tests__/compute-pack.test.ts index 811605c56d..f78a8dc7d1 100644 --- a/apps/expo/lib/utils/__tests__/compute-pack.test.ts +++ b/apps/expo/lib/utils/__tests__/compute-pack.test.ts @@ -12,6 +12,7 @@ function makePackItem( ): PackItem { return { id: 'item-1', + clientUuid: 'item-1', name: 'Test Item', description: null, quantity: overrides.quantity ?? 1, @@ -36,6 +37,7 @@ function makePackItem( function makePack(items: PackItem[] = [], overrides: Partial = {}): PackWithItems { return { id: 'pack-1', + clientUuid: 'pack-1', name: 'Test Pack', description: null, category: 'hiking', diff --git a/docs/design/client-uuid-split.md b/docs/design/client-uuid-split.md index 6f8f17cf35..d2610fd164 100644 --- a/docs/design/client-uuid-split.md +++ b/docs/design/client-uuid-split.md @@ -1,9 +1,8 @@ # Design: Client/Server ID split for offline-first tables -**Status:** Draft — for review, no code yet. +**Status:** Phase 1 PR 1 + 2 in progress — see [plan](../plans/2026-05-17-001-feat-client-uuid-split-phase1-plan.md). **Author:** Architecture follow-up to [PR #2433](https://github.com/PackRat-AI/PackRat/pull/2433) ("T9: server-side ID minting"). **Scope:** `packs`, `pack_items`, `weight_history`, `trips`, `pack_templates`, `pack_template_items`, `trail_condition_reports`. -**Decision needed before implementation:** see [Open questions](#8-open-questions). --- @@ -565,7 +564,7 @@ These need Andrew's call before implementation starts: Six shippable PRs, in order. Each PR is independently revertable and leaves the system in a consistent state. -### PR 1 — Schema shim (Phase 1 DB) +### PR 1 — Schema shim (Phase 1 DB) *[shipping in plan 2026-05-17-001]* - Add `client_uuid` column to seven tables. - Backfill from existing `id`. @@ -575,7 +574,7 @@ Six shippable PRs, in order. Each PR is independently revertable and leaves the **Risk:** Low. Reversible by dropping the column. **Touches:** `packages/api/drizzle/00XX_add_client_uuid.sql`, `packages/api/src/db/schema.ts`. -### PR 2 — API: accept and return `clientUuid` +### PR 2 — API: accept and return `clientUuid` *[shipping in plan 2026-05-17-001]* - Update Zod request schemas to accept optional `clientUuid`. - Update handlers to use `data.clientUuid ?? data.id ?? mintId(prefix)`. @@ -586,7 +585,7 @@ Six shippable PRs, in order. Each PR is independently revertable and leaves the **Risk:** Low. Additive on the wire. **Touches:** `packages/api/src/routes/{packs,trips,packTemplates,trailConditions}/*.ts`, `packages/api/src/schemas/*.ts`, `packages/api/src/utils/ids.ts` (rename `mintId` semantics in comments). -### PR 3 — Mobile: persisted store migration + read by `clientUuid` +### PR 3 — Mobile: persisted store migration + read by `clientUuid` *[deferred — own plan]* - `persistPlugin` migration: copy `id → clientUuid`, re-key store records. - Update `PackInStore`, `PackItem`, `TripInStore`, etc. types to include `clientUuid`. @@ -597,7 +596,7 @@ Six shippable PRs, in order. Each PR is independently revertable and leaves the **Risk:** Medium. Persisted-store migration is one-way. Test on a representative dataset. **Touches:** `apps/expo/features/packs/store/*.ts`, `apps/expo/features/packs/hooks/*.ts`, `apps/expo/features/{trips,trail-conditions,pack-templates}/store/*.ts`, `apps/expo/lib/persist-plugin/*.ts`, `apps/expo/features/packs/types.ts` and siblings. -### PR 4 — Mobile: switch sync wire format to `clientUuid` +### PR 4 — Mobile: switch sync wire format to `clientUuid` *[deferred — own plan]* - Update `createPack`, `createPackItem`, `createTrip`, etc. in the stores to send `clientUuid` on the wire. - Update `updatePack`, `updatePackItem`, etc. to throw-and-retry when local row has no server `id` yet. @@ -606,7 +605,7 @@ Six shippable PRs, in order. Each PR is independently revertable and leaves the **Risk:** Medium. Watch for retry storms in telemetry. **Touches:** Same files as PR 3. -### PR 5 — API: drop the `id`-on-create compatibility shim +### PR 5 — API: drop the `id`-on-create compatibility shim *[deferred — waits on PR 4 + 30 days clean telemetry]* - Remove `data.id ?? data.clientUuid` fallback. - Force `clientUuid` (server can still mint). diff --git a/docs/plans/2026-05-17-001-feat-client-uuid-split-phase1-plan.md b/docs/plans/2026-05-17-001-feat-client-uuid-split-phase1-plan.md new file mode 100644 index 0000000000..c081ed0ac6 --- /dev/null +++ b/docs/plans/2026-05-17-001-feat-client-uuid-split-phase1-plan.md @@ -0,0 +1,509 @@ +--- +title: "feat: client/server ID split — Phase 1 (DB shim + API)" +type: feat +status: active +created: 2026-05-17 +origin: docs/design/client-uuid-split.md +--- + +# Phase 1: `client_uuid` shim + API accept-and-return + +**Status:** active +**Plan depth:** Deep +**Scope:** PRs 1 + 2 from the [client-uuid-split design doc](../design/client-uuid-split.md) §9. +**Out of scope:** Mobile persisted-store migration (PR 3), mobile sync-wire switch (PR 4), legacy-`id`-shim drop (PR 5), Phase 2 bigint PK narrow (PR 6). + +--- + +## 1. Problem frame + +Today every offline-first table (`packs`, `pack_items`, `weight_history`, `pack_templates`, `pack_template_items`, `trips`, `trail_condition_reports`) has a single `id text` primary key with **dual ownership**: mobile mints client-side nanoid, T9 made it optional so MCP/CLI can omit it and the server mints `_<12hex>` via `mintId()`. PR #2433 reverted T9 — the user called out that optional `id` "breaks core data integrity," because dual ownership of the PK leaves the server unable to enforce a format, can't dedupe retries idempotently, and forces every FK in the seven-table graph to hold a client-shaped string. + +Phase 1 fixes the structural problem additively, without touching the PK type: + +1. Add `client_uuid text UNIQUE NOT NULL` to each affected table (backfilled from current `id`). +2. Add a CHECK constraint forcing `^[A-Za-z0-9_-]{1,64}$`. +3. API accepts optional `clientUuid` on POST (alongside legacy `id`), upserts on `(client_uuid)` conflict for idempotency, returns `clientUuid` on every row. +4. `mintId(prefix)` survives as the lean-caller default — MCP/CLI/web don't have to mint anything; server fills `clientUuid` when omitted. + +The result: retries are idempotent, the format is enforceable, mobile already has nanoids that map cleanly to `client_uuid`, and the PK type narrow (Phase 2) becomes a separate, reversible diff. + +**Why this is safe to land alone:** Phase 1 is additive at every layer. The DB adds a column, the API adds a request/response field. Old clients sending `id` continue to work via a compat shim (see §5). Rollback is `DROP COLUMN client_uuid` per table. + +--- + +## 2. Requirements traceability + +Each requirement is sourced from the [origin design doc](../design/client-uuid-split.md). All R-IDs are Phase 1 only. + +| ID | Requirement | Source | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | +| R1 | Add `client_uuid text UNIQUE NOT NULL` column to seven affected tables, backfilled from `id`. | §2.2, §3 Option C | +| R2 | DB enforces `client_uuid` format via CHECK constraint matching `^[A-Za-z0-9_-]{1,64}$`. | §2.3 | +| R3 | API POST endpoints accept optional `clientUuid` field, validated by the same regex at the Zod layer. | §5.1 | +| R4 | Handlers compute the value as `data.clientUuid ?? data.id ?? mintId(prefix)` (compat shim — Phase 1 only). | §5.4 | +| R5 | Handlers use `onConflictDoNothing(target: clientUuid)` + re-fetch to guarantee idempotent retries (same `clientUuid` → same row). | §5.3 | +| R6 | Every entity response schema (Pack, PackItem, PackWeightHistory, PackTemplate, PackTemplateItem, Trip, TrailConditionReport) returns `clientUuid` alongside `id`. | §5.2 | +| R7 | Phase 1 keeps `id` text-typed; `mintId` continues filling `id` for inserts when the caller doesn't supply one. | §3 Phase 1 sketch | +| R8 | The API logs a deprecation warning (`deprecated_id_field`) when a request supplies `id` but not `clientUuid`, with userId + route, so we can measure cutover. | §5.4 | +| R9 | The migration is reversible at the DB layer (`DROP COLUMN client_uuid` per table) without any data loss. | §7 Phase 1 | +| R10 | MCP and CLI commands stop minting their own ids — they omit `clientUuid` from the request, letting the server fill it via `mintId`. | §8 Q4 (locked: server mints) | + +--- + +## 3. Scope boundaries + +### In scope (this plan) + +- DB schema change: add `client_uuid` to seven tables with UNIQUE + format CHECK. +- Drizzle schema update in `packages/db/src/schema.ts`. +- Zod request schemas in `packages/schemas/src/{packs,packTemplates,trips,trailConditions}.ts` gain optional `clientUuid`. +- Zod response schemas gain `clientUuid` on the seven row shapes. +- Route handlers in `packages/api/src/routes/{packs,packTemplates,trips,trailConditions}/` accept the field, run the compat coalesce, do `onConflictDoNothing` upserts, return the new field. +- MCP/CLI consumers stop sending their own ids (the `shortId` helper from this session becomes dead code on the CLI side). +- Unit tests for idempotent retry behavior + the compat shim. + +### Deferred to Follow-Up Work + +- **PR 3 — Mobile persisted-store migration:** copy `id → clientUuid` in `apps/expo/lib/persist-plugin.ts`, re-key Legend State observables by `clientUuid`, add `packClientUuid`-style FK fields to the in-store types. Its own plan; depends on this landing first. +- **PR 4 — Mobile sync wire switch:** stores send `clientUuid` instead of `id`; `updatePack`/`updatePackItem` throw-and-retry on un-synced parents. Follows PR 3. +- **PR 5 — Drop the legacy `id`-on-create shim:** remove the `data.id ?? data.clientUuid` fallback in handlers once telemetry shows zero callers sending `id`-only. Waits on PR 4 reaching 100% of users + ~30 days of clean telemetry. + +### Outside this product's identity + +- **PR 6 — Phase 2 narrow `id` to `bigserial`:** separate design doc per §9 of origin. Big-bang FK rewrite, irreversible, requires maintenance window. Not even sequenced after this plan — needs its own brainstorm + plan. +- **Public-facing `slug` / `share_id`** for pack share URLs: orthogonal to this work. Origin §8 Q8. + +--- + +## 4. Output structure + +No new directories. All changes land in existing files: + +- `packages/api/drizzle/0048_add_client_uuid.sql` (new migration file) +- `packages/db/src/schema.ts` (modify seven `pgTable` definitions) +- `packages/schemas/src/{packs,packTemplates,trips,trailConditions}.ts` (modify request + response schemas) +- `packages/api/src/routes/{packs,packTemplates,trips,trailConditions}/*.ts` (modify route handlers) +- `packages/api/src/utils/ids.ts` (re-introduce — was removed in T9 revert) +- `packages/mcp/src/tools/{packs,trips}.ts` (drop client-side id minting from create tools) +- `packages/cli/src/commands/{packs,trips,templates}/*.ts` (drop `shortId` calls from create commands) +- `packages/api/test/{packs,trips}.test.ts` (new + extended idempotency tests) + +--- + +## 5. High-level technical design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +### Wire format change + +``` +POST /packs (Phase 1, additive) +───────────────────────────────────── +client request: + { clientUuid?, name, ... } ← new clients send clientUuid + { id, name, ... } ← old clients still work via shim + +server (request handler): + clientUuid = data.clientUuid ?? data.id ?? mintId('p') + id = mintId('p') ← always server-minted from Phase 1 onward + (was: data.id ?? mintId — T9 hack) + insert ... onConflictDoNothing(target: clientUuid) + if no row returned, SELECT by clientUuid (idempotent retry path) + +server response: + { id, clientUuid, name, ... } ← every response gains clientUuid +``` + +### DB column lifecycle per table + +``` +Migration 0048: + ADD COLUMN client_uuid text; + UPDATE table SET client_uuid = id WHERE client_uuid IS NULL; + ALTER COLUMN client_uuid SET NOT NULL; + ADD CONSTRAINT _client_uuid_unique UNIQUE (client_uuid); + ADD CONSTRAINT
_client_uuid_format + CHECK (client_uuid ~ '^[A-Za-z0-9_-]{1,64}$'); +``` + +### Conflict-resolution path + +``` +INSERT INTO packs (id, client_uuid, ...) VALUES (...) +ON CONFLICT (client_uuid) DO NOTHING +RETURNING *; + +if (returning.length === 0) { + // existing row — idempotent retry + return SELECT * FROM packs WHERE client_uuid = $clientUuid; +} +``` + +This is the recommended path (origin §5.3). `onConflictDoUpdate` was rejected in synthesis because it would refresh `localUpdatedAt` on every retry, polluting last-write-wins semantics for genuinely unchanged rows. + +### Compatibility coalesce (Phase 1 only — removed in PR 5) + +``` +const clientUuid = data.clientUuid ?? data.id ?? mintId(prefix); + +if (data.id && !data.clientUuid) { + console.warn('deprecated_id_field', { userId: user.userId, route }); +} +``` + +The `data.id` branch keeps mobile working unchanged on the wire until PR 4 ships. The warning log lets us see when callers stop using it. + +--- + +## 6. Key technical decisions + +### Decision 1: `onConflictDoNothing` + re-fetch (not `onConflictDoUpdate`) + +Pure idempotency. A retry of `POST /packs` with the same `clientUuid` returns the existing row byte-for-byte, no side effects. `onConflictDoUpdate` would write `localUpdatedAt` on every duplicate request, which silently re-orders LWW reconciliation on the mobile side. The cost is one extra SELECT in the rare conflict path; the benefit is determinism. + +(see origin: `docs/design/client-uuid-split.md` §5.3) + +### Decision 2: Keep `mintId(prefix)` as the lean-caller default + +The CLI's `shortId('p')` from earlier this session and the MCP tools' equivalent become dead code. Lean callers send nothing for `clientUuid`; server fills via `mintId`. This narrows the offline-first ID surface to just the mobile app — where it actually matters because Legend State needs a stable local key before the network round-trip. + +(see origin: `docs/design/client-uuid-split.md` §8 Q4 — locked decision) + +### Decision 3: CHECK constraint enforces `^[A-Za-z0-9_-]{1,64}$` + +This is the URL-safe nanoid charset, plus our `mintId` format (`_<12hex>` fits). The DB enforces it as a hard floor; the Zod schema applies the same regex at the API edge so format violations get a clean 422 with field-level context instead of a Postgres CHECK error. Tighter than today, where any string passes. + +(see origin: `docs/design/client-uuid-split.md` §2.3) + +### Decision 4: Re-introduce `packages/api/src/utils/ids.ts` + +The `mintId(prefix)` helper file was deleted in the T9 revert (commit `67e6afea`). Phase 1 brings it back, but now its sole purpose is filling `clientUuid` (and, until Phase 2, `id`) on the server side when the caller didn't supply one. The semantics in the comment header should make clear it's a *server-side* helper, not a client-side ID-format contract. + +### Decision 5: Deprecation log is `console.warn`, not metrics + +Workers don't have a metrics primitive that survives a hot reload, and adding one for "count cutover progress" is over-engineering. `console.warn('deprecated_id_field', ...)` is grep-able in `wrangler tail` and Cloudflare Logpush — enough to answer "are any callers still sending `id`-only?" before pulling the PR 5 trigger. + +--- + +## 7. System-wide impact + +| Surface | Impact | Mitigation | +| --- | --- | --- | +| **DB (production)** | Adds one nullable column → backfills → makes NOT NULL → adds UNIQUE + CHECK constraints. Per-table downtime is the UNIQUE index build on existing rows. | Migration runs in a single transaction per table. UNIQUE index on `client_uuid` after backfill is fast (existing `id` values are already unique). Rollout uses Neon's standard apply path. Validate on staging first. | +| **API (Cloudflare Workers)** | Route handlers gain a coalesce + a different insert path (`onConflictDoNothing` + re-fetch). Eden Treaty types regenerate; downstream consumers see `clientUuid?` on POST bodies. | Additive: no breaking type changes for callers that don't opt in. Existing E2E tests should continue passing without modification. | +| **MCP (Cloudflare Workers)** | `add_pack`, `add_trip`, `add_pack_item` etc. tools drop the `id` parameter — server mints. | Tool schemas change; MCP clients (Claude Desktop, custom integrations) get the regenerated tool definitions automatically on next connection. No data-shape change in responses except the new `clientUuid` field. | +| **CLI (`@packrat/cli`)** | `packrat packs create`, `packrat trips create`, `packrat templates create` stop minting `shortId('p')` etc. Server fills `clientUuid`. The `shortId` function and the `uuid` dep can stay — they'll be needed when CLI adds an explicit `--client-uuid` flag for idempotent retry support in a follow-up. | The `shortId` function lives on for that future use. The `uuid` dep stays. | +| **Mobile (`apps/expo`)** | Zero changes in Phase 1. The compat shim (`data.id ?? data.clientUuid ?? mintId`) lets the mobile sync continue sending `id` for now. Deprecation warning fires on every mobile request, which is the signal that PR 3 needs to happen. | Watch the warning rate post-deploy. PR 3 plan can be written once Phase 1 is in production. | +| **Web (`apps/web`, `apps/admin`, `apps/landing`, `apps/guides`)** | None — these don't write to the seven affected tables in a way that hits the changed shape. Admin SPA reads only. | n/a | +| **OpenAPI / Eden Treaty types** | Response schemas regenerate to include `clientUuid: string` on every row shape. | Type bump only; no consumer is currently destructuring response shapes in a way that would break on additive fields. | + +--- + +## 8. Implementation units + +### U1. Drizzle schema update + migration 0048 + +**Goal:** Add `client_uuid` to seven tables in the Drizzle schema and generate the matching SQL migration. Backfill from existing `id`. Add UNIQUE and format CHECK. + +**Requirements:** R1, R2, R9 + +**Dependencies:** none — starting point. + +**Files:** +- Modify: `packages/db/src/schema.ts` (seven `pgTable` definitions: `packs` L110, `packItems` L225, `packWeightHistory` L261, `packTemplates` L274, `packTemplateItems` L296, `trailConditionReports` L322, `trips` L358) +- Create: `packages/api/drizzle/0048_add_client_uuid.sql` +- Test (via `bun test:api:unit`): `packages/api/test/migrations.test.ts` if it exists; otherwise spot-checked manually + +**Approach:** +- For each table, add `clientUuid: text('client_uuid').unique().notNull()` to the column definitions. +- Run `bun drizzle-kit generate` to produce the migration SQL. +- Manually edit the generated SQL to add the backfill `UPDATE table SET client_uuid = id` between the `ADD COLUMN` and `SET NOT NULL` steps (Drizzle won't generate this automatically). +- Add the format CHECK constraint as a raw SQL `ALTER TABLE ... ADD CONSTRAINT ...` block at the end of the migration. +- Verify the migration is reversible by writing a sibling `0048_add_client_uuid_DOWN.sql` (or document the rollback as `DROP COLUMN client_uuid` per table in this plan). +- Pattern reference: existing `packages/api/drizzle/0040_uuid_pk_better_auth_migration.sql` for migration style (raw SQL with comments, explicit constraint names). + +**Test scenarios:** +- Test expectation: none — pure migration. Verification happens on staging Neon DB before production. + +**Verification:** +- `bun drizzle-kit generate` produces no further diff after the migration is written (schema matches migration output). +- Applying 0048 to a fresh DB seeded with existing data: all rows get `client_uuid = id`; no rows fail the UNIQUE or CHECK constraints (because existing `id` values are already unique and already match the charset). +- The seven tables show `client_uuid` as `not null` + `unique` + `check` in `\d table_name` on staging. + +--- + +### U2. Re-introduce `mintId` helper + +**Goal:** Re-add `packages/api/src/utils/ids.ts` with the `mintId(prefix)` helper. Header comment makes clear it's server-side, fills `clientUuid` (and `id` until Phase 2). + +**Requirements:** R4, R7 + +**Dependencies:** U1 (so the column it's filling exists in the schema types). + +**Files:** +- Create: `packages/api/src/utils/ids.ts` +- Test: covered indirectly via U3-U6 idempotency tests. + +**Approach:** +- One exported function: `export function mintId(prefix: string): string`. +- Format: `${prefix}_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}` — matches the format that's in production right now via every existing row. +- Header comment: explain this is a server-side default for `clientUuid` when the caller doesn't supply one. Distinct from any client-side ID generation. + +**Test scenarios:** +- Test expectation: none — single pure function, covered by integration tests in U3. + +**Verification:** +- `bun check-types` passes after the file is added. +- Routes that import `mintId` (added in U3-U6) compile. + +--- + +### U3. API: `/packs` and `/packs/:packId/items` accept + return `clientUuid` + +**Goal:** Update Zod schemas + handlers for the pack and pack-item endpoints to accept optional `clientUuid`, run the compat coalesce, do `onConflictDoNothing` upserts, return the new field. Includes the weight-history endpoint. + +**Requirements:** R3, R4, R5, R6, R8 + +**Dependencies:** U1 (schema), U2 (mintId). + +**Files:** +- Modify: `packages/schemas/src/packs.ts` (add `clientUuid` to `CreatePackBodySchema`, `AddPackItemBodySchema`, `CreatePackWeightHistoryBodySchema`, `PackSchema`, `PackItemSchema`, `PackWeightHistoryResponseSchema`) +- Modify: `packages/api/src/routes/packs/index.ts` (handlers for `POST /`, `POST /:packId/items`, `POST /:packId/weight-history`) +- Test: `packages/api/test/packs.test.ts` (extend existing; add idempotency tests) + +**Approach:** +- Zod: `clientUuid: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/).optional()` on request schemas. `clientUuid: z.string()` on response schemas. +- Handler pattern (pseudocode, directional): + ``` + const clientUuid = data.clientUuid ?? data.id ?? mintId('p'); + if (data.id && !data.clientUuid) { + console.warn('deprecated_id_field', { userId, route: 'POST /packs' }); + } + const [newRow] = await db.insert(packs).values({ ... + id: mintId('p'), // server-owned from here forward + clientUuid, + ... + }).onConflictDoNothing({ target: packs.clientUuid }).returning(); + if (!newRow) { + return await db.query.packs.findFirst({ + where: and(eq(packs.clientUuid, clientUuid), eq(packs.userId, user.userId)) + }); + } + ``` +- The `userId` scope on the re-fetch is important: it prevents a malicious client from probing the `clientUuid` namespace of other users (the UNIQUE constraint is global, but a user should only ever see their own rows). +- Response always includes `clientUuid` — populate from the inserted/fetched row. + +**Patterns to follow:** +- The pre-existing `pgCode === '23505'` re-fetch pattern in `packages/api/src/routes/trailConditions/reports.ts` POST handler — same structural shape, but `onConflictDoNothing` is cleaner than catching the unique-violation error. + +**Test scenarios:** +- **Happy path — new pack with `clientUuid`:** `POST /packs` with `{ clientUuid: 'p_abc...', name: 'Test' }`. Expect 200 with response `{ id, clientUuid: 'p_abc...', name: 'Test', ... }`. Verify DB row has `client_uuid = 'p_abc...'`. +- **Happy path — new pack without `clientUuid` (lean caller):** `POST /packs` with `{ name: 'Test' }`. Expect 200 with response containing a server-minted `clientUuid` matching `/^p_[a-f0-9]{12}$/`. +- **Compat path — old client sending `id`:** `POST /packs` with `{ id: 'p_legacy123', name: 'Test' }`. Expect 200 with response `{ id, clientUuid: 'p_legacy123', ... }`. Verify a deprecation warning fired (spy on `console.warn`). +- **Idempotency — same `clientUuid` twice:** Send `POST /packs` with `{ clientUuid: 'p_abc', name: 'Original' }`, then resend with `{ clientUuid: 'p_abc', name: 'Different' }`. Second call returns the *original* row (name still 'Original'). DB has exactly one row with `client_uuid = 'p_abc'`. +- **Cross-user isolation:** User A creates pack with `clientUuid: 'shared_uuid'`. User B's `POST /packs` with the same `clientUuid` succeeds (different `userId`) — verify two distinct rows exist with the same `client_uuid` only if the UNIQUE constraint is per-user-scoped. **Decision required during implementation:** the current design has `client_uuid UNIQUE` globally; if cross-user collision is a real concern, the UNIQUE becomes `(user_id, client_uuid)`. The design doc §2.2 says global UNIQUE; flag in PR review if telemetry suggests otherwise. +- **Format violation — invalid `clientUuid`:** `POST /packs` with `{ clientUuid: 'has spaces!', name: 'Test' }`. Expect 422 with field-level Zod error before hitting the DB. +- **Format violation — too long:** `POST /packs` with a 65-char `clientUuid`. Expect 422. +- **Edge — empty `clientUuid` string:** `POST /packs` with `{ clientUuid: '', name: 'Test' }`. Should be rejected at Zod (`.regex` doesn't match empty); expect 422. + +**Verification:** +- All test scenarios pass via `bun test:api:unit`. +- `bun check-types` clean. +- Manual smoke: `curl` against local dev API; confirm `clientUuid` round-trips and idempotency holds. + +--- + +### U4. API: `/trips` and `/pack-templates` accept + return `clientUuid` + +**Goal:** Same shape as U3, applied to the trip + pack-template + pack-template-item endpoints. + +**Requirements:** R3, R4, R5, R6, R8 + +**Dependencies:** U1 (schema), U2 (mintId), U3 (sets the handler pattern). + +**Files:** +- Modify: `packages/schemas/src/trips.ts` (add `clientUuid` to `CreateTripBodySchema`, `TripSchema`) +- Modify: `packages/schemas/src/packTemplates.ts` (add to `CreatePackTemplateRequestSchema`, `CreatePackTemplateItemRequestSchema`, and their response counterparts) +- Modify: `packages/api/src/routes/trips/index.ts` (POST `/` handler) +- Modify: `packages/api/src/routes/packTemplates/index.ts` (POST `/`, POST `/:templateId/items` handlers) +- Test: `packages/api/test/trips.test.ts`, `packages/api/test/packTemplates.test.ts` (extend or create) + +**Approach:** Mirror U3's pattern. Use prefix `t_` for trips, `pt_` for templates, `pti_` for template items. + +**Test scenarios:** +- For each of the three POST endpoints (`/trips`, `/pack-templates`, `/pack-templates/:templateId/items`), repeat the four core scenarios from U3: + - Happy path with explicit `clientUuid`. + - Happy path without `clientUuid` (server mints). + - Compat path with legacy `id` (deprecation warning fires). + - Idempotent retry with the same `clientUuid`. +- Trip-specific edge — `packId` FK is currently `text → packs.id`. Verify creating a trip with a `clientUuid` and a `packId` referencing an existing pack still works (the FK column doesn't change in Phase 1). + +**Verification:** +- All trip + template tests pass. +- `bun check-types` clean. + +--- + +### U5. API: `/trail-conditions/reports` accept + return `clientUuid` + +**Goal:** Same shape as U3-U4, applied to the trail-condition-reports POST endpoint. + +**Requirements:** R3, R4, R5, R6, R8 + +**Dependencies:** U1, U2, U3. + +**Files:** +- Modify: `packages/schemas/src/trailConditions.ts` +- Modify: `packages/api/src/routes/trailConditions/reports.ts` (POST handler — note it currently has a `pgCode === '23505'` re-fetch path that should be *replaced* with `onConflictDoNothing` for consistency with U3) +- Test: `packages/api/test/trailConditions.test.ts` (create if doesn't exist) + +**Approach:** Mirror U3. Use prefix `tcr_`. Replace the existing 23505-catch idempotency with `onConflictDoNothing` for parity with the other endpoints. + +**Test scenarios:** Same four-scenario template as U3-U4, applied to `POST /trail-conditions/reports`. + +**Verification:** Tests pass; check-types clean. + +--- + +### U6. MCP tools drop client-side ID minting + +**Goal:** Remove `id`-supplying behavior from the MCP create tools. Tools no longer accept `id` as input; the body sent to the API omits `clientUuid`; server mints. + +**Requirements:** R10 + +**Dependencies:** U3, U4, U5 (so server-mint path works). + +**Files:** +- Modify: `packages/mcp/src/tools/packs.ts` (`add_pack`, `add_pack_item`) +- Modify: `packages/mcp/src/tools/trips.ts` (`add_trip` if it exists) +- Modify: `packages/mcp/src/tools/packTemplates.ts` (`create_pack_template`, `add_pack_template_item`) +- Test: `packages/mcp/test/*.test.ts` (smoke — verify tool definitions don't accept `id`) + +**Approach:** +- Remove `id` from each tool's `inputSchema`. +- Remove `id` from the body sent in the `agent.api.user.packs.post({...})` call. +- Keep all other fields unchanged. + +**Test scenarios:** +- **Tool registration:** Confirm `add_pack` tool's input schema doesn't include `id` (the design's "server mints" intent is enforced at the schema level). +- **Tool invocation:** Calling `add_pack` with `{ name: 'X', ... }` returns a pack with server-minted `id` and `clientUuid`. +- Test expectation per tool: one happy-path test confirming the field is gone from the schema and the response shape has `clientUuid`. + +**Verification:** MCP test suite passes (`bun test:mcp`). Manual: invoke `add_pack` via the MCP dev harness, confirm the schema in `tools/list` lacks `id`. + +--- + +### U7. CLI commands drop client-side ID minting + +**Goal:** Remove `id: shortId('p')` etc. from the CLI create commands. Server mints via the omitted `clientUuid`. + +**Requirements:** R10 + +**Dependencies:** U3, U4, U6 (the API + MCP changes set the pattern). + +**Files:** +- Modify: `packages/cli/src/commands/packs/create.ts` (drop `id: shortId('p')`) +- Modify: `packages/cli/src/commands/trips/index.ts` (drop `id: shortId('t')`) +- Modify: `packages/cli/src/commands/templates/index.ts` (drop `id: shortId('pt')`) +- Keep: `packages/cli/src/api/ids.ts` — `shortId` function and `uuid` dep stay, for future use (e.g., explicit `--client-uuid` flag for retry idempotency from the shell). + +**Approach:** +- Remove the `id:` field from each `.post({...})` body. +- Leave the `shortId` import in place if it's used elsewhere; if not, remove the unused import. + +**Test scenarios:** +- Test expectation: none — CLI doesn't currently have unit tests for these commands. Manual smoke: `packrat packs create "My Pack"` succeeds and the printed response shows a server-minted `id`. + +**Verification:** +- `bun check-types` passes (no broken imports). +- Manual: run `packrat packs create "Test"` against local dev API, confirm a pack is created and the response includes both `id` and `clientUuid`. + +--- + +### U8. Documentation: design doc status + plan handoff notes + +**Goal:** Update the design doc to note Phase 1 PR 1+2 is shipping under this plan; note follow-up plans for PR 3, 4, 5; cross-link. + +**Requirements:** none (housekeeping). + +**Dependencies:** U1-U7 (so the doc reflects what actually landed). + +**Files:** +- Modify: `docs/design/client-uuid-split.md` (add a "Status" banner at top + per-PR status notes in §9) + +**Approach:** +- Top of doc: change `Status: Draft — for review, no code yet.` to `Status: Phase 1 PR 1+2 in progress (see [plan](../plans/2026-05-17-001-feat-client-uuid-split-phase1-plan.md)).` +- §9 PR 1 and PR 2: append `[shipping in plan 2026-05-17-001]`. +- §9 PR 3-5: note these are deferred to follow-up plans. + +**Test scenarios:** none. + +**Verification:** Markdown renders without broken links. + +--- + +## 9. Phased delivery + +This plan delivers two atomic PRs in sequence: + +### PR A — DB + helper (U1, U2) + +- Drizzle schema update. +- Migration 0048. +- `mintId` helper re-introduced. +- No route changes yet. + +**Mergeable signal:** Migration applies cleanly on staging; UNIQUE + CHECK constraints hold on existing data; Drizzle schema generates without further diff. + +### PR B — API + clients (U3, U4, U5, U6, U7, U8) + +- All Zod request + response schemas updated. +- All POST handlers updated. +- MCP tools updated. +- CLI commands updated. +- Design doc updated. + +**Mergeable signal:** All API tests pass (especially idempotency tests); `bun check-types` clean; manual smoke against local dev shows `clientUuid` round-trips and retries are idempotent. + +PR B depends on PR A landing (the column has to exist before the routes can write to it). Squash-merge both, keeping the design doc's PR numbering (PR 1 and PR 2 from §9). + +--- + +## 10. Risk analysis & mitigation + +| Risk | Likelihood | Severity | Mitigation | +| --- | --- | --- | --- | +| Backfill `UPDATE clients_uuid = id` fails because some existing `id` values exceed 64 chars or contain disallowed chars. | Low | Medium — blocks migration | Spot-check production data length distribution + charset before merging PR A. Existing IDs are either `_<12hex>` (16 chars) or 21-char nanoid (both URL-safe). Should be clean, but verify. | +| `onConflictDoNothing` semantics differ between drizzle-orm versions. | Low | Medium | We're on `drizzle-orm: ^0.45.2` (verified in package.json catalog). The conflict syntax is stable since 0.30. Pin the version via the catalog if needed. | +| Compat shim creates a race: client A sends `id: foo`, client B sends `clientUuid: foo` — second one hits a UNIQUE violation when it shouldn't. | Low | Low — manifests as a retryable 5xx | The CHECK constraint catches malformed values at insert time; the UNIQUE constraint correctly rejects duplicates. The "shouldn't" framing is wrong — two callers using the same UUID is genuinely a duplicate, and idempotent return-existing is the right behavior. | +| `console.warn('deprecated_id_field')` floods Cloudflare logs once mobile starts hitting it. | Medium | Low | Set up a Logpush filter to sample 1-in-100 of these. Or use `console.info` to keep it out of the warning tier. | +| Re-fetch after `onConflictDoNothing` is missing the user-scoped check, allowing a malicious client to probe other users' `clientUuid` namespace via timing. | Medium | High — privacy/IDOR | The re-fetch query MUST include `eq(packs.userId, user.userId)`. Code review must verify this on every endpoint. The test scenario "Cross-user isolation" in U3 covers it. | +| Mobile keeps sending `id` indefinitely because PR 3 (mobile store rewrite) gets deprioritized. | Medium | Low — Phase 2 stays blocked | This is fine. Phase 1 doesn't depend on mobile updating. The deprecation warning rate just stays high until PR 3 ships. Phase 2 only unblocks once mobile has cut over. | +| Drizzle migration generator produces wrong SQL for the column-add + backfill + NOT-NULL sequence. | Medium | Medium | The generator definitely won't produce the `UPDATE` between `ADD COLUMN` and `SET NOT NULL` — that has to be added manually. Always inspect the generated SQL; don't blindly trust `drizzle-kit generate`. | + +--- + +## 11. Open questions deferred to implementation + +These come up only once the code is being written, not before: + +- **Drizzle CHECK constraint syntax:** drizzle-orm `0.45.2` may or may not expose `pgTable`'s `check()` builder cleanly. If the generator emits `ALTER TABLE` for the CHECK separately from the column, we hand-write the SQL. Decide on inspection of the generated 0048. +- **Test fixture updates:** `packages/api/test/fixtures/{pack,trip}-fixtures.ts` likely supply `id` directly. They'll need to either omit `id` (let server mint) or supply `clientUuid` instead. Inspect when wiring U3. +- **Eden Treaty type regeneration:** does the OpenAPI generator pick up the new optional `clientUuid` automatically, or does something need to be re-run? Confirm on the first API change. +- **TrailConditionReports — does it have an existing test suite?** §6 of design doc says §5 is shipping in next release cycle. Confirm test infra exists before extending in U5. + +--- + +## 12. Success criteria + +- All API tests pass, including five idempotency scenarios per affected endpoint (U3-U5). +- Migration 0048 applies cleanly on staging; no existing rows fail UNIQUE or CHECK. +- `bun check-types` passes across the monorepo. +- Eden Treaty regenerates with `clientUuid` on every affected response shape. +- Manual smoke: `curl POST /packs` with the same `clientUuid` twice returns the same row both times. +- Deprecation warning fires exactly once per `id`-only request (verify in logs after deploy). +- MCP tool definitions no longer accept `id` for `add_pack`, `add_trip`, `create_pack_template`, `add_pack_template_item`. +- CLI commands `packrat packs create`, `packrat trips create`, `packrat templates create` succeed without supplying `id` to the body. + +Once Phase 1 is live for ~30 days with the deprecation rate trending to zero from mobile sources, PR 5 (drop the compat shim) can be planned. Phase 2 (`bigserial` narrow) starts as a separate design doc. From e098fd14da9077de84c4c82febda4de424e1fdc9 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 18 May 2026 04:37:39 -0600 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=90=9B=20fix=20typecheck:=20align=20O?= =?UTF-8?q?mit=20signature=20+=20mobile=20Trip=20cast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packService.buildPackItems return: add `clientUuid` to the omitted fields so the synthetic items in generatePacks satisfy NewPackItem. - trips/store/trips.ts: cast TripSchema-parsed return through `unknown as TripInStore` until the local Trip interface aligns with @packrat/schemas (PR 3 of the client-uuid-split). The interface drift (description: string vs string|null) pre-dates this PR but my schema surfaced it via syncedCrud's stricter generic resolution. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/expo/features/trips/store/trips.ts | 15 ++++++++++++--- packages/api/src/services/packService.ts | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/expo/features/trips/store/trips.ts b/apps/expo/features/trips/store/trips.ts index d14d5c0bc6..58b00bf473 100644 --- a/apps/expo/features/trips/store/trips.ts +++ b/apps/expo/features/trips/store/trips.ts @@ -7,10 +7,17 @@ import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; import type { TripInStore } from '../types'; +// The mobile `Trip` / `TripInStore` interface (apps/expo/features/trips/types.ts) +// pre-dates the @packrat/schemas TripSchema and drifts from it (description is +// not-nullable locally, nullable in schema). Aligning the two is part of PR 3 +// of the client-uuid-split (docs/design/client-uuid-split.md §9). For now, the +// schema-parsed return is `unknown`-cast through `TripInStore[]` to keep +// syncedCrud's generic happy. const listTrips = async () => { const { data, error } = await apiClient.trips.get({ query: { includePublic: 0 } }); if (error) throw new Error(`Failed to list trips: ${error.value}`); - return TripSchema.array().parse(data); + // safe-cast: mobile TripInStore drifts from TripSchema (description nullability); aligned in PR 3. + return TripSchema.array().parse(data) as unknown as TripInStore[]; }; const createTrip = async (tripData: TripInStore) => { @@ -30,7 +37,8 @@ const createTrip = async (tripData: TripInStore) => { localUpdatedAt: tripData.localUpdatedAt ?? new Date().toISOString(), }); if (error) throw new Error(`Failed to create trip: ${error.value}`); - return TripSchema.parse(data); + // safe-cast: mobile TripInStore drifts from TripSchema; aligned in PR 3. + return TripSchema.parse(data) as unknown as TripInStore; }; const updateTrip = async ({ id, ...data }: Partial) => { @@ -45,7 +53,8 @@ const updateTrip = async ({ id, ...data }: Partial) => { ...(data.localUpdatedAt ? { localUpdatedAt: data.localUpdatedAt } : {}), }); if (error) throw new Error(`Failed to update trip: ${error.value}`); - return TripSchema.parse(result); + // safe-cast: mobile TripInStore drifts from TripSchema; aligned in PR 3. + return TripSchema.parse(result) as unknown as TripInStore; }; // Observable trips store diff --git a/packages/api/src/services/packService.ts b/packages/api/src/services/packService.ts index e9c36d1d39..f665843999 100644 --- a/packages/api/src/services/packService.ts +++ b/packages/api/src/services/packService.ts @@ -132,7 +132,7 @@ export class PackService { private async getItems( packItemConcepts: PackItemConceptSchema[], - ): Promise[]> { + ): Promise[]> { const catalogService = new CatalogService(); const searchResults = await catalogService.batchVectorSearch( packItemConcepts.map((item) => item.item), From bb07111c0e75da6950c930fdc7222027fd8faac8 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Tue, 19 May 2026 21:39:43 -0600 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=94=92=20fix(test):=20use=20mintId=20?= =?UTF-8?q?in=20fixtures=20instead=20of=20Math.random?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged Math.random() in test fixtures as insecure (4 high alerts). Replace with the existing mintId helper from packages/api/src/utils/ids.ts — same crypto.randomUUID format as production rows. Dedupes the regex and the charset logic instead of duplicating it inline. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/test/fixtures/pack-fixtures.ts | 5 +++-- packages/api/test/fixtures/pack-template-fixtures.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/api/test/fixtures/pack-fixtures.ts b/packages/api/test/fixtures/pack-fixtures.ts index 45a04b709f..45fbcb61f5 100644 --- a/packages/api/test/fixtures/pack-fixtures.ts +++ b/packages/api/test/fixtures/pack-fixtures.ts @@ -1,3 +1,4 @@ +import { mintId } from '@packrat/api/utils/ids'; import type { packItems, packs } from '@packrat/db'; import type { InferInsertModel } from 'drizzle-orm'; @@ -10,7 +11,7 @@ type PackItemOverrides = Partial> & { userId: */ export const createTestPack = (overrides: PackOverrides): InferInsertModel => { const now = new Date(); - const id = overrides.id ?? `pack_test_${Date.now()}_${Math.random().toString(36).substring(7)}`; + const id = overrides.id ?? mintId('pack_test'); return { id, clientUuid: overrides.clientUuid ?? id, @@ -33,7 +34,7 @@ export const createTestPackItem = ( packId: string, overrides: PackItemOverrides, ): InferInsertModel => { - const id = overrides.id ?? `item_test_${Date.now()}_${Math.random().toString(36).substring(7)}`; + const id = overrides.id ?? mintId('item_test'); return { id, clientUuid: overrides.clientUuid ?? id, diff --git a/packages/api/test/fixtures/pack-template-fixtures.ts b/packages/api/test/fixtures/pack-template-fixtures.ts index 045f5fe125..cdd7fe904b 100644 --- a/packages/api/test/fixtures/pack-template-fixtures.ts +++ b/packages/api/test/fixtures/pack-template-fixtures.ts @@ -1,3 +1,4 @@ +import { mintId } from '@packrat/api/utils/ids'; import type { packTemplateItems, packTemplates } from '@packrat/db'; import type { InferInsertModel } from 'drizzle-orm'; @@ -11,7 +12,7 @@ export const createTestPackTemplate = ( overrides: PackTemplateOverrides, ): InferInsertModel => { const now = new Date(); - const id = overrides.id ?? `pt_test_${Date.now()}_${Math.random().toString(36).substring(7)}`; + const id = overrides.id ?? mintId('pt_test'); return { id, clientUuid: overrides.clientUuid ?? id, @@ -40,7 +41,7 @@ export const createTestPackTemplateItem = ( packTemplateId: string, overrides: PackTemplateItemOverrides, ): InferInsertModel => { - const id = overrides.id ?? `pti_test_${Date.now()}_${Math.random().toString(36).substring(7)}`; + const id = overrides.id ?? mintId('pti_test'); return { id, clientUuid: overrides.clientUuid ?? id, From 1852074ece11f38feb50107dbbdafc6f6cce8ada Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Tue, 19 May 2026 22:00:05 -0600 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=94=80=20merge:=20development=20into?= =?UTF-8?q?=20feat/client-uuid-split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves three CLI conflicts from dev's `runApi` refactor (positional args → options object). My "no clientUuid" changes preserved at the new call shape. Note: dev itself has 12 pre-existing typecheck errors (getRelativeTime test signature, embeddingHelper test tuple length, og-meta test imports). Confirmed by checking out origin/development directly — same 12 errors. Not introduced here. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../utils/__tests__/computeCategories.test.ts | 131 +++++++ .../lib/utils/__tests__/dateUtils.test.ts | 92 +++++ apps/guides/__tests__/og-meta.test.ts | 286 ++++++++++++++ apps/guides/app/global-error.tsx | 114 ++++++ apps/guides/pages/_document.tsx | 16 + apps/landing/README.md | 93 +++++ apps/landing/__tests__/og-meta.test.ts | 256 +++++++++++++ apps/landing/app/global-error.tsx | 114 ++++++ apps/landing/pages/_document.tsx | 16 + apps/trails/__tests__/og-image.test.ts | 77 ++++ apps/trails/__tests__/og-meta.test.ts | 121 ++++++ apps/trails/app/opengraph-image.tsx | 14 + apps/trails/app/twitter-image.tsx | 14 + apps/trails/lib/metadata.ts | 34 ++ apps/trails/lib/og-image.tsx | 118 ++++++ apps/trails/scripts/generate-og-images.ts | 62 ++++ apps/trails/vitest.config.ts | 17 + .../src/auth/__tests__/auth.helpers.test.ts | 116 ++++++ packages/api/src/auth/auth.helpers.ts | 46 +++ .../__tests__/passwordResetService.test.ts | 255 +++++++++++++ .../services/__tests__/userService.test.ts | 151 ++++++++ .../src/utils/__tests__/routeParams.test.ts | 103 ++++++ packages/mcp/src/__tests__/client.test.ts | 348 ++++++++++++++++++ packages/mcp/src/__tests__/constants.test.ts | 65 ++++ packages/mcp/src/__tests__/enums.test.ts | 125 +++++++ packages/overpass/src/client.test.ts | 134 +++++++ scripts/lint/no-owned-max-params.ts | 267 ++++++++++++++ 27 files changed, 3185 insertions(+) create mode 100644 apps/expo/features/packs/utils/__tests__/computeCategories.test.ts create mode 100644 apps/expo/lib/utils/__tests__/dateUtils.test.ts create mode 100644 apps/guides/__tests__/og-meta.test.ts create mode 100644 apps/guides/app/global-error.tsx create mode 100644 apps/guides/pages/_document.tsx create mode 100644 apps/landing/README.md create mode 100644 apps/landing/__tests__/og-meta.test.ts create mode 100644 apps/landing/app/global-error.tsx create mode 100644 apps/landing/pages/_document.tsx create mode 100644 apps/trails/__tests__/og-image.test.ts create mode 100644 apps/trails/__tests__/og-meta.test.ts create mode 100644 apps/trails/app/opengraph-image.tsx create mode 100644 apps/trails/app/twitter-image.tsx create mode 100644 apps/trails/lib/metadata.ts create mode 100644 apps/trails/lib/og-image.tsx create mode 100644 apps/trails/scripts/generate-og-images.ts create mode 100644 apps/trails/vitest.config.ts create mode 100644 packages/api/src/auth/__tests__/auth.helpers.test.ts create mode 100644 packages/api/src/auth/auth.helpers.ts create mode 100644 packages/api/src/services/__tests__/passwordResetService.test.ts create mode 100644 packages/api/src/services/__tests__/userService.test.ts create mode 100644 packages/api/src/utils/__tests__/routeParams.test.ts create mode 100644 packages/mcp/src/__tests__/client.test.ts create mode 100644 packages/mcp/src/__tests__/constants.test.ts create mode 100644 packages/mcp/src/__tests__/enums.test.ts create mode 100644 packages/overpass/src/client.test.ts create mode 100644 scripts/lint/no-owned-max-params.ts diff --git a/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts b/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts new file mode 100644 index 0000000000..471639df78 --- /dev/null +++ b/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts @@ -0,0 +1,131 @@ +import type { Pack, PackItem } from 'expo-app/features/packs/types'; +import { describe, expect, it, vi } from 'vitest'; +import { computeCategorySummaries } from '../computeCategories'; + +vi.mock('expo-app/features/auth/store', () => ({ + userStore: { + preferredWeightUnit: { + peek: vi.fn().mockReturnValue('g'), + }, + }, +})); + +function makeItem( + overrides: Partial & Pick, +): PackItem { + return { + id: 'item-1', + name: 'Test Item', + quantity: 1, + category: 'Shelter', + consumable: false, + worn: false, + packId: 'pack-1', + deleted: false, + isAIGenerated: false, + ...overrides, + }; +} + +function makePack(items: PackItem[]): Pack { + return { + id: 'pack-1', + name: 'Test Pack', + category: 'hiking', + isPublic: false, + deleted: false, + items, + baseWeight: 0, + totalWeight: 0, + }; +} + +describe('computeCategorySummaries', () => { + it('returns empty array for a pack with no items', () => { + expect(computeCategorySummaries(makePack([]))).toEqual([]); + }); + + it('groups items under the correct category name', () => { + const items = [ + makeItem({ id: 'i1', weight: 200, weightUnit: 'g', category: 'Shelter' }), + makeItem({ id: 'i2', weight: 300, weightUnit: 'g', category: 'Food' }), + ]; + const result = computeCategorySummaries(makePack(items)); + expect(result).toHaveLength(2); + const names = result.map((c) => c.name); + expect(names).toContain('Shelter'); + expect(names).toContain('Food'); + }); + + it('falls back to "Other" for empty category string', () => { + const items = [makeItem({ id: 'i1', weight: 100, weightUnit: 'g', category: '' })]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.name).toBe('Other'); + }); + + it('falls back to "Other" for whitespace-only category', () => { + const items = [makeItem({ id: 'i1', weight: 100, weightUnit: 'g', category: ' ' })]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.name).toBe('Other'); + }); + + it('computes weight in preferred unit (grams)', () => { + const items = [makeItem({ weight: 500, weightUnit: 'g', category: 'Pack' })]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.weight).toBe(500); + }); + + it('converts weight units before computing (kg → g)', () => { + const items = [makeItem({ weight: 1, weightUnit: 'kg', category: 'Pack' })]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.weight).toBe(1000); + }); + + it('multiplies weight by quantity', () => { + const items = [makeItem({ weight: 100, weightUnit: 'g', quantity: 3, category: 'Food' })]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.weight).toBe(300); + }); + + it('sets percentage to 100 for a single-category pack', () => { + const items = [makeItem({ weight: 300, weightUnit: 'g', category: 'Electronics' })]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.percentage).toBe(100); + }); + + it('splits percentage evenly across equal-weight categories', () => { + const items = [ + makeItem({ id: 'i1', weight: 500, weightUnit: 'g', category: 'Shelter' }), + makeItem({ id: 'i2', weight: 500, weightUnit: 'g', category: 'Food' }), + ]; + const result = computeCategorySummaries(makePack(items)); + for (const cat of result) { + expect(cat.percentage).toBe(50); + } + }); + + it('counts item rows (not total quantity) in each category', () => { + const items = [ + makeItem({ id: 'i1', weight: 100, weightUnit: 'g', quantity: 5, category: 'Food' }), + makeItem({ id: 'i2', weight: 200, weightUnit: 'g', quantity: 2, category: 'Food' }), + ]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.items).toBe(2); + }); + + it('merges multiple items in the same category', () => { + const items = [ + makeItem({ id: 'i1', weight: 300, weightUnit: 'g', category: 'Shelter' }), + makeItem({ id: 'i2', weight: 200, weightUnit: 'g', category: 'Shelter' }), + ]; + const result = computeCategorySummaries(makePack(items)); + expect(result).toHaveLength(1); + expect(result[0]?.weight).toBe(500); + }); + + it('sets percentage to 0 when total weight is zero', () => { + const items = [makeItem({ weight: 0, weightUnit: 'g', category: 'Empty' })]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.percentage).toBe(0); + }); +}); diff --git a/apps/expo/lib/utils/__tests__/dateUtils.test.ts b/apps/expo/lib/utils/__tests__/dateUtils.test.ts new file mode 100644 index 0000000000..51ff86223e --- /dev/null +++ b/apps/expo/lib/utils/__tests__/dateUtils.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { formatLocalDate, parseLocalDate } from '../dateUtils'; + +describe('parseLocalDate', () => { + it('returns null for undefined', () => { + expect(parseLocalDate(undefined)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(parseLocalDate('')).toBeNull(); + }); + + it('parses YYYY-MM-DD as a local date with correct year, month, and day', () => { + const result = parseLocalDate('2024-01-15'); + expect(result).not.toBeNull(); + expect(result?.getFullYear()).toBe(2024); + expect(result?.getMonth()).toBe(0); // January + expect(result?.getDate()).toBe(15); + }); + + it('parses end-of-year date correctly', () => { + const result = parseLocalDate('2023-12-31'); + expect(result).not.toBeNull(); + expect(result?.getFullYear()).toBe(2023); + expect(result?.getMonth()).toBe(11); // December + expect(result?.getDate()).toBe(31); + }); + + it('returns null for an invalid YYYY-MM-DD date (month 13)', () => { + expect(parseLocalDate('2024-13-01')).toBeNull(); + }); + + it('returns null for an invalid YYYY-MM-DD date (day 32)', () => { + expect(parseLocalDate('2024-01-32')).toBeNull(); + }); + + it('parses ISO datetime strings', () => { + const result = parseLocalDate('2024-06-15T10:30:00Z'); + expect(result).not.toBeNull(); + expect(result?.getUTCFullYear()).toBe(2024); + expect(result?.getUTCMonth()).toBe(5); // June + }); + + it('returns null for completely invalid input', () => { + expect(parseLocalDate('not-a-date')).toBeNull(); + }); + + it('returns null for a non-standard pattern that looks date-like', () => { + expect(parseLocalDate('foo-bar-baz')).toBeNull(); + }); + + it('YYYY-MM-DD parses as local time (not UTC)', () => { + const result = parseLocalDate('2024-03-10'); + expect(result).not.toBeNull(); + // date-fns parse() with 'yyyy-MM-dd' sets hours to 0 in local time + expect(result?.getHours()).toBe(0); + expect(result?.getMinutes()).toBe(0); + }); +}); + +describe('formatLocalDate', () => { + it('returns em dash for undefined', () => { + expect(formatLocalDate(undefined)).toBe('—'); + }); + + it('returns em dash for empty string', () => { + expect(formatLocalDate('')).toBe('—'); + }); + + it('returns a non-empty locale string for a valid YYYY-MM-DD date', () => { + const result = formatLocalDate('2024-01-15'); + expect(result).not.toBe('—'); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + it('returns em dash for a completely invalid date string', () => { + expect(formatLocalDate('not-a-date')).toBe('—'); + }); + + it('returns a formatted string for ISO datetime', () => { + const result = formatLocalDate('2024-06-15T10:30:00Z'); + expect(result).not.toBe('—'); + expect(typeof result).toBe('string'); + }); + + it('returns a formatted string for end-of-year date', () => { + const result = formatLocalDate('2023-12-31'); + expect(result).not.toBe('—'); + expect(typeof result).toBe('string'); + }); +}); diff --git a/apps/guides/__tests__/og-meta.test.ts b/apps/guides/__tests__/og-meta.test.ts new file mode 100644 index 0000000000..6f57a6a7b1 --- /dev/null +++ b/apps/guides/__tests__/og-meta.test.ts @@ -0,0 +1,286 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as cheerio from 'cheerio'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const APP_DIR = path.resolve(__dirname, '..'); +const OUT_DIR = path.join(APP_DIR, 'out'); +const GUIDE_OUT_DIR = path.join(OUT_DIR, 'guide'); +const ROOT_INDEX = path.join(OUT_DIR, 'index.html'); + +/** + * Required OG / Twitter meta tag names for every guide post HTML page. + * The full set is asserted for a small random sample; the rest of the + * 500+ posts just get a smoke check (presence + absolute og:image). + */ +const REQUIRED_OG_META = [ + 'og:title', + 'og:description', + 'og:image', + 'og:image:width', + 'og:image:height', + 'og:type', + 'og:url', + 'og:site_name', + 'twitter:card', + 'twitter:title', + 'twitter:description', + 'twitter:image', +] as const; + +type MetaMap = Map; + +function parseMeta(html: string): MetaMap { + const $ = cheerio.load(html); + const meta: MetaMap = new Map(); + $('meta').each((_, el) => { + const property = $(el).attr('property') ?? $(el).attr('name'); + const content = $(el).attr('content'); + if (property && content && !meta.has(property)) { + meta.set(property, content); + } + }); + return meta; +} + +/** + * Next.js static export with no `trailingSlash` config writes each guide as + * `out/guide/.html`, not `out/guide//index.html`. We also have + * sibling `out/guide//opengraph-image/route.js` directories — filter + * those out by extension. + */ +function listGuideHtmlFiles(): string[] { + if (!fs.existsSync(GUIDE_OUT_DIR)) return []; + return fs + .readdirSync(GUIDE_OUT_DIR, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.html')) + .map((entry) => path.join(GUIDE_OUT_DIR, entry.name)); +} + +function slugFromFile(file: string): string { + return path.basename(file, '.html'); +} + +function sampleN(arr: T[], n: number): T[] { + if (arr.length <= n) return [...arr]; + const copy = [...arr]; + const out: T[] = []; + for (let i = 0; i < n; i++) { + const idx = Math.floor(Math.random() * copy.length); + const [picked] = copy.splice(idx, 1); + if (picked !== undefined) out.push(picked); + } + return out; +} + +function isAbsoluteHttps(url: string | undefined): boolean { + return typeof url === 'string' && url.startsWith('https://'); +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +describe('guides built HTML OG meta', () => { + // Building the full guides site (build-content + generate-og-images + next build) + // can take 60–180s on cold caches; vitest's default hook timeout is 60s. + beforeAll(() => { + if (!fs.existsSync(ROOT_INDEX)) { + execSync('bun run build', { + cwd: APP_DIR, + stdio: 'inherit', + }); + } + }, 240_000); + + const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + + it('root out/index.html exists', () => { + expect(fs.existsSync(ROOT_INDEX), 'expected out/index.html to exist after build').toBe(true); + }); + + it('out/og-image.png is present in the static export', () => { + const ogPath = path.join(OUT_DIR, 'og-image.png'); + expect( + fs.existsSync(ogPath), + 'og-image.png must be copied from public/ into the out/ static export by next build. ' + + 'If missing, run scripts/generate-og-images.ts before building.', + ).toBe(true); + }); + + it('out/og-image.png is a valid 1200×630 PNG', () => { + const buf = fs.readFileSync(path.join(OUT_DIR, 'og-image.png')); + expect(buf.subarray(0, 8), 'PNG signature').toEqual(PNG_SIGNATURE); + expect(buf.readUInt32BE(16), 'width').toBe(1200); + expect(buf.readUInt32BE(20), 'height').toBe(630); + expect(buf.length, 'file size').toBeGreaterThan(1024); + }); + + it('root out/index.html has full OG meta with absolute, root-scoped og:image', () => { + const html = fs.readFileSync(ROOT_INDEX, 'utf8'); + const meta = parseMeta(html); + + for (const tag of REQUIRED_OG_META) { + expect(meta.get(tag), `root: missing `).toBeTruthy(); + } + + const ogImage = meta.get('og:image'); + expect( + isAbsoluteHttps(ogImage), + `root og:image must be absolute https URL, got: ${ogImage}`, + ).toBe(true); + + // Root site image must be the static /og-image.png generated by + // scripts/generate-og-images.ts. With `output: 'export'`, the Next.js + // /opengraph-image metadata route does NOT produce a plain PNG that a CDN + // can serve — only og-image.png (pre-generated at build time) is valid. + expect(ogImage, 'root og:image must be the site-wide image, not a per-post one').not.toMatch( + /\/og\/[^/]+\.png/, + ); + expect(ogImage, 'root og:image must be /og-image.png').toMatch(/\/og-image\.png(\?|$)/); + + const twitterImage = meta.get('twitter:image'); + expect( + isAbsoluteHttps(twitterImage), + `root twitter:image must be absolute, got: ${twitterImage}`, + ).toBe(true); + expect(twitterImage, 'root twitter:image must not be a per-post one').not.toMatch( + /\/og\/[^/]+\.png/, + ); + expect(twitterImage, 'root twitter:image must be /og-image.png').toMatch( + /\/og-image\.png(\?|$)/, + ); + + expect(meta.get('twitter:card')).toBe('summary_large_image'); + expect(meta.get('og:type')).toBe('website'); + expect(meta.get('og:site_name')).toBe('PackRat Guides'); + }); + + it('guide post HTML files exist (>=1)', () => { + const files = listGuideHtmlFiles(); + expect(files.length, 'expected at least one out/guide/.html').toBeGreaterThan(0); + }); + + it('every guide post HTML has og:image present and absolute https', () => { + const files = listGuideHtmlFiles(); + const failures: string[] = []; + for (const file of files) { + const meta = parseMeta(fs.readFileSync(file, 'utf8')); + const ogImage = meta.get('og:image'); + if (!ogImage) { + failures.push(`${path.relative(OUT_DIR, file)}: missing og:image`); + continue; + } + if (!isAbsoluteHttps(ogImage)) { + // Relative og:image URLs break OG previews on most platforms. + failures.push(`${path.relative(OUT_DIR, file)}: og:image not absolute (${ogImage})`); + } + } + expect(failures, `OG image issues:\n${failures.join('\n')}`).toEqual([]); + }); + + it('sampled guide posts have full OG meta + per-post /og/.png image', () => { + const files = listGuideHtmlFiles(); + const sample = sampleN(files, 3); + expect(sample.length, 'expected to sample at least 1 guide HTML file').toBeGreaterThan(0); + + for (const file of sample) { + const slug = slugFromFile(file); + const meta = parseMeta(fs.readFileSync(file, 'utf8')); + + for (const tag of REQUIRED_OG_META) { + expect(meta.get(tag), `${slug}: missing `).toBeTruthy(); + } + + const ogImage = meta.get('og:image'); + expect(isAbsoluteHttps(ogImage), `${slug}: og:image must be absolute, got ${ogImage}`).toBe( + true, + ); + expect(ogImage, `${slug}: og:image should point at /og/${slug}.png`).toMatch( + new RegExp(`/og/${escapeRegex(slug)}\\.png$`), + ); + + const twitterImage = meta.get('twitter:image'); + expect( + isAbsoluteHttps(twitterImage), + `${slug}: twitter:image must be absolute, got ${twitterImage}`, + ).toBe(true); + expect(twitterImage, `${slug}: twitter:image should match og:image`).toMatch( + new RegExp(`/og/${escapeRegex(slug)}\\.png$`), + ); + + expect(meta.get('twitter:card'), `${slug}: twitter:card`).toBe('summary_large_image'); + expect(meta.get('og:type'), `${slug}: og:type`).toBe('article'); + expect(meta.get('og:site_name'), `${slug}: og:site_name`).toBe('PackRat Guides'); + + const width = Number(meta.get('og:image:width')); + const height = Number(meta.get('og:image:height')); + expect(width, `${slug}: og:image:width`).toBe(1200); + expect(height, `${slug}: og:image:height`).toBe(630); + } + }); +}); + +/** + * Optional live OG check. + * + * Set OG_LIVE_CHECK_URL to the deployed origin (e.g. + * `https://guides.packratai.com`) to fetch the homepage + a sample guide + * page over the wire and run them through `open-graph-scraper` — the same + * parser most platforms (LinkedIn, FB, microlink) use. This catches + * post-deploy regressions that a built-HTML check can miss (CF transforms, + * cache layers, etc.) but it isn't run by default because it requires + * network + a live deploy. + */ +describe.skipIf(!process.env.OG_LIVE_CHECK_URL)('live OG check', () => { + const liveUrl = (process.env.OG_LIVE_CHECK_URL ?? '').replace(/\/$/, ''); + + it('root URL has valid OG metadata via open-graph-scraper', async () => { + const mod = await import('open-graph-scraper'); + // open-graph-scraper is CJS (`module.exports = run`). After Node's + // interop the callable can be at `.default` or be the module itself + // depending on bundler — pick whichever is a function. + const ogs = + typeof (mod as { default?: unknown }).default === 'function' + ? (mod as { default: typeof mod.default }).default + : (mod as unknown as typeof mod.default); + const { result, error } = await ogs({ url: liveUrl, timeout: 15_000 }); + expect(error, `og fetch failed for ${liveUrl}`).toBeFalsy(); + expect(result.ogTitle, 'ogTitle').toBeTruthy(); + expect(result.ogDescription, 'ogDescription').toBeTruthy(); + expect(result.twitterCard).toBe('summary_large_image'); + const firstImage = result.ogImage?.[0]?.url; + expect(isAbsoluteHttps(firstImage), `root ogImage[0].url absolute (got ${firstImage})`).toBe( + true, + ); + expect(firstImage, 'root ogImage[0].url must not be per-post').not.toMatch(/\/og\/[^/]+\.png/); + }, 30_000); + + it('a sample guide page has valid OG metadata via open-graph-scraper', async () => { + // Pick a sample slug from the built HTML so we don't have to import + // lib/content.ts (which would fail in environments without the build). + const files = listGuideHtmlFiles(); + const first = files[0]; + if (!first) throw new Error('no built guide HTML available to sample a slug from'); + const slug = slugFromFile(first); + const target = `${liveUrl}/guide/${slug}`; + + const mod = await import('open-graph-scraper'); + const ogs = + typeof (mod as { default?: unknown }).default === 'function' + ? (mod as { default: typeof mod.default }).default + : (mod as unknown as typeof mod.default); + const { result, error } = await ogs({ url: target, timeout: 15_000 }); + expect(error, `og fetch failed for ${target}`).toBeFalsy(); + expect(result.ogTitle, 'ogTitle').toBeTruthy(); + expect(result.twitterCard).toBe('summary_large_image'); + const firstImage = result.ogImage?.[0]?.url; + expect(isAbsoluteHttps(firstImage), `guide ogImage[0].url absolute (got ${firstImage})`).toBe( + true, + ); + expect(firstImage, `guide ogImage[0].url should point at /og/${slug}.png`).toMatch( + new RegExp(`/og/${escapeRegex(slug)}\\.png$`), + ); + }, 30_000); +}); diff --git a/apps/guides/app/global-error.tsx b/apps/guides/app/global-error.tsx new file mode 100644 index 0000000000..58346ad357 --- /dev/null +++ b/apps/guides/app/global-error.tsx @@ -0,0 +1,114 @@ +'use client'; + +/** + * Next.js global-error replaces the root layout when an error escapes it, + * so this component renders its own and . Styles are inlined + * so a failed stylesheet can't cascade into a blank page. + */ +export default function GlobalError({ + error: _error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + + Something went wrong + + + + + +
+

+ 500 +

+

+ Something went wrong +

+

+ An unexpected error occurred while loading this page. You can try again, or head back to + all PackRat guides. +

+
+ + + Return to all guides + +
+
+ + + ); +} diff --git a/apps/guides/pages/_document.tsx b/apps/guides/pages/_document.tsx new file mode 100644 index 0000000000..3f64cc251e --- /dev/null +++ b/apps/guides/pages/_document.tsx @@ -0,0 +1,16 @@ +// Custom _document so the Pages Router static-exported 404/500 pages get +// `` (Lighthouse "html-has-lang" + accessibility). The App +// Router routes set this via app/layout.tsx; this only affects pages/* output. +import { Head, Html, Main, NextScript } from 'next/document'; + +export default function Document() { + return ( + + + +
+ + + + ); +} diff --git a/apps/landing/README.md b/apps/landing/README.md new file mode 100644 index 0000000000..841afeb9cb --- /dev/null +++ b/apps/landing/README.md @@ -0,0 +1,93 @@ +# PackRat Landing + +Next.js 15 static-export marketing site for PackRat. Deployed to Cloudflare +Pages from `apps/landing/out/`. + +## Build pipeline + +The `build` script in `package.json` runs: + +``` +bun run generate-og-images → next build +``` + +- `scripts/generate-og-images.ts` renders a real `public/og-image.png` + from the same JSX used in `app/opengraph-image.tsx`. Static exports + cannot serve Next.js metadata-route images correctly from a CDN — + the `.body`/`.meta` files Next emits are a Next.js-internal format — + so we write a plain PNG to `public/` instead. +- `next build` produces the static export in `out/`. + +## Useful scripts + +| Script | Purpose | +|---|---| +| `bun run dev` | Local Next.js dev server | +| `bun run generate-og-images` | Render `public/og-image.png` | +| `bun run build` | Full static build (`out/`) | +| `bun run test` | Vitest suite (metadata + og-image PNG checks) | +| `bun run test:og-meta` | Parse built HTML and assert OG / Twitter meta tags | +| `bun run lighthouse` | Build + run LHCI assertions locally | +| `bun run lighthouse:ci` | Run LHCI against an already-built `out/` (CI mode) | + +## Open Graph metadata validation + +All PackRat web apps share the same OG validation pattern (see +[`apps/guides/README.md`](../guides/README.md) for the full rationale and +the per-post variant). Layers: + +1. **Image generation** — `bun run generate-og-images` produces + `public/og-image.png` at 1200×630. `__tests__/og-image.test.ts` asserts + the file exists, has the PNG signature, and matches expected dimensions. +2. **Static meta in built HTML** — `bun run test:og-meta` runs + `bun run build` (if `out/` is missing) and parses every + `out/*.html` plus `out//index.html` with cheerio. It asserts the + required tags (`og:title`, `og:description`, `og:image`, + `og:image:width`, `og:image:height`, `og:type`, `og:url`, + `og:site_name`, `twitter:card`, `twitter:title`, `twitter:description`, + `twitter:image`) are present on every page and that `og:image` is an + absolute `https://` URL pointing at the site-wide image (`/og-image.png` + or the Next.js auto-generated `/opengraph-image` route). + This step runs in the `Builds` workflow on every PR. +3. **Live OG meta on a deployed URL** — opt-in via + `OG_LIVE_CHECK_URL=https://packratai.com bun run test:og-meta`. + Hits the live origin via [`open-graph-scraper`][ogs] (the same parser + most platforms use under the hood) and asserts the same shape. + Skipped by default. + +### Manual validators + +For one-off checks after a deploy: + +- [opengraph.xyz](https://www.opengraph.xyz/) — quick visual preview +- [microlink.io](https://microlink.io/) — JSON view of every OG / Twitter tag +- [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/) — also flushes FB's cache +- [LinkedIn Post Inspector](https://www.linkedin.com/post-inspector/) — also flushes LI's cache + +## Lighthouse CI + +`.lighthouserc.js` (desktop) and `.lighthouserc.mobile.js` (mobile) drive +LHCI against the static `out/` directory. Budgets: + +- Performance ≥ 0.8 +- Accessibility ≥ 0.9 +- Best Practices ≥ 0.9 +- SEO ≥ 0.9 +- LCP < 2500 ms (desktop) / 4000 ms (mobile) +- CLS < 0.1 +- TBT < 300 ms (desktop) / 600 ms (mobile) + +The `Builds` GitHub Actions workflow runs `lighthouse:ci` after the OG +meta test on every PR and surfaces the scores in the GitHub Step Summary. +The step is marked `continue-on-error: true` — perf regressions appear as +a yellow check on the PR rather than a hard block, so reviewers can decide +whether a deploy is worth tightening the threshold for. + +To run locally: + +``` +bun run --cwd apps/landing lighthouse # full: build + LHCI +bun run --cwd apps/landing lighthouse:ci # CI mode: requires out/ to exist +``` + +[ogs]: https://github.com/jshemas/openGraphScraper diff --git a/apps/landing/__tests__/og-meta.test.ts b/apps/landing/__tests__/og-meta.test.ts new file mode 100644 index 0000000000..456f43c6bb --- /dev/null +++ b/apps/landing/__tests__/og-meta.test.ts @@ -0,0 +1,256 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as cheerio from 'cheerio'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const APP_DIR = path.resolve(__dirname, '..'); +const OUT_DIR = path.join(APP_DIR, 'out'); +const ROOT_INDEX = path.join(OUT_DIR, 'index.html'); + +/** + * Required OG / Twitter meta tag names for every landing page. Same set the + * guides test enforces; consistent shape across web apps is the whole point + * of this validation layer. + */ +const REQUIRED_OG_META = [ + 'og:title', + 'og:description', + 'og:image', + 'og:image:width', + 'og:image:height', + 'og:type', + 'og:url', + 'og:site_name', + 'twitter:card', + 'twitter:title', + 'twitter:description', + 'twitter:image', +] as const; + +type MetaMap = Map; + +function parseMeta(html: string): MetaMap { + const $ = cheerio.load(html); + const meta: MetaMap = new Map(); + $('meta').each((_, el) => { + const property = $(el).attr('property') ?? $(el).attr('name'); + const content = $(el).attr('content'); + if (property && content && !meta.has(property)) { + meta.set(property, content); + } + }); + return meta; +} + +/** + * Walk the static export for top-level pages. Next.js with `output: 'export'` + * and no `trailingSlash` config emits each route as either + * `out//index.html` (nested routes) or `out/.html`. We want both. + * Skip 404/error pages — they're conventional Next.js artifacts whose OG + * payload reasonably differs. + */ +function listLandingHtmlFiles(): string[] { + if (!fs.existsSync(OUT_DIR)) return []; + const results: string[] = []; + const seen = new Set(); + const skipNames = new Set(['404.html', '500.html', 'not-found.html']); + + // Top-level *.html + for (const entry of fs.readdirSync(OUT_DIR, { withFileTypes: true })) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.html')) continue; + if (skipNames.has(entry.name)) continue; + const full = path.join(OUT_DIR, entry.name); + if (!seen.has(full)) { + seen.add(full); + results.push(full); + } + } + + // Nested /index.html (one level deep — landing has flat routes). + for (const entry of fs.readdirSync(OUT_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('_')) continue; + const nested = path.join(OUT_DIR, entry.name, 'index.html'); + if (fs.existsSync(nested) && !seen.has(nested)) { + seen.add(nested); + results.push(nested); + } + } + + return results; +} + +function isAbsoluteHttps(url: string | undefined): boolean { + return typeof url === 'string' && url.startsWith('https://'); +} + +/** + * Landing's site-wide image must be the static `/og-image.png` written by + * `scripts/generate-og-images.ts`. With `output: 'export'`, the Next.js + * `/opengraph-image` metadata route does NOT produce a plain PNG file that a + * CDN can serve — only `og-image.png` (pre-generated at build time) is valid. + */ +function isLandingOgImageUrl(url: string | undefined): boolean { + if (!url) return false; + return /\/og-image\.png(\?|$)/.test(url); +} + +describe('landing built HTML OG meta', () => { + beforeAll(() => { + if (!fs.existsSync(ROOT_INDEX)) { + execSync('bun run build', { + cwd: APP_DIR, + stdio: 'inherit', + }); + } + }, 240_000); + + const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + + it('root out/index.html exists', () => { + expect(fs.existsSync(ROOT_INDEX), 'expected out/index.html to exist after build').toBe(true); + }); + + it('out/og-image.png is present in the static export', () => { + const ogPath = path.join(OUT_DIR, 'og-image.png'); + expect( + fs.existsSync(ogPath), + 'og-image.png must be copied from public/ into the out/ static export by next build. ' + + 'If missing, run scripts/generate-og-images.ts before building.', + ).toBe(true); + }); + + it('out/og-image.png is a valid 1200×630 PNG', () => { + const buf = fs.readFileSync(path.join(OUT_DIR, 'og-image.png')); + expect(buf.subarray(0, 8), 'PNG signature').toEqual(PNG_SIGNATURE); + expect(buf.readUInt32BE(16), 'width').toBe(1200); + expect(buf.readUInt32BE(20), 'height').toBe(630); + expect(buf.length, 'file size').toBeGreaterThan(1024); + }); + + it('discovers at least one landing HTML page beyond root', () => { + const files = listLandingHtmlFiles(); + // Expect index.html plus at least one of about / pricing / blog / + // privacy-policy / account-deletion. If this trips, either the build + // failed to emit nested routes or someone removed every secondary + // page — both are signal worth surfacing. + expect(files.length, `expected >=2 HTML files in out/, got ${files.length}`).toBeGreaterThan(1); + }); + + it('root out/index.html has full OG meta with absolute site-wide og:image', () => { + const html = fs.readFileSync(ROOT_INDEX, 'utf8'); + const meta = parseMeta(html); + + for (const tag of REQUIRED_OG_META) { + expect(meta.get(tag), `root: missing `).toBeTruthy(); + } + + const ogImage = meta.get('og:image'); + expect( + isAbsoluteHttps(ogImage), + `root og:image must be absolute https URL, got: ${ogImage}`, + ).toBe(true); + expect( + isLandingOgImageUrl(ogImage), + `root og:image must reference og-image or opengraph-image, got: ${ogImage}`, + ).toBe(true); + + const twitterImage = meta.get('twitter:image'); + expect( + isAbsoluteHttps(twitterImage), + `root twitter:image must be absolute, got: ${twitterImage}`, + ).toBe(true); + + expect(meta.get('twitter:card')).toBe('summary_large_image'); + expect(meta.get('og:type')).toBe('website'); + expect(meta.get('og:site_name')).toBe('PackRat'); + }); + + it('every landing HTML page has full OG meta + absolute og:image', () => { + const files = listLandingHtmlFiles(); + const failures: string[] = []; + + for (const file of files) { + const rel = path.relative(OUT_DIR, file); + const meta = parseMeta(fs.readFileSync(file, 'utf8')); + + for (const tag of REQUIRED_OG_META) { + if (!meta.get(tag)) { + failures.push(`${rel}: missing `); + } + } + + const ogImage = meta.get('og:image'); + if (!isAbsoluteHttps(ogImage)) { + failures.push(`${rel}: og:image not absolute (${ogImage})`); + } else if (!isLandingOgImageUrl(ogImage)) { + failures.push(`${rel}: og:image not a site-wide image route (${ogImage})`); + } + + const twitterImage = meta.get('twitter:image'); + if (!isAbsoluteHttps(twitterImage)) { + failures.push(`${rel}: twitter:image not absolute (${twitterImage})`); + } + + const card = meta.get('twitter:card'); + if (card !== 'summary_large_image') { + failures.push(`${rel}: twitter:card="${card}" (expected summary_large_image)`); + } + + const siteName = meta.get('og:site_name'); + if (siteName !== 'PackRat') { + failures.push(`${rel}: og:site_name="${siteName}" (expected PackRat)`); + } + } + + expect(failures, `OG meta issues:\n${failures.join('\n')}`).toEqual([]); + }); +}); + +/** + * Optional live OG check. Set OG_LIVE_CHECK_URL to the deployed origin + * (e.g. `https://packratai.com`) to fetch the homepage + a secondary page + * over the wire and run them through `open-graph-scraper` — the same + * parser most platforms (LinkedIn, FB, microlink) use. + * + * Skipped by default because it requires network + a live deploy. + */ +describe.skipIf(!process.env.OG_LIVE_CHECK_URL)('live OG check', () => { + const liveUrl = (process.env.OG_LIVE_CHECK_URL ?? '').replace(/\/$/, ''); + + it('root URL has valid OG metadata via open-graph-scraper', async () => { + const mod = await import('open-graph-scraper'); + const ogs = + typeof (mod as { default?: unknown }).default === 'function' + ? (mod as { default: typeof mod.default }).default + : (mod as unknown as typeof mod.default); + const { result, error } = await ogs({ url: liveUrl, timeout: 15_000 }); + expect(error, `og fetch failed for ${liveUrl}`).toBeFalsy(); + expect(result.ogTitle, 'ogTitle').toBeTruthy(); + expect(result.ogDescription, 'ogDescription').toBeTruthy(); + expect(result.twitterCard).toBe('summary_large_image'); + const firstImage = result.ogImage?.[0]?.url; + expect(isAbsoluteHttps(firstImage), `root ogImage[0].url absolute (got ${firstImage})`).toBe( + true, + ); + }, 30_000); + + it('/pricing has valid OG metadata via open-graph-scraper', async () => { + const target = `${liveUrl}/pricing`; + const mod = await import('open-graph-scraper'); + const ogs = + typeof (mod as { default?: unknown }).default === 'function' + ? (mod as { default: typeof mod.default }).default + : (mod as unknown as typeof mod.default); + const { result, error } = await ogs({ url: target, timeout: 15_000 }); + expect(error, `og fetch failed for ${target}`).toBeFalsy(); + expect(result.ogTitle, 'ogTitle').toBeTruthy(); + expect(result.twitterCard).toBe('summary_large_image'); + const firstImage = result.ogImage?.[0]?.url; + expect(isAbsoluteHttps(firstImage), `pricing ogImage[0].url absolute (got ${firstImage})`).toBe( + true, + ); + }, 30_000); +}); diff --git a/apps/landing/app/global-error.tsx b/apps/landing/app/global-error.tsx new file mode 100644 index 0000000000..7c0e7c8d53 --- /dev/null +++ b/apps/landing/app/global-error.tsx @@ -0,0 +1,114 @@ +'use client'; + +/** + * Next.js global-error replaces the root layout when an error escapes it, + * so this component renders its own and . Styles are inlined + * so a failed stylesheet can't cascade into a blank page. + */ +export default function GlobalError({ + error: _error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + + Something went wrong + + + + + +
+

+ 500 +

+

+ Something went wrong +

+

+ An unexpected error occurred while loading this page. You can try again, or head back to + the PackRat home page. +

+
+ + + Back to home + +
+
+ + + ); +} diff --git a/apps/landing/pages/_document.tsx b/apps/landing/pages/_document.tsx new file mode 100644 index 0000000000..3f64cc251e --- /dev/null +++ b/apps/landing/pages/_document.tsx @@ -0,0 +1,16 @@ +// Custom _document so the Pages Router static-exported 404/500 pages get +// `` (Lighthouse "html-has-lang" + accessibility). The App +// Router routes set this via app/layout.tsx; this only affects pages/* output. +import { Head, Html, Main, NextScript } from 'next/document'; + +export default function Document() { + return ( + + + +
+ + + + ); +} diff --git a/apps/trails/__tests__/og-image.test.ts b/apps/trails/__tests__/og-image.test.ts new file mode 100644 index 0000000000..ee645e2514 --- /dev/null +++ b/apps/trails/__tests__/og-image.test.ts @@ -0,0 +1,77 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { OG_IMAGE_URL, trailsMetadata } from '../lib/metadata'; + +const APP_DIR = path.resolve(__dirname, '..'); +const OG_IMAGE_PATH = path.resolve(APP_DIR, 'public/og-image.png'); +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function readUint32BE(buf: Buffer, offset: number): number { + return buf.readUInt32BE(offset); +} + +describe('trails OG image generation', () => { + beforeAll(() => { + execSync('bun run scripts/generate-og-images.ts', { + cwd: APP_DIR, + stdio: 'inherit', + }); + }); + + it('generates public/og-image.png', () => { + expect(fs.existsSync(OG_IMAGE_PATH)).toBe(true); + }); + + it('output is a valid PNG file', () => { + const buf = fs.readFileSync(OG_IMAGE_PATH); + expect(buf.subarray(0, 8)).toEqual(PNG_SIGNATURE); + }); + + it('PNG has correct dimensions (1200 × 630)', () => { + const buf = fs.readFileSync(OG_IMAGE_PATH); + expect(readUint32BE(buf, 16)).toBe(1200); + expect(readUint32BE(buf, 20)).toBe(630); + }); + + it('PNG is non-trivially sized (> 1 KB)', () => { + const { size } = fs.statSync(OG_IMAGE_PATH); + expect(size).toBeGreaterThan(1024); + }); + + it('metadata og:image URL references the generated file', () => { + const images = (trailsMetadata.openGraph as { images?: unknown })?.images; + const first = Array.isArray(images) ? images[0] : images; + const url = typeof first === 'string' ? first : (first as { url: string })?.url; + const pathname = new URL(url).pathname; + const filePath = path.resolve(APP_DIR, 'public', pathname.slice(1)); + expect( + fs.existsSync(filePath), + `og:image URL (${url}) → ${filePath} does not exist in public/`, + ).toBe(true); + }); +}); + +describe('trails layout metadata', () => { + it('openGraph.images[0].url is the absolute og-image.png URL', () => { + const images = (trailsMetadata.openGraph as { images?: unknown })?.images; + expect(images).toBeDefined(); + const first = Array.isArray(images) ? images[0] : images; + const url = typeof first === 'string' ? first : (first as { url: string })?.url; + expect(url).toBe(OG_IMAGE_URL); + }); + + it('twitter.images[0] is the absolute og-image.png URL', () => { + const images = (trailsMetadata.twitter as { images?: unknown })?.images; + expect(images).toBeDefined(); + const first = Array.isArray(images) ? images[0] : images; + const twitterUrl = + typeof first === 'string' ? first : ((first as { url?: string })?.url ?? first); + expect(twitterUrl).toBe(OG_IMAGE_URL); + }); + + it('twitter.card is summary_large_image', () => { + expect((trailsMetadata.twitter as { card?: string })?.card).toBe('summary_large_image'); + }); +}); diff --git a/apps/trails/__tests__/og-meta.test.ts b/apps/trails/__tests__/og-meta.test.ts new file mode 100644 index 0000000000..55bee4f671 --- /dev/null +++ b/apps/trails/__tests__/og-meta.test.ts @@ -0,0 +1,121 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as cheerio from 'cheerio'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const APP_DIR = path.resolve(__dirname, '..'); +const OUT_DIR = path.join(APP_DIR, 'out'); +const ROOT_INDEX = path.join(OUT_DIR, 'index.html'); + +const REQUIRED_OG_META = [ + 'og:title', + 'og:description', + 'og:image', + 'og:image:width', + 'og:image:height', + 'og:type', + 'og:url', + 'og:site_name', + 'twitter:card', + 'twitter:title', + 'twitter:description', + 'twitter:image', +] as const; + +type MetaMap = Map; + +function parseMeta(html: string): MetaMap { + const $ = cheerio.load(html); + const meta: MetaMap = new Map(); + $('meta').each((_, el) => { + const property = $(el).attr('property') ?? $(el).attr('name'); + const content = $(el).attr('content'); + if (property && content && !meta.has(property)) { + meta.set(property, content); + } + }); + return meta; +} + +function isAbsoluteHttps(url: string | undefined): boolean { + return typeof url === 'string' && url.startsWith('https://'); +} + +describe('trails built HTML OG meta', () => { + const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + + beforeAll(() => { + if (!fs.existsSync(ROOT_INDEX)) { + execSync('bun run build', { cwd: APP_DIR, stdio: 'inherit' }); + } + }, 240_000); + + it('root out/index.html exists', () => { + expect(fs.existsSync(ROOT_INDEX), 'expected out/index.html to exist after build').toBe(true); + }); + + it('out/og-image.png is present in the static export', () => { + const ogPath = path.join(OUT_DIR, 'og-image.png'); + expect( + fs.existsSync(ogPath), + 'og-image.png must be copied from public/ into the out/ static export by next build. ' + + 'If missing, run scripts/generate-og-images.ts before building.', + ).toBe(true); + }); + + it('out/og-image.png is a valid 1200×630 PNG', () => { + const buf = fs.readFileSync(path.join(OUT_DIR, 'og-image.png')); + expect(buf.subarray(0, 8), 'PNG signature').toEqual(PNG_SIGNATURE); + expect(buf.readUInt32BE(16), 'width').toBe(1200); + expect(buf.readUInt32BE(20), 'height').toBe(630); + expect(buf.length, 'file size').toBeGreaterThan(1024); + }); + + it('root out/index.html has full OG meta with absolute og:image', () => { + const html = fs.readFileSync(ROOT_INDEX, 'utf8'); + const meta = parseMeta(html); + + for (const tag of REQUIRED_OG_META) { + expect(meta.get(tag), `root: missing `).toBeTruthy(); + } + + const ogImage = meta.get('og:image'); + expect(isAbsoluteHttps(ogImage), `og:image must be absolute https, got: ${ogImage}`).toBe(true); + expect(ogImage, 'og:image must be /og-image.png').toMatch(/\/og-image\.png(\?|$)/); + + const twitterImage = meta.get('twitter:image'); + expect( + isAbsoluteHttps(twitterImage), + `twitter:image must be absolute, got: ${twitterImage}`, + ).toBe(true); + expect(twitterImage, 'twitter:image must be /og-image.png').toMatch(/\/og-image\.png(\?|$)/); + + expect(meta.get('twitter:card')).toBe('summary_large_image'); + expect(meta.get('og:type')).toBe('website'); + expect(meta.get('og:site_name')).toBe('PackRat'); + }); +}); + +/** + * Optional live OG check. Set OG_LIVE_CHECK_URL=https://trails.packratai.com + * to verify the deployed site serves correct OG metadata. + */ +describe.skipIf(!process.env.OG_LIVE_CHECK_URL)('live OG check', () => { + const liveUrl = (process.env.OG_LIVE_CHECK_URL ?? '').replace(/\/$/, ''); + + it('root URL has valid OG metadata via open-graph-scraper', async () => { + const mod = await import('open-graph-scraper'); + const ogs = + typeof (mod as { default?: unknown }).default === 'function' + ? (mod as { default: typeof mod.default }).default + : (mod as unknown as typeof mod.default); + const { result, error } = await ogs({ url: liveUrl, timeout: 15_000 }); + expect(error, `og fetch failed for ${liveUrl}`).toBeFalsy(); + expect(result.ogTitle, 'ogTitle').toBeTruthy(); + expect(result.twitterCard).toBe('summary_large_image'); + const firstImage = result.ogImage?.[0]?.url; + expect(isAbsoluteHttps(firstImage), `ogImage[0].url absolute (got ${firstImage})`).toBe(true); + expect(firstImage, 'ogImage must be og-image.png').toMatch(/\/og-image\.png(\?|$)/); + }, 30_000); +}); diff --git a/apps/trails/app/opengraph-image.tsx b/apps/trails/app/opengraph-image.tsx new file mode 100644 index 0000000000..c8c3a2cbbc --- /dev/null +++ b/apps/trails/app/opengraph-image.tsx @@ -0,0 +1,14 @@ +import { ImageResponse } from 'next/og'; +import { + getTrailsOgImageElement, + OG_IMAGE_CONTENT_TYPE, + OG_IMAGE_SIZE, +} from 'trails-app/lib/og-image'; + +export const dynamic = 'force-static'; +export const size = OG_IMAGE_SIZE; +export const contentType = OG_IMAGE_CONTENT_TYPE; + +export default function Image() { + return new ImageResponse(getTrailsOgImageElement(), { ...size }); +} diff --git a/apps/trails/app/twitter-image.tsx b/apps/trails/app/twitter-image.tsx new file mode 100644 index 0000000000..c8c3a2cbbc --- /dev/null +++ b/apps/trails/app/twitter-image.tsx @@ -0,0 +1,14 @@ +import { ImageResponse } from 'next/og'; +import { + getTrailsOgImageElement, + OG_IMAGE_CONTENT_TYPE, + OG_IMAGE_SIZE, +} from 'trails-app/lib/og-image'; + +export const dynamic = 'force-static'; +export const size = OG_IMAGE_SIZE; +export const contentType = OG_IMAGE_CONTENT_TYPE; + +export default function Image() { + return new ImageResponse(getTrailsOgImageElement(), { ...size }); +} diff --git a/apps/trails/lib/metadata.ts b/apps/trails/lib/metadata.ts new file mode 100644 index 0000000000..cc1c31244d --- /dev/null +++ b/apps/trails/lib/metadata.ts @@ -0,0 +1,34 @@ +import type { Metadata } from 'next'; +import { OG_IMAGE_SIZE } from 'trails-app/lib/og-image'; + +export const SITE_URL = 'https://trails.packratai.com'; +export const OG_IMAGE_URL = `${SITE_URL}/og-image.png`; + +export const trailsMetadata: Metadata = { + title: 'Trail Search — PackRat', + description: 'Discover hiking, cycling, and outdoor trails near you. Powered by PackRat.', + keywords: ['trail search', 'hiking trails', 'outdoor trails', 'trail finder', 'PackRat'], + metadataBase: new URL(SITE_URL), + openGraph: { + type: 'website', + url: SITE_URL, + title: 'Trail Search — PackRat', + description: 'Discover hiking, cycling, and outdoor trails near you.', + siteName: 'PackRat', + images: [ + { + url: OG_IMAGE_URL, + width: OG_IMAGE_SIZE.width, + height: OG_IMAGE_SIZE.height, + alt: 'Trail Search — PackRat', + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: 'Trail Search — PackRat', + description: 'Discover hiking, cycling, and outdoor trails near you.', + creator: '@packratai', + images: [OG_IMAGE_URL], + }, +}; diff --git a/apps/trails/lib/og-image.tsx b/apps/trails/lib/og-image.tsx new file mode 100644 index 0000000000..7645d9b914 --- /dev/null +++ b/apps/trails/lib/og-image.tsx @@ -0,0 +1,118 @@ +import type { ReactElement } from 'react'; + +export const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const; +export const OG_IMAGE_CONTENT_TYPE = 'image/png' as const; + +const MARK = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAAVBUlEQVR4nO2de5RfVXXH9zn3NxMyCSEQRXkoYkVUpA9FBG1BK/VF6VJ80pfVamutYlHran2s0LWUsrBWqwVRYamLVappsQoq9VGjFsEFQStEkSiQCElISOb9e9x79j6f/nHPnfnNZDL5zWR+v98g+a41ZHF/9567z977nLNf51yRQziERzJcvwlYCAAnJc3tdOOci30i6ZEBwAPZAe7JkoAeVlj2BAOZc85ERG6Glc8QObEu8gQRWTUksieK/HyVcw/Mdf8hHAQAV2n03jw/pQVXKNyrpkYbzJhQuLUFF22G1enZeUfLIRwAgHNOZD34poUPqtFkJgxQILZfDKZbmvCi1MYhISwGSfM9UCuwLwJEIoqFxPg4UxZEtWBmGiilEidzfX1qK2tvdwNkG6HGzL+H5drRNWzYsCETEZk0uzzpegE2m+mzZUAkAqaAmVpswu+KiAArAH+g9yZBHPC+pcaykny1gDYDzz+sJt+OZuqzLJMF0BnFzEuWBYl3bxH/6093rhARGYUjB0ROcmYnOpFHuSxb4aQYjjK4tSnyo6OcG2unoUtdXN6oNLBldmM5tZjOr/lzjIUYUcMAci2+mQe7tFC7sTB9cH/PBLP7c7OPNOGJ/eZB37A+MX8rHJNrMVnOPvvM9x3DbM5pK4KqQSj5jpavSc/AWA7/SDltOZGZ1tivNEgL5jj8dowAFmNcNP8rKCWz97GY9hUMLYAcvrEZBpm1OFMu2Eu+RvR80TkQvMgql3RvCZrLRKSW/p1Pi126BzP7flo3as45Nm6ktgXWOOfUORfTiOjYeiJZdMt+FFXaNQrPVCzGqCzBCFgIDIjB7J7dcJKIyEaoNbB/DmbbWujfj8NT5qDbJWHUNk4Lxqe/AzJ92UgFcM459uxhzZq1evdAVnuMiCGS9XKUIiJOTcctc7f46I4d8P7U6keLsTCRW53nmyrxW4UM/Gytc8PzNTgGzxmK8YWF959a5dyOqp/V730TQKUd7cQANRGJzSgfX+nlLSKmIlmtl3RFEXwbX2I08BlOLPpZtKjoLol+CyJbo/d3BZF7Vog8iMhkEImFyMgqCzcNZgPHN6N9ZSirnccsM7dnAkgM9yIi7QSkqceLiFXCeADWPdb051lWW2sx4p3z4lzPiE1CiCLRiXifBoaICCIxlv/6/a4rCOLEiUX5uoXi59mKwZcGsUtWutrVgO95+Jw5rAdgxYYNM+M1I7C2CW8u4JmjhBeXU3PElGCLt0i7ici0pTXb2lKAhhbv3LSdod5xexZIoQUB14KXFYSrgtomM7tXNdxVYDcU8Le7JzmmbvZBgMJsu4jIsPKnhVkLADNr6+RcMaHlBKOMi4yMwpPK7tPTqTTxvNTwUYrTAnbz/BTb7qZxWSvY1Q2zqyqCRwtOU/jv2fdHwIwY4/KSRUlX6cFPmF3Sd+Y3Ay8MpvVEXzVcre2vugZA07j+HjgitTFQtTcOv9M0uyy38L2A3V1YUADD4nISgKGVObtzjLF1dGiOLjXzS5u+yZPU2FNSpmEeukkRzwCQm359w7Q9vY/DsxFqDxU8szB7AIgHE7JYKkTKmCxmAWACLki86G1eghTP37R9+1CA2wAsLUidwLACoA5/2d4BkrMDZJU0ctMtU4/1ERGI0aZob1n4RFeZz7Q7Xnl8U642ab5rWfhcou8Amj8TlmL6LdM7NsEAs7SfNC1Nml1Z3t+5cLsFixFTLQBy9Po0eruT6JmvUSrmKxeWlNmCmA/Tc3pQzUebPIlSuAMkoYuI1Cn+urw3qMV+LQEx/WGkPHVh9qVfwkq6FUGtGr0LDm+pvqswri/Mrm+ovmMUjhQRGad4rkIBpnbATNYc3YqxnNWBvfD02TSMB15h5e8HinB2F2amplOjr2nhw+vT2kc3mL9+/XoPuD2wRs1unU1Pjv1kG5yi6FYAIyxqbZwKwKlOTCqva8E7A7zkgTHWDRecpVheCnbhwl0CVL7I1JqjsHky8JKK8XTL4iEN/9xsA4CZ5mqmirYAJs0ubypfS2TpYldFKx2umKvd2jL719TJOBns46phZ8kF7arhU00uhplWXu6s16nZT+pw4c2wMvEnk0UwvyMHAag557SB/t2g+FfFGNV7PyjiTERW1C1eX5gfP3JQXhJjNO+zRYcwKfMAPiC3rBQ522Js1aO/ZsjLeZmvPVZEou9ihJT03xiJmfdVnEpwIhblPnzc2NR43Zpa7ZurnAsiB5dHPqAAUuM6QThnUNwHRETFk0WR6MVlzRjvakW+vXZQPhRFzJdELxYx81mWW7xTHbfislfnMf77YT6eVfP+BBExKRMnXQRCdDHzzqvY7TG6r4jX7U0Z/PGIlztOdFlr6s7SzIxdS+KTFpVheHyw8GA1RailGhDVyV3KWwvT7WlYLsokKW0Ji4CqabNRcKaIyDicrWb3Vu9dzHSyYFgKomGf+WSbJ97OdHpRS0RyqDZCrWX59xN5adUvYx2jqu/JjZtKuhfrDMXkxJT+QlOLt4qI7IYnK9wz871dR6qmsFs3wKAr+TAA1Db0uoCLpP1N+JfEg5AoVIAxs6smzD5TXtODYpBZyfyGFVeLiIzBSS2zXjMfSs0KdYrTREQ2srH3QbTE/ExEpAGvToSFMupYan7T+P6eQt9fMs908fZIhMTgltltwOA2ODaY/Qyg3c7uNirFyo2vtvOgH9x3gNu0adNAwO5MXFJNdTZqdv8u5W1qOglqtthMSQQtp/YY0JFR+LUtsKalpY+htrAQxsGg8jwAcvhj2rzuPvC/nOd2wirDHiSFjc2CBbPWbuUi1UpDsbjI3Ihh0UpvjWE4T0QkN/1m2a6FsuWexdgiQIGGsVQRQY/qRPd5iXMOwB/jXD0Xf0t5T8y9r/mW+KsPd+GsLPMnxyjmnXjEy0JTy5SNmvdSq4tcfJRzNxRmnxn02TkWo2bO1/CI71HZUpQyResjjZpIoycvnQ+kJMIIPKEwfpq0ctew6nuT9h7copsW7abZDSIiDeUdUA6zKg7UY5QBQNN8BE6seNALXs/5kiprf6RzW+/3cqaKXNny8m81iWdIWRFwMO+MXrIsxPiLUe9fO05x1qDnwzFGw8cM14diPXDRLNZ8Nojq05neDNhfrG/Tgv+DVYWxvdTgxU3ORoiAFqqNSfiNYTgiN9sJRLODqsU9SMQpP6Qw+6zIMtphQ0oL/hTWBeOhkpGLiETG2OZs5a8XEambXZt+7ZnFMw8iENUY3QPHkRzRbvO3kxfgnLMoMgFxPD228MJZ59SL1HLRy1fWVnxmPHD+kPcXiJhKv0y+mXAiEjMvR6xRvcQ5x+23375sRkEmIlI3rgE6znTFFNitFt3c9H83w+B9sLahdi8QUxXBckJZUqL6h6nv+8SDeg6SAMbgjLQXK9DBhF3WnZTlI2q6axgeLyLSNPsHKKN5XWPjIpE2dmgwq48XnJX63/8RuiEJYQL7QKJ1RmZoP71JtTuajxXFmSIiu+GYYOw1s2iLjJ4eJKaSuXPBgGovsprtGYVniSyHRTnVwYuINI3LpglWTTEbLUPGVhZapcIphZF6yC9uwJmAa5pdBpC2B83HiyXHdNjKMKrw9hzyiEz9Xpjt3VsUz1keQpDpUMU4vDwkJ21/yM1um1DernAzQEN5VaHhfiAy9x6uriEmJgcLuVaCMALzxrLKERxM90zknJL6v6SW0aKcDVIK7j447FEi56+I8SXASQirETdWc9zdjPLlptSydbV4TSZ+yETuzKPcOOTl3RYtZr6nGy+iiDiTuFXFnzsZZM3qTK5a4eXpbb9XJeMuingn4pyIWFTLfC0LMd414P3pUoYqaN/X0BfQwXDcBasbWrxDYbyhXNcMWiZvepXdmkYAaGnxNxVte2BNE7tUsW1zP6JmYGUYPj1vdkWnfe8UB+Vu07bpQsrcKO3Xqlzp3jw/RbPBP1gndnGWZTVKDeslEBHXlHD2Shm46ScitWoD965drB46Ws4YMDlNnJxck3hyFHnigPePaXs+iggWo2+pP231CvdDHi4buoEVIiJNwiXt2thDpJSzFUzX6nvajIrZGIG144FXqOp3MZtI7RQARbBrUxtLMgq6at9SbkjLfwkrfYwXpLHSr62xeaPRmKpoSHO4MT1inZQjJTrnRkXkurrZs2uw28f4rJr3J0iM0dfkXBg9yjk3zBJsN+o2M5yIyMogT6t5/3iZSgX0BTRT2c/FbRedczjnLO0DtjSNZkAWvP9kFHlajPJfZlqI9z4Tv+YhW/3yDTAoVRnRQaDbzPAiIod5OdOnjXhdft98pLh1MuREZgpgLlRz+1rn7ilELnU+nheQD4nITovxR56w7Vlbt/qlsIS6LQBERLyLz+jyew4ML6451LnR4ZwzoHZErXZNEG4b8O619Sj/KV6aa2pZfuKJJ7aWah1YcjB96JLbAFlQuzMtZP0IvFVO1zhwXKKvI8VL/XDbYahJePFknv9WMGsp3DcMR9CP7Uf7IbTapLFP8dIWeHSB7Z3BjN4iFfGzZ3ycRyd6F820CXgNQE64UkRk48Y+BuoS4/fRpg2QbYMjBdwwjROK6f1h/Sopp4D7b4LDE90LEgBt50GITFeIjwV+P/2+6Klo0dKjzREZh6ceJvJ7mcRnR5GTReRoL/GoIsqOBoPv9dOb3Ps2XJ3I5I5FVjy0mawe8Fvr9QuPX7nq7CEXP7EHvicik8w6A6KrqCQ+CqcXZl9qV7dgqqj90mBzA77UUC4wsL4lfFPU1cy+lWg/KMODKjkVwisBWvDZ9utdR9WBuvIXafMCwWxzC33P3oLn7oBHtxMzCuckPvQn85Uq7My4PNF/0HN21UYR+BzACOHl6Xp3hcBUUoYXVN1rKe9KTsk+9wJ+FF5Y3tp7ASTzJxUC8LZ25h0kHzzgH4B1arpdYdfOCY6mq4n8qmYUBlqmtwE00HcngmacIkVbnKUOZ5Ts6P2erkjELMQITIRQHWO5JFpatTNJOBegbsXn0/XuWEVMb9Y4FaDQsGMTDLGf8zara+Pw1DB9gl6Pj8BKBcWqY+PwqETXkhkClRDqZp8GmEBf0369E3Q8XL5ThRVEniYi+Mz/8DTnquTEPgGpi9O/e0VGopfJfpg/PiVZzMlNh4vspbTcltJSiYCf9P4iNdl2WHRX7KF+XHW9Qxo7w/Ok1B4VOUlEJIi/i5n5gBm4OIUh6iLDEmV3utzbLNKUWnBfyfj/WNIRkITpHuPcZD3TN9e8P2pNPOxj1fWleo+ItFk/hd0AkOvUcNvvnMd0Er8vFXBxKvkeaWBXd+sIgaqfrRAuB5iE17VfX4oXeMA92Gw+MZg11Wg2Uo3PfEOtEk5V/dx7AZTnOFS+QG724SVlzHQ/HeB3snNVMLYE09E9dY5P1wfm41GnLxgQEWmlxabFVFZo3oarjg7D2W086akISksomUNEJgJLag3N7usEPA8gV73xoBtdX2r+gIhIU/XPzIgKY2NwMh3YvKShPgbrCtOH+iOEEtN7wPTbibYlt9dJm/oaND8KMKn6hkZRnN0oGr/Tzo8OGpq5R2pS9Y2qpUMzprqg+a26Lze+kXjRl3LEqiLLMB2F0xfSh05BUsr14IPZnWA5QNPyLy7qfbvgsS24supES4sLF9JQdd9koe8zo0E/D1YqS1rTiVz2T4m+JXWaSAt8E/3z6uT3loZfjsG69Pv8I4DkxY7A2pbqu9VsB0DAdo6HcH47UzshRkSkCW8oGWAVG7qFOT9rMuMGC2WRj+kP1x8gmUJbCDr9HbDfTHnH+qaAfSoP9lGAJvrGjnjHlNloXygJtljAJx+g8biFML/qXB2OVbW9lNHQbml/VY/ajvZT03X6RqtKVBrDcELq05z5jE762QkK1TsAto/P7YnPHoKloxT91SrxngnvP3+Uc3ekBzsuRLpYxDvntAV/kmX+KClPV9n/cEdEXCSKmMT0oTbvRSS66Z2SUWJERDIQIfOCRcvKEkcvweweMndHJv6cTOTwmS+IMYo4V9aDRe/9ypUiTxGRbTLLYar6uYtdqw+Xo8/1ITzZex+aMfvOEYPuB3QQ909CzZxzYTTLXrlW5EXHHi4TnfBursYW7LhUWqXwA6ZPl51Ph+Nit9sHuFvhvdvSiV17GzyuBecH7JICvhBMbwFIp2xVBy7RgneRUqmJ5qkA4gjhBQW2T+FxXYuLKp50yIfFOXwVYXMNzw6eLTdzhPDSir37522kYohGpQh2bY5+Q8025yE8UJiNmNmEmU2q2Xih+qBq+Gmh9rUAHwzw/C2p8q793bMxhr49TFdjtwCKYFenZ1Zsaj+fVHlLSKFzIxSkA6lys68ONxonsMBTsdqF3BNUQmuZfg9AzXI1VTWLZVjArDzlztL5ExDMJhtpS1BqxG3exeoJOLoOx9Xh+Ek4ZhiOeNUcTKZtlDJ91ujUWf4iImNwbsB2p2FQmNnW3a1yV7yIyGYYbMKHSpXRqGahWjNa2Mek7ZMmXWbh4lFp4GQoY+Sm81dBa2kof3k459T0fEejrm2EdvoVi5qIyB54aoHdUY04NdubG5/IjQ+0rFws1YJVWTQ1DXXlr1IbPdk1uWhUBG6ArDDbBKCq1gjhilz1f4LpDsW2qfHT3OwrLXj/ZJ7/Ztvz2ay2HODWt32NYqHDvx0bkxDug7W52XVzKUTQ6Q/CBWz3JKH6Kt/D4hMk5RE3qn80reFlIlxE5CE4HBiabeKxn9KWbqD93XXs4somNaxlpladW1SY/XgMnpzo6/8GvQOh0sztMBRM7622/QSzq9Lvh826f9GL/BLRWmbrAucbNlrKQSsv+foRWCsiwsY+Hdi0UExrf/HOkvEaSsPPHtrL9N6qZTOU22Jdw3BqYfwYoG52aXXLUjpjXUWlTSPwhMJUy72pIZA2wgWzHU3VN1Ud6nTh7AUqIfxigqPHW+FlIqVXtn45L7azUU0/e+FxLcK1lbFt5blLUw5YDreOBV7ab3pno30aBLwsE+VYNEZCeH7Avju9EGuhbZmwFlyZqimWR2WxzPSCH7aY0QnnJEdfV8DPmBoRmls6Y6Jh4SPpmYd3p5cjaPvAwmZY3YL3qTFMMvmAXE1HgKPT/ctiFPzKoV27R+DEFnza2j5P2yiK586+7xCWGLPn1jqcrqbfKSzsGIWjqnv6R+EjBOtTgKz6/wcfyR9O7idmmXyHNL8fYLlHFQ/hEA6hh/h/2xWz3sIOgC4AAAAASUVORK5CYII='; + +export function getTrailsOgImageElement(): ReactElement { + return ( +
+ {/* Header: mark + wordmark */} +
+ + + PackRat + +
+ + {/* Center: badge + headline + subtext */} +
+
+ + Trail Search + +
+ +
+ + Discover trails + + + near you. + +
+ + + Hiking, cycling, and outdoor trails — all in one place. + +
+ + {/* Footer: activity tags + domain */} +
+
+ {['Hiking', 'Cycling', 'Outdoors'].map((tag) => ( +
+ {tag} +
+ ))} +
+ + trails.packratai.com + +
+
+ ); +} diff --git a/apps/trails/scripts/generate-og-images.ts b/apps/trails/scripts/generate-og-images.ts new file mode 100644 index 0000000000..e02c0092e3 --- /dev/null +++ b/apps/trails/scripts/generate-og-images.ts @@ -0,0 +1,62 @@ +/** + * Pre-build script: generates the static Open Graph image PNG for the Trails app. + * + * Static exports (`output: 'export'`) cannot serve Next.js metadata-route images + * (opengraph-image.tsx) correctly from a CDN — the generated .body/.meta files + * are a Next.js-internal format, not plain PNG files. + * + * This script renders the same JSX used in opengraph-image.tsx via ImageResponse + * and writes a real .png file to public/ so the CDN can serve it with the correct + * Content-Type automatically. + * + * Run: `bun run scripts/generate-og-images.ts` + * Output: apps/trails/public/og-image.png + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { ImageResponse } from 'next/og'; +import { createElement } from 'react'; +import { getTrailsOgImageElement, OG_IMAGE_SIZE } from '../lib/og-image'; + +// Intercept Google Fonts requests — CF Pages' build network occasionally 4xx's +// fonts.googleapis.com, which kills the build. Return 404 to fall back to +// bundled fonts instead. +const FONT_HOSTS = new Set(['fonts.googleapis.com', 'fonts.gstatic.com']); +const originalFetch = globalThis.fetch; +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const href = input instanceof URL ? input.href : input instanceof Request ? input.url : input; + try { + if (FONT_HOSTS.has(new URL(href).hostname)) { + return new Response(null, { status: 404 }); + } + } catch { + // Not a parseable absolute URL — fall through to the real fetch. + } + return originalFetch(input, init); +}) as typeof fetch; + +const PUBLIC_DIR = path.join(import.meta.dir, '..', 'public'); + +async function generateOgImages(): Promise { + if (!fs.existsSync(PUBLIC_DIR)) { + fs.mkdirSync(PUBLIC_DIR, { recursive: true }); + } + + const response = new ImageResponse( + createElement(() => getTrailsOgImageElement()), + OG_IMAGE_SIZE, + ); + + const buffer = Buffer.from(await response.arrayBuffer()); + const outputPath = path.join(PUBLIC_DIR, 'og-image.png'); + fs.writeFileSync(outputPath, buffer); + + const rel = path.relative(process.cwd(), outputPath); + console.log(`✓ Generated ${rel} (${buffer.length} bytes)`); +} + +generateOgImages().catch((err) => { + console.error('Failed to generate OG images:', err); + process.exit(1); +}); diff --git a/apps/trails/vitest.config.ts b/apps/trails/vitest.config.ts new file mode 100644 index 0000000000..e0502cabe1 --- /dev/null +++ b/apps/trails/vitest.config.ts @@ -0,0 +1,17 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + root: __dirname, + resolve: { + alias: { + 'trails-app': resolve(__dirname, '.'), + }, + }, + test: { + name: 'trails-og', + environment: 'node', + globals: true, + include: ['__tests__/**/*.test.ts'], + }, +}); diff --git a/packages/api/src/auth/__tests__/auth.helpers.test.ts b/packages/api/src/auth/__tests__/auth.helpers.test.ts new file mode 100644 index 0000000000..fa08d228f0 --- /dev/null +++ b/packages/api/src/auth/__tests__/auth.helpers.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + bcryptCompare: vi.fn<(hash: string, data: string | Buffer) => Promise>(), + verifyPassword: vi.fn<(hash: string, password: string) => Promise>(), + importPKCS8: vi.fn(), + signJwt: vi.fn(), +})); + +vi.mock('bcryptjs', () => ({ compare: mocks.bcryptCompare })); +vi.mock('@better-auth/utils/password', () => ({ verifyPassword: mocks.verifyPassword })); +vi.mock('jose', () => ({ + importPKCS8: mocks.importPKCS8, + SignJWT: vi.fn(() => ({ + setProtectedHeader: vi.fn().mockReturnThis(), + setIssuer: vi.fn().mockReturnThis(), + setSubject: vi.fn().mockReturnThis(), + setAudience: vi.fn().mockReturnThis(), + setIssuedAt: vi.fn().mockReturnThis(), + setExpirationTime: vi.fn().mockReturnThis(), + sign: mocks.signJwt, + })), +})); + +import { generateAppleClientSecret, verifyPasswordCompat } from '../auth.helpers'; + +describe('verifyPasswordCompat()', () => { + beforeEach(() => vi.clearAllMocks()); + + it('uses bcrypt for $2a$ hashes', async () => { + mocks.bcryptCompare.mockResolvedValue(true); + const result = await verifyPasswordCompat({ hash: '$2a$10$abc', password: 'pw' }); + expect(mocks.bcryptCompare).toHaveBeenCalledWith('pw', '$2a$10$abc'); + expect(mocks.verifyPassword).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('uses bcrypt for $2b$ hashes', async () => { + mocks.bcryptCompare.mockResolvedValue(false); + const result = await verifyPasswordCompat({ hash: '$2b$12$xyz', password: 'wrong' }); + expect(mocks.bcryptCompare).toHaveBeenCalledWith('wrong', '$2b$12$xyz'); + expect(result).toBe(false); + }); + + it('uses bcrypt for $2y$ hashes', async () => { + mocks.bcryptCompare.mockResolvedValue(true); + await verifyPasswordCompat({ hash: '$2y$10$hash', password: 'pw' }); + expect(mocks.bcryptCompare).toHaveBeenCalled(); + expect(mocks.verifyPassword).not.toHaveBeenCalled(); + }); + + it('uses better-auth verifyPassword for non-bcrypt hashes', async () => { + mocks.verifyPassword.mockResolvedValue(true); + const result = await verifyPasswordCompat({ hash: 'argon2:somehash', password: 'pw' }); + expect(mocks.verifyPassword).toHaveBeenCalledWith('argon2:somehash', 'pw'); + expect(mocks.bcryptCompare).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('returns false from better-auth verifyPassword on mismatch', async () => { + mocks.verifyPassword.mockResolvedValue(false); + const result = await verifyPasswordCompat({ hash: 'scrypt:somehash', password: 'bad' }); + expect(result).toBe(false); + }); +}); + +describe('generateAppleClientSecret()', () => { + const baseEnv = { + APPLE_PRIVATE_KEY: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----', + APPLE_KEY_ID: 'KEYID123', + APPLE_TEAM_ID: 'TEAMID456', + APPLE_CLIENT_ID: 'com.example.app', + }; + + beforeEach(() => vi.clearAllMocks()); + + it('returns null when APPLE_PRIVATE_KEY is not set', async () => { + const result = await generateAppleClientSecret({ APPLE_PRIVATE_KEY: '' } as never); + expect(result).toBeNull(); + expect(mocks.importPKCS8).not.toHaveBeenCalled(); + }); + + it('returns a signed JWT string on success', async () => { + const fakeKey = {}; + mocks.importPKCS8.mockResolvedValue(fakeKey); + mocks.signJwt.mockResolvedValue('signed.jwt.token'); + + const result = await generateAppleClientSecret(baseEnv as never); + expect(result).toBe('signed.jwt.token'); + expect(mocks.importPKCS8).toHaveBeenCalledWith(baseEnv.APPLE_PRIVATE_KEY, 'ES256'); + expect(mocks.signJwt).toHaveBeenCalledWith(fakeKey); + }); + + it('returns null and warns when importPKCS8 throws', async () => { + mocks.importPKCS8.mockRejectedValue(new Error('bad key')); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await generateAppleClientSecret(baseEnv as never); + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Apple client-secret generation failed'), + expect.any(Error), + ); + warnSpy.mockRestore(); + }); + + it('returns null and warns when sign throws', async () => { + mocks.importPKCS8.mockResolvedValue({}); + mocks.signJwt.mockRejectedValue(new Error('sign failed')); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await generateAppleClientSecret(baseEnv as never); + expect(result).toBeNull(); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/api/src/auth/auth.helpers.ts b/packages/api/src/auth/auth.helpers.ts new file mode 100644 index 0000000000..e63fd2fc38 --- /dev/null +++ b/packages/api/src/auth/auth.helpers.ts @@ -0,0 +1,46 @@ +import { verifyPassword } from '@better-auth/utils/password'; +import type { ValidatedEnv } from '@packrat/api/utils/env-validation'; +import * as bcrypt from 'bcryptjs'; +import { importPKCS8, SignJWT } from 'jose'; + +// Matches bcrypt hashes ($2a$, $2b$, $2y$) left over from pre-migration auth. +const BCRYPT_HASH_RE = /^\$2[aby]\$/; + +export async function verifyPasswordCompat({ + hash, + password, +}: { + hash: string; + password: string; +}): Promise { + if (BCRYPT_HASH_RE.test(hash)) { + return bcrypt.compare(password, hash); + } + return verifyPassword(hash, password); +} + +// Apple requires a JWT as the OAuth2 client secret. It is valid for up to +// 6 months, so we regenerate it once per isolate (WeakMap cache in index.ts +// handles the per-request dedup). +// Returns null when Apple credentials are not configured (e.g., in tests). +export async function generateAppleClientSecret(env: ValidatedEnv): Promise { + if (!env.APPLE_PRIVATE_KEY) return null; + try { + const privateKey = await importPKCS8(env.APPLE_PRIVATE_KEY, 'ES256'); + const now = Math.floor(Date.now() / 1000); + return await new SignJWT({}) + .setProtectedHeader({ alg: 'ES256', kid: env.APPLE_KEY_ID }) + .setIssuer(env.APPLE_TEAM_ID) + .setSubject(env.APPLE_CLIENT_ID) + .setAudience('https://appleid.apple.com') + .setIssuedAt(now) + .setExpirationTime(now + 60 * 60 * 24 * 180) // 180 days + .sign(privateKey); + } catch (err) { + console.warn( + '[auth] Apple client-secret generation failed; web OAuth flow will be unavailable:', + err, + ); + return null; + } +} diff --git a/packages/api/src/services/__tests__/passwordResetService.test.ts b/packages/api/src/services/__tests__/passwordResetService.test.ts new file mode 100644 index 0000000000..0b40b01e58 --- /dev/null +++ b/packages/api/src/services/__tests__/passwordResetService.test.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const deleteFn = vi.fn(() => ({ where: deleteWhere })); + + const insertValues = vi.fn().mockResolvedValue(undefined); + const insertFn = vi.fn(() => ({ values: insertValues })); + + const updateReturning = vi.fn().mockResolvedValue([]); + const updateWhere = vi.fn(() => ({ returning: updateReturning })); + const updateSet = vi.fn(() => ({ where: updateWhere })); + const updateFn = vi.fn(() => ({ set: updateSet })); + + const findFirstUser = vi.fn(); + const findFirstVerification = vi.fn(); + + return { + deleteWhere, + deleteFn, + insertValues, + insertFn, + updateReturning, + updateWhere, + updateSet, + updateFn, + findFirstUser, + findFirstVerification, + createDb: vi.fn(() => ({ + query: { + users: { findFirst: findFirstUser }, + verification: { findFirst: findFirstVerification }, + }, + delete: deleteFn, + insert: insertFn, + update: updateFn, + })), + sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined), + timingSafeEqual: vi.fn((a: string, b: string) => a === b), + hashPassword: vi.fn((p: string) => Promise.resolve(`hashed_${p}`)), + }; +}); + +vi.mock('@packrat/api/db', () => ({ createDb: mocks.createDb })); +vi.mock('@packrat/api/utils/email', () => ({ + sendPasswordResetEmail: mocks.sendPasswordResetEmail, +})); +vi.mock('@packrat/api/utils/auth', () => ({ + timingSafeEqual: mocks.timingSafeEqual, +})); +vi.mock('@better-auth/utils/password', () => ({ + hashPassword: mocks.hashPassword, +})); +vi.mock('@packrat/db', () => ({ + users: {}, + verification: {}, + account: {}, +})); +vi.mock('drizzle-orm', () => ({ + and: vi.fn(), + eq: vi.fn(), + gt: vi.fn(), +})); + +import { requestPasswordReset, verifyOtpAndResetPassword } from '../passwordResetService'; + +describe('requestPasswordReset()', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.deleteWhere.mockResolvedValue(undefined); + mocks.insertValues.mockResolvedValue(undefined); + mocks.sendPasswordResetEmail.mockResolvedValue(undefined); + }); + + it('does nothing for an unknown email address', async () => { + mocks.findFirstUser.mockResolvedValue(undefined); + await requestPasswordReset('unknown@example.com'); + expect(mocks.sendPasswordResetEmail).not.toHaveBeenCalled(); + expect(mocks.insertFn).not.toHaveBeenCalled(); + }); + + it('deletes the existing verification record before inserting a new one', async () => { + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + await requestPasswordReset('user@example.com'); + expect(mocks.deleteFn).toHaveBeenCalled(); + expect(mocks.deleteWhere).toHaveBeenCalled(); + expect(mocks.insertValues).toHaveBeenCalled(); + expect(mocks.deleteWhere.mock.invocationCallOrder[0] ?? 0).toBeLessThan( + mocks.insertValues.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + }); + + it('inserts a new verification record for a known user', async () => { + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + await requestPasswordReset('user@example.com'); + expect(mocks.insertFn).toHaveBeenCalled(); + expect(mocks.insertValues).toHaveBeenCalledWith( + expect.objectContaining({ + identifier: 'password-reset:user@example.com', + }), + ); + }); + + it('sends the password reset email to the correct address', async () => { + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + await requestPasswordReset('user@example.com'); + expect(mocks.sendPasswordResetEmail).toHaveBeenCalledWith( + expect.objectContaining({ to: 'user@example.com' }), + ); + }); + + it('sends a 6-digit OTP in the email', async () => { + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + await requestPasswordReset('user@example.com'); + const emailCalls = mocks.sendPasswordResetEmail.mock.calls as Array< + [{ to: string; code: string }] + >; + const emailArg = emailCalls[0]?.[0]; + expect(emailArg?.code).toMatch(/^\d{6}$/); + }); + + it('stores the OTP value in the verification record', async () => { + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + await requestPasswordReset('user@example.com'); + const insertCalls = mocks.insertValues.mock.calls as Array<[{ value: string }]>; + const insertArg = insertCalls[0]?.[0]; + expect(insertArg?.value).toMatch(/^\d{6}$/); + }); + + it('stores the same OTP in both the record and the email', async () => { + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + await requestPasswordReset('user@example.com'); + const insertCalls = mocks.insertValues.mock.calls as Array<[{ value: string }]>; + const emailCalls = mocks.sendPasswordResetEmail.mock.calls as Array<[{ code: string }]>; + const insertedCode = insertCalls[0]?.[0]?.value; + const emailedCode = emailCalls[0]?.[0]?.code; + expect(insertedCode).toBe(emailedCode); + }); + + it('sets an expiry date in the future on the verification record', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + try { + const before = Date.now(); + await requestPasswordReset('user@example.com'); + const insertCalls = mocks.insertValues.mock.calls as Array< + [{ value: string; expiresAt: Date }] + >; + const insertArg = insertCalls[0]?.[0]; + expect(insertArg?.expiresAt.getTime()).toBeGreaterThan(before); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('verifyOtpAndResetPassword()', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.deleteWhere.mockResolvedValue(undefined); + mocks.updateReturning.mockResolvedValue([]); + }); + + it('throws for a missing or expired verification record', async () => { + mocks.findFirstVerification.mockResolvedValue(null); + await expect( + verifyOtpAndResetPassword({ email: 'user@example.com', code: '123456', newPassword: 'new' }), + ).rejects.toThrow('Invalid or expired reset code'); + }); + + it('throws when the OTP does not match', async () => { + mocks.findFirstVerification.mockResolvedValue({ value: '999999' }); + // timingSafeEqual is mocked as strict equality; '999999' !== '123456' + await expect( + verifyOtpAndResetPassword({ email: 'user@example.com', code: '123456', newPassword: 'new' }), + ).rejects.toThrow('Invalid or expired reset code'); + }); + + it('throws when the user cannot be found after OTP passes', async () => { + mocks.findFirstVerification.mockResolvedValue({ value: '123456' }); + mocks.findFirstUser.mockResolvedValue(null); + await expect( + verifyOtpAndResetPassword({ email: 'user@example.com', code: '123456', newPassword: 'new' }), + ).rejects.toThrow('User not found'); + }); + + it('hashes the new password before persisting it', async () => { + mocks.findFirstVerification.mockResolvedValue({ value: '123456' }); + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + mocks.updateReturning.mockResolvedValue([{ id: 'account-1' }]); + + await verifyOtpAndResetPassword({ + email: 'user@example.com', + code: '123456', + newPassword: 'plaintext', + }); + expect(mocks.hashPassword).toHaveBeenCalledWith('plaintext'); + }); + + it('updates the account table with the hashed password on success', async () => { + mocks.findFirstVerification.mockResolvedValue({ value: '123456' }); + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + mocks.updateReturning.mockResolvedValue([{ id: 'account-1' }]); + + await verifyOtpAndResetPassword({ + email: 'user@example.com', + code: '123456', + newPassword: 'newpass', + }); + expect(mocks.updateFn).toHaveBeenCalled(); + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ password: 'hashed_newpass' }), + ); + }); + + it('deletes the verification record after a successful reset', async () => { + mocks.findFirstVerification.mockResolvedValue({ value: '123456' }); + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + mocks.updateReturning.mockResolvedValue([{ id: 'account-1' }]); + + await verifyOtpAndResetPassword({ + email: 'user@example.com', + code: '123456', + newPassword: 'newpass', + }); + expect(mocks.deleteFn).toHaveBeenCalled(); + expect(mocks.deleteWhere).toHaveBeenCalled(); + }); + + it('falls back to updating the users table when no account record is found', async () => { + mocks.findFirstVerification.mockResolvedValue({ value: '123456' }); + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + + // First update call (account table) returns empty — triggers fallback + mocks.updateReturning.mockResolvedValueOnce([]); + + // Second update call (users table) — where() is awaited directly, no .returning() + const usersUpdateWhere = vi.fn().mockResolvedValue(undefined); + const usersUpdateSet = vi.fn(() => ({ where: usersUpdateWhere })); + mocks.updateFn + .mockReturnValueOnce({ set: mocks.updateSet }) + .mockReturnValueOnce({ set: usersUpdateSet }); + + await verifyOtpAndResetPassword({ + email: 'user@example.com', + code: '123456', + newPassword: 'newpass', + }); + expect(mocks.updateFn).toHaveBeenCalledTimes(2); + expect(usersUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ passwordHash: 'hashed_newpass' }), + ); + }); +}); diff --git a/packages/api/src/services/__tests__/userService.test.ts b/packages/api/src/services/__tests__/userService.test.ts new file mode 100644 index 0000000000..4bccbda13e --- /dev/null +++ b/packages/api/src/services/__tests__/userService.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const limitFn = vi.fn(); + const whereFn = vi.fn(() => ({ limit: limitFn })); + const fromFn = vi.fn(() => ({ where: whereFn })); + const selectFn = vi.fn(() => ({ from: fromFn })); + + const returningFn = vi.fn(); + const valuesFn = vi.fn(() => ({ returning: returningFn })); + const insertFn = vi.fn(() => ({ values: valuesFn })); + + return { + limitFn, + whereFn, + fromFn, + selectFn, + returningFn, + valuesFn, + insertFn, + createDb: vi.fn(() => ({ select: selectFn, insert: insertFn })), + hashPassword: vi.fn((p: string) => Promise.resolve(`hashed_${p}`)), + }; +}); + +vi.mock('@packrat/api/db', () => ({ createDb: mocks.createDb })); +vi.mock('@packrat/api/utils/auth', () => ({ hashPassword: mocks.hashPassword })); +vi.mock('@packrat/db', () => ({ users: { email: 'email', id: 'id' } })); +vi.mock('drizzle-orm', () => ({ eq: vi.fn() })); + +import { UserService } from '../userService'; + +describe('UserService', () => { + let service: UserService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new UserService(); + }); + + describe('findByEmail()', () => { + it('returns the user when found', async () => { + const fakeUser = { id: 'u1', email: 'alice@example.com' }; + mocks.limitFn.mockResolvedValue([fakeUser]); + + const result = await service.findByEmail('alice@example.com'); + expect(result).toEqual(fakeUser); + }); + + it('returns null when no user is found', async () => { + mocks.limitFn.mockResolvedValue([]); + const result = await service.findByEmail('nobody@example.com'); + expect(result).toBeNull(); + }); + + it('fetches only the first matching record', async () => { + const fakeUser = { id: 'u-x', email: 'test@example.com' }; + mocks.limitFn.mockResolvedValue([fakeUser]); + const result = await service.findByEmail('test@example.com'); + expect(result).toEqual(fakeUser); + expect(mocks.limitFn).toHaveBeenCalledWith(1); + }); + + it('lowercases the email before querying', async () => { + mocks.limitFn.mockResolvedValue([]); + await service.findByEmail('ALICE@EXAMPLE.COM'); + // UserService calls eq(users.email, email.toLowerCase()), which is called with the lowercased value + const { eq } = await import('drizzle-orm'); + const { users } = await import('@packrat/db'); + expect(vi.mocked(eq)).toHaveBeenCalledWith(users.email, 'alice@example.com'); + }); + }); + + describe('create()', () => { + it('creates a user and returns it', async () => { + const fakeUser = { id: 'u2', email: 'bob@example.com', role: 'USER' }; + mocks.returningFn.mockResolvedValue([fakeUser]); + + const result = await service.create({ email: 'Bob@Example.com', password: 'secret' }); + expect(result).toEqual(fakeUser); + }); + + it('lowercases the email before inserting', async () => { + mocks.returningFn.mockResolvedValue([{ id: 'u3', email: 'charlie@example.com' }]); + await service.create({ email: 'CHARLIE@EXAMPLE.COM' }); + expect(mocks.valuesFn).toHaveBeenCalledWith( + expect.objectContaining({ email: 'charlie@example.com' }), + ); + }); + + it('hashes the password when provided', async () => { + mocks.returningFn.mockResolvedValue([{ id: 'u4', email: 'test@example.com' }]); + await service.create({ email: 'test@example.com', password: 'mypassword' }); + expect(mocks.hashPassword).toHaveBeenCalledWith('mypassword'); + expect(mocks.valuesFn).toHaveBeenCalledWith( + expect.objectContaining({ passwordHash: 'hashed_mypassword' }), + ); + }); + + it('sets passwordHash to null when no password is provided', async () => { + mocks.returningFn.mockResolvedValue([{ id: 'u5', email: 'test@example.com' }]); + await service.create({ email: 'test@example.com' }); + expect(mocks.valuesFn).toHaveBeenCalledWith(expect.objectContaining({ passwordHash: null })); + }); + + it('defaults role to USER when not specified', async () => { + mocks.returningFn.mockResolvedValue([{ id: 'u6', email: 'test@example.com', role: 'USER' }]); + await service.create({ email: 'test@example.com' }); + expect(mocks.valuesFn).toHaveBeenCalledWith(expect.objectContaining({ role: 'USER' })); + }); + + it('accepts an explicit ADMIN role', async () => { + mocks.returningFn.mockResolvedValue([ + { id: 'u7', email: 'admin@example.com', role: 'ADMIN' }, + ]); + await service.create({ email: 'admin@example.com', role: 'ADMIN' }); + expect(mocks.valuesFn).toHaveBeenCalledWith(expect.objectContaining({ role: 'ADMIN' })); + }); + + it('defaults emailVerified to false', async () => { + mocks.returningFn.mockResolvedValue([{ id: 'u8', email: 'test@example.com' }]); + await service.create({ email: 'test@example.com' }); + expect(mocks.valuesFn).toHaveBeenCalledWith( + expect.objectContaining({ emailVerified: false }), + ); + }); + + it('accepts an explicit emailVerified: true', async () => { + mocks.returningFn.mockResolvedValue([{ id: 'u9', email: 'test@example.com' }]); + await service.create({ email: 'test@example.com', emailVerified: true }); + expect(mocks.valuesFn).toHaveBeenCalledWith(expect.objectContaining({ emailVerified: true })); + }); + + it('throws "Failed to create user" when insert returns no rows', async () => { + mocks.returningFn.mockResolvedValue([]); + await expect(service.create({ email: 'fail@example.com' })).rejects.toThrow( + 'Failed to create user', + ); + }); + + it('generates a UUID for the user id', async () => { + mocks.returningFn.mockResolvedValue([{ id: 'u10', email: 'test@example.com' }]); + await service.create({ email: 'test@example.com' }); + const insertCalls = mocks.valuesFn.mock.calls as unknown as Array<[{ id: string }]>; + const insertArg = insertCalls[0]?.[0]; + expect(insertArg?.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + }); +}); diff --git a/packages/api/src/utils/__tests__/routeParams.test.ts b/packages/api/src/utils/__tests__/routeParams.test.ts new file mode 100644 index 0000000000..0eddf3655a --- /dev/null +++ b/packages/api/src/utils/__tests__/routeParams.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { integerIdSchema, parseIntegerId } from '../routeParams'; + +describe('integerIdSchema', () => { + it('accepts a valid positive integer string', () => { + expect(integerIdSchema.safeParse('1').success).toBe(true); + expect(integerIdSchema.safeParse('42').success).toBe(true); + expect(integerIdSchema.safeParse('2147483647').success).toBe(true); // PG_INT4_MAX + }); + + it('rejects zero', () => { + expect(integerIdSchema.safeParse('0').success).toBe(false); + }); + + it('rejects negative numbers', () => { + expect(integerIdSchema.safeParse('-1').success).toBe(false); + expect(integerIdSchema.safeParse('-100').success).toBe(false); + }); + + it('rejects non-numeric strings', () => { + expect(integerIdSchema.safeParse('abc').success).toBe(false); + expect(integerIdSchema.safeParse('').success).toBe(false); + }); + + it('rejects leading zeros', () => { + expect(integerIdSchema.safeParse('007').success).toBe(false); + expect(integerIdSchema.safeParse('01').success).toBe(false); + }); + + it('rejects hex format', () => { + expect(integerIdSchema.safeParse('0x10').success).toBe(false); + }); + + it('rejects scientific notation', () => { + expect(integerIdSchema.safeParse('1e2').success).toBe(false); + }); + + it('rejects floats', () => { + expect(integerIdSchema.safeParse('4.0').success).toBe(false); + expect(integerIdSchema.safeParse('3.14').success).toBe(false); + }); + + it('rejects values exceeding PG_INT4_MAX', () => { + expect(integerIdSchema.safeParse('2147483648').success).toBe(false); + expect(integerIdSchema.safeParse('9999999999').success).toBe(false); + }); + + it('rejects whitespace-padded numbers', () => { + expect(integerIdSchema.safeParse(' 42 ').success).toBe(false); + expect(integerIdSchema.safeParse(' 1').success).toBe(false); + }); + + it('coerces valid string to number in output', () => { + const result = integerIdSchema.safeParse('99'); + expect(result.success).toBe(true); + if (result.success) expect(typeof result.data).toBe('number'); + }); +}); + +describe('parseIntegerId', () => { + it('returns the parsed number for a valid id', () => { + expect(parseIntegerId('1')).toBe(1); + expect(parseIntegerId('42')).toBe(42); + expect(parseIntegerId('2147483647')).toBe(2147483647); + }); + + it('returns null for undefined', () => { + expect(parseIntegerId(undefined)).toBeNull(); + }); + + it('returns null for non-numeric strings', () => { + expect(parseIntegerId('abc')).toBeNull(); + expect(parseIntegerId('')).toBeNull(); + }); + + it('returns null for zero', () => { + expect(parseIntegerId('0')).toBeNull(); + }); + + it('returns null for negative numbers', () => { + expect(parseIntegerId('-1')).toBeNull(); + }); + + it('returns null for values exceeding PG_INT4_MAX', () => { + expect(parseIntegerId('2147483648')).toBeNull(); + }); + + it('returns null for leading-zero strings', () => { + expect(parseIntegerId('007')).toBeNull(); + }); + + it('returns null for floats', () => { + expect(parseIntegerId('3.14')).toBeNull(); + }); + + it('returns null for hex-format strings', () => { + expect(parseIntegerId('0x1A')).toBeNull(); + }); + + it('returns null for scientific notation', () => { + expect(parseIntegerId('1e5')).toBeNull(); + }); +}); diff --git a/packages/mcp/src/__tests__/client.test.ts b/packages/mcp/src/__tests__/client.test.ts new file mode 100644 index 0000000000..af5454c14f --- /dev/null +++ b/packages/mcp/src/__tests__/client.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, it, vi } from 'vitest'; +import { call, createMcpClients, errMessage, nowIso, ok, shortId } from '../client'; + +vi.mock('@packrat/api-client', () => ({ + createApiClient: vi.fn((opts: unknown) => ({ _opts: opts })), +})); + +describe('ok()', () => { + it('wraps data as pretty-printed JSON in MCP text content', () => { + const result = ok({ id: 'pack-1', name: 'My Pack' }); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain('"id": "pack-1"'); + expect(result.isError).toBeUndefined(); + }); + + it('handles null data', () => { + const result = ok(null); + expect(result.content[0].text).toBe('null'); + }); + + it('handles array data', () => { + const result = ok([1, 2, 3]); + expect(result.content[0].text).toContain('1'); + }); +}); + +describe('errMessage()', () => { + it('returns an error result with isError: true', () => { + const result = errMessage('something went wrong'); + expect(result.isError).toBe(true); + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain('Error: something went wrong'); + }); + + it('prefixes the message with "Error:"', () => { + const result = errMessage('not found'); + expect(result.content[0].text).toMatch(/^Error:/); + }); +}); + +describe('shortId()', () => { + it('returns a string prefixed with the provided prefix', () => { + const id = shortId('pack'); + expect(id.startsWith('pack_')).toBe(true); + }); + + it('returns a unique id on each call', () => { + const id1 = shortId('item'); + const id2 = shortId('item'); + expect(id1).not.toBe(id2); + }); + + it('strips hyphens from the UUID portion', () => { + const id = shortId('trip'); + // The suffix after the prefix should not contain hyphens + const suffix = id.slice('trip_'.length); + expect(suffix).not.toContain('-'); + }); + + it('produces a 12-character suffix', () => { + const id = shortId('x'); + const suffix = id.slice('x_'.length); + expect(suffix).toHaveLength(12); + }); +}); + +describe('nowIso()', () => { + it('returns a valid ISO 8601 timestamp', () => { + const iso = nowIso(); + expect(() => new Date(iso)).not.toThrow(); + expect(new Date(iso).toISOString()).toBe(iso); + }); + + it('returns a string ending in Z (UTC)', () => { + expect(nowIso().endsWith('Z')).toBe(true); + }); +}); + +describe('call()', () => { + it('returns ok result when promise resolves with data', async () => { + const mockPromise = Promise.resolve({ data: { id: 'pack-1' }, error: null, status: 200 }); + const result = await call(mockPromise); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('"id": "pack-1"'); + }); + + it('returns error result when promise resolves with error', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 404, value: 'Not Found' }, + status: 404, + }); + const result = await call(mockPromise); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('404'); + }); + + it('returns error result when data is null', async () => { + const mockPromise = Promise.resolve({ data: null, error: null, status: 200 }); + const result = await call(mockPromise); + expect(result.isError).toBe(true); + }); + + it('returns error result when promise rejects', async () => { + const mockPromise = Promise.reject(new Error('network failure')); + const result = await call(mockPromise); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('network failure'); + }); + + it('uses action from options in error messages', async () => { + const mockPromise = Promise.reject(new Error('timeout')); + const result = await call(mockPromise, { action: 'fetch pack' }); + expect(result.content[0].text).toContain('fetch pack'); + }); + + it('formats 401 error with auth guidance', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 401, value: null }, + status: 401, + }); + const result = await call(mockPromise, { action: 'list packs' }); + expect(result.isError).toBe(true); + expect(result.content[0].text.toLowerCase()).toContain('authentication'); + }); + + it('formats 401 admin error with admin guidance when requiresAdmin is set', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 401, value: null }, + status: 401, + }); + const result = await call(mockPromise, { action: 'list packs', requiresAdmin: true }); + expect(result.isError).toBe(true); + expect(result.content[0].text.toLowerCase()).toContain('admin'); + }); + + it('formats 403 error', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 403, value: null }, + status: 403, + }); + const result = await call(mockPromise, { action: 'delete pack' }); + expect(result.isError).toBe(true); + expect(result.content[0].text.toLowerCase()).toContain('forbidden'); + }); + + it('formats 404 error', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 404, value: null }, + status: 404, + }); + const result = await call(mockPromise, { action: 'get pack', resourceHint: 'pack p_123' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('404'); + }); + + it('formats 409 conflict error', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 409, value: null }, + status: 409, + }); + const result = await call(mockPromise, { action: 'create pack' }); + expect(result.isError).toBe(true); + expect(result.content[0].text.toLowerCase()).toContain('conflict'); + }); + + it('formats 422 validation error', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 422, value: null }, + status: 422, + }); + const result = await call(mockPromise, { action: 'update pack' }); + expect(result.isError).toBe(true); + expect(result.content[0].text.toLowerCase()).toContain('validation'); + }); + + it('formats 429 rate limit error', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 429, value: null }, + status: 429, + }); + const result = await call(mockPromise, { action: 'search' }); + expect(result.isError).toBe(true); + expect(result.content[0].text.toLowerCase()).toContain('rate limit'); + }); + + it('formats generic HTTP error for unknown status codes', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 503, value: null }, + status: 503, + }); + const result = await call(mockPromise, { action: 'fetch data' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('503'); + }); + + it('includes error body message when available', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 400, value: { message: 'invalid input' } }, + status: 400, + }); + const result = await call(mockPromise); + expect(result.content[0].text).toContain('invalid input'); + }); + + it('handles non-Error thrown exceptions', async () => { + const mockPromise = Promise.reject('string error'); + const result = await call(mockPromise); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('string error'); + }); + + it('formats 403 admin error when requiresAdmin is set', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 403, value: null }, + status: 403, + }); + const result = await call(mockPromise, { action: 'delete user', requiresAdmin: true }); + expect(result.isError).toBe(true); + expect(result.content[0].text.toLowerCase()).toContain('admin'); + expect(result.content[0].text.toLowerCase()).toContain('forbidden'); + }); + + it('extracts error body from obj.error field when obj.message is absent', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 400, value: { error: 'bad request detail' } }, + status: 400, + }); + const result = await call(mockPromise); + expect(result.content[0].text).toContain('bad request detail'); + }); + + it('JSON-stringifies error body object when no message/error field present', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 400, value: { code: 42, detail: 'some info' } }, + status: 400, + }); + const result = await call(mockPromise); + expect(result.content[0].text).toContain('42'); + }); + + it('converts numeric error body to string', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 500, value: 12345 }, + status: 500, + }); + const result = await call(mockPromise); + expect(result.content[0].text).toContain('12345'); + }); +}); + +describe('createMcpClients()', () => { + it('returns user and admin clients', () => { + const clients = createMcpClients({ + baseUrl: 'https://api.example.com', + getUserToken: () => 'user-token', + getAdminToken: () => 'admin-token', + }); + expect(clients).toHaveProperty('user'); + expect(clients).toHaveProperty('admin'); + }); + + it('passes the base URL to each client', async () => { + const mod = await import('@packrat/api-client'); + const spy = vi.mocked(mod.createApiClient); + spy.mockClear(); + createMcpClients({ + baseUrl: 'https://api.test.com', + getUserToken: () => null, + getAdminToken: () => null, + }); + expect(spy).toHaveBeenCalledTimes(2); + for (const c of spy.mock.calls) { + expect((c[0] as { baseUrl: string }).baseUrl).toBe('https://api.test.com'); + } + }); + + it('noopHooks getAccessToken returns null when token provider returns null', async () => { + const mod = await import('@packrat/api-client'); + const spy = vi.mocked(mod.createApiClient); + spy.mockClear(); + createMcpClients({ + baseUrl: 'https://api.test.com', + getUserToken: () => null, + getAdminToken: () => null, + }); + const auth = (spy.mock.calls[0]?.[0] as { auth: { getAccessToken: () => string | null } }).auth; + expect(auth.getAccessToken()).toBeNull(); + }); + + it('noopHooks getAccessToken returns the token when provider returns one', async () => { + const mod = await import('@packrat/api-client'); + const spy = vi.mocked(mod.createApiClient); + spy.mockClear(); + createMcpClients({ + baseUrl: 'https://api.test.com', + getUserToken: () => 'my-token', + getAdminToken: () => null, + }); + const auth = (spy.mock.calls[0]?.[0] as { auth: { getAccessToken: () => string | null } }).auth; + expect(auth.getAccessToken()).toBe('my-token'); + }); + + it('noopHooks getRefreshToken always returns null', async () => { + const mod = await import('@packrat/api-client'); + const spy = vi.mocked(mod.createApiClient); + spy.mockClear(); + createMcpClients({ + baseUrl: 'https://api.test.com', + getUserToken: () => 'tok', + getAdminToken: () => null, + }); + const auth = (spy.mock.calls[0]?.[0] as { auth: { getRefreshToken: () => null } }).auth; + expect(auth.getRefreshToken()).toBeNull(); + }); + + it('noopHooks lifecycle callbacks are no-ops', async () => { + const mod = await import('@packrat/api-client'); + const spy = vi.mocked(mod.createApiClient); + spy.mockClear(); + createMcpClients({ + baseUrl: 'https://api.test.com', + getUserToken: () => null, + getAdminToken: () => null, + }); + const auth = ( + spy.mock.calls[0]?.[0] as { + auth: { onAccessTokenRefreshed: () => void; onNeedsReauth: () => void }; + } + ).auth; + expect(() => auth.onAccessTokenRefreshed()).not.toThrow(); + expect(() => auth.onNeedsReauth()).not.toThrow(); + }); +}); diff --git a/packages/mcp/src/__tests__/constants.test.ts b/packages/mcp/src/__tests__/constants.test.ts new file mode 100644 index 0000000000..fdbfba86ac --- /dev/null +++ b/packages/mcp/src/__tests__/constants.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { ServiceMeta, WorkerRoute } from '../constants'; + +describe('WorkerRoute', () => { + it('defines the root path', () => { + expect(WorkerRoute.Root).toBe('/'); + }); + + it('defines the health endpoint', () => { + expect(WorkerRoute.Health).toBe('/health'); + }); + + it('defines the MCP endpoint', () => { + expect(WorkerRoute.Mcp).toBe('/mcp'); + }); + + it('defines the OAuth authorize endpoint', () => { + expect(WorkerRoute.Authorize).toBe('/authorize'); + }); + + it('defines the login endpoint', () => { + expect(WorkerRoute.Login).toBe('/login'); + }); + + it('defines the OAuth callback endpoint', () => { + expect(WorkerRoute.Callback).toBe('/callback'); + }); + + it('defines the token endpoint', () => { + expect(WorkerRoute.Token).toBe('/token'); + }); + + it('defines the register endpoint', () => { + expect(WorkerRoute.Register).toBe('/register'); + }); + + it('has exactly 8 route entries', () => { + expect(Object.keys(WorkerRoute)).toHaveLength(8); + }); + + it('all routes start with /', () => { + for (const route of Object.values(WorkerRoute)) { + expect(route.startsWith('/')).toBe(true); + } + }); + + it('all route values are unique', () => { + const values = Object.values(WorkerRoute); + expect(new Set(values).size).toBe(values.length); + }); +}); + +describe('ServiceMeta', () => { + it('has the correct service name', () => { + expect(ServiceMeta.Name).toBe('packrat-mcp'); + }); + + it('has a semver-formatted version', () => { + expect(ServiceMeta.Version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('uses streamable-http transport', () => { + expect(ServiceMeta.Transport).toBe('streamable-http'); + }); +}); diff --git a/packages/mcp/src/__tests__/enums.test.ts b/packages/mcp/src/__tests__/enums.test.ts new file mode 100644 index 0000000000..4e1e179d29 --- /dev/null +++ b/packages/mcp/src/__tests__/enums.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { + CatalogSortField, + CrossingDifficulty, + ExperienceLevel, + ItemCategory, + PackCategory, + PackStyle, + SortOrder, + TrailCondition, + TrailSurface, + WeightPriority, +} from '../enums'; + +describe('PackCategory', () => { + it('maps all expected categories to their string values', () => { + expect(PackCategory.Backpacking).toBe('backpacking'); + expect(PackCategory.Camping).toBe('camping'); + expect(PackCategory.Climbing).toBe('climbing'); + expect(PackCategory.Cycling).toBe('cycling'); + expect(PackCategory.Hiking).toBe('hiking'); + expect(PackCategory.Skiing).toBe('skiing'); + expect(PackCategory.Travel).toBe('travel'); + expect(PackCategory.General).toBe('general'); + }); + + it('has 8 members', () => { + const values = Object.values(PackCategory); + expect(values).toHaveLength(8); + }); +}); + +describe('ItemCategory', () => { + it('maps all expected item categories to their string values', () => { + expect(ItemCategory.Shelter).toBe('shelter'); + expect(ItemCategory.Sleep).toBe('sleep'); + expect(ItemCategory.Clothing).toBe('clothing'); + expect(ItemCategory.Footwear).toBe('footwear'); + expect(ItemCategory.Navigation).toBe('navigation'); + expect(ItemCategory.Safety).toBe('safety'); + expect(ItemCategory.Food).toBe('food'); + expect(ItemCategory.Water).toBe('water'); + expect(ItemCategory.Hygiene).toBe('hygiene'); + expect(ItemCategory.Tools).toBe('tools'); + }); + + it('has 10 members', () => { + expect(Object.values(ItemCategory)).toHaveLength(10); + }); +}); + +describe('TrailSurface', () => { + it('maps all expected trail surfaces to their string values', () => { + expect(TrailSurface.Paved).toBe('paved'); + expect(TrailSurface.Gravel).toBe('gravel'); + expect(TrailSurface.Dirt).toBe('dirt'); + expect(TrailSurface.Rocky).toBe('rocky'); + expect(TrailSurface.Snow).toBe('snow'); + expect(TrailSurface.Mud).toBe('mud'); + }); +}); + +describe('TrailCondition', () => { + it('maps all expected conditions to their string values', () => { + expect(TrailCondition.Excellent).toBe('excellent'); + expect(TrailCondition.Good).toBe('good'); + expect(TrailCondition.Fair).toBe('fair'); + expect(TrailCondition.Poor).toBe('poor'); + }); +}); + +describe('CrossingDifficulty', () => { + it('maps all expected difficulties to their string values', () => { + expect(CrossingDifficulty.Easy).toBe('easy'); + expect(CrossingDifficulty.Moderate).toBe('moderate'); + expect(CrossingDifficulty.Difficult).toBe('difficult'); + }); +}); + +describe('SortOrder', () => { + it('has ascending and descending variants', () => { + expect(SortOrder.Asc).toBe('asc'); + expect(SortOrder.Desc).toBe('desc'); + }); +}); + +describe('ExperienceLevel', () => { + it('maps all experience levels to their string values', () => { + expect(ExperienceLevel.Beginner).toBe('beginner'); + expect(ExperienceLevel.Intermediate).toBe('intermediate'); + expect(ExperienceLevel.Advanced).toBe('advanced'); + }); +}); + +describe('PackStyle', () => { + it('maps all pack styles to their string values', () => { + expect(PackStyle.Ultralight).toBe('ultralight'); + expect(PackStyle.Lightweight).toBe('lightweight'); + expect(PackStyle.Traditional).toBe('traditional'); + }); +}); + +describe('WeightPriority', () => { + it('maps all weight priorities to their string values', () => { + expect(WeightPriority.Ultralight).toBe('ultralight'); + expect(WeightPriority.WeightConscious).toBe('weight-conscious'); + expect(WeightPriority.DurabilityFirst).toBe('durability-first'); + }); +}); + +describe('CatalogSortField', () => { + it('maps all sort fields to their string values', () => { + expect(CatalogSortField.Name).toBe('name'); + expect(CatalogSortField.Brand).toBe('brand'); + expect(CatalogSortField.Price).toBe('price'); + expect(CatalogSortField.Rating).toBe('ratingValue'); + expect(CatalogSortField.CreatedAt).toBe('createdAt'); + expect(CatalogSortField.UpdatedAt).toBe('updatedAt'); + expect(CatalogSortField.Usage).toBe('usage'); + }); + + it('has 7 members', () => { + expect(Object.values(CatalogSortField)).toHaveLength(7); + }); +}); diff --git a/packages/overpass/src/client.test.ts b/packages/overpass/src/client.test.ts new file mode 100644 index 0000000000..b74b733276 --- /dev/null +++ b/packages/overpass/src/client.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { queryOverpass } from './client'; + +const mockFetch = vi.fn(); +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = mockFetch as typeof globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.clearAllMocks(); +}); + +function makeResponse(body: unknown, status = 200) { + const ok = status < 400; + return { + ok, + status, + statusText: ok ? 'OK' : 'Service Unavailable', + json: vi.fn().mockResolvedValue(body), + }; +} + +const validResponse = { + version: 0.6, + generator: 'Overpass API 0.7.61.8 (244012)', + osm3s: { + timestamp_osm_base: '2024-01-01T00:00:00Z', + copyright: 'The data included in this document is from www.openstreetmap.org.', + }, + elements: [ + { + type: 'relation', + id: 12345, + tags: { name: 'Pacific Crest Trail', route: 'hiking' }, + bounds: { minlat: 32.5, minlon: -120.8, maxlat: 49.0, maxlon: -117.1 }, + members: [], + }, + ], +}; + +describe('queryOverpass', () => { + describe('HTTP request construction', () => { + it('sends a POST to the default Overpass endpoint', async () => { + mockFetch.mockResolvedValue(makeResponse(validResponse)); + await queryOverpass('[out:json];relation(12345);out geom;'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://overpass-api.de/api/interpreter', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('uses a custom endpoint when provided', async () => { + mockFetch.mockResolvedValue(makeResponse(validResponse)); + await queryOverpass('ql', { endpoint: 'https://custom.example.com/api' }); + expect(mockFetch).toHaveBeenCalledWith('https://custom.example.com/api', expect.any(Object)); + }); + + it('encodes the QL query as form-urlencoded body', async () => { + mockFetch.mockResolvedValue(makeResponse(validResponse)); + const ql = '[out:json];relation(42);out geom;'; + await queryOverpass(ql); + const firstCall = mockFetch.mock.calls.at(0) as [string, RequestInit] | undefined; + const init = firstCall?.[1]; + expect(init?.body).toBe(`data=${encodeURIComponent(ql)}`); + }); + + it('sets Content-Type to application/x-www-form-urlencoded', async () => { + mockFetch.mockResolvedValue(makeResponse(validResponse)); + await queryOverpass('ql'); + const firstCall = mockFetch.mock.calls.at(0) as [string, RequestInit] | undefined; + const headers = firstCall?.[1]?.headers as Record | undefined; + expect(headers?.['Content-Type']).toBe('application/x-www-form-urlencoded'); + }); + + it('sets a User-Agent header', async () => { + mockFetch.mockResolvedValue(makeResponse(validResponse)); + await queryOverpass('ql'); + const firstCall = mockFetch.mock.calls.at(0) as [string, RequestInit] | undefined; + const headers = firstCall?.[1]?.headers as Record | undefined; + expect(headers?.['User-Agent']).toBeDefined(); + expect(typeof headers?.['User-Agent']).toBe('string'); + }); + }); + + describe('error handling', () => { + it('throws when response status is not ok (429)', async () => { + mockFetch.mockResolvedValue(makeResponse({}, 429)); + await expect(queryOverpass('ql')).rejects.toThrow( + 'Overpass request failed: 429 Service Unavailable', + ); + }); + + it('throws when response status is not ok (500)', async () => { + mockFetch.mockResolvedValue(makeResponse({}, 500)); + await expect(queryOverpass('ql')).rejects.toThrow( + 'Overpass request failed: 500 Service Unavailable', + ); + }); + + it('throws when response JSON does not match expected schema', async () => { + mockFetch.mockResolvedValue(makeResponse({ unexpected: 'data' })); + await expect(queryOverpass('ql')).rejects.toThrow( + 'Overpass response did not match expected schema', + ); + }); + + it('throws when response is missing elements field', async () => { + mockFetch.mockResolvedValue(makeResponse({ version: 0.6 })); + await expect(queryOverpass('ql')).rejects.toThrow( + 'Overpass response did not match expected schema', + ); + }); + }); + + describe('successful response', () => { + it('returns the parsed response data', async () => { + mockFetch.mockResolvedValue(makeResponse(validResponse)); + const result = await queryOverpass('[out:json];relation(12345);out geom;'); + expect(result.elements).toHaveLength(1); + const [firstElement] = result.elements; + expect(firstElement?.id).toBe(12345); + }); + + it('returns empty elements array for no results', async () => { + mockFetch.mockResolvedValue(makeResponse({ ...validResponse, elements: [] })); + const result = await queryOverpass('ql'); + expect(result.elements).toHaveLength(0); + }); + }); +}); diff --git a/scripts/lint/no-owned-max-params.ts b/scripts/lint/no-owned-max-params.ts new file mode 100644 index 0000000000..4c9164fb9d --- /dev/null +++ b/scripts/lint/no-owned-max-params.ts @@ -0,0 +1,267 @@ +#!/usr/bin/env bun +// +// no-owned-max-params.ts - enforces object params for owned functions. +// +// Biome's useMaxParams rule is intentionally broad, so it also catches JS, +// React, test, and framework callbacks whose positional signatures are not +// ours to redesign. Biome stays at max: 2 as a general backstop. This check +// adds the project-specific rule: owned function definitions should take at +// most one parameter, while inline callbacks passed to other APIs are ignored. + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { extname, join } from 'node:path'; +import ts from 'typescript'; + +const ROOT = join(import.meta.dir, '..', '..'); +const SCAN_ROOTS = ['apps', 'packages']; +const MAX_OWNED_PARAMS = 1; + +const EXCLUDED_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + 'out', + '.next', + '.expo', + '.turbo', + 'coverage', +]); + +const EXCLUDED_PATH_PARTS = ['/test/', '/__tests__/', '/mocks/', '/playwright/']; +const EXCLUDED_FILES = new Set([ + // This service intentionally mirrors Cloudflare R2's positional API. + 'packages/api/src/services/r2-bucket.ts', + // These build scripts override globalThis.fetch with a shim that must + // match the runtime's (input, init) signature. + 'apps/landing/scripts/generate-og-images.ts', + 'apps/guides/scripts/generate-og-images.ts', +]); +const FRAMEWORK_METHOD_NAMES = new Set(['fetch', 'queue', 'resolveRequest']); +const EXTERNAL_CALLBACK_NAMES = new Set([ + 'fetcher', + 'keyExtractor', + 'list', + 'onChange', + 'onContentSizeChange', + 'onError', + 'onSettled', + 'onSuccess', + 'orderBy', + 'renderItem', + 'set', + 'setItem', + 'webpack', +]); +const TARGET_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']); + +interface Violation { + file: string; + line: number; + column: number; + name: string; + count: number; +} + +function isTargetFile(relPath: string): boolean { + if (EXCLUDED_FILES.has(relPath)) return false; + if (EXCLUDED_PATH_PARTS.some((part) => relPath.includes(part))) return false; + return TARGET_EXTENSIONS.has(extname(relPath)); +} + +function collectFiles(dir: string, relDir: string, files: string[]): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + + for (const entry of entries) { + if (EXCLUDED_DIRS.has(entry)) continue; + + const full = join(dir, entry); + const rel = `${relDir}/${entry}`; + + let isDir = false; + try { + isDir = statSync(full).isDirectory(); + } catch { + continue; + } + + if (isDir) { + collectFiles(full, rel, files); + } else if (isTargetFile(rel)) { + files.push(rel); + } + } +} + +function scriptKindForPath(file: string): ts.ScriptKind { + if (file.endsWith('.tsx')) return ts.ScriptKind.TSX; + if (file.endsWith('.jsx')) return ts.ScriptKind.JSX; + if (file.endsWith('.js') || file.endsWith('.mjs') || file.endsWith('.cjs')) + return ts.ScriptKind.JS; + return ts.ScriptKind.TS; +} + +function unwrapExpressionParent(node: ts.Node): ts.Node { + let current: ts.Node = node; + + while ( + ts.isParenthesizedExpression(current.parent) || + ts.isAsExpression(current.parent) || + ts.isSatisfiesExpression(current.parent) || + ts.isTypeAssertionExpression(current.parent) || + ts.isNonNullExpression(current.parent) + ) { + current = current.parent; + } + + return current.parent; +} + +function isInlineCallback(node: ts.FunctionLikeDeclaration): boolean { + if (!ts.isArrowFunction(node) && !ts.isFunctionExpression(node)) return false; + + const parent = unwrapExpressionParent(node); + if (ts.isCallExpression(parent) || ts.isNewExpression(parent)) { + return ( + parent.arguments?.some((argument) => { + let current: ts.Node = node; + while (current.parent && current.parent !== parent) current = current.parent; + return current === argument; + }) === true + ); + } + + return false; +} + +function hasBody(node: ts.FunctionLikeDeclaration): boolean { + return 'body' in node && node.body !== undefined; +} + +function isAssertionPredicate(node: ts.FunctionLikeDeclaration): boolean { + const type = node.type; + if (!type) return false; + return ts.isTypePredicateNode(type) && type.assertsModifier !== undefined; +} + +function functionName(node: ts.FunctionLikeDeclaration): string { + if ('name' in node && node.name) return node.name.getText(); + + const parent = node.parent; + if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return parent.name.text; + if (ts.isPropertyAssignment(parent)) return parent.name.getText(); + if (ts.isBinaryExpression(parent) && ts.isPropertyAccessExpression(parent.left)) { + return parent.left.name.text; + } + + return ''; +} + +function isFrameworkObjectMethod(node: ts.FunctionLikeDeclaration): boolean { + if ( + !ts.isMethodDeclaration(node) && + !ts.isFunctionExpression(node) && + !ts.isArrowFunction(node) + ) { + return false; + } + + const name = functionName(node).replace(/^['"]|['"]$/g, ''); + return FRAMEWORK_METHOD_NAMES.has(name); +} + +function isExternalCallback(node: ts.FunctionLikeDeclaration): boolean { + if (EXTERNAL_CALLBACK_NAMES.has(functionName(node).replace(/^['"]|['"]$/g, ''))) return true; + + if (!ts.isArrowFunction(node) && !ts.isFunctionExpression(node)) return false; + const parent = unwrapExpressionParent(node); + + if (ts.isPropertyAssignment(parent)) { + return EXTERNAL_CALLBACK_NAMES.has(parent.name.getText().replace(/^['"]|['"]$/g, '')); + } + + if ( + ts.isJsxExpression(parent) && + parent.parent && + ts.isJsxAttribute(parent.parent) && + ts.isIdentifier(parent.parent.name) + ) { + return EXTERNAL_CALLBACK_NAMES.has(parent.parent.name.text); + } + + return false; +} + +function shouldCheck(node: ts.FunctionLikeDeclaration): boolean { + if (!hasBody(node)) return false; + if (isInlineCallback(node)) return false; + if (isExternalCallback(node)) return false; + if (isAssertionPredicate(node)) return false; + if (isFrameworkObjectMethod(node)) return false; + if (ts.isGetAccessor(node) || ts.isSetAccessor(node)) return false; + return true; +} + +function scanFile(relPath: string, violations: Violation[]): void { + let content: string; + try { + content = readFileSync(join(ROOT, relPath), 'utf8'); + } catch { + return; + } + + const sourceFile = ts.createSourceFile( + relPath, + content, + ts.ScriptTarget.Latest, + true, + scriptKindForPath(relPath), + ); + + function visit(node: ts.Node): void { + if (ts.isFunctionLike(node) && shouldCheck(node) && node.parameters.length > MAX_OWNED_PARAMS) { + const pos = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + violations.push({ + file: relPath, + line: pos.line + 1, + column: pos.character + 1, + name: functionName(node), + count: node.parameters.length, + }); + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); +} + +const files: string[] = []; +for (const root of SCAN_ROOTS) { + collectFiles(join(ROOT, root), root, files); +} + +const violations: Violation[] = []; +for (const file of files) { + scanFile(file, violations); +} + +if (violations.length > 0) { + console.log( + `Owned functions with too many params found (${violations.length}). Use one object parameter for owned APIs; inline callbacks passed to external APIs are ignored:\n`, + ); + + for (const violation of violations) { + console.log( + `${violation.file}:${violation.line}:${violation.column}: ${violation.name} has ${violation.count} params`, + ); + } + + process.exit(1); +} + +console.log('No owned functions exceed one parameter.');